commit b00e5d9829e7124e8d543c7839a8313ae795c0ab Author: Jakob Scheid Date: Thu Mar 12 19:34:25 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e178647 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist/ +src/*.egg-info/ +.vscode/ +.pytest_cache/ +__pycache__/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc1733c --- /dev/null +++ b/LICENSE @@ -0,0 +1,215 @@ +Copyright 2026 jCloud Services GbR + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef8104 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# jcloud-config-parser + +A library to parse configuration files. + +## Installation +You can install the library using pip: + +```bash +pip install jcloud-config-parser --index-url https://repo.jcloud-services.ddns.net/simple/ +``` + +## Usage +Here are a few simple examples of how to use the `jcloud-config-parser` library to read an configuration files: + +### INI Configuration +```python +from jcloud_config_parser.ini import INIConfiguration + +with open('config.conf', 'r') as file: + config_content = file.read() + +parsed = INIConfiguration.from_string(config_content) +print(dict(parsed)) +``` + +If the configuration file content is: +```ini +global1=global value1 +global2=global value2 + +[section1] +key1=value1 +key2=value2 + +[section2] +hello=world + +[section3] +key=value +``` + +the result will be `{'section1': {'key1': 'value1', 'key2': 'value2'}, 'section2': {'hello': 'world'}, 'section3': {'key': 'value'}}` + +### JSON Configuration +```python +from jcloud_config_parser.json import JSONConfiguration + +with open('config.json', 'r') as file: + config_content = file.read() + +parsed = JSONConfiguration.from_string(config_content) +print(dict(parsed)) +``` + +If the configuration file content is: +```json +{ + "section1": { + "key1": "value1", + "key2": "value2", + "number": 42, + "number2": 3.14, + "number3": -1, + "boolean": true, + "boolean2": false, + "null": null + }, + "section2": { + "hello": "world" + }, + "section3": { + "key": "value" + } +} +``` + +the result will be `{'section1': {'key1': 'value1', 'key2': 'value2', 'number': 42, 'number2': 3.14, 'number3': -1, 'boolean': True, 'boolean2': False, 'null': None}, 'section2': {'hello': 'world'}, 'section3': {'key': 'value'}}` + + + +## Full documentation + +For the full documentation, see the Python docstrings. + +## Changelog + +### Version 0.1.0 +- Changed the name from `config-parser` to `jcloud-config-parser` \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..1ea7e15 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/bash + +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +python3 -m build +scp dist/* jcloud@jcloud-services.ddns.net:/srv/data/wwwstatic/repo/simple/jcloud-config-parser \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..50ab61c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "jcloud-config-parser" +version = "0.1.0" +description = "A configuration file parser." +license = "Apache-2.0" \ No newline at end of file diff --git a/src/jcloud_config_parser/__init__.py b/src/jcloud_config_parser/__init__.py new file mode 100644 index 0000000..da5917c --- /dev/null +++ b/src/jcloud_config_parser/__init__.py @@ -0,0 +1,42 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +''' +A library for parsing configuration files in various formats. + +Modules: +- _configuration: Base classes. +- ini: INI configuration. +- json: JSON configuration. +- exceptions: Exceptions for configuration parsing. +- parse: A package including the parsers. +- serialize: A package including the serializers. +''' + +from ._configuration import * +from ._configuration import __all__ as _configuration__all__ +from . import exceptions +from . import json +from . import ini +from . import parse +from . import serialize + +__all__ = [ + 'exceptions', + 'json', + 'ini', + 'parse', + 'serialize', + *_configuration__all__ +] \ No newline at end of file diff --git a/src/jcloud_config_parser/_configuration.py b/src/jcloud_config_parser/_configuration.py new file mode 100644 index 0000000..d62982b --- /dev/null +++ b/src/jcloud_config_parser/_configuration.py @@ -0,0 +1,129 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +from types import FunctionType +from typing import Any + +__all__ = [ + 'Configuration', + 'set_mutability', + 'is_mutable', +] + +class Configuration: + def __init__(self, _config: dict = {}, mutable: bool = True) -> None: + '''A base class for configurations.''' + + self._config = copy.deepcopy(_config) + self._mutable = mutable + + def _check_mutability(self) -> None: + return self._mutable + + def _mutate(self, func: FunctionType) -> FunctionType: + def wrapper(*args, **kwargs) -> Any: + if self._check_mutability(): + return func(*args, **kwargs) + else: + raise TypeError('configuration is immutable') + return wrapper + + + def _set(self, name: str, value: str) -> None: + @self._mutate + def _set(name: str, value: str) -> None: + self._config[name] = value + + _set(name, value) + + def _delete(self, name: str) -> None: + @self._mutate + def _delete(name: str) -> None: + del self._config[name] + + _delete(name) + + def _get(self, name: str) -> str: + return self._config[name] + + def __getattribute__(self, name: str): + if name.startswith('_') or name in self.__dir__(): + return super().__getattribute__(name) + else: + if name not in self._config: + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + return self._get(name) + + def __setattr__(self, name, value): + if name.startswith('_') or name in self.__dir__(): + super().__setattr__(name, value) + else: + self._set(name, value) + + def __delattr__(self, name): + if name.startswith('_') or name in self.__dir__(): + super().__delattr__(name) + else: + self._delete(name) + + def __setitem__(self, key, value): + self._set(key, value) + + def __getitem__(self, key): + return self._config[key] + + def __delitem__(self, key): + self._delete(key) + + def __iter__(self): + return iter(self._config.items()) + + def __contains__(self, item): + return item in self._config + +def set_mutability(configuration: Configuration, mutable: bool, *, operate_on_original_object: bool = True) -> Configuration: + ''' + Sets the mutability of a configuration. + + :param configuration: The configuration object. + :type configuration: Configuration + :param mutable: The new mutability. + :type mutable: bool + :param operate_on_original_object: If ``True``, the mutability of the + original object will be set. If ``False``, the original object will be + unchanged. The new configuration will always be returned. + :type operate_on_original_object: bool + + :return: The configuration object with the new mutability + :rtype: Configuration + ''' + + if not operate_on_original_object: + configuration = copy.deepcopy(configuration) + + configuration._mutable = mutable + return configuration + +def is_mutable(configuration: Configuration) -> Configuration: + ''' + Checks whether a configuration is mutable. + + :param configuration: The configuration. + :type configuration: Configuration + + :return: The mutability + :rtype: bool + ''' + return configuration._mutable \ No newline at end of file diff --git a/src/jcloud_config_parser/exceptions.py b/src/jcloud_config_parser/exceptions.py new file mode 100644 index 0000000..83eb5e3 --- /dev/null +++ b/src/jcloud_config_parser/exceptions.py @@ -0,0 +1,57 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__all__ = [ + 'ConfigurationSyntaxError', + 'EscapeSequenceSyntaxError', + 'INISyntaxError', + 'INIInvalidGroupHeader', + 'INIInvalidKeyValueLine', + 'JSONSyntaxError', + 'JSONValueSyntaxError', + 'JSONNumberSyntaxError', + 'JSONStringSyntaxError', + 'JSONBooleanSyntaxError', + 'JSONNullSyntaxError', + 'JSONArraySyntaxError', + 'JSONObjectSyntaxError', + 'ConfigurationTypeError', + 'JSONTypeError' +] + +class ConfigurationSyntaxError(Exception): ... + +class EscapeSequenceSyntaxError(ConfigurationSyntaxError): ... + +class INISyntaxError(ConfigurationSyntaxError): ... + +class INIInvalidGroupHeader(INISyntaxError): ... +class INIInvalidKeyValueLine(INISyntaxError): ... + + +class JSONSyntaxError(ConfigurationSyntaxError): ... + +class JSONValueSyntaxError(JSONSyntaxError): ... + +class JSONNumberSyntaxError(JSONSyntaxError): ... +class JSONStringSyntaxError(JSONSyntaxError): ... +class JSONBooleanSyntaxError(JSONSyntaxError): ... +class JSONNullSyntaxError(JSONSyntaxError): ... +class JSONArraySyntaxError(JSONSyntaxError): ... +class JSONObjectSyntaxError(JSONSyntaxError): ... + + +class ConfigurationTypeError(TypeError): ... + +class JSONTypeError(ConfigurationTypeError): ... \ No newline at end of file diff --git a/src/jcloud_config_parser/ini.py b/src/jcloud_config_parser/ini.py new file mode 100644 index 0000000..6fecfa7 --- /dev/null +++ b/src/jcloud_config_parser/ini.py @@ -0,0 +1,111 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations +import typing +from ._configuration import Configuration +from .parse.ini import _COMMENT_PREFIXES, _QUOTATION_MARKS, parse_ini +from .serialize.ini import serialize as serialize_ini + +__all__ = [ + 'INIConfiguration', + 'INIConfigurationSection' +] + +class INIConfiguration(Configuration): + def __iter__(self): + return iter({k: dict(v) if isinstance(v, INIConfigurationSection) else v for k, v in self._config.items()}.items()) + + @classmethod + def from_string(cls, data: str | bytes, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, quotation_marks: typing.Collection[str] = _QUOTATION_MARKS, ignore_errors: bool = False, default: INIConfiguration = None): + ''' + Parses INI configuration from a string and returns an instance of INIConfiguration. + + :param cls: The class method is called on. + :type cls: typing.Type[INIConfiguration] + :param data: The INI configuration data to parse. + :type data: str | bytes + :param comment_prefixes: A collection of prefixes that denote comments in the INI file. + :type comment_prefixes: typing.Collection[str] + :param quotation_marks: A collection of characters used for quoting values in the INI file. + :type quotation_marks: typing.Collection[str] + :param ignore_errors: If True, parsing errors will be ignored. + :type ignore_errors: bool + :param default: The default configuration. + :type default: INIConfiguration + + :raises INISyntaxError: If there is a syntax error in the INI configuration and ``ignore_errors`` is ``False``. + + :return: An instance of INIConfiguration representing the parsed INI configuration. + :rtype: INIConfiguration + ''' + if isinstance(data, bytes): + data = data.decode() + + configuration = cls() + for section_name, content in parse_ini(data, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors).items(): + group = INIConfigurationSection(section_name) + for key, value in content.items(): + group[key] = value + configuration[section_name] = group + + if default is not None: + for section in default._config: + if section not in configuration: + configuration[section] = INIConfigurationSection(section) + for propk, propv in default[section]._config.items(): + if propk not in dict(configuration[section]).keys(): + configuration[section][propk] = propv + + return configuration + + def to_string(self, separator: str = '='): + ''' + Serializes the INIConfiguration instance to an INI formatted string. + + :param separator: The separator to use between keys and values in the output string. + :type separator: str + + :return: An INI formatted string representing the INIConfiguration instance. + :rtype: str + ''' + + return serialize_ini(self._config, separator) + +class INIConfigurationSection(INIConfiguration): + def __init__(self, name: str): + ''' + A class representing a group in an INI configuration. + + :param name: The name of the group. + :type name: str + ''' + super().__init__() + self._name = name + + @property + def name(self): + return self._name + + def to_string(self, separator: str = '='): + ''' + Serializes the INIConfigurationGroup instance to an INI formatted string. + + :param separator: The separator to use between keys and values in the output string. + :type separator: str + + :return: An INI formatted string representing the INIConfigurationGroup instance. + :rtype: str + ''' + return ('[' + self._name + ']\n' + super().to_string(separator=separator)).strip() \ No newline at end of file diff --git a/src/jcloud_config_parser/json.py b/src/jcloud_config_parser/json.py new file mode 100644 index 0000000..e007209 --- /dev/null +++ b/src/jcloud_config_parser/json.py @@ -0,0 +1,73 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._configuration import Configuration +from .parse.json import parse_type, JSONObject, JSONString, JSONNumber, JSONNull, JSONBoolean, JSONArray +from .exceptions import JSONTypeError + +__all__ = [ + 'JSONConfiguration' +] + +def _configuration(data): + if isinstance(data, str): + return JSONString(data) + if isinstance(data, (int, float)): + return JSONNumber(data) + if data is None: + return JSONNull(None) + if isinstance(data, bool): + return JSONBoolean(data) + if isinstance(data, list): + return JSONArray(data) + if isinstance(data, dict): + return JSONObject(data) + +class JSONConfiguration(Configuration): + def __iter__(self) -> iter: + return iter({k: v.value for k, v in self._config.items()}.items()) + + @classmethod + def from_string(cls, data: str | bytes, ignore_errors: bool = False): + ''' + Parses JSON configuration from a string and returns an instance of JSONConfiguration. + + :param data: The JSON data + :type data: str + :param ignore_errors: If True, errors will be ignored. + :type ignore_errors: bool + + :raises JSONValueSyntaxError: If a value is invalid and ``ignore_errors`` is ``False``. + :raises JSONObjectSyntaxError: If an object is invalid and ``ignore_errors`` is ``False``. + :raises JSONArraySyntaxError: If an array is invalid and ``ignore_errors`` is ``False``. + :raises JSONStringSyntaxError: If a string is invalid and ``ignore_errors`` is ``False``. + :raises JSONNullSyntaxError: If a null value is invalid and ``ignore_errors`` is ``False``. + :raises JSONBooleanSyntaxError: If a null value is invalid and ``ignore_errors`` is ``False``. + :raises JSONNumberSyntaxError: If a number is invalid and ``ignore_errors`` is ``False``. + :raises EscapeSequenceSyntaxError: If an escape sequence is invalid and ``ignore_errors`` is ``False``. + + :return: An instance of JSONConfiguration representing the parsed JSON configuration + :rtype: JSONConfiguration + ''' + try: + data = parse_type(data).parse(data) + if isinstance(data, JSONObject): + return cls({k: _configuration(v) for k, v in data.value.items()}) + elif not ignore_errors: + raise JSONTypeError(f'expected object, got {data._type}. Use jcloud_config_parser.parse.json.parse_json to parse JSONs.') + except: + if ignore_errors: + return cls() + else: + raise \ No newline at end of file diff --git a/src/jcloud_config_parser/parse/__init__.py b/src/jcloud_config_parser/parse/__init__.py new file mode 100644 index 0000000..fd284e3 --- /dev/null +++ b/src/jcloud_config_parser/parse/__init__.py @@ -0,0 +1,29 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +''' +The parsers + +Modules: +- ini: INI parser. +- json: JSON parser. +''' + +from . import json +from . import ini + +__all__ = [ + 'json', + 'ini' +] \ No newline at end of file diff --git a/src/jcloud_config_parser/parse/ini.py b/src/jcloud_config_parser/parse/ini.py new file mode 100644 index 0000000..b3d9fd2 --- /dev/null +++ b/src/jcloud_config_parser/parse/ini.py @@ -0,0 +1,173 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing +from ..exceptions import INIInvalidGroupHeader, INIInvalidKeyValueLine, INISyntaxError + +__all__ = [ + 'parse_ini' +] + +_QUOTATION_MARKS = ['"', "'"] +_COMMENT_PREFIXES = ['#', ';'] +_KEY_VALUE_LINE = 0 +_GROUP_HEADER_LINE = 1 +_COMMENT_LINE = 2 + +def _reverse_dict(d: typing.Dict) -> typing.Dict: + ''' + Reverses the order of items in a dictionary. + + :param d: The dictionary to reverse. + :type d: typing.Dict + + :return: A new dictionary with the items in reversed order. + :rtype: typing.Dict + ''' + return {k: v for k, v in list(reversed(d.items()))} + +def _parse_key_value_line(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, quotation_marks: typing.Collection[str] = _QUOTATION_MARKS, ignore_errors: bool = False) -> typing.Dict[str, str]: + ''' + Parses a key-value line. + + :param line: The key-value-line + :type line: str + :param comment_prefixes: A collection of prefixes that denote comments in the INI file. + :type comment_prefixes: typing.Collection[str] + :param quotation_marks: A collection of characters used for quoting values in the INI file. + :type quotation_marks: typing.Collection[str] + :param ignore_errors: If True, errors are ignored. + :type ignore_errors: bool + + :raises INIInvalidKeyValueLine: If the key-value line is invalid and ignore_errors is False. + + :return: A dictionary with the key and the value (`{'key': 'value'}`) or an empty dictionary if an error has occured and ignore_errors is True. + :rtype: Dict[str, str] + ''' + separator = None + if ':' in line and '=' not in line: + separator = ':' + if '=' in line and ':' not in line: + separator = '=' + if ':' in line and '=' in line: + separator = '=' if line.index('=') < line.index(':') else ':' + if separator is None: + if ignore_errors: + return {} + raise INIInvalidKeyValueLine('the key-value line is invalid') + key = line.split(separator)[0].strip() + value = separator.join(line.split(separator)[1:]).strip() + + if any(value.endswith(mark) and not value.startswith(mark) for mark in quotation_marks): + if ignore_errors: + return {} + raise INISyntaxError('unterminated literal') + + if any(value.startswith(mark) for mark in quotation_marks): + if value[0] == value[-1]: + value = value[1:-1] + else: + if ignore_errors: + return {} + raise INISyntaxError('unterminated literal') + else: + for prefix in comment_prefixes: + if prefix in value: + value = value.split(prefix)[0].strip() + + return {key: value} + +def _parse_group_header(line: str, ignore_errors: bool = False) -> str: + ''' + Parses a group header line. + + :param line: The group header line to parse. + :type line: str + :param ignore_errors: If True, errors are ignored. + :type ignore_errors: bool + + :raises INIInvalidGroupHeader: If the group header is invalid and ignore_errors is False. + + :return: The name of the group. + :rtype: str + ''' + if line.startswith('[') and line.endswith(']') and len(line) > 2: + return line[1:-1] + else: + if ignore_errors: + return '' + raise INIInvalidGroupHeader('the header is invalid') + +def _parse_line_type(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, ignore_errors: bool = False) -> int: + ''' + Returns the type of a line. + + :param line: The line to check. + :type line: str + :param comment_prefixes: A collection of prefixes that denote comments in the INI file. + :type comment_prefixes: typing.Collection[str] + :param ignore_errors: If True, errors are ignored. + :type ignore_errors: bool + + :raises INISyntaxError: If the line is invalid and ignore_errors is False. + + :return: The type of the line (COMMENT_LINE, GROUP_HEADER_LINE, or KEY_VALUE_LINE). + :rtype: int + ''' + line = line.strip() + if any(line.startswith(prefix) for prefix in comment_prefixes) or not line.strip(): + return _COMMENT_LINE + elif line.startswith('[') and line.endswith(']') and len(line) > 2: + return _GROUP_HEADER_LINE + elif '=' in line or ':' in line: + return _KEY_VALUE_LINE + else: + if ignore_errors: + return _COMMENT_LINE + raise INISyntaxError('the line is invalid') + +def _compress_conf(conf): + while '\n\n' in conf: + conf = conf.replace('\n\n', '\n') + return conf.strip() + +def parse_ini(conf, comment_prefixes=_COMMENT_PREFIXES, quotation_marks=_QUOTATION_MARKS, ignore_errors: bool = False, global_group: bool = True) -> typing.Dict[str, typing.Dict[str, str]]: + '''Parses INI configuration from a string and returns a nested dictionary. + +:param conf: The INI configuration string to parse. +:param comment_prefixes: A collection of prefixes that denote comments in the INI file. +:param quotation_marks: A collection of characters used for quoting values in the INI file. +:param ignore_errors: If True, parsing errors will be ignored. +:param global_group: If True, key-value pairs outside of any group will be included. Otherwise, an exception will be raised. + +:return: A nested dictionary representing the parsed INI configuration. + +:raises INISyntaxError: If there is a syntax error in the INI configuration and ignore_errors is False. + +:rtype: typing.Dict[str, typing.Dict[str, str]] +''' + + conf = _compress_conf(conf) + result = {} + current_group = {} + for line in list(reversed(conf.split('\n'))): + line = line.strip() + if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _COMMENT_LINE: + continue + if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _GROUP_HEADER_LINE: + result[_parse_group_header(line, ignore_errors=ignore_errors)] = _reverse_dict(current_group) + current_group = {} + if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _KEY_VALUE_LINE: + current_group = current_group | _parse_key_value_line(line, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors) + return _reverse_dict(result) \ No newline at end of file diff --git a/src/jcloud_config_parser/parse/json.py b/src/jcloud_config_parser/parse/json.py new file mode 100644 index 0000000..24b990f --- /dev/null +++ b/src/jcloud_config_parser/parse/json.py @@ -0,0 +1,616 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ..exceptions import EscapeSequenceSyntaxError, JSONValueSyntaxError, JSONNumberSyntaxError, JSONStringSyntaxError, JSONBooleanSyntaxError, JSONNullSyntaxError, JSONArraySyntaxError, JSONObjectSyntaxError +import typing +import re + +__all__ = [ + 'JSONType', + 'JSONNumber', + 'JSONString', + 'JSONBoolean', + 'JSONNull', + 'JSONArray', + 'JSONObject', + 'parse_escape_sequences', + 'parse_type', + 'parse_json' +] + +_JSON_ESCAPE_SEQUENCES = { + '"': '"', + '\\': '\\', + '/': '/', + 'b': '\b', + 'f': '\f', + 'n': '\n', + 'r': '\r', + 't': '\t', +} +_JSON_NUMBER_RE = re.compile( + r""" + -? # optional minus + (?:0|[1-9][0-9]*) # integer part + (?:\.[0-9]+)? # optional fraction + (?:[eE][+-]?[0-9]+)? # optional exponent + \Z + """, + re.VERBOSE, +) + +class JSONType: + def __init__(self, value): + self.value = value + + def __repr__(self): + return self.value.__repr__() + +class JSONNumber(JSONType): + @property + def _type(self): + return 'number' + + @classmethod + def parse(cls, value: str) -> typing.Union[int, float]: + ''' + Parses a JSON number value. + + :param value: The value + :type value: str + + :raises JSONNumberSyntaxError: If the number is not a valid JSON number. + :raises JSONTypeError: If the value is not a number. + + :return: The number in ``value`` + :rtype: JSONNumber + ''' + + value = value.strip() + + if not isinstance(value, str): + raise TypeError('JSON numbers must be provided as strings') + + if not _JSON_NUMBER_RE.match(value): + raise JSONNumberSyntaxError(f'Invalid JSON number: {value!r}') + + if '.' not in value and 'e' not in value and 'E' not in value: + return cls(int(value)) + + return cls(float(value)) + +class JSONString(JSONType): + @property + def _type(self): + return 'string' + + @classmethod + def parse(cls, value: str): + ''' + Parses a JSON string value. + + :param value: The value + :type value: str + + :raises JSONStringSyntaxError: If the string is not a valid JSON string. + + :return: The string in ``value`` + :rtype: JSONString + ''' + + value = value.strip() + + if not (value[0] == '"' and value[-1] == '"' and value.count('"') - value.count('\\"') == 2): + raise JSONStringSyntaxError(f'Invalid JSON string (JSON string have to start and end with quotation marks): {value}') + else: + return cls(parse_escape_sequences(value[1:-1])) + +class JSONBoolean(JSONType): + @property + def _type(self): + return 'boolean' + + @classmethod + def parse(cls, value: str): + ''' + Parses a JSON boolean value. + + :param value: The value + :type value: str + + :raises JSONBooleanSyntaxError: If the boolean is neither ``true`` nor ``false``. + + :return: The boolean in ``value`` + :rtype: JSONBoolean + ''' + + value = value.strip() + + if value in ('true', 'false'): + return cls(True if value == 'true' else False) + else: + raise JSONBooleanSyntaxError(f'Invalid JSON boolean: {value}') + +class JSONNull(JSONType): + @property + def _type(self): + return 'null' + + @classmethod + def parse(cls, value: str): + ''' + Parses a JSON null value. + + :param value: The value + :type value: str + + :raises JSONNullSyntaxError: If the null value is not ``null``. + + :return: The null value + :rtype: JSONNull + ''' + + value = value.strip() + + if value == 'null': + return cls(None) + else: + raise JSONNullSyntaxError(f'Invalid JSON null value: {value}') + +class JSONArray(JSONType): + @property + def _type(self): + return 'array' + + @classmethod + def parse(cls, value: str): + ''' + Parses a JSON array. + + :param value: The array + :type value: str + + :raises JSONArraySyntaxError: If the array is invalid. + :raises JSONValueSyntaxError: If a value is invalid + :raises JSONObjectSyntaxError: If an object is invalid. + :raises JSONStringSyntaxError: If a string is invalid. + :raises JSONNullSyntaxError: If a null value is invalid. + :raises JSONBooleanSyntaxError: If a null value is invalid. + :raises JSONNumberSyntaxError: If a number is invalid. + :raises EscapeSequenceSyntaxError: If an escape sequence is invalid. + + :return: The array as a dictionary + :rtype: dict + ''' + + value = value.strip() + + value_type = parse_type(value) + if value_type != JSONArray: + raise JSONValueSyntaxError('expected an array, got', value_type._type) + + string = False + arrays = 0 + objects = 0 + current_value = '' + escape_sequence = False + _array = [] + + for c in value[1:-1]: + + if c == '"' and not escape_sequence: + string = not string + + if c == '[' and not string: + arrays += 1 + + if c == ']' and not string: + arrays -= 1 + + if c == '{' and not string: + objects += 1 + + if c == '}' and not string: + objects -= 1 + + current_value += c + + if string and c == '\\': + if not escape_sequence: + escape_sequence = True + else: + escape_sequence = False + else: + escape_sequence = False + + if c == ',' and not string and not arrays and not objects: + _array.append(current_value[:-1]) + current_value = '' + + if current_value: + _array.append(current_value) + + array = [] + for e in _array: + array.append(parse_type(e).parse(e).value) + + return cls(array) + +class JSONObject(JSONType): + @property + def _type(self): + return 'object' + + @classmethod + def parse(cls, value: str) -> str: + ''' + Parses a JSON object. + + :param value: The value + :type value: str + + :raises JSONObjectSyntaxError: If the object is invalid. + :raises JSONValueSyntaxError: If a value is invalid + :raises JSONArraySyntaxError: If an array is invalid. + :raises JSONStringSyntaxError: If a string is invalid. + :raises JSONNullSyntaxError: If a null value is invalid. + :raises JSONBooleanSyntaxError: If a null value is invalid. + :raises JSONNumberSyntaxError: If a number is invalid. + :raises EscapeSequenceSyntaxError: If an escape sequence is invalid. + + :return: The object as a dictionary + :rtype: dict + ''' + + value = value.strip() + + value_type = parse_type(value) + if value_type != JSONObject: + raise JSONValueSyntaxError('expected an object, got', value._type) + + + string = False + current_key = '' + current_value = None + arrays = 0 + objects = 0 + escape_sequence = False + _object = {} + + for c in value[1:-1]: + + if c == '"' and not escape_sequence: + string = not string + + if c == '[' and not string: + arrays += 1 + + if c == ']' and not string: + arrays -= 1 + + if c == '{' and not string: + objects += 1 + + if c == '}' and not string: + objects -= 1 + + if current_value is None: + current_key += c + else: + current_value += c + + if string and c == '\\': + if not escape_sequence: + escape_sequence = True + else: + escape_sequence = False + else: + escape_sequence = False + + if not string and c == ':' and not arrays and not objects: + current_key = current_key[:-1] + current_value = '' + + if not string and c == ',' and not arrays and not objects: + if current_value is not None: + _object[current_key] = current_value[:-1] + current_key = '' + current_value = None + else: + raise JSONObjectSyntaxError(f'expected \':\'') + + if current_key: + if current_value is not None: + _object[current_key] = current_value + current_key = '' + current_value = None + else: + raise JSONObjectSyntaxError(f'expected value') + + json_object = {} + for k, v in _object.items(): + json_object[parse_type(k).parse(k).value] = parse_type(v).parse(v).value + + return cls(json_object) + +def parse_escape_sequences(string: str) -> bool: + ''' + Returns the unescaped string. + + :param string: The string to validate + :type string: str + + :return: The unescaped string + :rtype: str + ''' + + result = '' + unicode_escape_sequence = None + escape_sequence = False + for c in string: + if c == '\\' and not escape_sequence: + escape_sequence = True + continue + + + if unicode_escape_sequence is not None: + if len(unicode_escape_sequence) == 4: + result += chr(int(unicode_escape_sequence, 16)) + unicode_escape_sequence = None + else: + if c.lower() not in '0123456789abcdef': + raise EscapeSequenceSyntaxError(f'Invalid unicode escape sequence in JSON string: \\u{unicode_escape_sequence}{c}') + unicode_escape_sequence += c + + if not escape_sequence and not unicode_escape_sequence: + result += c + + if escape_sequence: + escape_sequence = False + if c not in ('"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u'): + raise EscapeSequenceSyntaxError(f'Invalid escape sequence in JSON string: \\{c}') + if c == 'u': + unicode_escape_sequence = '' + else: + result += _JSON_ESCAPE_SEQUENCES[c] + + if unicode_escape_sequence: + raise EscapeSequenceSyntaxError(f'Invalid unicode escape sequence in JSON string: \\u{unicode_escape_sequence}') + return result + +def parse_type(value: str) -> typing.Type[JSONType]: + ''' + Parses the type of the value. + + :param value: The value + :type value: str + + :raises JSONValueSyntaxError: If a value is invalid + :raises JSONObjectSyntaxError: If an object is invalid. + :raises JSONArraySyntaxError: If an array is invalid. + :raises JSONStringSyntaxError: If a string is invalid. + :raises JSONNullSyntaxError: If a null value is invalid. + :raises JSONBooleanSyntaxError: If a null value is invalid. + :raises JSONNumberSyntaxError: If a number is invalid. + :raises EscapeSequenceSyntaxError: If an escape sequence is invalid. + + :return: The JSON type + :rtype: type + ''' + + value = value.strip() + + if not value: + raise JSONValueSyntaxError('the value cannot be empty') + + if value[0] == '{': + if value[-1] != '}': + raise JSONArraySyntaxError(f'an object has to be closed with \'}}\', but found \'{value[-1]}\'') + + string = False + current_key = '' + current_value = None + arrays = 0 + objects = 0 + escape_sequence = False + + for c in value[1:-1]: + terminating_quotation_mark = False + + if c == '"' and not escape_sequence: + if string: + terminating_quotation_mark = True + string = not string + + if c == '[' and not string: + arrays += 1 + + if c == ']' and not string: + arrays -= 1 + if arrays < 0: + raise JSONObjectSyntaxError('unexpected char: \']\'') + + if c == '{' and not string: + objects += 1 + + if c == '}' and not string: + objects -= 1 + if objects < 0: + raise JSONObjectSyntaxError('unexpected char: \'}\'') + + if not string and not terminating_quotation_mark and not arrays and not objects: + if c.strip().lower() not in '0123456789abcdefghijklmnopqrstuvwxyz,:[]{}.-' + ('"' if escape_sequence else ''): + raise JSONObjectSyntaxError(f'unexpected char: \'{c}\'') + + if current_value is None: + current_key += c + else: + current_value += c + + if string and c == '\\': + if not escape_sequence: + escape_sequence = True + else: + escape_sequence = False + else: + escape_sequence = False + + if not string and c == ':' and not arrays and not objects: + current_key = current_key[:-1] + if current_value is None: + current_value = '' + else: + raise JSONObjectSyntaxError(f'expected \',\'') + + if not string and c == ',' and not arrays and not objects: + if current_value is not None: + if parse_type(current_key) != JSONString: + raise JSONObjectSyntaxError(f'keys have to be strings') + parse_type(current_value[:-1]) + current_key = '' + current_value = None + else: + raise JSONObjectSyntaxError(f'expected \':\'') + + if current_key: + if current_value is not None: + if parse_type(current_key) != JSONString: + raise JSONObjectSyntaxError(f'keys have to be strings') + parse_type(current_value) + current_key = '' + current_value = None + else: + raise JSONObjectSyntaxError(f'expected value') + + if string: + raise JSONStringSyntaxError('unterminated string literal') + + if value[-2] == ',': + raise JSONObjectSyntaxError('expected value after \',\'') + + return JSONObject + + elif value[0] == '[': + if value[-1] != ']': + raise JSONArraySyntaxError(f'an array has to be closed with \']\', but found \'{value[-1]}\'') + + string = False + arrays = 0 + objects = 0 + current_value = '' + escape_sequence = False + + for c in value[1:-1]: + terminating_quotation_mark = False + + if c == '"' and not escape_sequence: + if string: + terminating_quotation_mark = True + string = not string + + if c == '[' and not string: + arrays += 1 + + if c == ']' and not string: + arrays -= 1 + if arrays < 0: + raise JSONArraySyntaxError('unexpected char: \']\'') + + if c == '{' and not string: + objects += 1 + + if c == '}' and not string: + objects -= 1 + if objects < 0: + raise JSONArraySyntaxError('unexpected char: \'}\'') + + if (not string) and (not terminating_quotation_mark) and (not arrays) and (not objects): + if c.strip().lower() not in '0123456789abcdefghijklmnopqrstuvwxyz,[]{}.-' + ('"' if escape_sequence else ''): + raise JSONArraySyntaxError(f'unexpected char: \'{c}\'') + + current_value += c + + if string and c == '\\': + if not escape_sequence: + escape_sequence = True + else: + escape_sequence = False + else: + escape_sequence = False + + if c == ',' and not string and not arrays and not objects: + parse_type(current_value[:-1]) + current_value = '' + + if string: + raise JSONStringSyntaxError('unterminated string literal') + + if value[-2] == ',': + raise JSONArraySyntaxError('expected value after \',\'') + + if current_value: + parse_type(current_value) + + return JSONArray + + elif value[0] == '"': # string + if value[0] != '"': + raise JSONStringSyntaxError(f'expected \'"\' as first char, got \'{value[0]}\'') + if '\n' in value: + raise JSONStringSyntaxError(f'line feeds are not allowed inside of strings. Use escape sequences.') + if value.count('"') < 2: + raise JSONStringSyntaxError('unterminated string literal') + if value.count('"') - value.count('\\"') > 2 or value[-1] != '"': + raise JSONStringSyntaxError(f'unexpected token: \'{"\"".join(value.split("\"")[2:])}\'') + parse_escape_sequences(value) + return JSONString + + elif value in ('null', 'false', 'true'): # null or boolean + if value == 'null': # null + return JSONNull + else: # boolean + return JSONBoolean + + elif _JSON_NUMBER_RE.match(value): # number + try: + float(value) + return JSONNumber + except: + raise JSONNumberSyntaxError(f'invalid number: {value}') + + else: + raise JSONValueSyntaxError(f'unexpected token: \'{value}\'') + +def parse_json(json: str) -> dict | list | str | int | float | None | bool: + ''' + Parses the JSON. + + :param json: The JSON data + :type json: str + + :raises JSONValueSyntaxError: If a value is invalid + :raises JSONObjectSyntaxError: If an object is invalid. + :raises JSONArraySyntaxError: If an array is invalid. + :raises JSONStringSyntaxError: If a string is invalid. + :raises JSONNullSyntaxError: If a null value is invalid. + :raises JSONBooleanSyntaxError: If a null value is invalid. + :raises JSONNumberSyntaxError: If a number is invalid. + :raises EscapeSequenceSyntaxError: If an escape sequence is invalid. + + :return: The JSON as a Python object (``dict``, ``list``, ``str``, ``int``/``float``, ``boolean``, ``None``) + :rtype: dict | list | str | int | float | boolean | None + ''' + + json_type = parse_type(json) + return json_type.parse(json).value \ No newline at end of file diff --git a/src/jcloud_config_parser/serialize/__init__.py b/src/jcloud_config_parser/serialize/__init__.py new file mode 100644 index 0000000..a89b6e0 --- /dev/null +++ b/src/jcloud_config_parser/serialize/__init__.py @@ -0,0 +1,29 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +''' +The serializers + +Modules: +- ini: INI serializer. +- json: JSON serializer. +''' + +from . import json +from . import ini + +__all__ = [ + 'json', + 'ini' +] \ No newline at end of file diff --git a/src/jcloud_config_parser/serialize/ini.py b/src/jcloud_config_parser/serialize/ini.py new file mode 100644 index 0000000..09f3863 --- /dev/null +++ b/src/jcloud_config_parser/serialize/ini.py @@ -0,0 +1,39 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__all__ = [ + 'serialize' +] + +def serialize(data: dict, separator: str) -> str: + ''' + Serializes the data to an INI formatted string. + + :param data: The INI configuration. + :type data: dict + + :param separator: The separator. + :type separator: str + + :return: The INI formatted string. + :rtype: str + ''' + + result = '' + for key, value in data.items(): + if hasattr(value, '_name'): + result += value.to_string(separator=separator) + else: + result += f'{key}{separator}{value}\n' + return result.strip() \ No newline at end of file diff --git a/src/jcloud_config_parser/serialize/json.py b/src/jcloud_config_parser/serialize/json.py new file mode 100644 index 0000000..cd7a05f --- /dev/null +++ b/src/jcloud_config_parser/serialize/json.py @@ -0,0 +1,152 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +from typing import * +import collections.abc + +__all__ = [ + 'serialize' +] + +_ESCAPE_SEQUENCES = { + '\"': r'\"', + '\\': r'\\', + '\b': r'\b', + '\f': r'\f', + '\n': r'\n', + '\r': r'\r', + '\t': r'\t', +} + +class _Mapper: + def __init__(self, mapping = {}): + self._mapping = copy.deepcopy(mapping) + + def _set(self, key, value): + self._mapping[key] = value + + def set(self, key, value): + self._set(key, value) + return self + + def map(self, value): + return self._mapping[value] + +_MAPPER = _Mapper().set(True, 'true').set(False, 'false').set(None, 'null') + +def _serialize_object(data: dict, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + result = '{' + _count = 0 + for _key, value in data.items(): + _count += 1 + key = _serialize(_key, 0, 0, '', separators) + value = _serialize(value, level + 1, indent, indent_char, separators) + if not isinstance(_key, str): + key = '"' + key + '"' + if indent: + result += '\n' + result += (level + 1) * indent * indent_char + result += key + result += separators[1] + result += value + if _count != len(data.keys()): + if indent: + result += separators[0].strip() + else: + result += separators[0] + if indent: + result += '\n' + result += level * indent * indent_char + '}' + return result + +def _serialize_array(data: Sequence, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + result = '[' + _count = 0 + for value in data: + _count += 1 + value = _serialize(value, level + 1, indent, indent_char, separators) + if indent: + result += '\n' + result += (level + 1) * indent * indent_char + result += value + if _count != len(data): + if indent: + result += separators[0].strip() + else: + result += separators[0] + if indent: + result += '\n' + result += level * indent * indent_char + ']' + return result + +def _serialize_string(data: str) -> str: + _data = data + data = '' + for c in _data: + data += _ESCAPE_SEQUENCES.get(c, c) + return '"' + data + '"' + +def _serialize_number(data: int | float) -> str: + return str(data) + +def _serialize(data: dict | list | str | int | float | bool | None, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + if isinstance(data, (bool, type(None))): + return _MAPPER.map(data) + elif isinstance(data, dict): + return _serialize_object(data, level, indent, indent_char, separators) + elif isinstance(data, str): + return _serialize_string(data) + elif isinstance(data, (int, float)): + return _serialize_number(data) + elif isinstance(data, collections.abc.Sequence): + return _serialize_array(data, level, indent, indent_char, separators) + else: + raise TypeError(f'Object of type {type(data).__name__} is not JSON serializable') + +def _char_whitespaces(whitespaces: Tuple[int | str, int | str] | int | str) -> Tuple[str, str]: + if isinstance(whitespaces, collections.abc.Sequence) and not isinstance(whitespaces, str): + if len(whitespaces) != 2: + raise ValueError(f'expected exactly two elements in whitespaces, got {len(whitespaces)}') + if isinstance(whitespaces, (str, int)): + whitespaces = whitespaces, whitespaces + if isinstance(whitespaces[0], int): + whitespaces = whitespaces[0] * ' ', whitespaces[1] + if isinstance(whitespaces[1], int): + whitespaces = whitespaces[0], whitespaces[1] * ' ' + return whitespaces + +def serialize(data: dict | list | str | int | float | bool | None, indent: int = 0, indent_char: str = ' ', separators: Tuple[str, str] = (', ', ': ')) -> str: + ''' + Serializes the data to a JSON formatted string. + + :param data: The data. + :type data: dict | list | str | int | float | bool | None + :param indent: The indentation + :type indent: int + :param indent_char: The character the indentation will be filled with + :type indent_char: str + :param separators: A tuple with the separators. The first string is the element separator and the second string is the key value separator. + :type separators: tuple + + :raises TypeError: If the type is not JSON serializable. + + :return: The JSON formatted string. + :rtype: str + ''' + + if len(separators) != 2: + raise ValueError(f'expected exactly two elements in separators, got {len(separators)}.') + + return _serialize(data, 0, indent, indent_char, separators) \ No newline at end of file diff --git a/tests/ini/test_ini.py b/tests/ini/test_ini.py new file mode 100644 index 0000000..5e969d8 --- /dev/null +++ b/tests/ini/test_ini.py @@ -0,0 +1,97 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_config_parser.ini import INIConfiguration, INIConfigurationSection +from src.jcloud_config_parser.exceptions import INISyntaxError +from src.jcloud_config_parser import set_mutability + +def test_generating(): + config = INIConfiguration() + config.test_group = INIConfigurationSection('test_group') + config.test_group.key1 = 'value1' + config.test_group.key2 = 'value2' + assert config.to_string() == '''[test_group] +key1=value1 +key2=value2''' + +def test_parsing(): + # Test deserializer + config_string = ''' +property1 = 42 +property2=123 + +[configuration_group1] +hello=world +key1=value1 + +[group2] +world=hel#lo +key2 = "val#ue2" +[] +hello2=world2 +invalid_line1 + +invalid_line2 + +# comment''' + + assert dict(INIConfiguration.from_string(config_string, ignore_errors=True)) == {'configuration_group1': {'hello': 'world', 'key1': 'value1'}, 'group2': {'world': 'hel', 'key2': 'val#ue2', 'hello2': 'world2'}} + + try: + INIConfiguration.from_string('key="value') + assert False, 'Expected INISyntaxError for unterminated literal' + except INISyntaxError: + pass + + try: + INIConfiguration.from_string('key=value"') + assert False, 'Expected INISyntaxError for unterminated literal' + except INISyntaxError: + pass + +def test_default(): + default_configuration = INIConfiguration.from_string('''[section1] +k1=v1 +k2=v2 +k3=v3 + +[section2] +l1=w1 +l2=w2''') + assert dict(INIConfiguration.from_string('''[section1] +k1=v1 +k2=v2 +k4=v4''', default = default_configuration)) == {'section1': {'k1': 'v1', 'k2': 'v2', 'k3': 'v3', 'k4': 'v4'}, 'section2': {'l1': 'w1', 'l2': 'w2'}} + +def test_mutability(): + config = INIConfiguration(mutable=False) + try: + config.key = 'value' + assert False, 'Expected TypeError' + except TypeError: + pass + + set_mutability(config, True) + assert config._mutable == True + + config.key = 'value' + + set_mutability(config, False) + assert config._mutable == False + + try: + del config.key + assert False, 'Expected TypeError' + except TypeError: + pass \ No newline at end of file diff --git a/tests/json/test.json b/tests/json/test.json new file mode 100644 index 0000000..ecdbf19 --- /dev/null +++ b/tests/json/test.json @@ -0,0 +1,168 @@ +{ + "level_1": { + "meta": { + "version": "1.0", + "generated": true, + "tags": ["test", "deep", "nested", "json"] + }, + "level_2": { + "array": [ + { + "id": 1, + "level_3": { + "level_4": { + "level_5": { + "config": { + "enabled": true, + "thresholds": { + "low": 0.1, + "medium": 0.5, + "high": 0.9 + }, + "modes": [ + { + "name": "alpha", + "params": { + "retry": 3, + "timeout": { + "connect": 1000, + "read": 5000, + "deep": { + "even_deeper": { + "flag": false, + "notes": [ + "still", + "going", + { + "deeper": { + "than": { + "most": { + "humans": { + "expect": { +"value": 42 + } + } + } + } + } + } + ] + } + } + } + } + } + ] + } + } + } + } + } + ], + "level_2_object": { + "a": { + "b": { + "c": { + "d": { + "e": { + "f": { + "g": { + "h": { + "i": { + "j": { + "k": "bottom" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "level3": { + "root": [ + [ + [ + [ + { + "a": [ + { + "b": [ + { + "c": [ + { + "d": [ + { "e": [ + { "f": "bottom" +} + ] + } + ] + } + ] + } + ] + } + ] + } + ] + ] + ] + ] +} + , "4": { + "level": { + "level": { + "level": { + "level": { + "level": { + "items": [ + {"x": {"y": {"z": [1,2,3,4,5]}}}, + {"x": {"y": {"z": [1,2,3,4,5]}}}, + {"x": {"y": {"z": [1,2,3,4,5]}}}, + {"x": {"y": {"z": [1,2,3,4,5]}}}, + {"x": {"y": {"z": [1,2,3,4,5]}}} + ] + } + } + } + } + } +} +, +"0":{ + "n0": { + "n1": { + "n2": { + "n3": { + "n4": { + "n5": { + "n6": { "n7": { "n8": { "n9": { "n10": { "n11": { "n12": { + "n13": { +"n14": { +"n15": { +"n16": { + "n17": { + "n18": { "n19": { "n20": "bottom" + } } } + } + } }} + } + } + } + } } + } + } + } + } + }} + } + } +} + +} \ No newline at end of file diff --git a/tests/json/test_json.py b/tests/json/test_json.py new file mode 100644 index 0000000..11d1e98 --- /dev/null +++ b/tests/json/test_json.py @@ -0,0 +1,246 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_config_parser.json import JSONConfiguration +from src.jcloud_config_parser.exceptions import JSONObjectSyntaxError, JSONValueSyntaxError, JSONStringSyntaxError, JSONArraySyntaxError, EscapeSequenceSyntaxError +from src.jcloud_config_parser import set_mutability + +def test_json_configuration(): + # Test valid JSON configuration parsing + assert dict(JSONConfiguration.from_string('{"key1": "value1", "key2": 42, "key3": true, "key4": {"k1": 42, "k2": [1, null, true]}}')) == {"key1": "value1", "key2": 42, "key3": True, "key4": {"k1": 42, "k2": [1, None, True]}} + assert dict(JSONConfiguration.from_string('''{ + "name": "John Doe", + "active": true, + "score": 99.5, + "roles": ["admin", "developer"], + "meta": null +}''')) == {'name': 'John Doe', 'active': True, 'score': 99.5, 'roles': ['admin', 'developer'], 'meta': None} + assert dict(JSONConfiguration.from_string('''{ + "items": [], + "config": {}, + "enabled": false +}''')) == {'items': [], 'config': {}, 'enabled': False} + assert dict(JSONConfiguration.from_string('''{ + "int": 1, + "float": 1.0, + "scientific": 1e3, + "negative": -42 +}''')) == { + 'int': 1, + 'float': 1.0, + 'scientific': 1000.0, + 'negative': -42 +} + assert dict(JSONConfiguration.from_string('''{ + "text": "🌍", + "escaped": "Line1\\nLine2\\tTabbed", + "unicode_escape": "\u263A" +}''')) == { + 'text': '🌍', + 'escaped': 'Line1\nLine2\tTabbed', + 'unicode_escape': '☺' +} + assert dict(JSONConfiguration.from_string('''{"m": [ + { "id": 1, "value": "a" }, + { "id": 2, "value": "b" }, + { "id": 3, "value": null } +]} +''')) == { + 'm': [ + {'id': 1, 'value': 'a'}, + {'id': 2, 'value': 'b'}, + {'id': 3, 'value': None}, + ] +} + assert dict(JSONConfiguration.from_string('''{ + "1": "one", + "true": "yes", + "null": "nothing" +} +''')) == { + '1': 'one', + 'true': 'yes', + 'null': 'nothing' +} + assert dict(JSONConfiguration.from_string('''{ + "a": { + "b": { + "c": { + "d": [1, 2, {"e": false}] + } + } + } +} +''')) == { + 'a': { + 'b': { + 'c': { + 'd': [1, 2, {'e': False}] + } + } + } +} + assert dict(JSONConfiguration.from_string('''{ + "x": 1, + "x": 2, + "x": 3 +} +''')) == {'x': 3} + assert dict(JSONConfiguration.from_string('''{"nested": [[[[[[[[[[[[[[[[[[[[0]]]]]]]]]]]]]]]]]]]]}''')) == {'nested': [[[[[[[[[[[[[[[[[[[[0]]]]]]]]]]]]]]]]]]]]} + assert dict(JSONConfiguration.from_string('''{ + "id": 9007199254740993 +} +''')) == {'id': 9007199254740993} + assert dict(JSONConfiguration.from_string('''{ + "value": 0.1 +}''')) == {'value': 0.1} + assert dict(JSONConfiguration.from_string('{ "x": null }')) == {'x': None} + assert dict(JSONConfiguration.from_string('{}')) == {} + assert dict(JSONConfiguration.from_string('''{ + "a": "", + "b": null +} + +''')) == {'a': '', 'b': None} + assert dict(JSONConfiguration.from_string('''{ + "char1": "é", + "char2": "e\u0301" +}''')) == { + 'char1': 'é', + 'char2': 'é' +} + assert dict(JSONConfiguration.from_string('''{ + "text": "hello\u0000world" +}''')) == {'text': 'hello\x00world'} + assert dict(JSONConfiguration.from_string('''{ + "enabled": "false" +} +''')) == {'enabled': 'false'} + assert dict(JSONConfiguration.from_string('''{"array": [1, "1", true, null, {}, []] +}''')) == {'array': [1, '1', True, None, {}, []]} + assert dict(JSONConfiguration.from_string('''{ "a" : + [ 1 ,2 +,3 ] } + +''')) == {'a': [1, 2, 3]} + + # Test exceptions + try: + JSONConfiguration.from_string('{"a": 1,}') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('''{ + // Comment + "a": 1 +}''') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"a": 1 /* Comment */}') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('{a: 1}') + assert False, 'Excepted JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + JSONConfiguration.from_string('{\'a\': \'b\'}') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"x": 012}') + assert False, 'Excepted JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"x": NaN, "y": Infinity}') + assert False, 'Excepted JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"text": "hello\nworld"}') # This is not a JSON escape sequence! + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"x": "\q"}') + assert False, 'Excepted EscapeSequenceSyntaxError' + except EscapeSequenceSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"x": "\\u123"}') + assert False, 'Excepted EscapeSequenceSyntaxError' + except EscapeSequenceSyntaxError: + pass + + try: + JSONConfiguration.from_string('{ "a": 1 } { "b": 2 }') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"a": 1 "b": 2}') + assert False, 'Excepted JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + JSONConfiguration.from_string('{"a": [1, 2, 3}') + assert False, 'Excepted JSONArraySyntaxError' + except JSONArraySyntaxError: + pass + + try: + JSONConfiguration.from_string('') + assert False, 'Excepted JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + +def test_mutability(): + config = JSONConfiguration(mutable=False) + try: + config.key = 'value' + assert False, 'Expected TypeError' + except TypeError: + pass + + set_mutability(config, True) + assert config._mutable == True + + config.key = 'value' + + set_mutability(config, False) + assert config._mutable == False + + try: + del config.key + assert False, 'Expected TypeError' + except TypeError: + pass \ No newline at end of file diff --git a/tests/json/test_parser.py b/tests/json/test_parser.py new file mode 100644 index 0000000..ff9f72e --- /dev/null +++ b/tests/json/test_parser.py @@ -0,0 +1,443 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_config_parser.parse.json import parse_escape_sequences, JSONNumber, JSONString, JSONBoolean, JSONNull, JSONArray, JSONObject, parse_type, parse_json +from src.jcloud_config_parser.exceptions import EscapeSequenceSyntaxError, JSONNumberSyntaxError, JSONStringSyntaxError, JSONBooleanSyntaxError, JSONNullSyntaxError, JSONArraySyntaxError, JSONObjectSyntaxError, JSONValueSyntaxError +import os + +def test_parse_escape_sequences(): + # Test valid escape sequences + assert parse_escape_sequences(r'Hello\nWorld') == 'Hello\nWorld' + assert parse_escape_sequences(r'Hello\tWorld') == 'Hello\tWorld' + assert parse_escape_sequences(r'Hello\\World') == 'Hello\\World' + assert parse_escape_sequences(r'Hello\"World\"') == 'Hello"World"' + assert parse_escape_sequences(r'Hello\/World') == 'Hello/World' + assert parse_escape_sequences(r'Hello\bWorld') == 'Hello\bWorld' + assert parse_escape_sequences(r'Hello\fWorld') == 'Hello\fWorld' + assert parse_escape_sequences(r'Hello\rWorld') == 'Hello\rWorld' + assert parse_escape_sequences(r'Hello\u0041World') == 'HelloAWorld' + + # Test invalid escape sequences + try: + parse_escape_sequences(r'Hello\qWorld') + assert False, 'Expected EscapeSequenceSyntaxError' + except EscapeSequenceSyntaxError: + pass + + try: + parse_escape_sequences(r'Hello\u00G1World') + assert False, 'Expected EscapeSequenceSyntaxError' + except EscapeSequenceSyntaxError: + pass + + try: + parse_escape_sequences(r'\u123') + assert False, 'Expected EscapeSequenceSyntaxError' + except EscapeSequenceSyntaxError: + pass + +def test_parse_number(): + # Test parsing integers + assert JSONNumber.parse('0').value == 0 + assert JSONNumber.parse('42').value == 42 + assert JSONNumber.parse('-42').value == -42 + + # Test parsing floats + assert JSONNumber.parse('0.0').value == 0.0 + assert JSONNumber.parse('3.14').value == 3.14 + assert JSONNumber.parse('-3.14').value == -3.14 + + # Test exceptions + try: + JSONNumber.parse('abc') + assert False, 'Expected JSONNumberSyntaxError' + except JSONNumberSyntaxError: + pass + + try: + JSONNumber.parse('01') + assert False, 'Expected JSONNumberSyntaxError' + except JSONNumberSyntaxError: + pass + +def test_parse_str(): + # Test parsing strings + assert JSONString.parse('"Hello, World!"').value == 'Hello, World!' + assert JSONString.parse(r'"Hello,\nWorld!"').value == 'Hello,\nWorld!' + assert JSONString.parse(r'"Hello,\\World!"').value == 'Hello,\\World!' + + # Test exceptions + try: + JSONString.parse('\'Hello, World!\'') + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + JSONString.parse('"Hello, World!') + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + JSONString.parse('Hello, World!"') + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + JSONString.parse('Hello, World!') + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + JSONString.parse('""Hello, World!"') + assert False, 'Excepted JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + +def test_parse_boolean(): + # Test parsing booleans + assert JSONBoolean.parse('true').value == True + assert JSONBoolean.parse('false').value == False + + # Test exceptions + try: + JSONBoolean.parse('True') + assert False, 'Expected JSONBooleanSyntaxError' + except JSONBooleanSyntaxError: + pass + + try: + JSONBoolean.parse('False') + assert False, 'Expected JSONBooleanSyntaxError' + except JSONBooleanSyntaxError: + pass + + try: + JSONBoolean.parse('') + assert False, 'Expected JSONBooleanSyntaxError' + except JSONBooleanSyntaxError: + pass + +def test_parse_null(): + # Test parsing null + assert JSONNull.parse('null').value is None + + # Test exceptions + try: + JSONNull.parse('NULL') + assert False, 'Expected JSONNullSyntaxError' + except JSONNullSyntaxError: + pass + + try: + JSONNull.parse('Null') + assert False, 'Expected JSONNullSyntaxError' + except JSONNullSyntaxError: + pass + + try: + JSONNull.parse('') + assert False, 'Expected JSONNullSyntaxError' + except JSONNullSyntaxError: + pass + +def test_parse_array(): + # Test parsing arrays + assert JSONArray.parse('[]').value == [] + assert JSONArray.parse('[1, 2, 3]').value == [1, 2, 3] + assert JSONArray.parse('[1, "2", true, null]').value == [1, '2', True, None] + assert JSONArray.parse(r'[1, "2", true, null]').value == [1, '2', True, None] + + # Test exceptions + try: + JSONArray.parse('[') + assert False, 'Expected JSONArraySyntaxError' + except JSONArraySyntaxError: + pass + + try: + JSONArray.parse('[1, 2') + assert False, 'Expected JSONArraySyntaxError' + except JSONArraySyntaxError: + pass + +def test_parse_object(): + # Test parsing objects + assert JSONObject.parse('{}').value == {} + assert JSONObject.parse('{"key": "value"}').value == {'key': 'value'} + assert JSONObject.parse('{"key1": 1, "key2": "value2", "key3": true, "key4": null}').value == {'key1': 1, 'key2': 'value2', 'key3': True, 'key4': None} + assert JSONObject.parse(r'{"key1": 1, "key2": "value2", "key3": true, "key4": null}').value == {'key1': 1, 'key2': 'value2', 'key3': True, 'key4': None} + + with open(os.path.dirname(__file__) + '/test.json') as jsonf: + assert JSONObject.parse(jsonf.read()).value == { + "level_1": { + "meta": { + "version": "1.0", + "generated": True, + "tags": ["test", "deep", "nested", "json"], + }, + "level_2": { + "array": [ + { + "id": 1, + "level_3": { + "level_4": { + "level_5": { + "config": { + "enabled": True, + "thresholds": { + "low": 0.1, + "medium": 0.5, + "high": 0.9, + }, + "modes": [ + { + "name": "alpha", + "params": { + "retry": 3, + "timeout": { + "connect": 1000, + "read": 5000, + "deep": { + "even_deeper": { + "flag": False, + "notes": [ + "still", + "going", + { + "deeper": { + "than": { + "most": { + "humans": { + "expect": { + "value": 42 + } + } + } + } + } + }, + ], + } + }, + }, + }, + } + ], + } + } + } + }, + } + ], + "level_2_object": { + "a": { + "b": { + "c": { + "d": { + "e": { + "f": { + "g": { + "h": { + "i": { + "j": { + "k": "bottom" + } + } + } + } + } + } + } + } + } + } + }, + }, + }, + "level3": { + "root": [ + [ + [ + [ + { + "a": [ + { + "b": [ + { + "c": [ + { + "d": [ + { + "e": [ + { + "f": "bottom" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + ] + ] + ] + }, + "4": { + "level": { + "level": { + "level": { + "level": { + "level": { + "items": [ + {"x": {"y": {"z": [1, 2, 3, 4, 5]}}}, + {"x": {"y": {"z": [1, 2, 3, 4, 5]}}}, + {"x": {"y": {"z": [1, 2, 3, 4, 5]}}}, + {"x": {"y": {"z": [1, 2, 3, 4, 5]}}}, + {"x": {"y": {"z": [1, 2, 3, 4, 5]}}}, + ] + } + } + } + } + } + }, + "0": { + "n0": { + "n1": { + "n2": { + "n3": { + "n4": { + "n5": { + "n6": { + "n7": { + "n8": { + "n9": { + "n10": { + "n11": { + "n12": { + "n13": { + "n14": { + "n15": { + "n16": { + "n17": { + "n18": { + "n19": { + "n20": "bottom" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, +} + + jsonf.close() + + # Test exceptions + +def test_parse_type(): + # Test detecting types + assert parse_type('42') == JSONNumber + assert parse_type('"abc"') == JSONString + assert parse_type('true') == JSONBoolean + assert parse_type('false') == JSONBoolean + assert parse_type('null') == JSONNull + assert parse_type('[1, 2, 3, "4", "5", "6", true, false, null]') == JSONArray + assert parse_type('{"1": 2}') == JSONObject + assert parse_type('{"1": \n\t 2 }') == JSONObject # test whitespaces + + # Test very large data + with open(os.path.dirname(__file__) + '/test.json', 'r') as jsonf: + assert parse_type('[' + jsonf.read() + ']') == JSONArray + jsonf.close() + + # Test exceptions + try: + parse_type('abc') + assert False, 'Expected JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + parse_type('1s') + assert False, 'Expected JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + parse_type('[') + assert False, 'Expected JSONArraySyntaxError' + except JSONArraySyntaxError: + pass + + try: + parse_type('[a b]') + assert False, 'Expected JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + parse_type('"abc"abc') + assert False, 'Expected JSONStringSyntaxError' + except JSONStringSyntaxError: + pass + + try: + parse_type('\'abc\'abc') + assert False, 'Expected JSONValueSyntaxError' + except JSONValueSyntaxError: + pass + + try: + parse_type('{1: 2}') + assert False, 'Expected JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + + try: + parse_type('{"1"}') + assert False, 'Expected JSONObjectSyntaxError' + except JSONObjectSyntaxError: + pass + +def test_parse_json(): + # Test parsing JSON + assert parse_json('{"key": "value"}') == {'key': 'value'} + assert parse_json('[1, 2, 3]') == [1, 2, 3] + assert parse_json('{"key1": 1, "key2": "value2", "key3": true, "key4": null}') == {'key1': 1, 'key2': 'value2', 'key3': True, 'key4': None} + assert parse_json(r'{"key1": 1, "key2": "value2", "key3": true, "key4": null}') == {'key1': 1, 'key2': 'value2', 'key3': True, 'key4': None} \ No newline at end of file diff --git a/tests/json/test_serializer.py b/tests/json/test_serializer.py new file mode 100644 index 0000000..08285f5 --- /dev/null +++ b/tests/json/test_serializer.py @@ -0,0 +1,65 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_config_parser.serialize.json import serialize as serialize_json + +def test_serialize_json(): + assert serialize_json(True) == 'true' + assert serialize_json(False) == 'false' + assert serialize_json(True, indent = 4) == 'true' + + assert serialize_json(None) == 'null' + + assert serialize_json({1: {None: True}}, indent = 4) == '''{ + "1": { + "null": true + } +}''' + assert serialize_json({1: {None: True}}) == '{"1": {"null": true}}' + assert serialize_json({1: {None: True}, 2: 'Hello, World!'}, separators = (',', ':')) == '{"1":{"null":true},"2":"Hello, World!"}' + + array = [True, False, 1, None, 'Hello, World!'] + assert serialize_json(array) == '[true, false, 1, null, "Hello, World!"]' + assert serialize_json(array, 4, 'X', (';', ':')) == '''[ +XXXXtrue; +XXXXfalse; +XXXX1; +XXXXnull; +XXXX"Hello, World!" +]''' + assert serialize_json((1, 2, 3)) == '[1, 2, 3]' + assert serialize_json(range(3)) == '[0, 1, 2]' + assert serialize_json(b'\x0142') == '[1, 52, 50]' + + assert serialize_json('Hello, World!') == '"Hello, World!"' + assert serialize_json('Hello,\nWorld!') == '"Hello,\\nWorld!"' + assert serialize_json('Hello,"World!') == '"Hello,\\"World!"' + assert serialize_json('Hello,\\World!') == '"Hello,\\\\World!"' + assert serialize_json('Hello,\bWorld!') == '"Hello,\\bWorld!"' + assert serialize_json('Hello,\tWorld!') == '"Hello,\\tWorld!"' + assert serialize_json('Hello,\rWorld!') == '"Hello,\\rWorld!"' + assert serialize_json('Hello,\fWorld!') == '"Hello,\\fWorld!"' + + assert serialize_json(0) == '0' + assert serialize_json(1) == '1' + assert serialize_json(-1) == '-1' + assert serialize_json(0.0) == '0.0' + assert serialize_json(-0.0) == '-0.0' + assert serialize_json(-42.0) == '-42.0' + + try: + serialize_json({1, 2, 3}) + assert False, 'Expected TypeError' + except TypeError: + pass \ No newline at end of file diff --git a/tests/test_configuration_class.py b/tests/test_configuration_class.py new file mode 100644 index 0000000..5946fa1 --- /dev/null +++ b/tests/test_configuration_class.py @@ -0,0 +1,112 @@ +# Copyright 2026 jCloud Services GbR +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_config_parser import Configuration, set_mutability, is_mutable + +def test_crud_configuration_attrs(): + config = Configuration() + + # Test setting an attribute + config.abc = 42 + + # Test getting an attribute + assert config.abc == 42 + + # Test getting a non-existing attribute + try: + config.non_existing + assert False, "AttributeError was not raised" + except AttributeError: + pass + + # Test updating an attribute + config.abc = 100 + + # Test getting the updated attribute + assert config.abc == 100 + + # Test getting dictionary + assert dict(config) == {'abc': 100} + + # Test deleting an attribute + del config.abc + try: + config.abc + assert False, "AttributeError was not raised" + except AttributeError: + pass + +def test_crud_configuration_config_items(): + config = Configuration() + + # Test setting an item + config['key1'] = 'value1' + + # Test getting an item + assert config['key1'] == 'value1' + assert config.key1 == 'value1' + + # Test updating an item + config['key1'] = 'value2' + + # Test getting the updated item + assert config['key1'] == 'value2' + assert config.key1 == 'value2' + + # Test deleting an item and exceptions + del config['key1'] + try: + config['key1'] + assert False, "KeyError was not raised" + except KeyError: + pass + + try: + config.key1 + assert False, "AttributeError was not raised" + except AttributeError: + pass + +def test_mutability(): + config = Configuration(mutable=False) + try: + config.key = 'value' + assert False, 'Expected TypeError' + except TypeError: + pass + + set_mutability(config, True) + assert config._mutable == True + + config.key = 'value' + + set_mutability(config, False) + assert config._mutable == False + assert is_mutable(config) == False + + try: + del config.key + assert False, 'Expected TypeError' + except TypeError: + pass + + + # Test operate_on_original_object parameter of set_mutability + config = Configuration(mutable = True) + set_mutability(config, False, operate_on_original_object = True) + assert is_mutable(config) == False + + new_config = set_mutability(config, True, operate_on_original_object = False) + assert is_mutable(new_config) == True + assert is_mutable(config) == False \ No newline at end of file