├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── docs ├── changelog.rst ├── cli.rst ├── conf.py ├── index.rst ├── plain.rst ├── propclass.rst ├── propfile.rst ├── requirements.txt ├── util.rst └── xmlprops.rst ├── pyproject.toml ├── src └── javaproperties │ ├── __init__.py │ ├── propclass.py │ ├── propfile.py │ ├── py.typed │ ├── reading.py │ ├── util.py │ ├── writing.py │ └── xmlprops.py ├── test ├── conftest.py ├── test_dump_xml.py ├── test_dumps.py ├── test_dumps_xml.py ├── test_escape.py ├── test_java_timestamp.py ├── test_join_key_value.py ├── test_jpreplace.py ├── test_load_xml.py ├── test_loads.py ├── test_loads_xml.py ├── test_parse.py ├── test_propclass.py ├── test_propfile.py ├── test_to_comment.py ├── test_unescape.py └── test_util.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: "[python]" 9 | labels: 10 | - dependencies 11 | - d:python 12 | 13 | - package-ecosystem: pip 14 | directory: /docs 15 | versioning-strategy: increase-if-necessary 16 | schedule: 17 | interval: weekly 18 | commit-message: 19 | prefix: "[python+docs]" 20 | labels: 21 | - dependencies 22 | - d:python 23 | 24 | - package-ecosystem: github-actions 25 | directory: / 26 | schedule: 27 | interval: weekly 28 | commit-message: 29 | prefix: "[gh-actions]" 30 | include: scope 31 | labels: 32 | - dependencies 33 | - d:github-actions 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | schedule: 9 | - cron: '0 6 * * *' 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python-version: 22 | - '3.8' 23 | - '3.9' 24 | - '3.10' 25 | - '3.11' 26 | - '3.12' 27 | - '3.13' 28 | - 'pypy-3.8' 29 | - 'pypy-3.9' 30 | - 'pypy-3.10' 31 | toxenv: [py] 32 | include: 33 | - python-version: '3.8' 34 | toxenv: lint 35 | - python-version: '3.8' 36 | toxenv: typing 37 | steps: 38 | - name: Check out repository 39 | uses: actions/checkout@v4 40 | 41 | - name: Set up Python 42 | uses: actions/setup-python@v5 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip wheel 49 | python -m pip install --upgrade --upgrade-strategy=eager coverage tox 50 | 51 | - name: Run tests 52 | run: tox -e ${{ matrix.toxenv }} 53 | 54 | - name: Generate XML coverage report 55 | if: matrix.toxenv == 'py' 56 | run: coverage xml 57 | 58 | - name: Upload coverage to Codecov 59 | if: matrix.toxenv == 'py' 60 | uses: codecov/codecov-action@v5 61 | with: 62 | fail_ci_if_error: false 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | name: ${{ matrix.python-version }} 65 | 66 | # vim:set et sts=2: 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage* 2 | .mypy_cache/ 3 | .tox/ 4 | __pycache__/ 5 | dist/ 6 | docs/_build/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-json 7 | - id: check-toml 8 | - id: check-yaml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 24.4.2 14 | hooks: 15 | - id: black 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.13.2 19 | hooks: 20 | - id: isort 21 | 22 | - repo: https://github.com/PyCQA/flake8 23 | rev: 7.0.0 24 | hooks: 25 | - id: flake8 26 | additional_dependencies: 27 | - flake8-bugbear 28 | - flake8-builtins 29 | - flake8-unused-arguments 30 | exclude: ^test/data 31 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | formats: all 3 | python: 4 | install: 5 | - requirements: docs/requirements.txt 6 | - method: pip 7 | path: . 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3" 12 | sphinx: 13 | configuration: docs/conf.py 14 | fail_on_warning: true 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.8.2 (2024-12-01) 2 | ------------------- 3 | - Drop support for Python 3.6 and 3.7 4 | - Support Python 3.11, 3.12, and 3.13 5 | - Migrated from setuptools to hatch 6 | 7 | v0.8.1 (2021-10-05) 8 | ------------------- 9 | - Fix a typing issue in Python 3.9 10 | - Support Python 3.10 11 | 12 | v0.8.0 (2020-11-28) 13 | ------------------- 14 | - Drop support for Python 2.7, 3.4, and 3.5 15 | - Support Python 3.9 16 | - `ensure_ascii` parameter added to `PropertiesFile.dump()` and 17 | `PropertiesFile.dumps()` 18 | - **Bugfix**: When parsing XML input, empty `` tags now produce an empty 19 | string as a value, not `None` 20 | - Added type annotations 21 | - `Properties` and `PropertiesFile` no longer raise `TypeError` when given a 22 | non-string key or value, as type correctness is now expected to be enforced 23 | through static type checking 24 | - The `PropertiesElement` classes returned by `parse()` are no longer 25 | subclasses of `namedtuple`, but they can still be iterated over to retrieve 26 | their fields like a tuple 27 | 28 | v0.7.0 (2020-03-09) 29 | ------------------- 30 | - `parse()` now accepts strings as input 31 | - **Breaking**: `parse()` now returns a generator of custom objects instead of 32 | triples of strings 33 | - Gave `PropertiesFile` a settable `timestamp` property 34 | - Gave `PropertiesFile` a settable `header_comment` property 35 | - Handle unescaping surrogate pairs on narrow Python builds 36 | 37 | v0.6.0 (2020-02-28) 38 | ------------------- 39 | - Include changelog in the Read the Docs site 40 | - Support Python 3.8 41 | - When dumping a value that begins with more than one space, only escape the 42 | first space in order to better match Java's behavior 43 | - Gave `dump()`, `dumps()`, `escape()`, and `join_key_value()` an 44 | `ensure_ascii` parameter for optionally not escaping non-ASCII characters in 45 | output 46 | - Gave `dump()` and `dumps()` an `ensure_ascii_comments` parameter for 47 | controlling what characters in the `comments` parameter are escaped 48 | - Gave `to_comment()` an `ensure_ascii` parameter for controlling what 49 | characters are escaped 50 | - Added a custom encoding error handler `'javapropertiesreplace'` that encodes 51 | invalid characters as `\uXXXX` escape sequences 52 | 53 | v0.5.2 (2019-04-08) 54 | ------------------- 55 | - Added an example of each format to the format descriptions in the docs 56 | - Fix building in non-UTF-8 environments 57 | 58 | v0.5.1 (2018-10-25) 59 | ------------------- 60 | - **Bugfix**: `java_timestamp()` now properly handles naïve `datetime` objects 61 | with `fold=1` 62 | - Include installation instructions, examples, and GitHub links in the Read the 63 | Docs site 64 | 65 | v0.5.0 (2018-09-18) 66 | ------------------- 67 | - **Breaking**: Invalid `\uXXXX` escape sequences now cause an 68 | `InvalidUEscapeError` to be raised 69 | - `Properties` instances can now compare equal to `dict`s and other mapping 70 | types 71 | - Gave `Properties` a `copy` method 72 | - Drop support for Python 2.6 and 3.3 73 | - Fixed a `DeprecationWarning` in Python 3.7 74 | 75 | v0.4.0 (2017-04-22) 76 | ------------------- 77 | - Split off the command-line programs into a separate package, 78 | [`javaproperties-cli`](https://github.com/jwodder/javaproperties-cli) 79 | 80 | v0.3.0 (2017-04-13) 81 | ------------------- 82 | - Added the `PropertiesFile` class for preserving comments in files [#1] 83 | - The `ordereddict` package is now required under Python 2.6 84 | 85 | v0.2.1 (2017-03-20) 86 | ------------------- 87 | - **Bugfix** to `javaproperties` command: Don't die horribly on missing 88 | non-ASCII keys 89 | - PyPy now supported 90 | 91 | v0.2.0 (2016-11-14) 92 | ------------------- 93 | - Added a `javaproperties` command for basic command-line manipulating of 94 | `.properties` files 95 | - Gave `json2properties` a `--separator` option 96 | - Gave `json2properties` and `properties2json` `--encoding` options 97 | - Exported the `java_timestamp()` function 98 | - `to_comment` now converts CR LF and CR line endings inside comments to LF 99 | - Some minor documentation improvements 100 | 101 | v0.1.0 (2016-10-02) 102 | ------------------- 103 | Initial release 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2024 John Thorvald Wodder II 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |repostatus| |ci-status| |coverage| |pyversions| |license| 2 | 3 | .. |repostatus| image:: https://www.repostatus.org/badges/latest/active.svg 4 | :target: https://www.repostatus.org/#active 5 | :alt: Project Status: Active - The project has reached a stable, usable 6 | state and is being actively developed. 7 | 8 | .. |ci-status| image:: https://github.com/jwodder/javaproperties/actions/workflows/test.yml/badge.svg 9 | :target: https://github.com/jwodder/javaproperties/actions/workflows/test.yml 10 | :alt: CI Status 11 | 12 | .. |coverage| image:: https://codecov.io/gh/jwodder/javaproperties/branch/master/graph/badge.svg 13 | :target: https://codecov.io/gh/jwodder/javaproperties 14 | 15 | .. |pyversions| image:: https://img.shields.io/pypi/pyversions/javaproperties.svg 16 | :target: https://pypi.org/project/javaproperties 17 | 18 | .. |license| image:: https://img.shields.io/github/license/jwodder/javaproperties.svg?maxAge=2592000 19 | :target: https://opensource.org/licenses/MIT 20 | :alt: MIT License 21 | 22 | `GitHub `_ 23 | | `PyPI `_ 24 | | `Documentation `_ 25 | | `Issues `_ 26 | | `Changelog `_ 27 | 28 | ``javaproperties`` provides support for reading & writing |properties|_ (both 29 | the simple line-oriented format and XML) with a simple API based on the 30 | ``json`` module — though, for recovering Java addicts, it also includes a 31 | ``Properties`` class intended to match the behavior of |propclass|_ as much as 32 | is Pythonically possible. 33 | 34 | Previous versions of ``javaproperties`` included command-line programs for 35 | basic manipulation of ``.properties`` files. As of version 0.4.0, these 36 | programs have been split off into a separate package, |clipkg|_. 37 | 38 | 39 | Installation 40 | ============ 41 | ``javaproperties`` requires Python 3.8 or higher. Just use `pip 42 | `_ for Python 3 (You have pip, right?) to install it:: 43 | 44 | python3 -m pip install javaproperties 45 | 46 | 47 | Examples 48 | ======== 49 | 50 | Dump some keys & values (output order not guaranteed): 51 | 52 | >>> properties = {"key": "value", "host:port": "127.0.0.1:80", "snowman": "☃", "goat": "🐐"} 53 | >>> print(javaproperties.dumps(properties)) 54 | #Mon Sep 26 14:57:44 EDT 2016 55 | key=value 56 | goat=\ud83d\udc10 57 | host\:port=127.0.0.1\:80 58 | snowman=\u2603 59 | 60 | Load some keys & values: 61 | 62 | >>> javaproperties.loads(''' 63 | ... #Mon Sep 26 14:57:44 EDT 2016 64 | ... key = value 65 | ... goat: \\ud83d\\udc10 66 | ... host\\:port=127.0.0.1:80 67 | ... #foo = bar 68 | ... snowman ☃ 69 | ... ''') 70 | {'goat': '🐐', 'host:port': '127.0.0.1:80', 'key': 'value', 'snowman': '☃'} 71 | 72 | Dump some properties to a file and read them back in again: 73 | 74 | >>> with open('example.properties', 'w', encoding='latin-1') as fp: 75 | ... javaproperties.dump(properties, fp) 76 | ... 77 | >>> with open('example.properties', 'r', encoding='latin-1') as fp: 78 | ... javaproperties.load(fp) 79 | ... 80 | {'goat': '🐐', 'host:port': '127.0.0.1:80', 'key': 'value', 'snowman': '☃'} 81 | 82 | Sort the properties you're dumping: 83 | 84 | >>> print(javaproperties.dumps(properties, sort_keys=True)) 85 | #Mon Sep 26 14:57:44 EDT 2016 86 | goat=\ud83d\udc10 87 | host\:port=127.0.0.1\:80 88 | key=value 89 | snowman=\u2603 90 | 91 | Turn off the timestamp: 92 | 93 | >>> print(javaproperties.dumps(properties, timestamp=None)) 94 | key=value 95 | goat=\ud83d\udc10 96 | host\:port=127.0.0.1\:80 97 | snowman=\u2603 98 | 99 | Use your own timestamp (automatically converted to local time): 100 | 101 | >>> print(javaproperties.dumps(properties, timestamp=1234567890)) 102 | #Fri Feb 13 18:31:30 EST 2009 103 | key=value 104 | goat=\ud83d\udc10 105 | host\:port=127.0.0.1\:80 106 | snowman=\u2603 107 | 108 | Dump as XML: 109 | 110 | >>> print(javaproperties.dumps_xml(properties)) 111 | 112 | 113 | value 114 | 🐐 115 | 127.0.0.1:80 116 | 117 | 118 | 119 | New in v0.6.0: Dump Unicode characters as-is instead of escaping them: 120 | 121 | >>> print(javaproperties.dumps(properties, ensure_ascii=False)) 122 | #Tue Feb 25 19:13:27 EST 2020 123 | key=value 124 | goat=🐐 125 | host\:port=127.0.0.1\:80 126 | snowman=☃ 127 | 128 | `And more! `_ 129 | 130 | 131 | .. |properties| replace:: Java ``.properties`` files 132 | .. _properties: https://en.wikipedia.org/wiki/.properties 133 | 134 | .. |propclass| replace:: Java 8's ``java.util.Properties`` 135 | .. _propclass: https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html 136 | 137 | .. |clipkg| replace:: ``javaproperties-cli`` 138 | .. _clipkg: https://github.com/jwodder/javaproperties-cli 139 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | Changelog 4 | ========= 5 | 6 | v0.8.2 (2024-12-01) 7 | ------------------- 8 | - Drop support for Python 3.6 and 3.7 9 | - Support Python 3.11, 3.12, and 3.13 10 | - Migrated from setuptools to hatch 11 | 12 | 13 | v0.8.1 (2021-10-05) 14 | ------------------- 15 | - Fix a typing issue in Python 3.9 16 | - Support Python 3.10 17 | 18 | 19 | v0.8.0 (2020-11-28) 20 | ------------------- 21 | - Drop support for Python 2.7, 3.4, and 3.5 22 | - Support Python 3.9 23 | - ``ensure_ascii`` parameter added to `PropertiesFile.dump()` and 24 | `PropertiesFile.dumps()` 25 | - **Bugfix**: When parsing XML input, empty ```` tags now produce an 26 | empty string as a value, not `None` 27 | - Added type annotations 28 | - `Properties` and `PropertiesFile` no longer raise `TypeError` when given a 29 | non-string key or value, as type correctness is now expected to be enforced 30 | through static type checking 31 | - The `PropertiesElement` classes returned by `parse()` are no longer 32 | subclasses of `~collections.namedtuple`, but they can still be iterated over 33 | to retrieve their fields like a tuple 34 | 35 | 36 | v0.7.0 (2020-03-09) 37 | ------------------- 38 | - `parse()` now accepts strings as input 39 | - **Breaking**: `parse()` now returns a generator of custom objects instead of 40 | triples of strings 41 | - Gave `PropertiesFile` a settable `~PropertiesFile.timestamp` property 42 | - Gave `PropertiesFile` a settable `~PropertiesFile.header_comment` property 43 | - Handle unescaping surrogate pairs on narrow Python builds 44 | 45 | 46 | v0.6.0 (2020-02-28) 47 | ------------------- 48 | - Include changelog in the Read the Docs site 49 | - Support Python 3.8 50 | - When dumping a value that begins with more than one space, only escape the 51 | first space in order to better match Java's behavior 52 | - Gave `dump()`, `dumps()`, `escape()`, and `join_key_value()` an 53 | ``ensure_ascii`` parameter for optionally not escaping non-ASCII characters 54 | in output 55 | - Gave `dump()` and `dumps()` an ``ensure_ascii_comments`` parameter for 56 | controlling what characters in the ``comments`` parameter are escaped 57 | - Gave `to_comment()` an ``ensure_ascii`` parameter for controlling what 58 | characters are escaped 59 | - Added a custom encoding error handler ``'javapropertiesreplace'`` that 60 | encodes invalid characters as ``\uXXXX`` escape sequences 61 | 62 | 63 | v0.5.2 (2019-04-08) 64 | ------------------- 65 | - Added an example of each format to the format descriptions in the docs 66 | - Fix building in non-UTF-8 environments 67 | 68 | 69 | v0.5.1 (2018-10-25) 70 | ------------------- 71 | - **Bugfix**: `java_timestamp()` now properly handles naïve 72 | `~datetime.datetime` objects with ``fold=1`` 73 | - Include installation instructions, examples, and GitHub links in the Read the 74 | Docs site 75 | 76 | 77 | v0.5.0 (2018-09-18) 78 | ------------------- 79 | - **Breaking**: Invalid ``\uXXXX`` escape sequences now cause an 80 | `InvalidUEscapeError` to be raised 81 | - `Properties` instances can now compare equal to `dict`\s and other mapping 82 | types 83 | - Gave `Properties` a ``copy`` method 84 | - Drop support for Python 2.6 and 3.3 85 | - Fixed a `DeprecationWarning` in Python 3.7 86 | 87 | 88 | v0.4.0 (2017-04-22) 89 | ------------------- 90 | - Split off the command-line programs into a separate package, |clipkg|_ 91 | 92 | .. |clipkg| replace:: ``javaproperties-cli`` 93 | .. _clipkg: https://github.com/jwodder/javaproperties-cli 94 | 95 | 96 | v0.3.0 (2017-04-13) 97 | ------------------- 98 | - Added the `PropertiesFile` class for preserving comments in files [#1] 99 | - The ``ordereddict`` package is now required under Python 2.6 100 | 101 | 102 | v0.2.1 (2017-03-20) 103 | ------------------- 104 | - **Bugfix** to :program:`javaproperties` command: Don't die horribly on 105 | missing non-ASCII keys 106 | - PyPy now supported 107 | 108 | 109 | v0.2.0 (2016-11-14) 110 | ------------------- 111 | - Added a :program:`javaproperties` command for basic command-line manipulating 112 | of ``.properties`` files 113 | - Gave :program:`json2properties` a ``--separator`` option 114 | - Gave :program:`json2properties` and :program:`properties2json` ``--encoding`` 115 | options 116 | - Exported the `java_timestamp()` function 117 | - `to_comment()` now converts CR LF and CR line endings inside comments to LF 118 | - Some minor documentation improvements 119 | 120 | 121 | v0.1.0 (2016-10-02) 122 | ------------------- 123 | Initial release 124 | -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | Command-Line Utilities 2 | ====================== 3 | As of version 0.4.0, the command-line programs have been split off into a 4 | separate package, |clipkg|_, which must be installed separately in order to use 5 | them. See `the package's documentation 6 | `_ for details. 7 | 8 | .. |clipkg| replace:: ``javaproperties-cli`` 9 | .. _clipkg: https://github.com/jwodder/javaproperties-cli 10 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from javaproperties import __version__ 2 | 3 | project = "javaproperties" 4 | author = "John T. Wodder II" 5 | copyright = "2016-2024 John T. Wodder II" # noqa: A001 6 | 7 | extensions = [ 8 | "sphinx.ext.autodoc", 9 | "sphinx.ext.intersphinx", 10 | "sphinx.ext.viewcode", 11 | "sphinx_copybutton", 12 | ] 13 | 14 | autodoc_default_options = { 15 | "members": True, 16 | "undoc-members": True, 17 | } 18 | # NOTE: Do not set 'inherited-members', as it will cause all of the 19 | # MutableMapping methods to be listed under `Properties`. 20 | 21 | intersphinx_mapping = { 22 | "python": ("https://docs.python.org/3", None), 23 | } 24 | 25 | exclude_patterns = ["_build"] 26 | source_suffix = ".rst" 27 | source_encoding = "utf-8" 28 | master_doc = "index" 29 | version = __version__ 30 | release = __version__ 31 | today_fmt = "%Y %b %d" 32 | default_role = "py:obj" 33 | pygments_style = "sphinx" 34 | 35 | html_theme = "sphinx_rtd_theme" 36 | html_theme_options = { 37 | "collapse_navigation": False, 38 | "prev_next_buttons_location": "both", 39 | } 40 | html_last_updated_fmt = "%Y %b %d" 41 | html_show_sourcelink = True 42 | html_show_sphinx = True 43 | html_show_copyright = True 44 | 45 | copybutton_prompt_text = r">>> |\.\.\. |\$ " 46 | copybutton_prompt_is_regexp = True 47 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. module:: javaproperties 2 | 3 | ============================================================== 4 | javaproperties — Read & write Java .properties files in Python 5 | ============================================================== 6 | 7 | `GitHub `_ 8 | | `PyPI `_ 9 | | `Documentation `_ 10 | | `Issues `_ 11 | | :doc:`Changelog ` 12 | 13 | .. toctree:: 14 | :hidden: 15 | 16 | plain 17 | xmlprops 18 | propclass 19 | propfile 20 | util 21 | cli 22 | changelog 23 | 24 | `javaproperties` provides support for reading & writing |properties|_ (both the 25 | simple line-oriented format and XML) with a simple API based on the `json` 26 | module — though, for recovering Java addicts, it also includes a `Properties` 27 | class intended to match the behavior of |java8properties|_ as much as is 28 | Pythonically possible. 29 | 30 | Previous versions of `javaproperties` included command-line programs for 31 | basic manipulation of ``.properties`` files. As of version 0.4.0, these 32 | programs have been split off into a separate package, |clipkg|_. 33 | 34 | Installation 35 | ============ 36 | ``javaproperties`` requires Python 3.8 or higher. Just use `pip 37 | `_ for Python 3 (You have pip, right?) to install it:: 38 | 39 | python3 -m pip install javaproperties 40 | 41 | 42 | Examples 43 | ======== 44 | 45 | Dump some keys & values (output order not guaranteed): 46 | 47 | >>> properties = {"key": "value", "host:port": "127.0.0.1:80", "snowman": "☃", "goat": "🐐"} 48 | >>> print(javaproperties.dumps(properties)) 49 | #Mon Sep 26 14:57:44 EDT 2016 50 | key=value 51 | goat=\ud83d\udc10 52 | host\:port=127.0.0.1\:80 53 | snowman=\u2603 54 | 55 | Load some keys & values: 56 | 57 | >>> javaproperties.loads(''' 58 | ... #Mon Sep 26 14:57:44 EDT 2016 59 | ... key = value 60 | ... goat: \\ud83d\\udc10 61 | ... host\\:port=127.0.0.1:80 62 | ... #foo = bar 63 | ... snowman ☃ 64 | ... ''') 65 | {'goat': '🐐', 'host:port': '127.0.0.1:80', 'key': 'value', 'snowman': '☃'} 66 | 67 | Dump some properties to a file and read them back in again: 68 | 69 | >>> with open('example.properties', 'w', encoding='latin-1') as fp: 70 | ... javaproperties.dump(properties, fp) 71 | ... 72 | >>> with open('example.properties', 'r', encoding='latin-1') as fp: 73 | ... javaproperties.load(fp) 74 | ... 75 | {'goat': '🐐', 'host:port': '127.0.0.1:80', 'key': 'value', 'snowman': '☃'} 76 | 77 | Sort the properties you're dumping: 78 | 79 | >>> print(javaproperties.dumps(properties, sort_keys=True)) 80 | #Mon Sep 26 14:57:44 EDT 2016 81 | goat=\ud83d\udc10 82 | host\:port=127.0.0.1\:80 83 | key=value 84 | snowman=\u2603 85 | 86 | Turn off the timestamp: 87 | 88 | >>> print(javaproperties.dumps(properties, timestamp=None)) 89 | key=value 90 | goat=\ud83d\udc10 91 | host\:port=127.0.0.1\:80 92 | snowman=\u2603 93 | 94 | Use your own timestamp (automatically converted to local time): 95 | 96 | >>> print(javaproperties.dumps(properties, timestamp=1234567890)) 97 | #Fri Feb 13 18:31:30 EST 2009 98 | key=value 99 | goat=\ud83d\udc10 100 | host\:port=127.0.0.1\:80 101 | snowman=\u2603 102 | 103 | Dump as XML: 104 | 105 | >>> print(javaproperties.dumps_xml(properties)) 106 | 107 | 108 | value 109 | 🐐 110 | 127.0.0.1:80 111 | 112 | 113 | 114 | New in v0.6.0: Dump Unicode characters as-is instead of escaping them: 115 | 116 | >>> print(javaproperties.dumps(properties, ensure_ascii=False)) 117 | #Tue Feb 25 19:13:27 EST 2020 118 | key=value 119 | goat=🐐 120 | host\:port=127.0.0.1\:80 121 | snowman=☃ 122 | 123 | 124 | Indices and tables 125 | ================== 126 | * :ref:`genindex` 127 | * :ref:`search` 128 | 129 | .. |properties| replace:: Java ``.properties`` files 130 | .. _properties: https://en.wikipedia.org/wiki/.properties 131 | 132 | .. |java8properties| replace:: Java 8's ``java.util.Properties`` 133 | .. _java8properties: https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html 134 | 135 | .. |clipkg| replace:: ``javaproperties-cli`` 136 | .. _clipkg: http://javaproperties-cli.readthedocs.io 137 | -------------------------------------------------------------------------------- /docs/plain.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | Simple Line-Oriented ``.properties`` Format 4 | =========================================== 5 | 6 | Format Overview 7 | --------------- 8 | 9 | The simple line-oriented ``.properties`` file format consists of a series of 10 | key-value string pairs, one (or fewer) per line, with the key & value separated 11 | by the first occurrence of an equals sign (``=``, optionally with surrounding 12 | whitespace), a colon (``:``, optionally with surrounding whitespace), or 13 | non-leading whitespace. A line without a separator is treated as a key whose 14 | value is the empty string. If the same key occurs more than once in a single 15 | file, only its last value is used. 16 | 17 | .. note:: 18 | 19 | Lines are terminated by ``\n`` (LF), ``\r\n`` (CR LF), or ``\r`` (CR). 20 | 21 | .. note:: 22 | 23 | For the purposes of this format, only the space character (ASCII 0x20), the 24 | tab character (ASCII 0x09), and the form feed character (ASCII 0x0C) count 25 | as whitespace. 26 | 27 | Leading whitespace on a line is ignored, but trailing whitespace (after 28 | stripping trailing newlines) is not. Lines whose first non-whitespace 29 | character is ``#`` or ``!`` (not escaped) are comments and are ignored. 30 | 31 | Entries can be extended across multiple lines by ending all but the last line 32 | with a backslash; the backslash, the line ending after it, and any leading 33 | whitespace on the next line will all be discarded. A backslash at the end of a 34 | comment line has no effect. A comment line after a line that ends with a 35 | backslash is treated as part of a normal key-value entry, not as a comment. 36 | 37 | Occurrences of ``=``, ``:``, ``#``, ``!``, and whitespace inside a key or value 38 | are escaped with a backslash. In addition, the following escape sequences are 39 | recognized:: 40 | 41 | \t \n \f \r \uXXXX \\ 42 | 43 | Unicode characters outside the Basic Multilingual Plane can be represented by a 44 | pair of ``\uXXXX`` escape sequences encoding the corresponding UTF-16 surrogate 45 | pair. 46 | 47 | If a backslash is followed by character other than those listed above, the 48 | backslash is discarded. 49 | 50 | An example simple line-oriented ``.properties`` file: 51 | 52 | .. code-block:: properties 53 | 54 | #This is a comment. 55 | foo=bar 56 | baz: quux 57 | gnusto cleesh 58 | snowman = \u2603 59 | goat = \ud83d\udc10 60 | novalue 61 | host\:port=127.0.0.1\:80 62 | 63 | This corresponds to the Python `dict`: 64 | 65 | .. code-block:: python 66 | 67 | { 68 | "foo": "bar", 69 | "baz": "quux", 70 | "gnusto": "cleesh", 71 | "snowman": "☃", 72 | "goat": "🐐", 73 | "novalue": "", 74 | "host:port": "127.0.0.1:80", 75 | } 76 | 77 | File Encoding 78 | ------------- 79 | 80 | Although the `load()` and `loads()` functions accept arbitrary Unicode 81 | characters in their input, by default the `dump()` and `dumps()` functions 82 | limit the characters in their output as follows: 83 | 84 | - When ``ensure_ascii`` is `True` (the default), `dump()` and `dumps()` output 85 | keys & values in pure ASCII; non-ASCII and unprintable characters are escaped 86 | with the escape sequences listed above. When ``ensure_ascii`` is `False`, 87 | the functions instead pass all non-ASCII characters through as-is; 88 | unprintable characters are still escaped. 89 | 90 | - When ``ensure_ascii_comments`` is `None` (the default), `dump()` and 91 | `dumps()` output the ``comments`` argument (if set) using only Latin-1 92 | (ISO-8859-1) characters; all other characters are escaped. When 93 | ``ensure_ascii_comments`` is `True`, the functions instead escape all 94 | non-ASCII characters in ``comments``. When ``ensure_ascii_comments`` is 95 | `False`, the functions instead pass all characters in ``comments`` through 96 | as-is. 97 | 98 | - Note that, in order to match the behavior of Java's ``Properties`` class, 99 | unprintable ASCII characters in ``comments`` are always passed through 100 | as-is rather than escaped. 101 | 102 | - Newlines inside ``comments`` are not escaped, but a ``#`` is inserted 103 | after every one not already followed by a ``#`` or ``!``. 104 | 105 | When writing properties to a file, you must either (a) open the file using an 106 | encoding that supports all of the characters in the formatted output or else 107 | (b) open the file using the :ref:`'javapropertiesreplace' error handler 108 | ` defined by this module. The latter option allows one 109 | to write valid simple-format properties files in any encoding without having to 110 | worry about whether the properties or comment contain any characters not 111 | representable in the encoding. 112 | 113 | Functions 114 | --------- 115 | .. autofunction:: dump 116 | .. autofunction:: dumps 117 | .. autofunction:: load 118 | .. autofunction:: loads 119 | -------------------------------------------------------------------------------- /docs/propclass.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | ``Properties`` Class 4 | ==================== 5 | .. autoclass:: Properties 6 | -------------------------------------------------------------------------------- /docs/propfile.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | ``PropertiesFile`` Class 4 | ======================== 5 | .. autoclass:: PropertiesFile 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx~=8.0 2 | sphinx-copybutton~=0.5.0 3 | sphinx_rtd_theme~=3.0 4 | -------------------------------------------------------------------------------- /docs/util.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | Low-Level Utilities 4 | =================== 5 | .. autofunction:: escape 6 | .. autofunction:: java_timestamp 7 | .. autofunction:: join_key_value 8 | .. autofunction:: to_comment 9 | .. autofunction:: unescape 10 | .. autoexception:: InvalidUEscapeError 11 | :show-inheritance: 12 | 13 | Low-Level Parsing 14 | ----------------- 15 | .. autofunction:: parse 16 | .. autoclass:: PropertiesElement 17 | .. autoclass:: Comment 18 | .. autoclass:: KeyValue 19 | .. autoclass:: Whitespace 20 | 21 | .. _javapropertiesreplace: 22 | .. index:: 23 | single: javapropertiesreplace 24 | 25 | Custom Encoding Error Handler 26 | ----------------------------- 27 | .. versionadded:: 0.6.0 28 | 29 | Importing `javaproperties` causes a custom error handler, 30 | ``'javapropertiesreplace'``, to be automatically defined that can then be 31 | supplied as the *errors* argument to `str.encode`, `open`, or similar 32 | encoding operations in order to cause all unencodable characters to be replaced 33 | by ``\uXXXX`` escape sequences (with non-BMP characters converted to surrogate 34 | pairs first). 35 | 36 | This is useful, for example, when calling ``javaproperties.dump(obj, fp, 37 | ensure_ascii=False)`` where ``fp`` has been opened using an encoding that does 38 | not contain all Unicode characters (e.g., Latin-1); in such a case, if 39 | ``errors='javapropertiesreplace'`` is supplied when opening ``fp``, then any 40 | characters in a key or value of ``obj`` that exist outside ``fp``'s character 41 | set will be safely encoded as ``.properties`` file format-compatible escape 42 | sequences instead of raising an error. 43 | 44 | Note that the hexadecimal value used in a ``\uXXXX`` escape sequences is always 45 | based on the source character's codepoint value in Unicode regardless of the 46 | target encoding: 47 | 48 | >>> # Here we see one character encoded to the byte 0x00f0 (because that's 49 | >>> # how the target encoding represents it) and a completely different 50 | >>> # character encoded as the escape sequence \u00f0 (because that's its 51 | >>> # value in Unicode): 52 | >>> 'apple: \uF8FF; edh: \xF0'.encode('mac_roman', 'javapropertiesreplace') 53 | b'apple: \xf0; edh: \\u00f0' 54 | 55 | .. autofunction:: javapropertiesreplace_errors 56 | -------------------------------------------------------------------------------- /docs/xmlprops.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: javaproperties 2 | 3 | XML ``.properties`` Format 4 | ========================== 5 | 6 | Format Overview 7 | --------------- 8 | 9 | The XML ``.properties`` file format encodes a series of key-value string pairs 10 | (and optionally also a comment) as an XML document conforming to the following 11 | Document Type Definition (published at 12 | ): 13 | 14 | .. code-block:: dtd 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | An example XML ``.properties`` file: 23 | 24 | .. code-block:: xml 25 | 26 | 27 | 28 | This is a comment. 29 | bar 30 | 31 | 🐐 32 | 127.0.0.1:80 33 | 34 | 35 | This corresponds to the Python `dict`: 36 | 37 | .. code-block:: python 38 | 39 | { 40 | "foo": "bar", 41 | "snowman": "☃", 42 | "goat": "🐐", 43 | "host:port": "127.0.0.1:80", 44 | } 45 | 46 | Functions 47 | --------- 48 | .. autofunction:: dump_xml 49 | .. autofunction:: dumps_xml 50 | .. autofunction:: load_xml 51 | .. autofunction:: loads_xml 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "javaproperties" 7 | dynamic = ["version"] 8 | description = "Read & write Java .properties files" 9 | readme = "README.rst" 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | license-files = ["LICENSE"] 13 | authors = [ 14 | { name = "John Thorvald Wodder II", email = "javaproperties@varonathe.org" } 15 | ] 16 | 17 | keywords = [ 18 | "java", 19 | "properties", 20 | "javaproperties", 21 | "configfile", 22 | "config", 23 | "configuration", 24 | ] 25 | 26 | classifiers = [ 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Programming Language :: Python :: Implementation :: CPython", 36 | "Programming Language :: Python :: Implementation :: PyPy", 37 | "Intended Audience :: Developers", 38 | "Topic :: Software Development", 39 | "Topic :: Software Development :: Libraries :: Java Libraries", 40 | "Topic :: Utilities", 41 | "Typing :: Typed", 42 | ] 43 | 44 | dependencies = [] 45 | 46 | [project.urls] 47 | "Source Code" = "https://github.com/jwodder/javaproperties" 48 | "Bug Tracker" = "https://github.com/jwodder/javaproperties/issues" 49 | "Documentation" = "https://javaproperties.readthedocs.io" 50 | 51 | [tool.hatch.version] 52 | path = "src/javaproperties/__init__.py" 53 | 54 | [tool.hatch.build.targets.sdist] 55 | include = [ 56 | "/docs", 57 | "/src", 58 | "/test", 59 | "CHANGELOG.*", 60 | "CONTRIBUTORS.*", 61 | "tox.ini", 62 | ] 63 | 64 | [tool.hatch.envs.default] 65 | python = "3" 66 | 67 | [tool.mypy] 68 | allow_incomplete_defs = false 69 | allow_untyped_defs = false 70 | ignore_missing_imports = false 71 | # : 72 | no_implicit_optional = true 73 | implicit_reexport = false 74 | local_partial_types = true 75 | pretty = true 76 | show_error_codes = true 77 | show_traceback = true 78 | strict_equality = true 79 | warn_redundant_casts = true 80 | warn_return_any = true 81 | warn_unreachable = true 82 | -------------------------------------------------------------------------------- /src/javaproperties/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read & write Java .properties files 3 | 4 | ``javaproperties`` provides support for reading & writing Java ``.properties`` 5 | files (both the simple line-oriented format and XML) with a simple API based on 6 | the ``json`` module — though, for recovering Java addicts, it also includes a 7 | ``Properties`` class intended to match the behavior of Java 8's 8 | ``java.util.Properties`` as much as is Pythonically possible. 9 | 10 | Visit or 11 | for more information. 12 | """ 13 | 14 | import codecs 15 | from .propclass import Properties 16 | from .propfile import PropertiesFile 17 | from .reading import ( 18 | Comment, 19 | InvalidUEscapeError, 20 | KeyValue, 21 | PropertiesElement, 22 | Whitespace, 23 | load, 24 | loads, 25 | parse, 26 | unescape, 27 | ) 28 | from .writing import ( 29 | dump, 30 | dumps, 31 | escape, 32 | java_timestamp, 33 | javapropertiesreplace_errors, 34 | join_key_value, 35 | to_comment, 36 | ) 37 | from .xmlprops import dump_xml, dumps_xml, load_xml, loads_xml 38 | 39 | __version__ = "0.8.2" 40 | __author__ = "John Thorvald Wodder II" 41 | __author_email__ = "javaproperties@varonathe.org" 42 | __license__ = "MIT" 43 | __url__ = "https://github.com/jwodder/javaproperties" 44 | 45 | __all__ = [ 46 | "Comment", 47 | "InvalidUEscapeError", 48 | "KeyValue", 49 | "Properties", 50 | "PropertiesElement", 51 | "PropertiesFile", 52 | "Whitespace", 53 | "dump", 54 | "dump_xml", 55 | "dumps", 56 | "dumps_xml", 57 | "escape", 58 | "java_timestamp", 59 | "javapropertiesreplace_errors", 60 | "join_key_value", 61 | "load", 62 | "load_xml", 63 | "loads", 64 | "loads_xml", 65 | "parse", 66 | "to_comment", 67 | "unescape", 68 | ] 69 | 70 | codecs.register_error("javapropertiesreplace", javapropertiesreplace_errors) 71 | -------------------------------------------------------------------------------- /src/javaproperties/propclass.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections.abc import Iterable, Iterator, Mapping 3 | from typing import Any, BinaryIO, IO, MutableMapping, Optional, TextIO, TypeVar 4 | from .reading import load 5 | from .writing import dump 6 | from .xmlprops import dump_xml, load_xml 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | class Properties(MutableMapping[str, str]): 12 | """ 13 | A port of |java8properties|_ that tries to match its behavior as much as is 14 | Pythonically possible. `Properties` behaves like a normal 15 | `~collections.abc.MutableMapping` class (i.e., you can do ``props[key] = 16 | value`` and so forth), except that it may only be used to store `str` 17 | values. 18 | 19 | Two `Properties` instances compare equal iff both their key-value pairs and 20 | :attr:`defaults` attributes are equal. When comparing a `Properties` 21 | instance to any other type of mapping, only the key-value pairs are 22 | considered. 23 | 24 | .. versionchanged:: 0.5.0 25 | `Properties` instances can now compare equal to `dict`\\s and other 26 | mapping types 27 | 28 | :param data: A mapping or iterable of ``(key, value)`` pairs with which to 29 | initialize the `Properties` instance. All keys and values in ``data`` 30 | must be text strings. 31 | :type data: mapping or `None` 32 | :param Optional[Properties] defaults: a set of default properties that will 33 | be used as fallback for `getProperty` 34 | 35 | .. |java8properties| replace:: Java 8's ``java.util.Properties`` 36 | .. _java8properties: https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html 37 | """ 38 | 39 | def __init__( 40 | self, 41 | data: None | Mapping[str, str] | Iterable[tuple[str, str]] = None, 42 | defaults: Optional["Properties"] = None, 43 | ) -> None: 44 | self.data: dict[str, str] = {} 45 | #: A `Properties` subobject used as fallback for `getProperty`. Only 46 | #: `getProperty`, `propertyNames`, `stringPropertyNames`, and `__eq__` 47 | #: use this attribute; all other methods (including the standard 48 | #: mapping methods) ignore it. 49 | self.defaults = defaults 50 | if data is not None: 51 | self.update(data) 52 | 53 | def __getitem__(self, key: str) -> str: 54 | return self.data[key] 55 | 56 | def __setitem__(self, key: str, value: str) -> None: 57 | self.data[key] = value 58 | 59 | def __delitem__(self, key: str) -> None: 60 | del self.data[key] 61 | 62 | def __iter__(self) -> Iterator[str]: 63 | return iter(self.data) 64 | 65 | def __len__(self) -> int: 66 | return len(self.data) 67 | 68 | def __repr__(self) -> str: 69 | return ( 70 | "{0.__module__}.{0.__name__}({1.data!r}, defaults={1.defaults!r})".format( 71 | type(self), self 72 | ) 73 | ) 74 | 75 | def __eq__(self, other: Any) -> bool: 76 | if isinstance(other, Properties): 77 | return self.data == other.data and self.defaults == other.defaults 78 | elif isinstance(other, Mapping): 79 | return dict(self) == other 80 | else: 81 | return NotImplemented 82 | 83 | def getProperty(self, key: str, defaultValue: Optional[T] = None) -> str | T | None: 84 | """ 85 | Fetch the value associated with the key ``key`` in the `Properties` 86 | instance. If the key is not present, `defaults` is checked, and then 87 | *its* `defaults`, etc., until either a value for ``key`` is found or 88 | the next `defaults` is `None`, in which case `defaultValue` is 89 | returned. 90 | 91 | :param str key: the key to look up the value of 92 | :param Any defaultValue: the value to return if ``key`` is not found in 93 | the `Properties` instance 94 | :rtype: str (if ``key`` was found) 95 | """ 96 | try: 97 | return self[key] 98 | except KeyError: 99 | if self.defaults is not None: 100 | return self.defaults.getProperty(key, defaultValue) 101 | else: 102 | return defaultValue 103 | 104 | def load(self, inStream: IO) -> None: 105 | """ 106 | Update the `Properties` instance with the entries in a ``.properties`` 107 | file or file-like object. 108 | 109 | ``inStream`` may be either a text or binary filehandle, with or without 110 | universal newlines enabled. If it is a binary filehandle, its contents 111 | are decoded as Latin-1. 112 | 113 | .. versionchanged:: 0.5.0 114 | Invalid ``\\uXXXX`` escape sequences will now cause an 115 | `InvalidUEscapeError` to be raised 116 | 117 | :param IO inStream: the file from which to read the ``.properties`` 118 | document 119 | :return: `None` 120 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 121 | occurs in the input 122 | """ 123 | self.data.update(load(inStream)) 124 | 125 | def propertyNames(self) -> Iterator[str]: 126 | r""" 127 | Returns a generator of all distinct keys in the `Properties` instance 128 | and its `defaults` (and its `defaults`\’s `defaults`, etc.) in 129 | unspecified order 130 | 131 | :rtype: Iterator[str] 132 | """ 133 | for k in self.data: 134 | yield k 135 | if self.defaults is not None: 136 | for k in self.defaults.propertyNames(): 137 | if k not in self.data: 138 | yield k 139 | 140 | def setProperty(self, key: str, value: str) -> None: 141 | """Equivalent to ``self[key] = value``""" 142 | self[key] = value 143 | 144 | def store(self, out: TextIO, comments: Optional[str] = None) -> None: 145 | """ 146 | Write the `Properties` instance's entries (in unspecified order) in 147 | ``.properties`` format to ``out``, including the current timestamp. 148 | 149 | :param TextIO out: A file-like object to write the properties to. It 150 | must have been opened as a text file with a Latin-1-compatible 151 | encoding. 152 | :param Optional[str] comments: If non-`None`, ``comments`` will be 153 | written to ``out`` as a comment before any other content 154 | :return: `None` 155 | """ 156 | dump(self.data, out, comments=comments) 157 | 158 | def stringPropertyNames(self) -> set[str]: 159 | r""" 160 | Returns a `set` of all keys in the `Properties` instance and its 161 | `defaults` (and its `defaults`\ ’s `defaults`, etc.) 162 | 163 | :rtype: set[str] 164 | """ 165 | names = set(self.data) 166 | if self.defaults is not None: 167 | names.update(self.defaults.stringPropertyNames()) 168 | return names 169 | 170 | def loadFromXML(self, inStream: IO) -> None: 171 | """ 172 | Update the `Properties` instance with the entries in the XML properties 173 | file ``inStream``. 174 | 175 | Beyond basic XML well-formedness, `loadFromXML` only checks that the 176 | root element is named ``properties`` and that all of its ``entry`` 177 | children have ``key`` attributes; no further validation is performed. 178 | 179 | :param IO inStream: the file from which to read the XML properties 180 | document 181 | :return: `None` 182 | :raises ValueError: if the root of the XML tree is not a 183 | ```` tag or an ```` element is missing a ``key`` 184 | attribute 185 | """ 186 | self.data.update(load_xml(inStream)) 187 | 188 | def storeToXML( 189 | self, 190 | out: BinaryIO, 191 | comment: Optional[str] = None, 192 | encoding: str = "UTF-8", 193 | ) -> None: 194 | """ 195 | Write the `Properties` instance's entries (in unspecified order) in XML 196 | properties format to ``out``. 197 | 198 | :param BinaryIO out: a file-like object to write the properties to 199 | :param Optional[str] comment: if non-`None`, ``comment`` will be output 200 | as a ```` element before the ```` elements 201 | :param str encoding: the name of the encoding to use for the XML 202 | document (also included in the XML declaration) 203 | :return: `None` 204 | """ 205 | dump_xml(self.data, out, comment=comment, encoding=encoding) 206 | 207 | def copy(self) -> Properties: 208 | """ 209 | .. versionadded:: 0.5.0 210 | 211 | Create a shallow copy of the mapping. The copy's `defaults` attribute 212 | will be the same instance as the original's `defaults`. 213 | """ 214 | return type(self)(self.data, self.defaults) 215 | -------------------------------------------------------------------------------- /src/javaproperties/propfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections import OrderedDict 3 | from collections.abc import Iterable, Iterator, Mapping 4 | from datetime import datetime 5 | from io import BytesIO, StringIO 6 | from typing import Any, AnyStr, IO, MutableMapping, Optional, Reversible, TextIO, cast 7 | from .reading import Comment, KeyValue, PropertiesElement, Whitespace, loads, parse 8 | from .util import CONTINUED_RGX, LinkedList, LinkedListNode, ascii_splitlines 9 | from .writing import java_timestamp, join_key_value, to_comment 10 | 11 | _NOSOURCE = "" # .source value for new or modified KeyValue instances 12 | 13 | 14 | class PropertiesFile(MutableMapping[str, str]): 15 | """ 16 | .. versionadded:: 0.3.0 17 | 18 | A custom mapping class for reading from, editing, and writing to a 19 | ``.properties`` file while preserving comments & whitespace in the original 20 | input. 21 | 22 | A `PropertiesFile` instance can be constructed from another mapping and/or 23 | iterable of pairs, after which it will act like an 24 | `~collections.OrderedDict`. Alternatively, an instance can be constructed 25 | from a file or string with `PropertiesFile.load()` or 26 | `PropertiesFile.loads()`, and the resulting instance will remember the 27 | formatting of its input and retain that formatting when written back to a 28 | file or string with the `~PropertiesFile.dump()` or 29 | `~PropertiesFile.dumps()` method. The formatting information attached to 30 | an instance ``pf`` can be forgotten by constructing another mapping from it 31 | via ``dict(pf)``, ``OrderedDict(pf)``, or even ``PropertiesFile(pf)`` (Use 32 | the `copy()` method if you want to create another `PropertiesFile` instance 33 | with the same data & formatting). 34 | 35 | When not reading or writing, `PropertiesFile` behaves like a normal 36 | `~collections.abc.MutableMapping` class (i.e., you can do ``props[key] = 37 | value`` and so forth), except that (a) like `~collections.OrderedDict`, key 38 | insertion order is remembered and is used when iterating & dumping (and 39 | `reversed` is supported), and (b) like `Properties`, it may only be used to 40 | store strings and will raise a `TypeError` if passed a non-string object as 41 | key or value. 42 | 43 | Two `PropertiesFile` instances compare equal iff both their key-value pairs 44 | and comment & whitespace lines are equal and in the same order. When 45 | comparing a `PropertiesFile` to any other type of mapping, only the 46 | key-value pairs are considered, and order is ignored. 47 | 48 | `PropertiesFile` currently only supports reading & writing the simple 49 | line-oriented format, not XML. 50 | """ 51 | 52 | def __init__( 53 | self, 54 | mapping: None | Mapping[str, str] | Iterable[tuple[str, str]] = None, 55 | **kwargs: str, 56 | ) -> None: 57 | #: mapping from keys to list of LinkedListNode's in self._lines 58 | self._key2nodes: MutableMapping[ 59 | str, list[LinkedListNode[PropertiesElement]] 60 | ] = OrderedDict() 61 | #: linked list of PropertiesElement's in order of appearance in file 62 | self._lines: LinkedList[PropertiesElement] = LinkedList() 63 | if mapping is not None: 64 | self.update(mapping) 65 | self.update(kwargs) 66 | 67 | def _check(self) -> None: 68 | """ 69 | Assert the internal consistency of the instance's data structures. 70 | This method is for debugging only. 71 | """ 72 | for k, ns in self._key2nodes.items(): 73 | assert k is not None, "null key" 74 | assert ns, "Key does not map to any nodes" 75 | indices = [] 76 | for n in ns: 77 | ix = self._lines.find_node(n) 78 | assert ix is not None, "Key has node not in line list" 79 | indices.append(ix) 80 | assert isinstance(n.value, KeyValue), "Key maps to comment" 81 | assert n.value.key == k, "Key does not map to itself" 82 | assert n.value.value is not None, "Key has null value" 83 | assert indices == sorted(indices), "Key's nodes are not in order" 84 | for line in self._lines: 85 | if not isinstance(line, KeyValue): 86 | assert line.source is not None, "Comment source not stored" 87 | assert loads(line.source) == {}, "Comment source is not comment" 88 | else: 89 | assert line.value is not None, "Key has null value" 90 | if line.source != _NOSOURCE: 91 | assert loads(line.source) == { 92 | line.key: line.value 93 | }, "Key source does not deserialize to itself" 94 | assert line.key in self._key2nodes, "Key is missing from map" 95 | assert any( 96 | line is n.value for n in self._key2nodes[line.key] 97 | ), "Key does not map to itself" # pragma: no cover 98 | 99 | def __getitem__(self, key: str) -> str: 100 | pe = self._key2nodes[key][-1].value 101 | assert isinstance(pe, KeyValue) 102 | return pe.value 103 | 104 | def __setitem__(self, key: str, value: str) -> None: 105 | try: 106 | nodes = self._key2nodes[key] 107 | except KeyError: 108 | if self._lines.end is not None: 109 | # We're adding a line to the end of the file, so make sure the 110 | # line before it ends with a newline and (if it's not a 111 | # comment) doesn't end with a trailing line continuation. 112 | lastline = self._lines.end.value 113 | if not ( 114 | isinstance(lastline, KeyValue) and lastline.source == _NOSOURCE 115 | ): 116 | lastsrc = lastline.source 117 | if isinstance(lastline, KeyValue): 118 | lastsrc = CONTINUED_RGX.sub(r"\1", lastsrc) 119 | if not lastsrc.endswith(("\r", "\n")): 120 | lastsrc += "\n" 121 | self._lines.end.value = lastline._with_source(lastsrc) 122 | n = self._lines.append(KeyValue(key, value, _NOSOURCE)) 123 | self._key2nodes[key] = [n] 124 | else: 125 | # Update the first occurrence of the key and discard the rest. 126 | # This way, the order in which the keys are listed in the file and 127 | # dict will be preserved. 128 | n0 = nodes.pop(0) 129 | for n2 in nodes: 130 | n2.unlink() 131 | self._key2nodes[key] = [n0] 132 | n0.value = KeyValue(key, value, _NOSOURCE) 133 | 134 | def __delitem__(self, key: str) -> None: 135 | for n in self._key2nodes.pop(key): 136 | n.unlink() 137 | 138 | def __iter__(self) -> Iterator[str]: 139 | return iter(self._key2nodes) 140 | 141 | def __reversed__(self) -> Iterator[str]: 142 | return reversed(cast(Reversible[str], self._key2nodes)) 143 | 144 | def __len__(self) -> int: 145 | return len(self._key2nodes) 146 | 147 | def _comparable(self) -> list[tuple[Optional[str], str]]: 148 | return [ 149 | (p.key, p.value) if isinstance(p, KeyValue) else (None, p.source) 150 | for n in self._lines.iternodes() 151 | for p in [n.value] 152 | ### TODO: Also include non-final repeated keys??? 153 | if not isinstance(p, KeyValue) or n is self._key2nodes[p.key][-1] 154 | ] 155 | 156 | def __eq__(self, other: Any) -> bool: 157 | if isinstance(other, PropertiesFile): 158 | return self._comparable() == other._comparable() 159 | ### TODO: Special-case OrderedDict? 160 | elif isinstance(other, Mapping): 161 | return dict(self) == other 162 | else: 163 | return NotImplemented 164 | 165 | @classmethod 166 | def load(cls, fp: IO) -> PropertiesFile: 167 | """ 168 | Parse the contents of the `~io.IOBase.readline`-supporting file-like 169 | object ``fp`` as a simple line-oriented ``.properties`` file and return 170 | a `PropertiesFile` instance. 171 | 172 | ``fp`` may be either a text or binary filehandle, with or without 173 | universal newlines enabled. If it is a binary filehandle, its contents 174 | are decoded as Latin-1. 175 | 176 | .. versionchanged:: 0.5.0 177 | Invalid ``\\uXXXX`` escape sequences will now cause an 178 | `InvalidUEscapeError` to be raised 179 | 180 | :param IO fp: the file from which to read the ``.properties`` document 181 | :rtype: PropertiesFile 182 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 183 | occurs in the input 184 | """ 185 | obj = cls() 186 | for elem in parse(fp): 187 | n = obj._lines.append(elem) 188 | if isinstance(elem, KeyValue): 189 | obj._key2nodes.setdefault(elem.key, []).append(n) 190 | return obj 191 | 192 | @classmethod 193 | def loads(cls, s: AnyStr) -> PropertiesFile: 194 | """ 195 | Parse the contents of the string ``s`` as a simple line-oriented 196 | ``.properties`` file and return a `PropertiesFile` instance. 197 | 198 | ``s`` may be either a text string or bytes string. If it is a bytes 199 | string, its contents are decoded as Latin-1. 200 | 201 | .. versionchanged:: 0.5.0 202 | Invalid ``\\uXXXX`` escape sequences will now cause an 203 | `InvalidUEscapeError` to be raised 204 | 205 | :param Union[str,bytes] s: the string from which to read the 206 | ``.properties`` document 207 | :rtype: PropertiesFile 208 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 209 | occurs in the input 210 | """ 211 | if isinstance(s, bytes): 212 | fp = BytesIO(s) 213 | else: 214 | fp = StringIO(s) 215 | return cls.load(fp) 216 | 217 | def dump(self, fp: TextIO, separator: str = "=", ensure_ascii: bool = True) -> None: 218 | """ 219 | Write the mapping to a file in simple line-oriented ``.properties`` 220 | format. 221 | 222 | If the instance was originally created from a file or string with 223 | `PropertiesFile.load()` or `PropertiesFile.loads()`, then the output 224 | will include the comments and whitespace from the original input, and 225 | any keys that haven't been deleted or reassigned will retain their 226 | original formatting and multiplicity. Key-value pairs that have been 227 | modified or added to the mapping will be reformatted with 228 | `join_key_value()` using the given separator and ``ensure_ascii`` 229 | setting. All key-value pairs are output in the order they were 230 | defined, with new keys added to the end. 231 | 232 | .. versionchanged:: 0.8.0 233 | ``ensure_ascii`` parameter added 234 | 235 | .. note:: 236 | 237 | Serializing a `PropertiesFile` instance with the :func:`dump()` 238 | function instead will cause all formatting information to be 239 | ignored, as :func:`dump()` will treat the instance like a normal 240 | mapping. 241 | 242 | :param TextIO fp: A file-like object to write the mapping to. It must 243 | have been opened as a text file with a Latin-1-compatible encoding. 244 | :param str separator: The string to use for separating new or modified 245 | keys & values. Only ``" "``, ``"="``, and ``":"`` (possibly with 246 | added whitespace) should ever be used as the separator. 247 | :param bool ensure_ascii: if true, all non-ASCII characters in new or 248 | modified key-value pairs will be replaced with ``\\uXXXX`` escape 249 | sequences in the output; if false, non-ASCII characters will be 250 | passed through as-is 251 | :return: `None` 252 | """ 253 | for line in self._lines: 254 | if isinstance(line, KeyValue) and line.source == _NOSOURCE: 255 | print( 256 | join_key_value( 257 | line.key, 258 | line.value, 259 | separator=separator, 260 | ensure_ascii=ensure_ascii, 261 | ), 262 | file=fp, 263 | ) 264 | else: 265 | fp.write(line.source) 266 | 267 | def dumps(self, separator: str = "=", ensure_ascii: bool = True) -> str: 268 | """ 269 | Convert the mapping to a `str` in simple line-oriented ``.properties`` 270 | format. 271 | 272 | If the instance was originally created from a file or string with 273 | `PropertiesFile.load()` or `PropertiesFile.loads()`, then the output 274 | will include the comments and whitespace from the original input, and 275 | any keys that haven't been deleted or reassigned will retain their 276 | original formatting and multiplicity. Key-value pairs that have been 277 | modified or added to the mapping will be reformatted with 278 | `join_key_value()` using the given separator and ``ensure_ascii`` 279 | setting. All key-value pairs are output in the order they were 280 | defined, with new keys added to the end. 281 | 282 | .. versionchanged:: 0.8.0 283 | ``ensure_ascii`` parameter added 284 | 285 | .. note:: 286 | 287 | Serializing a `PropertiesFile` instance with the :func:`dumps()` 288 | function instead will cause all formatting information to be 289 | ignored, as :func:`dumps()` will treat the instance like a normal 290 | mapping. 291 | 292 | :param str separator: The string to use for separating new or modified 293 | keys & values. Only ``" "``, ``"="``, and ``":"`` (possibly with 294 | added whitespace) should ever be used as the separator. 295 | :param bool ensure_ascii: if true, all non-ASCII characters in new or 296 | modified key-value pairs will be replaced with ``\\uXXXX`` escape 297 | sequences in the output; if false, non-ASCII characters will be 298 | passed through as-is 299 | :rtype: str 300 | """ 301 | s = StringIO() 302 | self.dump(s, separator=separator, ensure_ascii=ensure_ascii) 303 | return s.getvalue() 304 | 305 | def copy(self) -> PropertiesFile: 306 | """Create a copy of the mapping, including formatting information""" 307 | dup = type(self)() 308 | for elem in self._lines: 309 | n = dup._lines.append(elem) 310 | if isinstance(elem, KeyValue): 311 | dup._key2nodes.setdefault(elem.key, []).append(n) 312 | return dup 313 | 314 | @property 315 | def timestamp(self) -> Optional[str]: 316 | """ 317 | .. versionadded:: 0.7.0 318 | 319 | The value of the timestamp comment, with the comment marker, any 320 | whitespace leading up to it, and the trailing newline removed. The 321 | timestamp comment is the first comment that appears to be a valid 322 | timestamp as produced by Java 8's ``Date.toString()`` and that does not 323 | come after any key-value pairs; if there is no such comment, the value 324 | of this property is `None`. 325 | 326 | The timestamp can be changed by assigning to this property. Assigning 327 | a string ``s`` replaces the timestamp comment with the output of 328 | ``to_comment(s)``; no check is made as to whether the result is a valid 329 | timestamp comment. Assigning `None` or `False` causes the timestamp 330 | comment to be deleted (also achievable with ``del pf.timestamp``). 331 | Assigning any other value ``x`` replaces the timestamp comment with the 332 | output of ``to_comment(java_timestamp(x))``. 333 | 334 | >>> pf = PropertiesFile.loads('''\\ 335 | ... #This is a comment. 336 | ... #Tue Feb 25 19:13:27 EST 2020 337 | ... key = value 338 | ... zebra: apple 339 | ... ''') 340 | >>> pf.timestamp 341 | 'Tue Feb 25 19:13:27 EST 2020' 342 | >>> pf.timestamp = 1234567890 343 | >>> pf.timestamp 344 | 'Fri Feb 13 18:31:30 EST 2009' 345 | >>> print(pf.dumps(), end='') 346 | #This is a comment. 347 | #Fri Feb 13 18:31:30 EST 2009 348 | key = value 349 | zebra: apple 350 | >>> del pf.timestamp 351 | >>> pf.timestamp is None 352 | True 353 | >>> print(pf.dumps(), end='') 354 | #This is a comment. 355 | key = value 356 | zebra: apple 357 | """ 358 | for elem in self._lines: 359 | if isinstance(elem, Comment) and elem.is_timestamp(): 360 | return elem.value 361 | elif isinstance(elem, KeyValue): 362 | return None 363 | return None 364 | 365 | @timestamp.setter 366 | def timestamp(self, value: str | None | bool | float | datetime) -> None: 367 | if value is not None and value is not False: 368 | if not isinstance(value, str): 369 | value = java_timestamp(value) 370 | comments = [Comment(c) for c in ascii_splitlines(to_comment(value) + "\n")] 371 | else: 372 | comments = [] 373 | for n in self._lines.iternodes(): 374 | if isinstance(n.value, Comment) and n.value.is_timestamp(): 375 | if comments: 376 | n.value = comments[0] 377 | for c in comments[1:]: 378 | n = n.insert_after(c) 379 | else: 380 | n.unlink() 381 | return 382 | elif isinstance(n.value, KeyValue): 383 | for c in comments: 384 | n.insert_before(c) 385 | return 386 | else: 387 | for c in comments: 388 | self._lines.append(c) 389 | 390 | @timestamp.deleter 391 | def timestamp(self) -> None: 392 | for n in self._lines.iternodes(): 393 | if isinstance(n.value, Comment) and n.value.is_timestamp(): 394 | n.unlink() 395 | return 396 | elif isinstance(n.value, KeyValue): 397 | return 398 | 399 | @property 400 | def header_comment(self) -> Optional[str]: 401 | """ 402 | .. versionadded:: 0.7.0 403 | 404 | The concatenated values of all comments at the top of the file, up to 405 | (but not including) the first key-value pair or timestamp comment, 406 | whichever comes first. The comments are returned with comment markers 407 | and the whitespace leading up to them removed, with line endings 408 | changed to ``\\n``, and with the line ending on the final comment (if 409 | any) removed. Blank/all-whitespace lines among the comments are 410 | ignored. 411 | 412 | The header comment can be changed by assigning to this property. 413 | Assigning a string ``s`` causes everything before the first key-value 414 | pair or timestamp comment to be replaced by the output of 415 | ``to_comment(s)``. Assigning `None` causes the header comment to be 416 | deleted (also achievable with ``del pf.header_comment``). 417 | 418 | >>> pf = PropertiesFile.loads('''\\ 419 | ... #This is a comment. 420 | ... ! This is also a comment. 421 | ... #Tue Feb 25 19:13:27 EST 2020 422 | ... key = value 423 | ... zebra: apple 424 | ... ''') 425 | >>> pf.header_comment 426 | 'This is a comment.\\n This is also a comment.' 427 | >>> pf.header_comment = 'New comment' 428 | >>> print(pf.dumps(), end='') 429 | #New comment 430 | #Tue Feb 25 19:13:27 EST 2020 431 | key = value 432 | zebra: apple 433 | >>> del pf.header_comment 434 | >>> pf.header_comment is None 435 | True 436 | >>> print(pf.dumps(), end='') 437 | #Tue Feb 25 19:13:27 EST 2020 438 | key = value 439 | zebra: apple 440 | """ 441 | comments = [] 442 | for elem in self._lines: 443 | if isinstance(elem, Whitespace): 444 | pass 445 | elif isinstance(elem, KeyValue): 446 | break 447 | else: 448 | assert isinstance(elem, Comment) 449 | if elem.is_timestamp(): 450 | break 451 | comments.append(elem.value) 452 | if comments: 453 | return "\n".join(comments) 454 | else: 455 | return None 456 | 457 | @header_comment.setter 458 | def header_comment(self, value: Optional[str]) -> None: 459 | if value is None: 460 | comments = [] 461 | else: 462 | comments = [Comment(c) for c in ascii_splitlines(to_comment(value) + "\n")] 463 | while self._lines.start is not None: 464 | n = self._lines.start 465 | if isinstance(n.value, KeyValue) or ( 466 | isinstance(n.value, Comment) and n.value.is_timestamp() 467 | ): 468 | break 469 | else: 470 | n.unlink() 471 | if self._lines.start is None: 472 | for c in comments: 473 | self._lines.append(c) 474 | else: 475 | n = self._lines.start 476 | for c in comments: 477 | n.insert_before(c) 478 | 479 | @header_comment.deleter 480 | def header_comment(self) -> None: 481 | while self._lines.start is not None: 482 | n = self._lines.start 483 | if isinstance(n.value, KeyValue) or ( 484 | isinstance(n.value, Comment) and n.value.is_timestamp() 485 | ): 486 | break 487 | else: 488 | n.unlink() 489 | -------------------------------------------------------------------------------- /src/javaproperties/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwodder/javaproperties/4dd3d7eb35deca4a4a4a24d17d37e094492e52b4/src/javaproperties/py.typed -------------------------------------------------------------------------------- /src/javaproperties/reading.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections.abc import Callable, Iterator 3 | from io import BytesIO, StringIO 4 | import re 5 | from typing import Any, IO, Iterable, TypeVar, overload 6 | from .util import CONTINUED_RGX, ascii_splitlines 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | @overload 12 | def load(fp: IO) -> dict[str, str]: ... 13 | 14 | 15 | @overload 16 | def load(fp: IO, object_pairs_hook: type[T]) -> T: ... 17 | 18 | 19 | @overload 20 | def load(fp: IO, object_pairs_hook: Callable[[Iterator[tuple[str, str]]], T]) -> T: ... 21 | 22 | 23 | def load(fp, object_pairs_hook=dict): # type: ignore[no-untyped-def] 24 | """ 25 | Parse the contents of the `~io.IOBase.readline`-supporting file-like object 26 | ``fp`` as a simple line-oriented ``.properties`` file and return a `dict` 27 | of the key-value pairs. 28 | 29 | ``fp`` may be either a text or binary filehandle, with or without universal 30 | newlines enabled. If it is a binary filehandle, its contents are decoded 31 | as Latin-1. 32 | 33 | By default, the key-value pairs extracted from ``fp`` are combined into a 34 | `dict` with later occurrences of a key overriding previous occurrences of 35 | the same key. To change this behavior, pass a callable as the 36 | ``object_pairs_hook`` argument; it will be called with one argument, a 37 | generator of ``(key, value)`` pairs representing the key-value entries in 38 | ``fp`` (including duplicates) in order of occurrence. `load` will then 39 | return the value returned by ``object_pairs_hook``. 40 | 41 | .. versionchanged:: 0.5.0 42 | Invalid ``\\uXXXX`` escape sequences will now cause an 43 | `InvalidUEscapeError` to be raised 44 | 45 | :param IO fp: the file from which to read the ``.properties`` document 46 | :param callable object_pairs_hook: class or function for combining the 47 | key-value pairs 48 | :rtype: `dict` of text strings or the return value of ``object_pairs_hook`` 49 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 50 | occurs in the input 51 | """ 52 | return object_pairs_hook( 53 | (kv.key, kv.value) for kv in parse(fp) if isinstance(kv, KeyValue) 54 | ) 55 | 56 | 57 | @overload 58 | def loads(s: str | bytes) -> dict[str, str]: ... 59 | 60 | 61 | @overload 62 | def loads(s: str | bytes, object_pairs_hook: type[T]) -> T: ... 63 | 64 | 65 | @overload 66 | def loads( 67 | s: str | bytes, object_pairs_hook: Callable[[Iterator[tuple[str, str]]], T] 68 | ) -> T: ... 69 | 70 | 71 | def loads(s, object_pairs_hook=dict): # type: ignore[no-untyped-def] 72 | """ 73 | Parse the contents of the string ``s`` as a simple line-oriented 74 | ``.properties`` file and return a `dict` of the key-value pairs. 75 | 76 | ``s`` may be either a text string or bytes string. If it is a bytes 77 | string, its contents are decoded as Latin-1. 78 | 79 | By default, the key-value pairs extracted from ``s`` are combined into a 80 | `dict` with later occurrences of a key overriding previous occurrences of 81 | the same key. To change this behavior, pass a callable as the 82 | ``object_pairs_hook`` argument; it will be called with one argument, a 83 | generator of ``(key, value)`` pairs representing the key-value entries in 84 | ``s`` (including duplicates) in order of occurrence. `loads` will then 85 | return the value returned by ``object_pairs_hook``. 86 | 87 | .. versionchanged:: 0.5.0 88 | Invalid ``\\uXXXX`` escape sequences will now cause an 89 | `InvalidUEscapeError` to be raised 90 | 91 | :param Union[str,bytes] s: the string from which to read the 92 | ``.properties`` document 93 | :param callable object_pairs_hook: class or function for combining the 94 | key-value pairs 95 | :rtype: `dict` of text strings or the return value of ``object_pairs_hook`` 96 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 97 | occurs in the input 98 | """ 99 | fp = BytesIO(s) if isinstance(s, bytes) else StringIO(s) 100 | return load(fp, object_pairs_hook=object_pairs_hook) 101 | 102 | 103 | TIMESTAMP_RGX = re.compile( 104 | r"\A[ \t\f]*[#!][ \t\f]*" 105 | r"(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)" 106 | r" (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" 107 | r" (?:[012][0-9]|3[01])" 108 | r" (?:[01][0-9]|2[0-3]):[0-5][0-9]:(?:[0-5][0-9]|6[01])" 109 | r" (?:[A-Za-z_0-9]{3})?" 110 | r" [0-9]{4,}" 111 | r"[ \t\f]*\r?\n?\Z" 112 | ) 113 | 114 | 115 | class PropertiesElement(Iterable[str]): 116 | """ 117 | .. versionadded:: 0.7.0 118 | 119 | Superclass of objects returned by `parse()` 120 | """ 121 | 122 | def __init__(self, source: str) -> None: 123 | #: The raw, unmodified input line (including trailing newlines) 124 | self.source: str = source 125 | 126 | def __iter__(self) -> Iterator[str]: 127 | return iter((self.source,)) 128 | 129 | def __eq__(self, other: Any) -> bool: 130 | if type(self) is type(other): 131 | return tuple(self) == tuple(other) 132 | else: 133 | return NotImplemented 134 | 135 | def __repr__(self) -> str: 136 | return "{0.__module__}.{0.__name__}(source={1.source!r})".format( 137 | type(self), self 138 | ) 139 | 140 | @property 141 | def source_stripped(self) -> str: 142 | """ 143 | Like `source`, but with the final trailing newline and line 144 | continuation (if any) removed 145 | """ 146 | s = self.source.rstrip("\r\n") 147 | if CONTINUED_RGX.search(s): 148 | s = s[:-1] 149 | return s 150 | 151 | def _with_source(self, newsource: str) -> PropertiesElement: 152 | return type(self)(source=newsource) 153 | 154 | 155 | class Comment(PropertiesElement): 156 | """ 157 | .. versionadded:: 0.7.0 158 | 159 | Subclass of `PropertiesElement` representing a comment 160 | """ 161 | 162 | @property 163 | def value(self) -> str: 164 | """ 165 | Returns the contents of the comment, with the comment marker, any 166 | whitespace leading up to it, and the trailing newline removed 167 | """ 168 | s = self.source.lstrip(" \t\f") 169 | if s.startswith(("#", "!")): 170 | s = s[1:] 171 | return s.rstrip("\r\n") 172 | 173 | @property 174 | def source_stripped(self) -> str: 175 | """ 176 | Like `source`, but with the final trailing newline (if any) removed 177 | """ 178 | return self.source.rstrip("\r\n") 179 | 180 | def is_timestamp(self) -> bool: 181 | """ 182 | Returns `True` iff the comment's value appears to be a valid timestamp 183 | as produced by Java 8's ``Date.toString()`` 184 | """ 185 | return bool(TIMESTAMP_RGX.fullmatch(self.source)) 186 | 187 | 188 | class Whitespace(PropertiesElement): 189 | """ 190 | .. versionadded:: 0.7.0 191 | 192 | Subclass of `PropertiesElement` representing a line that is either empty or 193 | contains only whitespace (and possibly some line continuations) 194 | """ 195 | 196 | 197 | class KeyValue(PropertiesElement): 198 | """ 199 | .. versionadded:: 0.7.0 200 | 201 | Subclass of `PropertiesElement` representing a key-value entry 202 | """ 203 | 204 | def __init__(self, key: str, value: str, source: str): 205 | super().__init__(source=source) 206 | #: The entry's key, after processing escape sequences 207 | self.key: str = key 208 | #: The entry's value, after processing escape sequences 209 | self.value: str = value 210 | 211 | def __iter__(self) -> Iterator[str]: 212 | return iter((self.key, self.value, self.source)) 213 | 214 | def __repr__(self) -> str: 215 | return ( 216 | "{0.__module__}.{0.__name__}(key={1.key!r}, value={1.value!r}," 217 | " source={1.source!r})".format(type(self), self) 218 | ) 219 | 220 | def _with_source(self, newsource: str) -> KeyValue: 221 | return type(self)(key=self.key, value=self.value, source=newsource) 222 | 223 | 224 | COMMENT_RGX = re.compile(r"^[ \t\f]*[#!]") 225 | BLANK_RGX = re.compile(r"^[ \t\f]*\r?\n?\Z") 226 | SEPARATOR_RGX = re.compile(r"(? Iterator[PropertiesElement]: 230 | """ 231 | Parse the given data as a simple line-oriented ``.properties`` file and 232 | return a generator of `PropertiesElement` objects representing the 233 | key-value pairs (as `KeyValue` objects), comments (as `Comment` objects), 234 | and blank lines (as `Whitespace` objects) in the input in order of 235 | occurrence. 236 | 237 | If the same key appears multiple times in the input, a separate `KeyValue` 238 | object is emitted for each entry. 239 | 240 | ``src`` may be a text string, a bytes string, or a text or binary 241 | filehandle/file-like object supporting the `~io.IOBase.readline` method 242 | (with or without universal newlines enabled). Bytes input is decoded as 243 | Latin-1. 244 | 245 | .. versionchanged:: 0.5.0 246 | Invalid ``\\uXXXX`` escape sequences will now cause an 247 | `InvalidUEscapeError` to be raised 248 | 249 | .. versionchanged:: 0.7.0 250 | `parse()` now accepts strings as input, and it now returns a generator 251 | of custom objects instead of triples of strings 252 | 253 | :param src: the ``.properties`` document 254 | :type src: string or file-like object 255 | :rtype: Iterator[PropertiesElement] 256 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 257 | occurs in the input 258 | """ 259 | liter: Iterator[str] 260 | if isinstance(src, bytes): 261 | liter = iter(ascii_splitlines(src.decode("iso-8859-1"))) 262 | elif isinstance(src, str): 263 | liter = iter(ascii_splitlines(src)) 264 | else: 265 | fp: IO = src 266 | 267 | def lineiter() -> Iterator[str]: 268 | while True: 269 | line = fp.readline() 270 | ll: str 271 | if isinstance(line, bytes): 272 | ll = line.decode("iso-8859-1") 273 | else: 274 | ll = line 275 | if ll == "": 276 | return 277 | for ln in ascii_splitlines(ll): 278 | yield ln 279 | 280 | liter = lineiter() 281 | for source in liter: 282 | line = source 283 | if COMMENT_RGX.match(line): 284 | yield Comment(source) 285 | continue 286 | elif BLANK_RGX.match(line): 287 | yield Whitespace(source) 288 | continue 289 | line = line.lstrip(" \t\f").rstrip("\r\n") 290 | while CONTINUED_RGX.search(line): 291 | line = line[:-1] 292 | nextline = next(liter, "") 293 | source += nextline 294 | line += nextline.lstrip(" \t\f").rstrip("\r\n") 295 | if line == "": # series of otherwise-blank lines with continuations 296 | yield Whitespace(source) 297 | continue 298 | m = SEPARATOR_RGX.search(line) 299 | if m: 300 | yield KeyValue( 301 | unescape(line[: m.start(1)]), 302 | unescape(line[m.end() :]), 303 | source, 304 | ) 305 | else: 306 | yield KeyValue(unescape(line), "", source) 307 | 308 | 309 | SURROGATE_PAIR_RGX = re.compile(r"[\uD800-\uDBFF][\uDC00-\uDFFF]") 310 | ESCAPE_RGX = re.compile(r"\\(u.{0,4}|.)") 311 | U_ESCAPE_RGX = re.compile(r"^u[0-9A-Fa-f]{4}\Z") 312 | 313 | 314 | def unescape(field: str) -> str: 315 | """ 316 | Decode escape sequences in a ``.properties`` key or value. The following 317 | escape sequences are recognized:: 318 | 319 | \\t \\n \\f \\r \\uXXXX \\\\ 320 | 321 | If a backslash is followed by any other character, the backslash is 322 | dropped. 323 | 324 | In addition, any valid UTF-16 surrogate pairs in the string after 325 | escape-decoding are further decoded into the non-BMP characters they 326 | represent. (Invalid & isolated surrogate code points are left as-is.) 327 | 328 | .. versionchanged:: 0.5.0 329 | Invalid ``\\uXXXX`` escape sequences will now cause an 330 | `InvalidUEscapeError` to be raised 331 | 332 | :param str field: the string to decode 333 | :rtype: str 334 | :raises InvalidUEscapeError: if an invalid ``\\uXXXX`` escape sequence 335 | occurs in the input 336 | """ 337 | return SURROGATE_PAIR_RGX.sub(_unsurrogate, ESCAPE_RGX.sub(_unesc, field)) 338 | 339 | 340 | _unescapes = {"t": "\t", "n": "\n", "f": "\f", "r": "\r"} 341 | 342 | 343 | def _unesc(m: re.Match[str]) -> str: 344 | esc = m.group(1) 345 | if esc[0] == "u": 346 | if not U_ESCAPE_RGX.match(esc): 347 | # We can't rely on `int` failing, because it succeeds when `esc` 348 | # has trailing whitespace or a leading minus. 349 | raise InvalidUEscapeError("\\" + esc) 350 | return chr(int(esc[1:], 16)) 351 | else: 352 | return _unescapes.get(esc, esc) 353 | 354 | 355 | def _unsurrogate(m: re.Match[str]) -> str: 356 | c, d = map(ord, m.group()) 357 | uord = ((c - 0xD800) << 10) + (d - 0xDC00) + 0x10000 358 | return chr(uord) 359 | 360 | 361 | class InvalidUEscapeError(ValueError): 362 | """ 363 | .. versionadded:: 0.5.0 364 | 365 | Raised when an invalid ``\\uXXXX`` escape sequence (i.e., a ``\\u`` not 366 | immediately followed by four hexadecimal digits) is encountered in a simple 367 | line-oriented ``.properties`` file 368 | """ 369 | 370 | def __init__(self, escape: str) -> None: 371 | #: The invalid ``\uXXXX`` escape sequence encountered 372 | self.escape: str = escape 373 | 374 | def __str__(self) -> str: 375 | return "Invalid \\u escape sequence: " + self.escape 376 | -------------------------------------------------------------------------------- /src/javaproperties/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections.abc import Iterable, Iterator, Mapping 3 | import re 4 | from typing import Generic, Optional, TypeVar 5 | 6 | CONTINUED_RGX = re.compile(r"(? None: 17 | self.start: Optional["LinkedListNode[T]"] = None 18 | self.end: Optional["LinkedListNode[T]"] = None 19 | 20 | def __iter__(self) -> Iterator[T]: 21 | return (n.value for n in self.iternodes()) 22 | 23 | def iternodes(self) -> Iterator["LinkedListNode[T]"]: 24 | n = self.start 25 | while n is not None: 26 | yield n 27 | n = n.next 28 | 29 | def append(self, value: T) -> LinkedListNode[T]: 30 | n = LinkedListNode(value, self) 31 | if self.start is None: 32 | self.start = n 33 | else: 34 | assert self.end is not None 35 | self.end.next = n 36 | n.prev = self.end 37 | self.end = n 38 | return n 39 | 40 | def find_node(self, node: "LinkedListNode[T]") -> Optional[int]: 41 | for i, n in enumerate(self.iternodes()): 42 | if n is node: 43 | return i 44 | return None 45 | 46 | 47 | class LinkedListNode(Generic[T]): 48 | def __init__(self, value: T, lst: LinkedList[T]) -> None: 49 | self.value: T = value 50 | self.lst: LinkedList[T] = lst 51 | self.prev: Optional["LinkedListNode[T]"] = None 52 | self.next: Optional["LinkedListNode[T]"] = None 53 | 54 | def unlink(self) -> None: 55 | if self.prev is not None: 56 | self.prev.next = self.next 57 | if self.next is not None: 58 | self.next.prev = self.prev 59 | if self is self.lst.start: 60 | self.lst.start = self.next 61 | if self is self.lst.end: 62 | self.lst.end = self.prev 63 | 64 | def insert_after(self, value: T) -> LinkedListNode[T]: 65 | """Inserts a new node with value ``value`` after the node ``self``""" 66 | n = LinkedListNode(value, self.lst) 67 | n.prev = self 68 | n.next = self.next 69 | self.next = n 70 | if n.next is not None: 71 | n.next.prev = n 72 | else: 73 | assert self is self.lst.end 74 | self.lst.end = n 75 | return n 76 | 77 | def insert_before(self, value: T) -> LinkedListNode[T]: 78 | """ 79 | Inserts a new node with value ``value`` before the node ``self`` 80 | """ 81 | n = LinkedListNode(value, self.lst) 82 | n.next = self 83 | n.prev = self.prev 84 | self.prev = n 85 | if n.prev is not None: 86 | n.prev.next = n 87 | else: 88 | assert self is self.lst.start 89 | self.lst.start = n 90 | return n 91 | 92 | 93 | def itemize( 94 | kvs: Mapping[K, V] | Iterable[tuple[K, V]], 95 | sort_keys: bool = False, 96 | ) -> Iterable[tuple[K, V]]: 97 | items: Iterable[tuple[K, V]] 98 | if isinstance(kvs, Mapping): 99 | items = ((k, kvs[k]) for k in kvs) 100 | else: 101 | items = kvs 102 | if sort_keys: 103 | items = sorted(items) 104 | return items 105 | 106 | 107 | def ascii_splitlines(s: str) -> list[str]: 108 | """ 109 | Like `str.splitlines(True)`, except it only treats LF, CR LF, and CR as 110 | line endings 111 | """ 112 | lines = [] 113 | lastend = 0 114 | for m in EOL_RGX.finditer(s): 115 | lines.append(s[lastend : m.end()]) 116 | lastend = m.end() 117 | if lastend < len(s): 118 | lines.append(s[lastend:]) 119 | return lines 120 | -------------------------------------------------------------------------------- /src/javaproperties/writing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections.abc import Iterable, Mapping 3 | from datetime import datetime 4 | from io import StringIO 5 | import re 6 | import time 7 | from typing import Optional, TextIO 8 | from .util import itemize 9 | 10 | 11 | def dump( 12 | props: Mapping[str, str] | Iterable[tuple[str, str]], 13 | fp: TextIO, 14 | separator: str = "=", 15 | comments: Optional[str] = None, 16 | timestamp: None | bool | float | datetime = True, 17 | sort_keys: bool = False, 18 | ensure_ascii: bool = True, 19 | ensure_ascii_comments: Optional[bool] = None, 20 | ) -> None: 21 | """ 22 | Write a series of key-value pairs to a file in simple line-oriented 23 | ``.properties`` format. 24 | 25 | .. versionchanged:: 0.6.0 26 | ``ensure_ascii`` and ``ensure_ascii_comments`` parameters added 27 | 28 | :param props: A mapping or iterable of ``(key, value)`` pairs to write to 29 | ``fp``. All keys and values in ``props`` must be `str` values. If 30 | ``sort_keys`` is `False`, the entries are output in iteration order. 31 | :param TextIO fp: A file-like object to write the values of ``props`` to. 32 | It must have been opened as a text file. 33 | :param str separator: The string to use for separating keys & values. Only 34 | ``" "``, ``"="``, and ``":"`` (possibly with added whitespace) should 35 | ever be used as the separator. 36 | :param Optional[str] comments: if non-`None`, ``comments`` will be written 37 | to ``fp`` as a comment before any other content 38 | :param timestamp: If neither `None` nor `False`, a timestamp in the form of 39 | ``Mon Sep 02 14:00:54 EDT 2016`` is written as a comment to ``fp`` 40 | after ``comments`` (if any) and before the key-value pairs. If 41 | ``timestamp`` is `True`, the current date & time is used. If it is a 42 | number, it is converted from seconds since the epoch to local time. If 43 | it is a `datetime.datetime` object, its value is used directly, with 44 | naïve objects assumed to be in the local timezone. 45 | :type timestamp: `None`, `bool`, number, or `datetime.datetime` 46 | :param bool sort_keys: if true, the elements of ``props`` are sorted 47 | lexicographically by key in the output 48 | :param bool ensure_ascii: if true, all non-ASCII characters will be 49 | replaced with ``\\uXXXX`` escape sequences in the output; if false, 50 | non-ASCII characters will be passed through as-is 51 | :param Optional[bool] ensure_ascii_comments: if true, all non-ASCII 52 | characters in ``comments`` will be replaced with ``\\uXXXX`` escape 53 | sequences in the output; if `None`, only non-Latin-1 characters will be 54 | escaped; if false, no characters will be escaped 55 | :return: `None` 56 | """ 57 | if comments is not None: 58 | print(to_comment(comments, ensure_ascii=ensure_ascii_comments), file=fp) 59 | if timestamp is not None and timestamp is not False: 60 | print(to_comment(java_timestamp(timestamp)), file=fp) 61 | for k, v in itemize(props, sort_keys=sort_keys): 62 | print( 63 | join_key_value(k, v, separator, ensure_ascii=ensure_ascii), 64 | file=fp, 65 | ) 66 | 67 | 68 | def dumps( 69 | props: Mapping[str, str] | Iterable[tuple[str, str]], 70 | separator: str = "=", 71 | comments: Optional[str] = None, 72 | timestamp: None | bool | float | datetime = True, 73 | sort_keys: bool = False, 74 | ensure_ascii: bool = True, 75 | ensure_ascii_comments: Optional[bool] = None, 76 | ) -> str: 77 | """ 78 | Convert a series of key-value pairs to a `str` in simple line-oriented 79 | ``.properties`` format. 80 | 81 | .. versionchanged:: 0.6.0 82 | ``ensure_ascii`` and ``ensure_ascii_comments`` parameters added 83 | 84 | :param props: A mapping or iterable of ``(key, value)`` pairs to serialize. 85 | All keys and values in ``props`` must be `str` values. If 86 | ``sort_keys`` is `False`, the entries are output in iteration order. 87 | :param str separator: The string to use for separating keys & values. Only 88 | ``" "``, ``"="``, and ``":"`` (possibly with added whitespace) should 89 | ever be used as the separator. 90 | :param Optional[str] comments: if non-`None`, ``comments`` will be output 91 | as a comment before any other content 92 | :param timestamp: If neither `None` nor `False`, a timestamp in the form of 93 | ``Mon Sep 02 14:00:54 EDT 2016`` is output as a comment after 94 | ``comments`` (if any) and before the key-value pairs. If ``timestamp`` 95 | is `True`, the current date & time is used. If it is a number, it is 96 | converted from seconds since the epoch to local time. If it is a 97 | `datetime.datetime` object, its value is used directly, with naïve 98 | objects assumed to be in the local timezone. 99 | :type timestamp: `None`, `bool`, number, or `datetime.datetime` 100 | :param bool sort_keys: if true, the elements of ``props`` are sorted 101 | lexicographically by key in the output 102 | :param bool ensure_ascii: if true, all non-ASCII characters will be 103 | replaced with ``\\uXXXX`` escape sequences in the output; if false, 104 | non-ASCII characters will be passed through as-is 105 | :param Optional[bool] ensure_ascii_comments: if true, all non-ASCII 106 | characters in ``comments`` will be replaced with ``\\uXXXX`` escape 107 | sequences in the output; if `None`, only non-Latin-1 characters will be 108 | escaped; if false, no characters will be escaped 109 | :rtype: text string 110 | """ 111 | s = StringIO() 112 | dump( 113 | props, 114 | s, 115 | separator=separator, 116 | comments=comments, 117 | timestamp=timestamp, 118 | sort_keys=sort_keys, 119 | ensure_ascii=ensure_ascii, 120 | ensure_ascii_comments=ensure_ascii_comments, 121 | ) 122 | return s.getvalue() 123 | 124 | 125 | NON_ASCII_RGX = re.compile(r"[^\x00-\x7F]") 126 | NON_LATIN1_RGX = re.compile(r"[^\x00-\xFF]") 127 | NEWLINE_OLD_COMMENT_RGX = re.compile(r"\n(?![#!])") 128 | NON_N_EOL_RGX = re.compile(r"\r\n?") 129 | 130 | 131 | def to_comment(comment: str, ensure_ascii: Optional[bool] = None) -> str: 132 | """ 133 | Convert a string to a ``.properties`` file comment. Non-Latin-1 or 134 | non-ASCII characters in the string may be escaped using ``\\uXXXX`` escapes 135 | (depending on the value of ``ensure_ascii``), a ``#`` is prepended to the 136 | string, any CR LF or CR line breaks in the string are converted to LF, and 137 | a ``#`` is inserted after any line break not already followed by a ``#`` or 138 | ``!``. No trailing newline is added. 139 | 140 | >>> to_comment('They say foo=bar,\\r\\nbut does bar=foo?') 141 | '#They say foo=bar,\\n#but does bar=foo?' 142 | 143 | .. versionchanged:: 0.6.0 144 | ``ensure_ascii`` parameter added 145 | 146 | :param str comment: the string to convert to a comment 147 | :param Optional[bool] ensure_ascii: if true, all non-ASCII characters will 148 | be replaced with ``\\uXXXX`` escape sequences in the output; if `None`, 149 | only non-Latin-1 characters will be escaped; if false, no characters 150 | will be escaped 151 | :rtype: str 152 | """ 153 | comment = NON_N_EOL_RGX.sub("\n", comment) 154 | comment = NEWLINE_OLD_COMMENT_RGX.sub("\n#", comment) 155 | if ensure_ascii is None: 156 | comment = NON_LATIN1_RGX.sub(_esc, comment) 157 | elif ensure_ascii: 158 | comment = NON_ASCII_RGX.sub(_esc, comment) 159 | return "#" + comment 160 | 161 | 162 | def join_key_value( 163 | key: str, 164 | value: str, 165 | separator: str = "=", 166 | ensure_ascii: bool = True, 167 | ) -> str: 168 | r""" 169 | Join a key and value together into a single line suitable for adding to a 170 | simple line-oriented ``.properties`` file. No trailing newline is added. 171 | 172 | >>> join_key_value('possible separators', '= : space') 173 | 'possible\\ separators=\\= \\: space' 174 | 175 | .. versionchanged:: 0.6.0 176 | ``ensure_ascii`` parameter added 177 | 178 | :param str key: the key 179 | :param str value: the value 180 | :param str separator: the string to use for separating the key & value. 181 | Only ``" "``, ``"="``, and ``":"`` (possibly with added whitespace) 182 | should ever be used as the separator. 183 | :param bool ensure_ascii: if true, all non-ASCII characters will be 184 | replaced with ``\\uXXXX`` escape sequences in the output; if false, 185 | non-ASCII characters will be passed through as-is 186 | :rtype: str 187 | """ 188 | # Escapes `key` and `value` the same way as java.util.Properties.store() 189 | value = _base_escape(value, ensure_ascii=ensure_ascii) 190 | if value.startswith(" "): 191 | value = "\\" + value 192 | return escape(key, ensure_ascii=ensure_ascii) + separator + value 193 | 194 | 195 | _escapes = { 196 | "\t": r"\t", 197 | "\n": r"\n", 198 | "\f": r"\f", 199 | "\r": r"\r", 200 | "!": r"\!", 201 | "#": r"\#", 202 | ":": r"\:", 203 | "=": r"\=", 204 | "\\": r"\\", 205 | } 206 | 207 | 208 | def _esc(m: re.Match[str]) -> str: 209 | c = m.group() 210 | try: 211 | return _escapes[c] 212 | except KeyError: 213 | return _to_u_escape(c) 214 | 215 | 216 | def _to_u_escape(c: str) -> str: 217 | co = ord(c) 218 | if co > 0xFFFF: 219 | # Does Python really not have a decent builtin way to calculate 220 | # surrogate pairs? 221 | assert co <= 0x10FFFF 222 | co -= 0x10000 223 | return "\\u{0:04x}\\u{1:04x}".format(0xD800 + (co >> 10), 0xDC00 + (co & 0x3FF)) 224 | else: 225 | return f"\\u{co:04x}" 226 | 227 | 228 | NEEDS_ESCAPE_ASCII_RGX = re.compile(r"[^\x20-\x7E]|[\\#!=:]") 229 | NEEDS_ESCAPE_UNICODE_RGX = re.compile(r"[\x00-\x1F\x7F]|[\\#!=:]") 230 | 231 | 232 | def _base_escape(field: str, ensure_ascii: bool = True) -> str: 233 | rgx = NEEDS_ESCAPE_ASCII_RGX if ensure_ascii else NEEDS_ESCAPE_UNICODE_RGX 234 | return rgx.sub(_esc, field) 235 | 236 | 237 | def escape(field: str, ensure_ascii: bool = True) -> str: 238 | """ 239 | Escape a string so that it can be safely used as either a key or value in a 240 | ``.properties`` file. All non-ASCII characters, all nonprintable or space 241 | characters, and the characters ``\\ # ! = :`` are all escaped using either 242 | the single-character escapes recognized by `unescape` (when they exist) or 243 | ``\\uXXXX`` escapes (after converting non-BMP characters to surrogate 244 | pairs). 245 | 246 | .. versionchanged:: 0.6.0 247 | ``ensure_ascii`` parameter added 248 | 249 | :param str field: the string to escape 250 | :param bool ensure_ascii: if true, all non-ASCII characters will be 251 | replaced with ``\\uXXXX`` escape sequences in the output; if false, 252 | non-ASCII characters will be passed through as-is 253 | :rtype: str 254 | """ 255 | return _base_escape(field, ensure_ascii=ensure_ascii).replace(" ", r"\ ") 256 | 257 | 258 | DAYS_OF_WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 259 | 260 | MONTHS = [ 261 | "Jan", 262 | "Feb", 263 | "Mar", 264 | "Apr", 265 | "May", 266 | "Jun", 267 | "Jul", 268 | "Aug", 269 | "Sep", 270 | "Oct", 271 | "Nov", 272 | "Dec", 273 | ] 274 | 275 | 276 | def java_timestamp(timestamp: None | bool | float | datetime = True) -> str: 277 | """ 278 | .. versionadded:: 0.2.0 279 | 280 | Returns a timestamp in the format produced by |date_tostring|_, e.g.:: 281 | 282 | Mon Sep 02 14:00:54 EDT 2016 283 | 284 | If ``timestamp`` is `True` (the default), the current date & time is 285 | returned. 286 | 287 | If ``timestamp`` is `None` or `False`, an empty string is returned. 288 | 289 | If ``timestamp`` is a number, it is converted from seconds since the epoch 290 | to local time. 291 | 292 | If ``timestamp`` is a `datetime.datetime` object, its value is used 293 | directly, with naïve objects assumed to be in the local timezone. 294 | 295 | The timestamp is always constructed using the C locale. 296 | 297 | :param timestamp: the date & time to display 298 | :type timestamp: `None`, `bool`, number, or `datetime.datetime` 299 | :rtype: str 300 | 301 | .. |date_tostring| replace:: Java 8's ``Date.toString()`` 302 | .. _date_tostring: https://docs.oracle.com/javase/8/docs/api/java/util/Date.html#toString-- 303 | """ 304 | if timestamp is None or timestamp is False: 305 | return "" 306 | if isinstance(timestamp, datetime) and timestamp.tzinfo is not None: 307 | timebits = timestamp.timetuple() 308 | # Assumes `timestamp.tzinfo.tzname()` is meaningful/useful 309 | tzname = timestamp.tzname() 310 | else: 311 | ### TODO: Reimplement this using datetime.astimezone() to convert 312 | ### everything to an aware datetime? 313 | ts: Optional[float] 314 | if timestamp is True: 315 | ts = None 316 | elif isinstance(timestamp, datetime): 317 | # Use `datetime.timestamp()`, as it (unlike `datetime.timetuple()`) 318 | # takes `fold` into account for naïve datetimes. 319 | ts = timestamp.timestamp() 320 | else: 321 | # If it's not a number, it's localtime()'s problem now. 322 | ts = timestamp 323 | timebits = time.localtime(ts) 324 | tzname = timebits.tm_zone 325 | assert 1 <= timebits.tm_mon <= 12, "invalid month" 326 | assert 0 <= timebits.tm_wday <= 6, "invalid day of week" 327 | return ( 328 | "{wday} {mon} {t.tm_mday:02d}" 329 | " {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}" 330 | " {tz} {t.tm_year:04d}".format( 331 | t=timebits, 332 | tz=tzname, 333 | mon=MONTHS[timebits.tm_mon - 1], 334 | wday=DAYS_OF_WEEK[timebits.tm_wday], 335 | ) 336 | ) 337 | 338 | 339 | def javapropertiesreplace_errors(e: UnicodeError) -> tuple[str, int]: 340 | """ 341 | .. versionadded:: 0.6.0 342 | 343 | Implements the ``'javapropertiesreplace'`` error handling (for text 344 | encodings only): unencodable characters are replaced by ``\\uXXXX`` escape 345 | sequences (with non-BMP characters converted to surrogate pairs first) 346 | """ 347 | if isinstance(e, UnicodeEncodeError): 348 | return ("".join(map(_to_u_escape, e.object[e.start : e.end])), e.end) 349 | else: 350 | raise e # pragma: no cover 351 | -------------------------------------------------------------------------------- /src/javaproperties/xmlprops.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections.abc import Callable, Iterable, Iterator, Mapping 3 | from typing import AnyStr, BinaryIO, IO, Optional, TypeVar, overload 4 | import xml.etree.ElementTree as ET 5 | from xml.sax.saxutils import escape, quoteattr 6 | from .util import itemize 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | @overload 12 | def load_xml(fp: IO) -> dict[str, str]: ... 13 | 14 | 15 | @overload 16 | def load_xml(fp: IO, object_pairs_hook: type[T]) -> T: ... 17 | 18 | 19 | @overload 20 | def load_xml( 21 | fp: IO, object_pairs_hook: Callable[[Iterator[tuple[str, str]]], T] 22 | ) -> T: ... 23 | 24 | 25 | def load_xml(fp, object_pairs_hook=dict): # type: ignore[no-untyped-def] 26 | r""" 27 | Parse the contents of the file-like object ``fp`` as an XML properties file 28 | and return a `dict` of the key-value pairs. 29 | 30 | Beyond basic XML well-formedness, `load_xml` only checks that the root 31 | element is named "``properties``" and that all of its ```` children 32 | have ``key`` attributes. No further validation is performed; if any 33 | ````\s happen to contain nested tags, the behavior is undefined. 34 | 35 | By default, the key-value pairs extracted from ``fp`` are combined into a 36 | `dict` with later occurrences of a key overriding previous occurrences of 37 | the same key. To change this behavior, pass a callable as the 38 | ``object_pairs_hook`` argument; it will be called with one argument, a 39 | generator of ``(key, value)`` pairs representing the key-value entries in 40 | ``fp`` (including duplicates) in order of occurrence. `load_xml` will then 41 | return the value returned by ``object_pairs_hook``. 42 | 43 | :param IO fp: the file from which to read the XML properties document 44 | :param callable object_pairs_hook: class or function for combining the 45 | key-value pairs 46 | :rtype: `dict` or the return value of ``object_pairs_hook`` 47 | :raises ValueError: if the root of the XML tree is not a ```` 48 | tag or an ```` element is missing a ``key`` attribute 49 | """ 50 | tree = ET.parse(fp) 51 | return object_pairs_hook(_fromXML(tree.getroot())) 52 | 53 | 54 | @overload 55 | def loads_xml(s: AnyStr) -> dict[str, str]: ... 56 | 57 | 58 | @overload 59 | def loads_xml(fp: IO, object_pairs_hook: type[T]) -> T: ... 60 | 61 | 62 | @overload 63 | def loads_xml( 64 | s: AnyStr, object_pairs_hook: Callable[[Iterator[tuple[str, str]]], T] 65 | ) -> T: ... 66 | 67 | 68 | def loads_xml(s, object_pairs_hook=dict): # type: ignore[no-untyped-def] 69 | r""" 70 | Parse the contents of the string ``s`` as an XML properties document and 71 | return a `dict` of the key-value pairs. 72 | 73 | Beyond basic XML well-formedness, `loads_xml` only checks that the root 74 | element is named "``properties``" and that all of its ```` children 75 | have ``key`` attributes. No further validation is performed; if any 76 | ````\s happen to contain nested tags, the behavior is undefined. 77 | 78 | By default, the key-value pairs extracted from ``s`` are combined into a 79 | `dict` with later occurrences of a key overriding previous occurrences of 80 | the same key. To change this behavior, pass a callable as the 81 | ``object_pairs_hook`` argument; it will be called with one argument, a 82 | generator of ``(key, value)`` pairs representing the key-value entries in 83 | ``s`` (including duplicates) in order of occurrence. `loads_xml` will then 84 | return the value returned by ``object_pairs_hook``. 85 | 86 | :param Union[str,bytes] s: the string from which to read the XML properties 87 | document 88 | :param callable object_pairs_hook: class or function for combining the 89 | key-value pairs 90 | :rtype: `dict` or the return value of ``object_pairs_hook`` 91 | :raises ValueError: if the root of the XML tree is not a ```` 92 | tag or an ```` element is missing a ``key`` attribute 93 | """ 94 | elem = ET.fromstring(s) 95 | return object_pairs_hook(_fromXML(elem)) 96 | 97 | 98 | def _fromXML(root: ET.Element) -> Iterator[tuple[str, str]]: 99 | if root.tag != "properties": 100 | raise ValueError("XML tree is not rooted at ") 101 | for entry in root.findall("entry"): 102 | key = entry.get("key") 103 | if key is None: 104 | raise ValueError(' is missing "key" attribute') 105 | yield (key, entry.text or "") 106 | 107 | 108 | def dump_xml( 109 | props: Mapping[str, str] | Iterable[tuple[str, str]], 110 | fp: BinaryIO, 111 | comment: Optional[str] = None, 112 | encoding: str = "UTF-8", 113 | sort_keys: bool = False, 114 | ) -> None: 115 | """ 116 | Write a series ``props`` of key-value pairs to a binary filehandle ``fp`` 117 | in the format of an XML properties file. The file will include both an XML 118 | declaration and a doctype declaration. 119 | 120 | :param props: A mapping or iterable of ``(key, value)`` pairs to write to 121 | ``fp``. All keys and values in ``props`` must be `str` values. If 122 | ``sort_keys`` is `False`, the entries are output in iteration order. 123 | :param BinaryIO fp: a file-like object to write the values of ``props`` to 124 | :param Optional[str] comment: if non-`None`, ``comment`` will be output as 125 | a ```` element before the ```` elements 126 | :param str encoding: the name of the encoding to use for the XML document 127 | (also included in the XML declaration) 128 | :param bool sort_keys: if true, the elements of ``props`` are sorted 129 | lexicographically by key in the output 130 | :return: `None` 131 | """ 132 | # This gives type errors : 133 | # fptxt = codecs.lookup(encoding).streamwriter(fp, errors='xmlcharrefreplace') 134 | # print('' 135 | # .format(quoteattr(encoding)), file=fptxt) 136 | # for s in _stream_xml(props, comment, sort_keys): 137 | # print(s, file=fptxt) 138 | fp.write( 139 | '\n'.format( 140 | quoteattr(encoding) 141 | ).encode(encoding, "xmlcharrefreplace") 142 | ) 143 | for s in _stream_xml(props, comment, sort_keys): 144 | fp.write((s + "\n").encode(encoding, "xmlcharrefreplace")) 145 | 146 | 147 | def dumps_xml( 148 | props: Mapping[str, str] | Iterable[tuple[str, str]], 149 | comment: Optional[str] = None, 150 | sort_keys: bool = False, 151 | ) -> str: 152 | """ 153 | Convert a series ``props`` of key-value pairs to a `str` containing an XML 154 | properties document. The document will include a doctype declaration but 155 | not an XML declaration. 156 | 157 | :param props: A mapping or iterable of ``(key, value)`` pairs to serialize. 158 | All keys and values in ``props`` must be `str` values. If 159 | ``sort_keys`` is `False`, the entries are output in iteration order. 160 | :param Optional[str] comment: if non-`None`, ``comment`` will be output as 161 | a ```` element before the ```` elements 162 | :param bool sort_keys: if true, the elements of ``props`` are sorted 163 | lexicographically by key in the output 164 | :rtype: str 165 | """ 166 | return "".join(s + "\n" for s in _stream_xml(props, comment, sort_keys)) 167 | 168 | 169 | def _stream_xml( 170 | props: Mapping[str, str] | Iterable[tuple[str, str]], 171 | comment: Optional[str] = None, 172 | sort_keys: bool = False, 173 | ) -> Iterator[str]: 174 | yield '' 175 | yield "" 176 | if comment is not None: 177 | yield "" + escape(comment) + "" 178 | for k, v in itemize(props, sort_keys=sort_keys): 179 | yield "{1}".format(quoteattr(k), escape(v)) 180 | yield "" 181 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def fixed_timestamp(mocker): 7 | mocker.patch("time.localtime", return_value=time.localtime(1478550580)) 8 | yield "Mon Nov 07 15:29:40 EST 2016" 9 | time.localtime.assert_called_once_with(None) 10 | -------------------------------------------------------------------------------- /test/test_dump_xml.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import pytest 3 | from javaproperties import dump_xml 4 | 5 | # The only thing special about `dump_xml` compared to `dumps_xml` is encoding, 6 | # so that's the only thing we'll test here. 7 | 8 | 9 | @pytest.mark.parametrize("enc", ["ASCII", "Latin-1", "UTF-16BE", "UTF-8"]) 10 | def test_dump_xml_encoding(enc): 11 | fp = BytesIO() 12 | dump_xml( 13 | [ 14 | ("key", "value"), 15 | ("edh", "\xF0"), 16 | ("snowman", "\u2603"), 17 | ("goat", "\U0001F410"), 18 | ], 19 | fp, 20 | encoding=enc, 21 | ) 22 | assert ( 23 | fp.getvalue() 24 | == """\ 25 | 26 | 27 | 28 | value 29 | \xF0 30 | \u2603 31 | \U0001F410 32 | 33 | """.format( 34 | enc 35 | ).encode( 36 | enc, "xmlcharrefreplace" 37 | ) 38 | ) 39 | -------------------------------------------------------------------------------- /test/test_dumps.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from datetime import datetime 3 | from dateutil.tz import tzoffset 4 | import pytest 5 | from javaproperties import dumps, to_comment 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "d,s", 10 | [ 11 | ({}, ""), 12 | ({"key": "value"}, "key=value\n"), 13 | ([("key", "value"), ("zebra", "apple")], "key=value\nzebra=apple\n"), 14 | ([("zebra", "apple"), ("key", "value")], "zebra=apple\nkey=value\n"), 15 | ( 16 | OrderedDict([("key", "value"), ("zebra", "apple")]), 17 | "key=value\nzebra=apple\n", 18 | ), 19 | ( 20 | OrderedDict([("zebra", "apple"), ("key", "value")]), 21 | "zebra=apple\nkey=value\n", 22 | ), 23 | ({"two words": "value"}, "two\\ words=value\n"), 24 | ({"key": "two words"}, "key=two words\n"), 25 | ({" key": "value"}, "\\ key=value\n"), 26 | ({"key": " value"}, "key=\\ value\n"), 27 | ({"key ": "value"}, "key\\ =value\n"), 28 | ({"key": "value "}, "key=value \n"), 29 | ({" ": "value"}, "\\ \\ \\ =value\n"), 30 | ({"key": " "}, "key=\\ \n"), 31 | ({"US": "\x1F"}, "US=\\u001f\n"), 32 | ({"tilde": "~"}, "tilde=~\n"), 33 | ({"delete": "\x7F"}, "delete=\\u007f\n"), 34 | ({"edh": "\xF0"}, "edh=\\u00f0\n"), 35 | ({"snowman": "\u2603"}, "snowman=\\u2603\n"), 36 | ({"goat": "\U0001F410"}, "goat=\\ud83d\\udc10\n"), 37 | ({"taog": "\uDC10\uD83D"}, "taog=\\udc10\\ud83d\n"), 38 | ({"newline": "\n"}, "newline=\\n\n"), 39 | ({"carriage-return": "\r"}, "carriage-return=\\r\n"), 40 | ({"tab": "\t"}, "tab=\\t\n"), 41 | ({"form-feed": "\f"}, "form-feed=\\f\n"), 42 | ({"bell": "\a"}, "bell=\\u0007\n"), 43 | ({"escape": "\x1B"}, "escape=\\u001b\n"), 44 | ({"vertical-tab": "\v"}, "vertical-tab=\\u000b\n"), 45 | ({"backslash": "\\"}, "backslash=\\\\\n"), 46 | ({"equals": "="}, "equals=\\=\n"), 47 | ({"colon": ":"}, "colon=\\:\n"), 48 | ({"hash": "#"}, "hash=\\#\n"), 49 | ({"exclamation": "!"}, "exclamation=\\!\n"), 50 | ({"null": "\0"}, "null=\\u0000\n"), 51 | ({"backspace": "\b"}, "backspace=\\u0008\n"), 52 | ], 53 | ) 54 | def test_dumps(d, s): 55 | assert dumps(d, timestamp=False) == s 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "d,s", 60 | [ 61 | ({"key": "value", "zebra": "apple"}, "key=value\nzebra=apple\n"), 62 | ([("key", "value"), ("zebra", "apple")], "key=value\nzebra=apple\n"), 63 | ([("zebra", "apple"), ("key", "value")], "key=value\nzebra=apple\n"), 64 | ( 65 | OrderedDict([("key", "value"), ("zebra", "apple")]), 66 | "key=value\nzebra=apple\n", 67 | ), 68 | ( 69 | OrderedDict([("zebra", "apple"), ("key", "value")]), 70 | "key=value\nzebra=apple\n", 71 | ), 72 | ], 73 | ) 74 | def test_dumps_sorted(d, s): 75 | assert dumps(d, timestamp=False, sort_keys=True) == s 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "ts,s", 80 | [ 81 | (None, "key=value\n"), 82 | (1473703254, "#Mon Sep 12 14:00:54 EDT 2016\nkey=value\n"), 83 | ( 84 | datetime.fromtimestamp(1473703254), 85 | "#Mon Sep 12 14:00:54 EDT 2016\nkey=value\n", 86 | ), 87 | ( 88 | datetime.fromtimestamp(1473703254, tzoffset("PDT", -25200)), 89 | "#Mon Sep 12 11:00:54 PDT 2016\nkey=value\n", 90 | ), 91 | ], 92 | ) 93 | def test_dump_timestamp(ts, s): 94 | assert dumps({"key": "value"}, timestamp=ts) == s 95 | 96 | 97 | @pytest.mark.parametrize( 98 | "d,s", 99 | [ 100 | ({"US": "\x1F"}, "US=\\u001f\n"), 101 | ({"delete": "\x7F"}, "delete=\\u007f\n"), 102 | ({"padding": "\x80"}, "padding=\x80\n"), 103 | ({"nbsp": "\xA0"}, "nbsp=\xA0\n"), 104 | ({"edh": "\xF0"}, "edh=\xF0\n"), 105 | ({"snowman": "\u2603"}, "snowman=\u2603\n"), 106 | ({"goat": "\U0001F410"}, "goat=\U0001F410\n"), 107 | ({"taog": "\uDC10\uD83D"}, "taog=\uDC10\uD83D\n"), 108 | ({"newline": "\n"}, "newline=\\n\n"), 109 | ({"carriage-return": "\r"}, "carriage-return=\\r\n"), 110 | ({"tab": "\t"}, "tab=\\t\n"), 111 | ({"form-feed": "\f"}, "form-feed=\\f\n"), 112 | ({"bell": "\a"}, "bell=\\u0007\n"), 113 | ({"escape": "\x1B"}, "escape=\\u001b\n"), 114 | ({"vertical-tab": "\v"}, "vertical-tab=\\u000b\n"), 115 | ({"backslash": "\\"}, "backslash=\\\\\n"), 116 | ({"equals": "="}, "equals=\\=\n"), 117 | ({"colon": ":"}, "colon=\\:\n"), 118 | ({"hash": "#"}, "hash=\\#\n"), 119 | ({"exclamation": "!"}, "exclamation=\\!\n"), 120 | ({"null": "\0"}, "null=\\u0000\n"), 121 | ({"backspace": "\b"}, "backspace=\\u0008\n"), 122 | ], 123 | ) 124 | def test_dumps_no_ensure_ascii(d, s): 125 | assert dumps(d, timestamp=False, ensure_ascii=False) == s 126 | 127 | 128 | @pytest.mark.parametrize( 129 | "c", 130 | [ 131 | "", 132 | "foobar", 133 | " leading", 134 | "trailing ", 135 | " ", 136 | "This is a comment.", 137 | "#This is a double comment.", 138 | "trailing newline\n", 139 | "trailing CRLF\r\n", 140 | "trailing carriage return\r", 141 | "line one\nline two", 142 | "line one\n#line two", 143 | "line one\n!line two", 144 | "\0", 145 | "\a", 146 | "\b", 147 | "\t", 148 | "\n", 149 | "\v", 150 | "\f", 151 | "\r", 152 | "\x1B", 153 | "\x1F", 154 | "!", 155 | "#", 156 | ":", 157 | "=", 158 | "\\", 159 | "\\u2603", 160 | "~", 161 | "\x7F", 162 | "\x80", 163 | "\xA0", 164 | "\xF0", 165 | "\xFF", 166 | "\u0100", 167 | "\u2603", 168 | "\U0001F410", 169 | "\uDC10\uD83D", 170 | "".join( 171 | chr(i) 172 | for i in list(range(0x20)) + list(range(0x7F, 0xA0)) 173 | if i not in (10, 13) 174 | ), 175 | ], 176 | ) 177 | @pytest.mark.parametrize("ensure_ascii_comments", [None, True, False]) 178 | def test_dumps_comments(c, ensure_ascii_comments): 179 | s = dumps( 180 | {"key": "value"}, 181 | timestamp=False, 182 | comments=c, 183 | ensure_ascii_comments=ensure_ascii_comments, 184 | ) 185 | assert s == to_comment(c, ensure_ascii=ensure_ascii_comments) + "\nkey=value\n" 186 | if ensure_ascii_comments is None: 187 | assert s == dumps({"key": "value"}, timestamp=False, comments=c) 188 | 189 | 190 | @pytest.mark.parametrize( 191 | "pout,ea", 192 | [ 193 | ("x\\u00f0=\\u2603\\ud83d\\udc10\n", True), 194 | ("x\xF0=\u2603\U0001F410\n", False), 195 | ], 196 | ) 197 | @pytest.mark.parametrize( 198 | "cout,eac", 199 | [ 200 | ("#x\\u00f0\\u2603\\ud83d\\udc10\n", True), 201 | ("#x\xF0\\u2603\\ud83d\\udc10\n", None), 202 | ("#x\xF0\u2603\U0001F410\n", False), 203 | ], 204 | ) 205 | def test_dumps_ensure_ascii_cross_ensure_ascii_comments(pout, ea, cout, eac): 206 | assert ( 207 | dumps( 208 | {"x\xF0": "\u2603\U0001F410"}, 209 | timestamp=False, 210 | comments="x\xF0\u2603\U0001F410", 211 | ensure_ascii=ea, 212 | ensure_ascii_comments=eac, 213 | ) 214 | == cout + pout 215 | ) 216 | 217 | 218 | def test_dumps_tab_separator(): 219 | assert dumps({"key": "value"}, separator="\t", timestamp=False) == "key\tvalue\n" 220 | 221 | 222 | def test_dumps_timestamp_and_comment(): 223 | assert ( 224 | dumps( 225 | {"key": "value"}, 226 | comments="This is a comment.", 227 | timestamp=1473703254, 228 | ) 229 | == "#This is a comment.\n#Mon Sep 12 14:00:54 EDT 2016\nkey=value\n" 230 | ) 231 | -------------------------------------------------------------------------------- /test/test_dumps_xml.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import pytest 3 | from javaproperties import dumps_xml 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "d,s", 8 | [ 9 | ( 10 | {}, 11 | '\n' 12 | "\n" 13 | "\n", 14 | ), 15 | ( 16 | {"key": "value"}, 17 | '\n' 18 | "\n" 19 | 'value\n' 20 | "\n", 21 | ), 22 | ( 23 | [("key", "value"), ("zebra", "apple")], 24 | '\n' 25 | "\n" 26 | 'value\n' 27 | 'apple\n' 28 | "\n", 29 | ), 30 | ( 31 | [("zebra", "apple"), ("key", "value")], 32 | '\n' 33 | "\n" 34 | 'apple\n' 35 | 'value\n' 36 | "\n", 37 | ), 38 | ( 39 | OrderedDict([("key", "value"), ("zebra", "apple")]), 40 | '\n' 41 | "\n" 42 | 'value\n' 43 | 'apple\n' 44 | "\n", 45 | ), 46 | ( 47 | OrderedDict([("zebra", "apple"), ("key", "value")]), 48 | '\n' 49 | "\n" 50 | 'apple\n' 51 | 'value\n' 52 | "\n", 53 | ), 54 | ( 55 | [ 56 | ("key", "value"), 57 | ("edh", "\xF0"), 58 | ("snowman", "\u2603"), 59 | ("goat", "\U0001F410"), 60 | ], 61 | '\n' 62 | "\n" 63 | 'value\n' 64 | '\xF0\n' 65 | '\u2603\n' 66 | '\U0001F410\n' 67 | "\n", 68 | ), 69 | ( 70 | [ 71 | ("key", "value"), 72 | ("\xF0", "edh"), 73 | ("\u2603", "snowman"), 74 | ("\U0001F410", "goat"), 75 | ], 76 | '\n' 77 | "\n" 78 | 'value\n' 79 | 'edh\n' 80 | 'snowman\n' 81 | 'goat\n' 82 | "\n", 83 | ), 84 | ], 85 | ) 86 | def test_dumps_xml(d, s): 87 | assert dumps_xml(d) == s 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "d,s", 92 | [ 93 | ( 94 | {"key": "value", "zebra": "apple"}, 95 | '\n' 96 | "\n" 97 | 'value\n' 98 | 'apple\n' 99 | "\n", 100 | ), 101 | ( 102 | [("key", "value"), ("zebra", "apple")], 103 | '\n' 104 | "\n" 105 | 'value\n' 106 | 'apple\n' 107 | "\n", 108 | ), 109 | ( 110 | [("zebra", "apple"), ("key", "value")], 111 | '\n' 112 | "\n" 113 | 'value\n' 114 | 'apple\n' 115 | "\n", 116 | ), 117 | ( 118 | OrderedDict([("key", "value"), ("zebra", "apple")]), 119 | '\n' 120 | "\n" 121 | 'value\n' 122 | 'apple\n' 123 | "\n", 124 | ), 125 | ( 126 | OrderedDict([("zebra", "apple"), ("key", "value")]), 127 | '\n' 128 | "\n" 129 | 'value\n' 130 | 'apple\n' 131 | "\n", 132 | ), 133 | ], 134 | ) 135 | def test_dumps_xml_sorted(d, s): 136 | assert dumps_xml(d, sort_keys=True) == s 137 | 138 | 139 | def test_dumps_xml_comment(): 140 | assert ( 141 | dumps_xml({"key": "value"}, comment="This is a comment.") 142 | == """\ 143 | 144 | 145 | This is a comment. 146 | value 147 | 148 | """ 149 | ) 150 | 151 | 152 | def test_dumps_xml_entities(): 153 | assert ( 154 | dumps_xml({"&<>\"'": "&<>\"'"}, comment="&<>\"'") 155 | == """\ 156 | 157 | 158 | &<>"' 159 | &<>"' 160 | 161 | """ 162 | ) 163 | -------------------------------------------------------------------------------- /test/test_escape.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties import escape 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "sin,sout", 7 | [ 8 | ("", ""), 9 | ("foobar", "foobar"), 10 | ("inner space", "inner\\ space"), 11 | (" leading", "\\ leading"), 12 | ("trailing ", "trailing\\ "), 13 | (" ", "\\ \\ \\ "), 14 | ("\0", "\\u0000"), 15 | ("\a", "\\u0007"), 16 | ("\b", "\\u0008"), 17 | ("\t", "\\t"), 18 | ("\n", "\\n"), 19 | ("\v", "\\u000b"), 20 | ("\f", "\\f"), 21 | ("\r", "\\r"), 22 | ("\x1B", "\\u001b"), 23 | ("\x1F", "\\u001f"), 24 | ("!", "\\!"), 25 | ("#", "\\#"), 26 | (":", "\\:"), 27 | ("=", "\\="), 28 | ("\\", "\\\\"), 29 | ("\\u2603", "\\\\u2603"), 30 | ("~", "~"), 31 | ("\x7F", "\\u007f"), 32 | ("\xF0", "\\u00f0"), 33 | ("\u2603", "\\u2603"), 34 | ("\U0001F410", "\\ud83d\\udc10"), 35 | ("\uDC10\uD83D", "\\udc10\\ud83d"), 36 | ], 37 | ) 38 | def test_escape(sin, sout): 39 | assert escape(sin) == sout 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "sin,sout", 44 | [ 45 | ("", ""), 46 | ("foobar", "foobar"), 47 | ("inner space", "inner\\ space"), 48 | (" leading", "\\ leading"), 49 | ("trailing ", "trailing\\ "), 50 | (" ", "\\ \\ \\ "), 51 | ("\0", "\\u0000"), 52 | ("\a", "\\u0007"), 53 | ("\b", "\\u0008"), 54 | ("\t", "\\t"), 55 | ("\n", "\\n"), 56 | ("\v", "\\u000b"), 57 | ("\f", "\\f"), 58 | ("\r", "\\r"), 59 | ("\x1B", "\\u001b"), 60 | ("\x1F", "\\u001f"), 61 | ("!", "\\!"), 62 | ("#", "\\#"), 63 | (":", "\\:"), 64 | ("=", "\\="), 65 | ("\\", "\\\\"), 66 | ("\\u2603", "\\\\u2603"), 67 | ("~", "~"), 68 | ("\x7F", "\\u007f"), 69 | ("\x80", "\x80"), 70 | ("\xA0", "\xA0"), 71 | ("\xF0", "\xF0"), 72 | ("\u2603", "\u2603"), 73 | ("\U0001F410", "\U0001F410"), 74 | ("\uDC10\uD83D", "\udc10\ud83d"), 75 | ], 76 | ) 77 | def test_escape_no_ensure_ascii(sin, sout): 78 | assert escape(sin, ensure_ascii=False) == sout 79 | -------------------------------------------------------------------------------- /test/test_java_timestamp.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import sys 3 | from dateutil.tz import tzstr 4 | import pytest 5 | from javaproperties import java_timestamp 6 | 7 | # Unix timestamps and datetime objects don't support leap seconds or month 13, 8 | # so there's no need (and no way) to test handling of them here. 9 | 10 | old_pacific = tzstr("PST8PDT,M4.1.0,M10.5.0") 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "ts,s", 15 | [ 16 | (None, ""), 17 | (False, ""), 18 | (0, "Wed Dec 31 19:00:00 EST 1969"), 19 | (1234567890.101112, "Fri Feb 13 18:31:30 EST 2009"), 20 | (1234567890.987654, "Fri Feb 13 18:31:30 EST 2009"), 21 | # Months: 22 | (1451624400, "Fri Jan 01 00:00:00 EST 2016"), 23 | (1454396522, "Tue Feb 02 02:02:02 EST 2016"), 24 | (1456992183, "Thu Mar 03 03:03:03 EST 2016"), 25 | (1459757044, "Mon Apr 04 04:04:04 EDT 2016"), 26 | (1462439105, "Thu May 05 05:05:05 EDT 2016"), 27 | (1465207566, "Mon Jun 06 06:06:06 EDT 2016"), 28 | (1467889627, "Thu Jul 07 07:07:07 EDT 2016"), 29 | (1470658088, "Mon Aug 08 08:08:08 EDT 2016"), 30 | (1473426549, "Fri Sep 09 09:09:09 EDT 2016"), 31 | (1476108610, "Mon Oct 10 10:10:10 EDT 2016"), 32 | (1478880671, "Fri Nov 11 11:11:11 EST 2016"), 33 | (1481562732, "Mon Dec 12 12:12:12 EST 2016"), 34 | # Days of the week: 35 | (1451818800, "Sun Jan 03 06:00:00 EST 2016"), 36 | (1451883600, "Mon Jan 04 00:00:00 EST 2016"), 37 | (1451973600, "Tue Jan 05 01:00:00 EST 2016"), 38 | (1452063600, "Wed Jan 06 02:00:00 EST 2016"), 39 | (1452153600, "Thu Jan 07 03:00:00 EST 2016"), 40 | (1452243600, "Fri Jan 08 04:00:00 EST 2016"), 41 | (1452333600, "Sat Jan 09 05:00:00 EST 2016"), 42 | # Leap day: 43 | (1456733655, "Mon Feb 29 03:14:15 EST 2016"), 44 | # PM/24-hour time: 45 | (1463159593, "Fri May 13 13:13:13 EDT 2016"), 46 | # Before spring ahead: 47 | (1457852399, "Sun Mar 13 01:59:59 EST 2016"), 48 | (datetime(2016, 3, 13, 1, 59, 59), "Sun Mar 13 01:59:59 EST 2016"), 49 | ( 50 | datetime(2006, 4, 2, 1, 59, 59, 0, old_pacific), 51 | "Sun Apr 02 01:59:59 PST 2006", 52 | ), 53 | # Skipped by spring ahead: 54 | (datetime(2016, 3, 13, 2, 30, 0), "Sun Mar 13 03:30:00 EDT 2016"), 55 | ( 56 | datetime(2006, 4, 2, 2, 30, 0, 0, old_pacific), 57 | "Sun Apr 02 02:30:00 PDT 2006", 58 | ), 59 | # After spring ahead: 60 | (1457852401, "Sun Mar 13 03:00:01 EDT 2016"), 61 | (datetime(2016, 3, 13, 3, 0, 1), "Sun Mar 13 03:00:01 EDT 2016"), 62 | ( 63 | datetime(2006, 4, 2, 3, 0, 1, 0, old_pacific), 64 | "Sun Apr 02 03:00:01 PDT 2006", 65 | ), 66 | # Before fall back: 67 | (1478411999, "Sun Nov 06 01:59:59 EDT 2016"), 68 | (datetime(2016, 11, 6, 0, 59, 59), "Sun Nov 06 00:59:59 EDT 2016"), 69 | ( 70 | datetime(2006, 10, 29, 0, 59, 59, 0, old_pacific), 71 | "Sun Oct 29 00:59:59 PDT 2006", 72 | ), 73 | # Duplicated by fall back: 74 | # Times duplicated by DST are interpreted non-deterministically by Python 75 | # pre-3.6 (cf. 76 | # ), 77 | # so there are two possible return values for these calls. 78 | ( 79 | datetime(2016, 11, 6, 1, 30, 0), 80 | ("Sun Nov 06 01:30:00 EDT 2016", "Sun Nov 06 01:30:00 EST 2016"), 81 | ), 82 | ( 83 | datetime(2006, 10, 29, 1, 30, 0, 0, old_pacific), 84 | ("Sun Oct 29 01:30:00 PDT 2006", "Sun Oct 29 01:30:00 PST 2006"), 85 | ), 86 | # After fall back: 87 | (1478412001, "Sun Nov 06 01:00:01 EST 2016"), 88 | (datetime(2016, 11, 6, 2, 0, 1), "Sun Nov 06 02:00:01 EST 2016"), 89 | ( 90 | datetime(2006, 10, 29, 2, 0, 1, 0, old_pacific), 91 | "Sun Oct 29 02:00:01 PST 2006", 92 | ), 93 | ], 94 | ) 95 | def test_java_timestamp(ts, s): 96 | r = java_timestamp(ts) 97 | if isinstance(s, tuple): 98 | assert r in s 99 | else: 100 | assert r == s 101 | 102 | 103 | # Times duplicated by fall back, disambiguated with `fold`: 104 | @pytest.mark.xfail( 105 | hasattr(sys, "pypy_version_info") and sys.pypy_version_info[:3] < (7, 2, 0), 106 | reason="Broken on this version of PyPy", 107 | # Certain versions of pypy3.6 (including the one on Travis as of 108 | # 2020-02-23) have a bug in their datetime libraries that prevents the 109 | # `fold` attribute from working correctly. The latest known version to 110 | # feature this bug is 7.1.1 (Python version 3.6.1), and the earliest known 111 | # version to feature a fix is 7.2.0 (Python version 3.6.9); I don't *think* 112 | # there were any releases in between those two versions, but it isn't 113 | # entirely clear. 114 | ) 115 | @pytest.mark.parametrize( 116 | "ts,fold,s", 117 | [ 118 | (datetime(2016, 11, 6, 1, 30, 0), 0, "Sun Nov 06 01:30:00 EDT 2016"), 119 | ( 120 | datetime(2006, 10, 29, 1, 30, 0, 0, old_pacific), 121 | 0, 122 | "Sun Oct 29 01:30:00 PDT 2006", 123 | ), 124 | (datetime(2016, 11, 6, 1, 30, 0), 1, "Sun Nov 06 01:30:00 EST 2016"), 125 | ( 126 | datetime(2006, 10, 29, 1, 30, 0, 0, old_pacific), 127 | 1, 128 | "Sun Oct 29 01:30:00 PST 2006", 129 | ), 130 | ], 131 | ) 132 | def test_java_timestamp_fold(ts, fold, s): 133 | assert java_timestamp(ts.replace(fold=fold)) == s 134 | 135 | 136 | def test_java_timestamp_now(fixed_timestamp): 137 | assert java_timestamp() == fixed_timestamp 138 | 139 | 140 | def test_java_timestamp_dogfood_type_error(): 141 | with pytest.raises(TypeError): 142 | java_timestamp("Mon Dec 12 12:12:12 EST 2016") 143 | -------------------------------------------------------------------------------- /test/test_join_key_value.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties import join_key_value 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "key,value,s", 7 | [ 8 | ("", "", "="), 9 | ("key", "value", "key=value"), 10 | ("two words", "value", "two\\ words=value"), 11 | ("key", "two words", "key=two words"), 12 | (" key", "value", "\\ key=value"), 13 | ("key", " value", "key=\\ value"), 14 | ("key ", "value", "key\\ =value"), 15 | ("key", "value ", "key=value "), 16 | (" ", "value", "\\ \\ \\ =value"), 17 | ("key", " ", "key=\\ "), 18 | ("US", "\x1F", "US=\\u001f"), 19 | ("tilde", "~", "tilde=~"), 20 | ("delete", "\x7F", "delete=\\u007f"), 21 | ("padding", "\x80", "padding=\\u0080"), 22 | ("nbsp", "\xA0", "nbsp=\\u00a0"), 23 | ("edh", "\xF0", "edh=\\u00f0"), 24 | ("snowman", "\u2603", "snowman=\\u2603"), 25 | ("goat", "\U0001F410", "goat=\\ud83d\\udc10"), 26 | ("taog", "\uDC10\uD83D", "taog=\\udc10\\ud83d"), 27 | ("newline", "\n", "newline=\\n"), 28 | ("carriage-return", "\r", "carriage-return=\\r"), 29 | ("tab", "\t", "tab=\\t"), 30 | ("form-feed", "\f", "form-feed=\\f"), 31 | ("bell", "\a", "bell=\\u0007"), 32 | ("escape", "\x1B", "escape=\\u001b"), 33 | ("vertical-tab", "\v", "vertical-tab=\\u000b"), 34 | ("backslash", "\\", "backslash=\\\\"), 35 | ("equals", "=", "equals=\\="), 36 | ("colon", ":", "colon=\\:"), 37 | ("hash", "#", "hash=\\#"), 38 | ("exclamation", "!", "exclamation=\\!"), 39 | ("null", "\0", "null=\\u0000"), 40 | ("backspace", "\b", "backspace=\\u0008"), 41 | ], 42 | ) 43 | def test_join_key_value(key, value, s): 44 | assert join_key_value(key, value) == s 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "key,value,sep,s", 49 | [ 50 | ("key", "value", " = ", "key = value"), 51 | ("key", "value", ":", "key:value"), 52 | ("key", "value", " ", "key value"), 53 | ("key", "value", "\t", "key\tvalue"), 54 | (" key ", " value ", " : ", "\\ key\\ : \\ value "), 55 | ], 56 | ) 57 | def test_join_key_value_separator(key, value, sep, s): 58 | assert join_key_value(key, value, separator=sep) == s 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "key,value,s", 63 | [ 64 | ("", "", "="), 65 | ("key", "value", "key=value"), 66 | ("two words", "value", "two\\ words=value"), 67 | ("key", "two words", "key=two words"), 68 | (" key", "value", "\\ key=value"), 69 | ("key", " value", "key=\\ value"), 70 | ("key ", "value", "key\\ =value"), 71 | ("key", "value ", "key=value "), 72 | (" ", "value", "\\ \\ \\ =value"), 73 | ("key", " ", "key=\\ "), 74 | ("US", "\x1F", "US=\\u001f"), 75 | ("tilde", "~", "tilde=~"), 76 | ("delete", "\x7F", "delete=\\u007f"), 77 | ("padding", "\x80", "padding=\x80"), 78 | ("nbsp", "\xA0", "nbsp=\xA0"), 79 | ("edh", "\xF0", "edh=\xF0"), 80 | ("snowman", "\u2603", "snowman=\u2603"), 81 | ("goat", "\U0001F410", "goat=\U0001F410"), 82 | ("taog", "\uDC10\uD83D", "taog=\udc10\ud83d"), 83 | ("newline", "\n", "newline=\\n"), 84 | ("carriage-return", "\r", "carriage-return=\\r"), 85 | ("tab", "\t", "tab=\\t"), 86 | ("form-feed", "\f", "form-feed=\\f"), 87 | ("bell", "\a", "bell=\\u0007"), 88 | ("escape", "\x1B", "escape=\\u001b"), 89 | ("vertical-tab", "\v", "vertical-tab=\\u000b"), 90 | ("backslash", "\\", "backslash=\\\\"), 91 | ("equals", "=", "equals=\\="), 92 | ("colon", ":", "colon=\\:"), 93 | ("hash", "#", "hash=\\#"), 94 | ("exclamation", "!", "exclamation=\\!"), 95 | ("null", "\0", "null=\\u0000"), 96 | ("backspace", "\b", "backspace=\\u0008"), 97 | ], 98 | ) 99 | def test_join_key_value_no_ensure_ascii(key, value, s): 100 | assert join_key_value(key, value, ensure_ascii=False) == s 101 | -------------------------------------------------------------------------------- /test/test_jpreplace.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | 4 | import javaproperties # noqa 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "s,enc,b", 9 | [ 10 | ("foobar", "us-ascii", b"foobar"), 11 | ("f\xFCbar", "us-ascii", b"f\\u00fcbar"), 12 | ("f\xFC\xDFar", "us-ascii", b"f\\u00fc\\u00dfar"), 13 | ("killer \u2603", "us-ascii", b"killer \\u2603"), 14 | ("kid \U0001F410", "us-ascii", b"kid \\ud83d\\udc10"), 15 | ("foobar", "iso-8859-1", b"foobar"), 16 | ("f\xFCbar", "iso-8859-1", b"f\xFCbar"), 17 | ("f\xFC\xDFar", "iso-8859-1", b"f\xFC\xDFar"), 18 | ("killer \u2603", "iso-8859-1", b"killer \\u2603"), 19 | ("kid \U0001F410", "iso-8859-1", b"kid \\ud83d\\udc10"), 20 | ("foobar", "utf-8", b"foobar"), 21 | ("f\xFCbar", "utf-8", b"f\xC3\xBCbar"), 22 | ("f\xFC\xDFar", "utf-8", b"f\xC3\xBC\xC3\x9Far"), 23 | ("killer \u2603", "utf-8", b"killer \xE2\x98\x83"), 24 | ("kid \U0001F410", "utf-8", b"kid \xF0\x9F\x90\x90"), 25 | ("foobar", "utf-16be", "foobar".encode("utf-16be")), 26 | ("f\xFCbar", "utf-16be", "f\xFCbar".encode("utf-16be")), 27 | ("f\xFC\xDFar", "utf-16be", "f\xFC\xDFar".encode("utf-16be")), 28 | ("killer \u2603", "utf-16be", "killer \u2603".encode("utf-16be")), 29 | ("kid \U0001F410", "utf-16be", "kid \U0001F410".encode("utf-16be")), 30 | ("foobar", "mac_roman", b"foobar"), 31 | ("f\xFCbar", "mac_roman", b"f\x9Fbar"), 32 | ("f\xFC\xDFar", "mac_roman", b"f\x9F\xA7ar"), 33 | ("killer \u2603", "mac_roman", b"killer \\u2603"), 34 | ("kid \U0001F410", "mac_roman", b"kid \\ud83d\\udc10"), 35 | ("e\xF0", "mac_roman", b"e\\u00f0"), 36 | ("\u201CHello!\u201D", "mac_roman", b"\xD2Hello!\xD3"), 37 | ("foobar", "cp500", "foobar".encode("cp500")), 38 | ("f\xFCbar", "cp500", "f\xFCbar".encode("cp500")), 39 | ("f\xFC\xDFar", "cp500", "f\xFC\xDFar".encode("cp500")), 40 | ("killer \u2603", "cp500", "killer \\u2603".encode("cp500")), 41 | ("kid \U0001F410", "cp500", "kid \\ud83d\\udc10".encode("cp500")), 42 | ], 43 | ) 44 | def test_javapropertiesreplace(s, enc, b): 45 | assert s.encode(enc, "javapropertiesreplace") == b 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "s,esc", 50 | [ 51 | ("\uD83D\uDC10", "\\ud83d\\udc10"), 52 | ("\uD83D+\uDC10", "\\ud83d+\\udc10"), 53 | ("\uDC10\uD83D", "\\udc10\\ud83d"), 54 | ], 55 | ) 56 | @pytest.mark.parametrize( 57 | "enc", 58 | [ 59 | "us-ascii", 60 | "iso-8859-1", 61 | "utf-8", 62 | pytest.param( 63 | "utf-16be", 64 | marks=[ 65 | # Certain versions of pypy3.6 (including the one on Travis as of 66 | # 2020-02-23) have a bug in their handling of encoding errors when 67 | # the target encoding is UTF-16. The latest known version to 68 | # feature this bug is 7.1.1 (Python version 3.6.1), and the 69 | # earliest known version after this to feature a fix is 7.2.0 70 | # (Python version 3.6.9); I don't *think* there were any releases 71 | # in between those two versions, but it isn't entirely clear. 72 | pytest.mark.xfail( 73 | hasattr(sys, "pypy_version_info") 74 | and sys.pypy_version_info[:3] < (7, 2, 0), 75 | reason="Broken on this version of PyPy", 76 | ) 77 | ], 78 | ), 79 | "mac_roman", 80 | "cp500", 81 | ], 82 | ) 83 | def test_javaproperties_bad_surrogates(s, enc, esc): 84 | assert s.encode(enc, "javapropertiesreplace") == esc.encode(enc) 85 | -------------------------------------------------------------------------------- /test/test_load_xml.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import pytest 3 | from javaproperties import load_xml 4 | 5 | # The only thing special about `load_xml` compared to `loads_xml` is encoding, 6 | # so that's the only thing we'll test here. 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "b", 11 | [ 12 | b"""\ 13 | 14 | 15 | 16 | value 17 | ð 18 | 19 | 🐐 20 | 21 | """, 22 | b"""\ 23 | 24 | 25 | 26 | value 27 | \xF0 28 | 29 | 🐐 30 | 31 | """, 32 | """\ 33 | 34 | 35 | 36 | value 37 | \xF0 38 | \u2603 39 | \U0001F410 40 | 41 | """.encode( 42 | "utf-16be" 43 | ), 44 | b"""\ 45 | 46 | 47 | 48 | value 49 | \xC3\xB0 50 | \xE2\x98\x83 51 | \xF0\x9F\x90\x90 52 | 53 | """, 54 | ], 55 | ) 56 | def test_load_xml(b): 57 | assert load_xml(BytesIO(b)) == { 58 | "key": "value", 59 | "edh": "\xF0", 60 | "snowman": "\u2603", 61 | "goat": "\U0001F410", 62 | } 63 | -------------------------------------------------------------------------------- /test/test_loads.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import pytest 3 | from javaproperties import InvalidUEscapeError, loads 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "s,d", 8 | [ 9 | ("key=value", {"key": "value"}), 10 | ("key", {"key": ""}), 11 | ("key ", {"key": ""}), 12 | ("key =value", {"key": "value"}), 13 | ("key= value", {"key": "value"}), 14 | ("key = value", {"key": "value"}), 15 | ("=value", {"": "value"}), 16 | (" =value", {"": "value"}), 17 | ("key=value ", {"key": "value "}), 18 | (" key=value", {"key": "value"}), 19 | (" = ", {"": ""}), 20 | ("=", {"": ""}), 21 | ("", {}), 22 | (" ", {}), 23 | ("\n", {}), 24 | ("\r\n", {}), 25 | ("\r", {}), 26 | ("#This is a comment.", {}), 27 | ("#This is a comment.\nkey = value", {"key": "value"}), 28 | ("key = value\n#This is a comment.", {"key": "value"}), 29 | ("!This is a comment.", {}), 30 | ("!This is a comment.\nkey = value", {"key": "value"}), 31 | ("key = value\n!This is a comment.", {"key": "value"}), 32 | ("key = val\\\nue", {"key": "value"}), 33 | ("key = val\\\n ue", {"key": "value"}), 34 | ("key = val \\\nue", {"key": "val ue"}), 35 | ("key = val \\\n ue", {"key": "val ue"}), 36 | ("ke\\\ny = value", {"key": "value"}), 37 | ("ke\\\n y = value", {"key": "value"}), 38 | ("one two three", {"one": "two three"}), 39 | ("key=value\n", {"key": "value"}), 40 | ("key=value\r\n", {"key": "value"}), 41 | ("key=value\r", {"key": "value"}), 42 | ("key:value", {"key": "value"}), 43 | ("key value", {"key": "value"}), 44 | ("\\ key\\ = \\ value ", {" key ": " value "}), 45 | ("\\ key\\ : \\ value ", {" key ": " value "}), 46 | ("\\ key\\ \t \\ value ", {" key ": " value "}), 47 | ("\\ key\\ \\ value ", {" key ": " value "}), 48 | ("\\ key\\ =\\ value ", {" key ": " value "}), 49 | ("\\ key\\ :\\ value ", {" key ": " value "}), 50 | ("\\ key\\ \\ value ", {" key ": " value "}), 51 | ("\\ key\\ \t\\ value ", {" key ": " value "}), 52 | ("goat = \\uD83D\\uDC10", {"goat": "\U0001F410"}), 53 | ("taog = \\uDC10\\uD83D", {"taog": "\uDC10\uD83D"}), 54 | ("goat = \uD83D\uDC10", {"goat": "\U0001F410"}), 55 | ("goat = \uD83D\\uDC10", {"goat": "\U0001F410"}), 56 | ("goat = \\uD83D\uDC10", {"goat": "\U0001F410"}), 57 | ("taog = \uDC10\uD83D", {"taog": "\uDC10\uD83D"}), 58 | ("goat = \\uD83D\\\n \\uDC10", {"goat": "\U0001F410"}), 59 | ("\\\n# comment", {"#": "comment"}), 60 | (" \\\n# comment", {"#": "comment"}), 61 | ("key = value\\\n # comment", {"key": "value# comment"}), 62 | ("key = value\\\n", {"key": "value"}), 63 | ("key = value\\", {"key": "value"}), 64 | ("key = value\\\n ", {"key": "value"}), 65 | ("# comment\\\nkey = value", {"key": "value"}), 66 | ("\\\n", {}), 67 | ("\\\nkey = value", {"key": "value"}), 68 | (" \\\nkey = value", {"key": "value"}), 69 | ("key = value\nfoo = bar", {"key": "value", "foo": "bar"}), 70 | ("key = value\r\nfoo = bar", {"key": "value", "foo": "bar"}), 71 | ("key = value\rfoo = bar", {"key": "value", "foo": "bar"}), 72 | ("key = value1\nkey = value2", {"key": "value2"}), 73 | ("snowman = \\u2603", {"snowman": "\u2603"}), 74 | ("pokmon = \\u00E9", {"pokmon": "\u00E9"}), 75 | ("newline = \\u000a", {"newline": "\n"}), 76 | ("key = value\\\n\\\nend", {"key": "valueend"}), 77 | ("key = value\\\n \\\nend", {"key": "valueend"}), 78 | ("key = value\\\\\nend", {"key": "value\\", "end": ""}), 79 | ("c#sharp = sucks", {"c#sharp": "sucks"}), 80 | ("fifth = #5", {"fifth": "#5"}), 81 | ("edh = \xF0", {"edh": "\xF0"}), 82 | ("snowman = \u2603", {"snowman": "\u2603"}), 83 | ("goat = \U0001F410", {"goat": "\U0001F410"}), 84 | ("newline = \\n", {"newline": "\n"}), 85 | ("tab = \\t", {"tab": "\t"}), 86 | ("form.feed = \\f", {"form.feed": "\f"}), 87 | ("two\\ words = one key", {"two words": "one key"}), 88 | ("hour\\:minute = 1440", {"hour:minute": "1440"}), 89 | ("E\\=mc^2 = Einstein", {"E=mc^2": "Einstein"}), 90 | ("two\\\\ words = not a key", {"two\\": "words = not a key"}), 91 | ("two\\\\\\ words = one key", {"two\\ words": "one key"}), 92 | ("invalid-escape = \\0", {"invalid-escape": "0"}), 93 | ("invalid-escape = \\q", {"invalid-escape": "q"}), 94 | ("invalid-escape = \\?", {"invalid-escape": "?"}), 95 | ("invalid-escape = \\x40", {"invalid-escape": "x40"}), 96 | (" \\ key = value", {" key": "value"}), 97 | (" \\u0020key = value", {" key": "value"}), 98 | (" \\ key = value", {" ": "key = value"}), 99 | ("key = \\ value", {"key": " value"}), 100 | ("\nkey = value", {"key": "value"}), 101 | (" \nkey = value", {"key": "value"}), 102 | ("key = value\n", {"key": "value"}), 103 | ("key = value\n ", {"key": "value"}), 104 | ("key = value\n\nfoo = bar", {"key": "value", "foo": "bar"}), 105 | ("key = value\n \nfoo = bar", {"key": "value", "foo": "bar"}), 106 | (b"key=value\nedh=\xF0", {"key": "value", "edh": "\xF0"}), 107 | ( 108 | b"key=value\n" 109 | b"edh=\xC3\xB0\n" 110 | b"snowman=\xE2\x98\x83\n" 111 | b"goat=\xF0\x9F\x90\x90", 112 | { 113 | "key": "value", 114 | "edh": "\xC3\xB0", 115 | "snowman": "\xE2\x98\x83", 116 | "goat": "\xF0\x9F\x90\x90", 117 | }, 118 | ), 119 | ("key\tvalue=pair", {"key": "value=pair"}), 120 | ("key\\\tvalue=pair", {"key\tvalue": "pair"}), 121 | ("key\fvalue=pair", {"key": "value=pair"}), 122 | ("key\\\fvalue=pair", {"key\fvalue": "pair"}), 123 | ("key\0value", {"key\0value": ""}), 124 | ("key\\\0value", {"key\0value": ""}), 125 | ("the = \\u00f0e", {"the": "\xF0e"}), 126 | ("\\u00f0e = the", {"\xF0e": "the"}), 127 | ("goat = \\U0001F410", {"goat": "U0001F410"}), 128 | ("key\\u003Dvalue", {"key=value": ""}), 129 | ("key\\u003Avalue", {"key:value": ""}), 130 | ("key\\u0020value", {"key value": ""}), 131 | ("key=\\\\u2603", {"key": "\\u2603"}), 132 | ("key=\\\\u260x", {"key": "\\u260x"}), 133 | ], 134 | ) 135 | def test_loads(s, d): 136 | assert loads(s) == d 137 | 138 | 139 | def test_loads_multiple_ordereddict(): 140 | assert loads( 141 | "key = value\nfoo = bar", object_pairs_hook=OrderedDict 142 | ) == OrderedDict([("key", "value"), ("foo", "bar")]) 143 | 144 | 145 | def test_loads_multiple_ordereddict_rev(): 146 | assert loads( 147 | "foo = bar\nkey = value", object_pairs_hook=OrderedDict 148 | ) == OrderedDict([("foo", "bar"), ("key", "value")]) 149 | 150 | 151 | @pytest.mark.parametrize( 152 | "s,esc", 153 | [ 154 | ("\\u = bad", "\\u"), 155 | ("\\u abcx = bad", "\\u"), 156 | ("\\u", "\\u"), 157 | ("\\uab bad", "\\uab"), 158 | ("\\uab:bad", "\\uab"), 159 | ("\\uab=bad", "\\uab"), 160 | ("\\uabc = bad", "\\uabc"), 161 | ("\\uabcx = bad", "\\uabcx"), 162 | ("\\ux = bad", "\\ux"), 163 | ("\\uxabc = bad", "\\uxabc"), 164 | ("bad = \\u ", "\\u "), 165 | ("bad = \\u abcx", "\\u abc"), 166 | ("bad = \\u", "\\u"), 167 | ("bad = \\uab\\cd", "\\uab\\c"), 168 | ("bad = \\uab\\u0063d", "\\uab\\u"), 169 | ("bad = \\uabc ", "\\uabc "), 170 | ("bad = \\uabc", "\\uabc"), 171 | ("bad = \\uabcx", "\\uabcx"), 172 | ("bad = \\ux", "\\ux"), 173 | ("bad = \\uxabc", "\\uxabc"), 174 | ], 175 | ) 176 | def test_loads_invalid_u_escape(s, esc): 177 | with pytest.raises(InvalidUEscapeError) as excinfo: 178 | loads(s) 179 | assert excinfo.value.escape == esc 180 | assert str(excinfo.value) == "Invalid \\u escape sequence: " + esc 181 | -------------------------------------------------------------------------------- /test/test_loads_xml.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import pytest 3 | from javaproperties import loads_xml 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "s,d", 8 | [ 9 | ("", {}), 10 | ( 11 | 'value', 12 | {"key": "value"}, 13 | ), 14 | ( 15 | ' ', 16 | {"key": " "}, 17 | ), 18 | ( 19 | '\n', 20 | {"key": "\n"}, 21 | ), 22 | ( 23 | '', 24 | {"key": ""}, 25 | ), 26 | ( 27 | '', 28 | {"key": ""}, 29 | ), 30 | ( 31 | "" 32 | '\n' 33 | 'bar' 34 | "", 35 | {"key": "\n"}, 36 | ), 37 | ( 38 | '\n value\n\n', 39 | {"key": "value"}, 40 | ), 41 | ( 42 | "" 43 | 'value' 44 | 'bar' 45 | "", 46 | {"key": "value", "foo": "bar"}, 47 | ), 48 | ( 49 | "\n" 50 | ' value1\n' 51 | ' value2\n' 52 | "\n", 53 | {"key": "value2"}, 54 | ), 55 | ( 56 | "\n" 57 | ' &\n' 58 | ' <\n' 59 | ' >\n' 60 | ' "\n' 61 | ' \n' 62 | "\n", 63 | { 64 | "ampersand": "&", 65 | "less than": "<", 66 | "greater than": ">", 67 | '"': '"', 68 | "snowman": "\u2603", 69 | }, 70 | ), 71 | ( 72 | "\n" 73 | ' \\n\\r\\t\\u2603\\f\\\\\n' 74 | "\n", 75 | {"escapes": "\\n\\r\\t\\u2603\\f\\\\"}, 76 | ), 77 | ( 78 | "\n" 79 | " This is a comment.\n" 80 | ' value\n' 81 | "\n", 82 | {"key": "value"}, 83 | ), 84 | ( 85 | "\n" 86 | ' value\n' 87 | ' bar\n' 88 | "\n", 89 | {"key": "value"}, 90 | ), 91 | ( 92 | '🐐', 93 | {"goat": "\U0001F410"}, 94 | ), 95 | ], 96 | ) 97 | def test_loads_xml(s, d): 98 | assert loads_xml(s) == d 99 | 100 | 101 | def test_loads_xml_bad_root(): 102 | with pytest.raises(ValueError) as excinfo: 103 | loads_xml('value') 104 | assert "not rooted at " in str(excinfo.value) 105 | 106 | 107 | def test_loads_xml_no_key(): 108 | with pytest.raises(ValueError) as excinfo: 109 | loads_xml("value") 110 | assert ' is missing "key" attribute' in str(excinfo.value) 111 | 112 | 113 | def test_loads_xml_multiple_ordereddict(): 114 | assert ( 115 | loads_xml( 116 | """ 117 | 118 | value 119 | bar 120 | 121 | """, 122 | object_pairs_hook=OrderedDict, 123 | ) 124 | == OrderedDict([("key", "value"), ("foo", "bar")]) 125 | ) 126 | 127 | 128 | def test_loads_xml_multiple_ordereddict_rev(): 129 | assert ( 130 | loads_xml( 131 | """ 132 | 133 | bar 134 | value 135 | 136 | """, 137 | object_pairs_hook=OrderedDict, 138 | ) 139 | == OrderedDict([("foo", "bar"), ("key", "value")]) 140 | ) 141 | -------------------------------------------------------------------------------- /test/test_parse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties import Comment, KeyValue, Whitespace, parse 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "s,objects", 7 | [ 8 | ("", []), 9 | ("\n", [Whitespace("\n")]), 10 | (" \n\t\n", [Whitespace(" \n"), Whitespace("\t\n")]), 11 | ("key=value\n", [KeyValue("key", "value", "key=value\n")]), 12 | ("\xF0=\u2603\n", [KeyValue("\xF0", "\u2603", "\xF0=\u2603\n")]), 13 | ("\\u00F0=\\u2603\n", [KeyValue("\xF0", "\u2603", "\\u00F0=\\u2603\n")]), 14 | (" key :\t value \n", [KeyValue("key", "value ", " key :\t value \n")]), 15 | ( 16 | "#This is a comment.\n" 17 | "# So is this.\n" 18 | "comment: no\n" 19 | " ! Also a comment\n", 20 | [ 21 | Comment("#This is a comment.\n"), 22 | Comment("# So is this.\n"), 23 | KeyValue("comment", "no", "comment: no\n"), 24 | Comment(" ! Also a comment\n"), 25 | ], 26 | ), 27 | ( 28 | "#Before blank\n" 29 | "\n" 30 | "#After blank\n" 31 | "\n" 32 | "before=blank\n" 33 | "\n" 34 | "after=blank\n", 35 | [ 36 | Comment("#Before blank\n"), 37 | Whitespace( 38 | "\n", 39 | ), 40 | Comment("#After blank\n"), 41 | Whitespace("\n"), 42 | KeyValue("before", "blank", "before=blank\n"), 43 | Whitespace("\n"), 44 | KeyValue("after", "blank", "after=blank\n"), 45 | ], 46 | ), 47 | ("key va\\\n lue\n", [KeyValue("key", "value", "key va\\\n lue\n")]), 48 | ("key va\\\n", [KeyValue("key", "va", "key va\\\n")]), 49 | ("key va\\", [KeyValue("key", "va", "key va\\")]), 50 | (" \\\n\t\\\r\n\f\\\r \n", [Whitespace(" \\\n\t\\\r\n\f\\\r \n")]), 51 | ( 52 | "key = v\\\n\ta\\\r\n\fl\\\r u\\\ne\n", 53 | [KeyValue("key", "value", "key = v\\\n\ta\\\r\n\fl\\\r u\\\ne\n")], 54 | ), 55 | ], 56 | ) 57 | def test_parse(s, objects): 58 | assert list(parse(s)) == objects 59 | 60 | 61 | def test_keyvalue_attributes(): 62 | kv = KeyValue("a", "b", "c") 63 | assert kv.key == "a" 64 | assert kv.value == "b" 65 | assert kv.source == "c" 66 | assert repr(kv) == "javaproperties.reading.KeyValue(key='a', value='b', source='c')" 67 | k, v, s = kv 68 | assert k == kv.key 69 | assert v == kv.value 70 | assert s == kv.source 71 | 72 | 73 | def test_comment_attributes(): 74 | c = Comment("a") 75 | assert c.source == "a" 76 | assert repr(c) == "javaproperties.reading.Comment(source='a')" 77 | (s,) = c 78 | assert s == c.source 79 | 80 | 81 | def test_whitespace_attributes(): 82 | ws = Whitespace("a") 83 | assert ws.source == "a" 84 | assert repr(ws) == "javaproperties.reading.Whitespace(source='a')" 85 | (s,) = ws 86 | assert s == ws.source 87 | 88 | 89 | @pytest.mark.parametrize( 90 | "c,is_t", 91 | [ 92 | ("#\n", False), 93 | ("#Mon Sep 26 14:57:44 EDT 2016", True), 94 | ("#Mon Sep 26 14:57:44 EDT 2016\n", True), 95 | (" # Mon Sep 26 14:57:44 EDT 2016\n", True), 96 | ("#Wed Dec 31 19:00:00 EST 1969\n", True), 97 | ("#Fri Jan 01 00:00:00 EST 2016\n", True), 98 | ("#Tue Feb 02 02:02:02 EST 2016\n", True), 99 | ("#Thu Mar 03 03:03:03 EST 2016\n", True), 100 | ("#Mon Apr 04 04:04:04 EDT 2016\n", True), 101 | ("#Thu May 05 05:05:05 EDT 2016\n", True), 102 | ("#Mon Jun 06 06:06:06 EDT 2016\n", True), 103 | ("#Thu Jul 07 07:07:07 EDT 2016\n", True), 104 | ("#Mon Aug 08 08:08:08 EDT 2016\n", True), 105 | ("#Fri Sep 09 09:09:09 EDT 2016\n", True), 106 | ("#Mon Oct 10 10:10:10 EDT 2016\n", True), 107 | ("#Fri Nov 11 11:11:11 EST 2016\n", True), 108 | ("#Mon Dec 12 12:12:12 EST 2016\n", True), 109 | ("#Sun Jan 03 06:00:00 EST 2016\n", True), 110 | ("#Mon Jan 04 00:00:00 EST 2016\n", True), 111 | ("#Tue Jan 05 01:00:00 EST 2016\n", True), 112 | ("#Wed Jan 06 02:00:00 EST 2016\n", True), 113 | ("#Thu Jan 07 03:00:00 EST 2016\n", True), 114 | ("#Fri Jan 08 04:00:00 EST 2016\n", True), 115 | ("#Sat Jan 09 05:00:00 EST 2016\n", True), 116 | ("#Mon Feb 29 03:14:15 EST 2016\n", True), 117 | ("#Fri May 13 13:13:13 EDT 2016\n", True), 118 | ("#Mon Sep 26 14:57:44 2016\n", True), 119 | ("#Sat Jan 09 05:00:60 EST 2016\n", True), 120 | ("#Sat Jan 09 05:00:61 EST 2016\n", True), 121 | ("#Mon Feb 32 03:14:15 EST 2016\n", False), 122 | ("#Sun Jan 3 06:00:00 EST 2016\n", False), 123 | ("#Sun Jan 03 6:00:00 EST 2016\n", False), 124 | ("#Sat Jan 09 05:00:62 EST 2016\n", False), 125 | ("#Sat Jan 09 24:00:00 EST 2016\n", False), 126 | ("#Sat Jan 09 05:60:00 EST 2016\n", False), 127 | ("#Mo Mär 02 13:59:03 EST 2020\n", False), 128 | ], 129 | ) 130 | def test_comment_is_timestamp(c, is_t): 131 | assert Comment(c).is_timestamp() == is_t 132 | 133 | 134 | @pytest.mark.parametrize( 135 | "s,ss", 136 | [ 137 | ("key=value", "key=value"), 138 | ("key=value\n", "key=value"), 139 | ("key=value\r\n", "key=value"), 140 | ("key=value\r", "key=value"), 141 | ("key va\\\n", "key va"), 142 | ("key va\\\\\n", "key va\\\\"), 143 | ("key va\\\\\\\n", "key va\\\\"), 144 | ("key va\\", "key va"), 145 | ("key va\\\n \\", "key va\\\n "), 146 | ("key va\\\n \\\n", "key va\\\n "), 147 | ("key va\\\n\\", "key va\\\n"), 148 | ("key va\\\n\\\n", "key va\\\n"), 149 | ], 150 | ) 151 | def test_keyvalue_source_stripped(s, ss): 152 | assert KeyValue(None, None, s).source_stripped == ss 153 | 154 | 155 | @pytest.mark.parametrize( 156 | "s,ss", 157 | [ 158 | ("#comment", "#comment"), 159 | ("#comment\n", "#comment"), 160 | ("#comment\r\n", "#comment"), 161 | ("#comment\r", "#comment"), 162 | ("#comment\\\n", "#comment\\"), 163 | ], 164 | ) 165 | def test_comment_source_stripped(s, ss): 166 | assert Comment(s).source_stripped == ss 167 | 168 | 169 | @pytest.mark.parametrize( 170 | "s,ss", 171 | [ 172 | (" ", " "), 173 | ("\n", ""), 174 | ("\r\n", ""), 175 | ("\r", ""), 176 | ("\\", ""), 177 | ("\\\n", ""), 178 | ("\\\\\n", "\\\\"), 179 | ("\\\\\\\n", "\\\\"), 180 | ("\\\n \\", "\\\n "), 181 | ("\\\n \\\n", "\\\n "), 182 | ("\\\n\\", "\\\n"), 183 | ("\\\n\\\n", "\\\n"), 184 | ], 185 | ) 186 | def test_whitespace_source_stripped(s, ss): 187 | assert Whitespace(s).source_stripped == ss 188 | 189 | 190 | @pytest.mark.parametrize( 191 | "s,v", 192 | [ 193 | ("#comment", "comment"), 194 | ("#comment\n", "comment"), 195 | ("!comment\n", "comment"), 196 | (" #comment\n", "comment"), 197 | ("# comment\n", " comment"), 198 | (" # comment\n", " comment"), 199 | ("\t#comment\n", "comment"), 200 | ("#\tcomment\n", "\tcomment"), 201 | ("\t#\tcomment\n", "\tcomment"), 202 | ("#comment value \n", "comment value "), 203 | (" weird edge # case", "weird edge # case"), 204 | ], 205 | ) 206 | def test_comment_value(s, v): 207 | assert Comment(s).value == v 208 | -------------------------------------------------------------------------------- /test/test_propclass.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from io import BytesIO, StringIO 3 | import pytest 4 | from javaproperties import Properties, dumps 5 | 6 | # Making the global INPUT object a StringIO would cause it be exhausted after 7 | # the first test and thereafter appear to be empty. Thus, a new StringIO must 8 | # be created for each test instead. 9 | INPUT = """\ 10 | # A comment before the timestamp 11 | #Thu Mar 16 17:06:52 EDT 2017 12 | # A comment after the timestamp 13 | foo: first definition 14 | bar=only definition 15 | 16 | # Comment between values 17 | 18 | key = value 19 | 20 | zebra \\ 21 | apple 22 | foo : second definition 23 | 24 | # Comment at end of file 25 | """ 26 | 27 | XML_INPUT = """\ 28 | 29 | 30 | Thu Mar 16 17:06:52 EDT 2017 31 | first definition 32 | only definition 33 | value 34 | apple 35 | second definition 36 | 37 | """ 38 | 39 | 40 | def test_propclass_empty(fixed_timestamp): 41 | p = Properties() 42 | assert len(p) == 0 43 | assert not bool(p) 44 | assert dict(p) == {} 45 | s = StringIO() 46 | p.store(s) 47 | assert s.getvalue() == "#" + fixed_timestamp + "\n" 48 | assert list(p.items()) == [] 49 | 50 | 51 | def test_propclass_load(): 52 | p = Properties() 53 | p.load(StringIO(INPUT)) 54 | assert len(p) == 4 55 | assert bool(p) 56 | assert dict(p) == { 57 | "foo": "second definition", 58 | "bar": "only definition", 59 | "key": "value", 60 | "zebra": "apple", 61 | } 62 | assert list(p.items()) == [ 63 | ("foo", "second definition"), 64 | ("bar", "only definition"), 65 | ("key", "value"), 66 | ("zebra", "apple"), 67 | ] 68 | 69 | 70 | def test_propclass_nonempty_load(): 71 | p = Properties({"key": "lock", "horse": "orange"}) 72 | p.load(StringIO(INPUT)) 73 | assert len(p) == 5 74 | assert bool(p) 75 | assert dict(p) == { 76 | "foo": "second definition", 77 | "bar": "only definition", 78 | "horse": "orange", 79 | "key": "value", 80 | "zebra": "apple", 81 | } 82 | assert list(p.items()) == [ 83 | ("key", "value"), 84 | ("horse", "orange"), 85 | ("foo", "second definition"), 86 | ("bar", "only definition"), 87 | ("zebra", "apple"), 88 | ] 89 | 90 | 91 | def test_propclass_loadFromXML(): 92 | p = Properties() 93 | p.loadFromXML(StringIO(XML_INPUT)) 94 | assert len(p) == 4 95 | assert bool(p) 96 | assert dict(p) == { 97 | "foo": "second definition", 98 | "bar": "only definition", 99 | "key": "value", 100 | "zebra": "apple", 101 | } 102 | assert list(p.items()) == [ 103 | ("foo", "second definition"), 104 | ("bar", "only definition"), 105 | ("key", "value"), 106 | ("zebra", "apple"), 107 | ] 108 | 109 | 110 | def test_propclass_nonempty_loadFromXML(): 111 | p = Properties({"key": "lock", "horse": "orange"}) 112 | p.loadFromXML(StringIO(XML_INPUT)) 113 | assert len(p) == 5 114 | assert bool(p) 115 | assert dict(p) == { 116 | "foo": "second definition", 117 | "bar": "only definition", 118 | "horse": "orange", 119 | "key": "value", 120 | "zebra": "apple", 121 | } 122 | assert list(p.items()) == [ 123 | ("key", "value"), 124 | ("horse", "orange"), 125 | ("foo", "second definition"), 126 | ("bar", "only definition"), 127 | ("zebra", "apple"), 128 | ] 129 | 130 | 131 | def test_propclass_getitem(): 132 | p = Properties() 133 | p.load(StringIO(INPUT)) 134 | assert p["key"] == "value" 135 | assert p["foo"] == "second definition" 136 | with pytest.raises(KeyError): 137 | p["missing"] 138 | 139 | 140 | def test_propclass_setitem(): 141 | p = Properties() 142 | p.load(StringIO(INPUT)) 143 | p["key"] = "lock" 144 | assert len(p) == 4 145 | assert bool(p) 146 | assert dict(p) == { 147 | "foo": "second definition", 148 | "bar": "only definition", 149 | "key": "lock", 150 | "zebra": "apple", 151 | } 152 | assert list(p.items()) == [ 153 | ("foo", "second definition"), 154 | ("bar", "only definition"), 155 | ("key", "lock"), 156 | ("zebra", "apple"), 157 | ] 158 | 159 | 160 | def test_propclass_additem(): 161 | p = Properties() 162 | p.load(StringIO(INPUT)) 163 | p["new"] = "old" 164 | assert len(p) == 5 165 | assert bool(p) 166 | assert dict(p) == { 167 | "foo": "second definition", 168 | "bar": "only definition", 169 | "key": "value", 170 | "zebra": "apple", 171 | "new": "old", 172 | } 173 | assert list(p.items()) == [ 174 | ("foo", "second definition"), 175 | ("bar", "only definition"), 176 | ("key", "value"), 177 | ("zebra", "apple"), 178 | ("new", "old"), 179 | ] 180 | 181 | 182 | def test_propclass_delitem(): 183 | p = Properties() 184 | p.load(StringIO(INPUT)) 185 | del p["key"] 186 | assert len(p) == 3 187 | assert bool(p) 188 | assert dict(p) == { 189 | "foo": "second definition", 190 | "bar": "only definition", 191 | "zebra": "apple", 192 | } 193 | assert list(p.items()) == [ 194 | ("foo", "second definition"), 195 | ("bar", "only definition"), 196 | ("zebra", "apple"), 197 | ] 198 | 199 | 200 | def test_propclass_delitem_missing(): 201 | p = Properties() 202 | p.load(StringIO(INPUT)) 203 | with pytest.raises(KeyError): 204 | del p["missing"] 205 | assert len(p) == 4 206 | assert bool(p) 207 | assert dict(p) == { 208 | "foo": "second definition", 209 | "bar": "only definition", 210 | "key": "value", 211 | "zebra": "apple", 212 | } 213 | assert list(p.items()) == [ 214 | ("foo", "second definition"), 215 | ("bar", "only definition"), 216 | ("key", "value"), 217 | ("zebra", "apple"), 218 | ] 219 | 220 | 221 | def test_propclass_from_dict(): 222 | p = Properties({"key": "value", "apple": "zebra"}) 223 | assert len(p) == 2 224 | assert bool(p) 225 | assert dict(p) == {"apple": "zebra", "key": "value"} 226 | assert list(p.items()) == [ 227 | ("key", "value"), 228 | ("apple", "zebra"), 229 | ] 230 | 231 | 232 | def test_propclass_from_pairs_list(): 233 | p = Properties([("key", "value"), ("apple", "zebra")]) 234 | assert len(p) == 2 235 | assert bool(p) 236 | assert dict(p) == {"apple": "zebra", "key": "value"} 237 | assert list(p.items()) == [ 238 | ("key", "value"), 239 | ("apple", "zebra"), 240 | ] 241 | 242 | 243 | def test_propclass_copy(): 244 | p = Properties({"Foo": "bar"}) 245 | p2 = p.copy() 246 | assert p is not p2 247 | assert isinstance(p2, Properties) 248 | assert p == p2 249 | assert dict(p) == dict(p2) == {"Foo": "bar"} 250 | assert list(p.items()) == list(p2.items()) == [("Foo", "bar")] 251 | p2["Foo"] = "gnusto" 252 | assert dict(p) == {"Foo": "bar"} 253 | assert list(p.items()) == [("Foo", "bar")] 254 | assert dict(p2) == {"Foo": "gnusto"} 255 | assert list(p2.items()) == [("Foo", "gnusto")] 256 | assert p != p2 257 | p2["fOO"] = "quux" 258 | assert dict(p) == {"Foo": "bar"} 259 | assert list(p.items()) == [("Foo", "bar")] 260 | assert dict(p2) == {"Foo": "gnusto", "fOO": "quux"} 261 | assert list(p2.items()) == [("Foo", "gnusto"), ("fOO", "quux")] 262 | assert p != p2 263 | 264 | 265 | def test_propclass_copy_more(): 266 | p = Properties() 267 | p.load(StringIO(INPUT)) 268 | p2 = p.copy() 269 | assert p is not p2 270 | assert isinstance(p2, Properties) 271 | assert p == p2 272 | assert ( 273 | dict(p) 274 | == dict(p2) 275 | == { 276 | "foo": "second definition", 277 | "bar": "only definition", 278 | "key": "value", 279 | "zebra": "apple", 280 | } 281 | ) 282 | p2["foo"] = "third definition" 283 | del p2["bar"] 284 | p2["key"] = "value" 285 | p2["zebra"] = "horse" 286 | p2["new"] = "old" 287 | assert p != p2 288 | assert dict(p) == { 289 | "foo": "second definition", 290 | "bar": "only definition", 291 | "key": "value", 292 | "zebra": "apple", 293 | } 294 | assert dict(p2) == { 295 | "foo": "third definition", 296 | "key": "value", 297 | "zebra": "horse", 298 | "new": "old", 299 | } 300 | 301 | 302 | def test_propclass_eq_empty(): 303 | p = Properties() 304 | p2 = Properties() 305 | assert p is not p2 306 | assert p == p2 307 | assert p2 == p 308 | 309 | 310 | def test_propclass_defaults_neq_empty(): 311 | p = Properties() 312 | p2 = Properties(defaults=Properties({"key": "lock", "horse": "orange"})) 313 | assert p != p2 314 | assert p2 != p 315 | 316 | 317 | def test_propclass_eq_nonempty(): 318 | p = Properties({"Foo": "bar"}) 319 | p2 = Properties({"Foo": "bar"}) 320 | assert p is not p2 321 | assert p == p2 322 | assert p2 == p 323 | 324 | 325 | def test_propclass_eq_nonempty_defaults(): 326 | p = Properties({"Foo": "bar"}, defaults=Properties({"key": "lock"})) 327 | p2 = Properties({"Foo": "bar"}, defaults=Properties({"key": "lock"})) 328 | assert p is not p2 329 | assert p == p2 330 | assert p2 == p 331 | 332 | 333 | def test_propclass_neq_nonempty_neq_defaults(): 334 | p = Properties({"Foo": "bar"}, defaults=Properties({"key": "lock"})) 335 | p2 = Properties({"Foo": "bar"}, defaults=Properties({"key": "Florida"})) 336 | assert p != p2 337 | assert p2 != p 338 | 339 | 340 | def test_propclass_eq_self(): 341 | p = Properties() 342 | p.load(StringIO(INPUT)) 343 | assert p == p 344 | 345 | 346 | def test_propclass_neq(): 347 | assert Properties({"Foo": "bar"}) != Properties({"Foo": "BAR"}) 348 | 349 | 350 | def test_propclass_eq_dict(): 351 | p = Properties({"Foo": "BAR"}) 352 | assert p == {"Foo": "BAR"} 353 | assert {"Foo": "BAR"} == p 354 | assert p != {"Foo": "bar"} 355 | assert {"Foo": "bar"} != p 356 | 357 | 358 | def test_propclass_defaults_eq_dict(): 359 | defs = Properties({"key": "lock", "horse": "orange"}) 360 | p = Properties({"Foo": "BAR"}, defaults=defs) 361 | assert p == {"Foo": "BAR"} 362 | assert {"Foo": "BAR"} == p 363 | assert p != {"Foo": "bar"} 364 | assert {"Foo": "bar"} != p 365 | 366 | 367 | def test_propclass_eq_set_nochange(): 368 | p = Properties() 369 | p.load(StringIO(INPUT)) 370 | p2 = Properties() 371 | p2.load(StringIO(INPUT)) 372 | assert p == p2 373 | assert p["key"] == p2["key"] == "value" 374 | p2["key"] = "value" 375 | assert p == p2 376 | assert dict(p) == dict(p2) 377 | 378 | 379 | def test_propclass_eq_one_comment(): 380 | p = Properties() 381 | p.load(StringIO("#This is a comment.\nkey=value\n")) 382 | p2 = Properties() 383 | p2.load(StringIO("key=value\n")) 384 | assert p == p2 385 | assert dict(p) == dict(p2) 386 | 387 | 388 | def test_propclass_eq_different_comments(): 389 | p = Properties() 390 | p.load(StringIO("#This is a comment.\nkey=value\n")) 391 | p2 = Properties() 392 | p2.load(StringIO("#This is also a comment.\nkey=value\n")) 393 | assert p == p2 394 | assert dict(p) == dict(p2) 395 | 396 | 397 | def test_propclass_eq_one_repeated_key(): 398 | p = Properties() 399 | p.load(StringIO("key = value\nkey: other value\n")) 400 | p2 = Properties() 401 | p2.load(StringIO("key other value")) 402 | assert p == p2 403 | assert dict(p) == dict(p2) == {"key": "other value"} 404 | 405 | 406 | def test_propclass_eq_repeated_keys(): 407 | p = Properties() 408 | p.load(StringIO("key = value\nkey: other value\n")) 409 | p2 = Properties() 410 | p2.load(StringIO("key: whatever\nkey other value")) 411 | assert p == p2 412 | assert dict(p) == dict(p2) == {"key": "other value"} 413 | 414 | 415 | def test_propclass_load_eq_from_dict(): 416 | p = Properties() 417 | p.load(StringIO(INPUT)) 418 | assert p == Properties( 419 | { 420 | "foo": "second definition", 421 | "bar": "only definition", 422 | "key": "value", 423 | "zebra": "apple", 424 | } 425 | ) 426 | 427 | 428 | def test_propclass_neq_string(): 429 | p = Properties() 430 | p.load(StringIO(INPUT)) 431 | assert p != INPUT 432 | assert INPUT != p 433 | 434 | 435 | def test_propclass_propertyNames(): 436 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 437 | names = p.propertyNames() 438 | assert isinstance(names, Iterator) 439 | assert sorted(names) == ["apple", "foo", "key"] 440 | 441 | 442 | def test_propclass_stringPropertyNames(): 443 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 444 | assert p.stringPropertyNames() == set(["key", "apple", "foo"]) 445 | 446 | 447 | def test_propclass_getProperty(): 448 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 449 | assert p.getProperty("key") == "value" 450 | 451 | 452 | def test_propclass_getProperty_default(): 453 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 454 | assert p.getProperty("key", "default") == "value" 455 | 456 | 457 | def test_propclass_getProperty_missing(): 458 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 459 | assert p.getProperty("missing") is None 460 | 461 | 462 | def test_propclass_getProperty_missing_default(): 463 | p = Properties({"key": "value", "apple": "zebra", "foo": "bar"}) 464 | assert p.getProperty("missing", "default") == "default" 465 | 466 | 467 | def test_propclass_defaults(): 468 | defs = Properties({"key": "lock", "horse": "orange"}) 469 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 470 | assert len(p) == 2 471 | assert bool(p) 472 | assert dict(p) == {"key": "value", "apple": "zebra"} 473 | 474 | 475 | def test_propclass_defaults_getitem(): 476 | defs = Properties({"key": "lock", "horse": "orange"}) 477 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 478 | assert p["apple"] == "zebra" 479 | 480 | 481 | def test_propclass_defaults_getitem_overridden(): 482 | defs = Properties({"key": "lock", "horse": "orange"}) 483 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 484 | assert p["key"] == "value" 485 | 486 | 487 | def test_propclass_defaults_getitem_defaulted(): 488 | defs = Properties({"key": "lock", "horse": "orange"}) 489 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 490 | with pytest.raises(KeyError): 491 | p["horse"] 492 | 493 | 494 | def test_propclass_defaults_getProperty(): 495 | defs = Properties({"key": "lock", "horse": "orange"}) 496 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 497 | assert p.getProperty("apple") == "zebra" 498 | 499 | 500 | def test_propclass_defaults_getProperty_overridden(): 501 | defs = Properties({"key": "lock", "horse": "orange"}) 502 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 503 | assert p.getProperty("key") == "value" 504 | 505 | 506 | def test_propclass_defaults_getProperty_defaulted(): 507 | defs = Properties({"key": "lock", "horse": "orange"}) 508 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 509 | assert p.getProperty("horse") == "orange" 510 | 511 | 512 | def test_propclass_defaults_propertyNames(): 513 | defs = Properties({"key": "lock", "horse": "orange"}) 514 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 515 | names = p.propertyNames() 516 | assert isinstance(names, Iterator) 517 | assert sorted(names) == ["apple", "horse", "key"] 518 | 519 | 520 | def test_propclass_defaults_stringPropertyNames(): 521 | defs = Properties({"key": "lock", "horse": "orange"}) 522 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 523 | assert p.stringPropertyNames() == set(["key", "apple", "horse"]) 524 | 525 | 526 | def test_propclass_setProperty(): 527 | p = Properties() 528 | p.load(StringIO(INPUT)) 529 | p.setProperty("key", "lock") 530 | assert len(p) == 4 531 | assert bool(p) 532 | assert dict(p) == { 533 | "foo": "second definition", 534 | "bar": "only definition", 535 | "key": "lock", 536 | "zebra": "apple", 537 | } 538 | 539 | 540 | def test_propclass_defaults_setProperty(): 541 | defs = Properties({"key": "lock", "horse": "orange"}) 542 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 543 | p.setProperty("apple", "banana") 544 | assert dict(p) == {"key": "value", "apple": "banana"} 545 | assert dict(defs) == {"key": "lock", "horse": "orange"} 546 | 547 | 548 | def test_propclass_defaults_setProperty_overridden(): 549 | defs = Properties({"key": "lock", "horse": "orange"}) 550 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 551 | p.setProperty("key", "hole") 552 | assert dict(p) == {"key": "hole", "apple": "zebra"} 553 | assert dict(defs) == {"key": "lock", "horse": "orange"} 554 | 555 | 556 | def test_propclass_defaults_setProperty_new(): 557 | defs = Properties({"key": "lock", "horse": "orange"}) 558 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 559 | p.setProperty("new", "old") 560 | assert dict(p) == {"key": "value", "apple": "zebra", "new": "old"} 561 | assert dict(defs) == {"key": "lock", "horse": "orange"} 562 | 563 | 564 | def test_propclass_defaults_setProperty_new_override(): 565 | defs = Properties({"key": "lock", "horse": "orange"}) 566 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 567 | p.setProperty("horse", "pony") 568 | assert dict(p) == {"key": "value", "apple": "zebra", "horse": "pony"} 569 | assert dict(defs) == {"key": "lock", "horse": "orange"} 570 | 571 | 572 | def test_propclass_defaults_setitem(): 573 | defs = Properties({"key": "lock", "horse": "orange"}) 574 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 575 | p["apple"] = "banana" 576 | assert dict(p) == {"key": "value", "apple": "banana"} 577 | assert dict(defs) == {"key": "lock", "horse": "orange"} 578 | 579 | 580 | def test_propclass_defaults_setitem_overridden(): 581 | defs = Properties({"key": "lock", "horse": "orange"}) 582 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 583 | p["key"] = "hole" 584 | assert dict(p) == {"key": "hole", "apple": "zebra"} 585 | assert dict(defs) == {"key": "lock", "horse": "orange"} 586 | 587 | 588 | def test_propclass_defaults_setitem_new(): 589 | defs = Properties({"key": "lock", "horse": "orange"}) 590 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 591 | p["new"] = "old" 592 | assert dict(p) == {"key": "value", "apple": "zebra", "new": "old"} 593 | assert dict(defs) == {"key": "lock", "horse": "orange"} 594 | 595 | 596 | def test_propclass_defaults_setitem_new_override(): 597 | defs = Properties({"key": "lock", "horse": "orange"}) 598 | p = Properties({"key": "value", "apple": "zebra"}, defaults=defs) 599 | p["horse"] = "pony" 600 | assert dict(p) == {"key": "value", "apple": "zebra", "horse": "pony"} 601 | assert dict(defs) == {"key": "lock", "horse": "orange"} 602 | 603 | 604 | def test_propclass_empty_setitem(fixed_timestamp): 605 | p = Properties() 606 | p["key"] = "value" 607 | assert len(p) == 1 608 | assert bool(p) 609 | assert dict(p) == {"key": "value"} 610 | s = StringIO() 611 | p.store(s) 612 | assert s.getvalue() == "#" + fixed_timestamp + "\nkey=value\n" 613 | 614 | 615 | def test_propclass_store(fixed_timestamp): 616 | p = Properties({"key": "value"}) 617 | s = StringIO() 618 | p.store(s) 619 | assert s.getvalue() == "#" + fixed_timestamp + "\nkey=value\n" 620 | 621 | 622 | def test_propclass_store_comment(fixed_timestamp): 623 | p = Properties({"key": "value"}) 624 | s = StringIO() 625 | p.store(s, comments="Testing") 626 | assert s.getvalue() == "#Testing\n#" + fixed_timestamp + "\nkey=value\n" 627 | 628 | 629 | def test_propclass_store_defaults(fixed_timestamp): 630 | defs = Properties({"key": "lock", "horse": "orange"}) 631 | p = Properties({"key": "value"}, defaults=defs) 632 | s = StringIO() 633 | p.store(s) 634 | assert s.getvalue() == "#" + fixed_timestamp + "\nkey=value\n" 635 | 636 | 637 | def test_propclass_storeToXML(): 638 | p = Properties({"key": "value"}) 639 | s = BytesIO() 640 | p.storeToXML(s) 641 | assert ( 642 | s.getvalue() 643 | == b"""\ 644 | 645 | 646 | 647 | value 648 | 649 | """ 650 | ) 651 | 652 | 653 | def test_propclass_storeToXML_comment(): 654 | p = Properties({"key": "value"}) 655 | s = BytesIO() 656 | p.storeToXML(s, comment="Testing") 657 | assert ( 658 | s.getvalue() 659 | == b"""\ 660 | 661 | 662 | 663 | Testing 664 | value 665 | 666 | """ 667 | ) 668 | 669 | 670 | def test_propclass_storeToXML_defaults(): 671 | defs = Properties({"key": "lock", "horse": "orange"}) 672 | p = Properties({"key": "value"}, defaults=defs) 673 | s = BytesIO() 674 | p.storeToXML(s) 675 | assert ( 676 | s.getvalue() 677 | == b"""\ 678 | 679 | 680 | 681 | value 682 | 683 | """ 684 | ) 685 | 686 | 687 | def test_propclass_dumps_function(): 688 | assert dumps(Properties({"key": "value"}), timestamp=False) == "key=value\n" 689 | 690 | 691 | @pytest.mark.parametrize( 692 | "data", 693 | [ 694 | {}, 695 | {"foo": "bar"}, 696 | {"foo": "bar", "key": "value"}, 697 | ], 698 | ) 699 | @pytest.mark.parametrize( 700 | "defaults", 701 | [ 702 | None, 703 | Properties(), 704 | Properties({"zebra": "apple"}), 705 | ], 706 | ) 707 | def test_propclass_repr(data, defaults): 708 | p = Properties(data, defaults=defaults) 709 | assert repr(p) == ( 710 | f"javaproperties.propclass.Properties({data!r}, defaults={defaults!r})" 711 | ) 712 | 713 | 714 | def test_propclass_repr_noinit(): 715 | p = Properties() 716 | assert repr(p) == "javaproperties.propclass.Properties({}, defaults=None)" 717 | 718 | 719 | # defaults with defaults 720 | -------------------------------------------------------------------------------- /test/test_to_comment.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties import to_comment 3 | 4 | # All C0 and C1 control characters other than \n and \r: 5 | s = "".join( 6 | chr(i) for i in list(range(0x20)) + list(range(0x7F, 0xA0)) if i not in (10, 13) 7 | ) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "cin,cout", 12 | [ 13 | ("", "#"), 14 | ("foobar", "#foobar"), 15 | (" leading", "# leading"), 16 | ("trailing ", "#trailing "), 17 | (" ", "# "), 18 | ("This is a comment.", "#This is a comment."), 19 | ("#This is a double comment.", "##This is a double comment."), 20 | ("trailing newline\n", "#trailing newline\n#"), 21 | ("trailing CRLF\r\n", "#trailing CRLF\n#"), 22 | ("trailing carriage return\r", "#trailing carriage return\n#"), 23 | ("line one\nline two", "#line one\n#line two"), 24 | ("line one\n#line two", "#line one\n#line two"), 25 | ("line one\n!line two", "#line one\n!line two"), 26 | ("\0", "#\0"), 27 | ("\a", "#\a"), 28 | ("\b", "#\b"), 29 | ("\t", "#\t"), 30 | ("\n", "#\n#"), 31 | ("\v", "#\v"), 32 | ("\f", "#\f"), 33 | ("\r", "#\n#"), 34 | ("\x1B", "#\x1B"), 35 | ("\x1F", "#\x1F"), 36 | ("!", "#!"), 37 | ("#", "##"), 38 | (":", "#:"), 39 | ("=", "#="), 40 | ("\\", "#\\"), 41 | ("\\u2603", "#\\u2603"), 42 | ("~", "#~"), 43 | ("\x7F", "#\x7F"), 44 | ("\x80", "#\x80"), 45 | ("\xA0", "#\xA0"), 46 | ("\xF0", "#\xF0"), 47 | ("\xFF", "#\xFF"), 48 | ("\u0100", "#\\u0100"), 49 | ("\u2603", "#\\u2603"), 50 | ("\U0001F410", "#\\ud83d\\udc10"), 51 | ("\uDC10\uD83D", "#\\udc10\\ud83d"), 52 | (s, "#" + s), 53 | ], 54 | ) 55 | def test_to_comment(cin, cout): 56 | assert to_comment(cin) == cout 57 | assert to_comment(cin, ensure_ascii=None) == cout 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "cin,cout", 62 | [ 63 | ("", "#"), 64 | ("foobar", "#foobar"), 65 | (" leading", "# leading"), 66 | ("trailing ", "#trailing "), 67 | (" ", "# "), 68 | ("This is a comment.", "#This is a comment."), 69 | ("#This is a double comment.", "##This is a double comment."), 70 | ("trailing newline\n", "#trailing newline\n#"), 71 | ("trailing CRLF\r\n", "#trailing CRLF\n#"), 72 | ("trailing carriage return\r", "#trailing carriage return\n#"), 73 | ("line one\nline two", "#line one\n#line two"), 74 | ("line one\n#line two", "#line one\n#line two"), 75 | ("line one\n!line two", "#line one\n!line two"), 76 | ("\0", "#\0"), 77 | ("\a", "#\a"), 78 | ("\b", "#\b"), 79 | ("\t", "#\t"), 80 | ("\n", "#\n#"), 81 | ("\v", "#\v"), 82 | ("\f", "#\f"), 83 | ("\r", "#\n#"), 84 | ("\x1B", "#\x1B"), 85 | ("\x1F", "#\x1F"), 86 | ("!", "#!"), 87 | ("#", "##"), 88 | (":", "#:"), 89 | ("=", "#="), 90 | ("\\", "#\\"), 91 | ("\\u2603", "#\\u2603"), 92 | ("~", "#~"), 93 | ("\x7F", "#\x7F"), 94 | ("\x80", "#\x80"), 95 | ("\xA0", "#\xA0"), 96 | ("\xF0", "#\xF0"), 97 | ("\xFF", "#\xFF"), 98 | ("\u0100", "#\u0100"), 99 | ("\u2603", "#\u2603"), 100 | ("\U0001F410", "#\U0001F410"), 101 | ("\uDC10\uD83D", "#\uDC10\uD83D"), 102 | (s, "#" + s), 103 | ], 104 | ) 105 | def test_to_comment_no_ensure_ascii(cin, cout): 106 | assert to_comment(cin, ensure_ascii=False) == cout 107 | 108 | 109 | @pytest.mark.parametrize( 110 | "cin,cout", 111 | [ 112 | ("", "#"), 113 | ("foobar", "#foobar"), 114 | (" leading", "# leading"), 115 | ("trailing ", "#trailing "), 116 | (" ", "# "), 117 | ("This is a comment.", "#This is a comment."), 118 | ("#This is a double comment.", "##This is a double comment."), 119 | ("trailing newline\n", "#trailing newline\n#"), 120 | ("trailing CRLF\r\n", "#trailing CRLF\n#"), 121 | ("trailing carriage return\r", "#trailing carriage return\n#"), 122 | ("line one\nline two", "#line one\n#line two"), 123 | ("line one\n#line two", "#line one\n#line two"), 124 | ("line one\n!line two", "#line one\n!line two"), 125 | ("\0", "#\0"), 126 | ("\a", "#\a"), 127 | ("\b", "#\b"), 128 | ("\t", "#\t"), 129 | ("\n", "#\n#"), 130 | ("\v", "#\v"), 131 | ("\f", "#\f"), 132 | ("\r", "#\n#"), 133 | ("\x1B", "#\x1B"), 134 | ("\x1F", "#\x1F"), 135 | ("!", "#!"), 136 | ("#", "##"), 137 | (":", "#:"), 138 | ("=", "#="), 139 | ("\\", "#\\"), 140 | ("\\u2603", "#\\u2603"), 141 | ("~", "#~"), 142 | ("\x7F", "#\x7F"), 143 | ("\x80", "#\\u0080"), 144 | ("\xA0", "#\\u00a0"), 145 | ("\xF0", "#\\u00f0"), 146 | ("\xFF", "#\\u00ff"), 147 | ("\u0100", "#\\u0100"), 148 | ("\u2603", "#\\u2603"), 149 | ("\U0001F410", "#\\ud83d\\udc10"), 150 | ("\uDC10\uD83D", "#\\udc10\\ud83d"), 151 | ( 152 | s, 153 | "#" 154 | + "".join(chr(i) for i in list(range(0x20)) + [0x7F] if i not in (10, 13)) 155 | + "".join(f"\\u{i:04x}" for i in range(0x80, 0xA0)), 156 | ), 157 | ], 158 | ) 159 | def test_to_comment_ensure_ascii(cin, cout): 160 | assert to_comment(cin, ensure_ascii=True) == cout 161 | -------------------------------------------------------------------------------- /test/test_unescape.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties import InvalidUEscapeError, unescape 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "sin,sout", 7 | [ 8 | ("", ""), 9 | ("foobar", "foobar"), 10 | (" space around ", " space around "), 11 | ("\\ space\\ around\\ ", " space around "), 12 | ("\\ \\ \\ ", " "), 13 | ("\\u0000", "\0"), 14 | ("\\0", "0"), 15 | ("\\a", "a"), 16 | ("\\b", "b"), 17 | ("\\t", "\t"), 18 | ("\\n", "\n"), 19 | ("\\v", "v"), 20 | ("\\f", "\f"), 21 | ("\\r", "\r"), 22 | ("\\e", "e"), 23 | ("\\u001F", "\x1F"), 24 | ("\\q", "q"), 25 | ("\\xF0", "xF0"), 26 | ("\\!", "!"), 27 | ("\\#", "#"), 28 | ("\\:", ":"), 29 | ("\\=", "="), 30 | ("\\\\", "\\"), 31 | ("\\\\u2603", "\\u2603"), 32 | ("\\u007f", "\x7F"), 33 | ("\\u00f0", "\xF0"), 34 | ("\\u2603", "\u2603"), 35 | ("\\u012345678", "\u012345678"), 36 | ("\\uabcd", "\uABCD"), 37 | ("\\uABCD", "\uABCD"), 38 | ("\\ud83d\\udc10", "\U0001F410"), 39 | ("\\U0001f410", "U0001f410"), 40 | ("\\udc10\\ud83d", "\uDC10\uD83D"), 41 | ("\0", "\0"), 42 | ("\t", "\t"), 43 | ("\n", "\n"), 44 | ("\x7F", "\x7F"), 45 | ("\xF0", "\xF0"), 46 | ("\u2603", "\u2603"), 47 | ("\U0001F410", "\U0001F410"), 48 | ], 49 | ) 50 | def test_unescape(sin, sout): 51 | assert unescape(sin) == sout 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "s,esc", 56 | [ 57 | ("\\u", "\\u"), 58 | ("\\u ", "\\u "), 59 | ("\\ux", "\\ux"), 60 | ("\\uab", "\\uab"), 61 | ("\\uab\\cd", "\\uab\\c"), 62 | ("\\uab\\u0063d", "\\uab\\u"), 63 | ("\\uabc", "\\uabc"), 64 | ("\\uabc ", "\\uabc "), 65 | ("\\uabcx", "\\uabcx"), 66 | ("\\uxabc", "\\uxabc"), 67 | ("\\u abcx", "\\u abc"), 68 | ], 69 | ) 70 | def test_unescape_invalid_u_escape(s, esc): 71 | with pytest.raises(InvalidUEscapeError) as excinfo: 72 | unescape(s) 73 | assert excinfo.value.escape == esc 74 | assert str(excinfo.value) == "Invalid \\u escape sequence: " + esc 75 | -------------------------------------------------------------------------------- /test/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from javaproperties.util import LinkedList, ascii_splitlines 3 | 4 | 5 | def test_linkedlist_empty(): 6 | ll = LinkedList() 7 | assert list(ll) == [] 8 | assert list(ll.iternodes()) == [] 9 | assert ll.start is None 10 | assert ll.end is None 11 | 12 | 13 | def test_linkedlist_one_elem(): 14 | ll = LinkedList() 15 | n = ll.append(42) 16 | assert list(ll) == [42] 17 | assert list(ll.iternodes()) == [n] 18 | assert ll.find_node(n) == 0 19 | assert ll.start is n 20 | assert ll.end is n 21 | assert n.prev is None 22 | assert n.next is None 23 | 24 | 25 | def test_linkedlist_two_elem(): 26 | ll = LinkedList() 27 | n1 = ll.append(42) 28 | n2 = ll.append("fnord") 29 | assert list(ll) == [42, "fnord"] 30 | assert list(ll.iternodes()) == [n1, n2] 31 | assert ll.find_node(n1) == 0 32 | assert ll.find_node(n2) == 1 33 | assert ll.start is n1 34 | assert ll.end is n2 35 | assert n1.prev is None 36 | assert n1.next is n2 37 | assert n2.prev is n1 38 | assert n2.next is None 39 | 40 | 41 | def test_linked_list_three_elem(): 42 | ll = LinkedList() 43 | n1 = ll.append(42) 44 | n2 = ll.append("fnord") 45 | n3 = ll.append([0, 1, 2]) 46 | assert list(ll) == [42, "fnord", [0, 1, 2]] 47 | assert list(ll.iternodes()) == [n1, n2, n3] 48 | assert ll.find_node(n1) == 0 49 | assert ll.find_node(n2) == 1 50 | assert ll.find_node(n3) == 2 51 | assert ll.start is n1 52 | assert ll.end is n3 53 | assert n1.prev is None 54 | assert n1.next is n2 55 | assert n2.prev is n1 56 | assert n2.next is n3 57 | assert n3.prev is n2 58 | assert n3.next is None 59 | 60 | 61 | def test_linked_list_unlink_only(): 62 | ll = LinkedList() 63 | n = ll.append(42) 64 | n.unlink() 65 | assert list(ll) == [] 66 | assert list(ll.iternodes()) == [] 67 | assert ll.start is None 68 | assert ll.end is None 69 | assert ll.find_node(n) is None 70 | 71 | 72 | def test_linked_list_unlink_first(): 73 | ll = LinkedList() 74 | n1 = ll.append(42) 75 | n2 = ll.append("fnord") 76 | n3 = ll.append([0, 1, 2]) 77 | n1.unlink() 78 | assert list(ll) == ["fnord", [0, 1, 2]] 79 | assert list(ll.iternodes()) == [n2, n3] 80 | assert ll.find_node(n1) is None 81 | assert ll.find_node(n2) == 0 82 | assert ll.find_node(n3) == 1 83 | assert ll.start is n2 84 | assert ll.end is n3 85 | assert n2.prev is None 86 | assert n2.next is n3 87 | assert n3.prev is n2 88 | assert n3.next is None 89 | 90 | 91 | def test_linked_list_unlink_middle(): 92 | ll = LinkedList() 93 | n1 = ll.append(42) 94 | n2 = ll.append("fnord") 95 | n3 = ll.append([0, 1, 2]) 96 | n2.unlink() 97 | assert list(ll) == [42, [0, 1, 2]] 98 | assert list(ll.iternodes()) == [n1, n3] 99 | assert ll.find_node(n1) == 0 100 | assert ll.find_node(n2) is None 101 | assert ll.find_node(n3) == 1 102 | assert ll.start is n1 103 | assert ll.end is n3 104 | assert n1.prev is None 105 | assert n1.next is n3 106 | assert n3.prev is n1 107 | assert n3.next is None 108 | 109 | 110 | def test_linked_list_unlink_last(): 111 | ll = LinkedList() 112 | n1 = ll.append(42) 113 | n2 = ll.append("fnord") 114 | n3 = ll.append([0, 1, 2]) 115 | n3.unlink() 116 | assert list(ll) == [42, "fnord"] 117 | assert list(ll.iternodes()) == [n1, n2] 118 | assert ll.find_node(n1) == 0 119 | assert ll.find_node(n2) == 1 120 | assert ll.find_node(n3) is None 121 | assert ll.start is n1 122 | assert ll.end is n2 123 | assert n1.prev is None 124 | assert n1.next is n2 125 | assert n2.prev is n1 126 | assert n2.next is None 127 | 128 | 129 | def test_linked_list_insert_before_first(): 130 | ll = LinkedList() 131 | n1 = ll.append(42) 132 | n2 = ll.append("fnord") 133 | n3 = ll.append([0, 1, 2]) 134 | nx = n1.insert_before(3.14) 135 | assert list(ll) == [3.14, 42, "fnord", [0, 1, 2]] 136 | assert list(ll.iternodes()) == [nx, n1, n2, n3] 137 | assert ll.find_node(n1) == 1 138 | assert ll.find_node(n2) == 2 139 | assert ll.find_node(n3) == 3 140 | assert ll.find_node(nx) == 0 141 | assert ll.start is nx 142 | assert ll.end is n3 143 | assert nx.prev is None 144 | assert nx.next is n1 145 | assert n1.prev is nx 146 | assert n1.next is n2 147 | assert n2.prev is n1 148 | assert n2.next is n3 149 | assert n3.prev is n2 150 | assert n3.next is None 151 | 152 | 153 | def test_linked_list_insert_before_middle(): 154 | ll = LinkedList() 155 | n1 = ll.append(42) 156 | n2 = ll.append("fnord") 157 | n3 = ll.append([0, 1, 2]) 158 | nx = n2.insert_before(3.14) 159 | assert list(ll) == [42, 3.14, "fnord", [0, 1, 2]] 160 | assert list(ll.iternodes()) == [n1, nx, n2, n3] 161 | assert ll.find_node(n1) == 0 162 | assert ll.find_node(n2) == 2 163 | assert ll.find_node(n3) == 3 164 | assert ll.find_node(nx) == 1 165 | assert ll.start is n1 166 | assert ll.end is n3 167 | assert n1.prev is None 168 | assert n1.next is nx 169 | assert nx.prev is n1 170 | assert nx.next is n2 171 | assert n2.prev is nx 172 | assert n2.next is n3 173 | assert n3.prev is n2 174 | assert n3.next is None 175 | 176 | 177 | def test_linked_list_insert_before_last(): 178 | ll = LinkedList() 179 | n1 = ll.append(42) 180 | n2 = ll.append("fnord") 181 | n3 = ll.append([0, 1, 2]) 182 | nx = n3.insert_before(3.14) 183 | assert list(ll) == [42, "fnord", 3.14, [0, 1, 2]] 184 | assert list(ll.iternodes()) == [n1, n2, nx, n3] 185 | assert ll.find_node(n1) == 0 186 | assert ll.find_node(n2) == 1 187 | assert ll.find_node(n3) == 3 188 | assert ll.find_node(nx) == 2 189 | assert ll.start is n1 190 | assert ll.end is n3 191 | assert n1.prev is None 192 | assert n1.next is n2 193 | assert n2.prev is n1 194 | assert n2.next is nx 195 | assert nx.prev is n2 196 | assert nx.next is n3 197 | assert n3.prev is nx 198 | assert n3.next is None 199 | 200 | 201 | def test_linked_list_insert_after_first(): 202 | ll = LinkedList() 203 | n1 = ll.append(42) 204 | n2 = ll.append("fnord") 205 | n3 = ll.append([0, 1, 2]) 206 | nx = n1.insert_after(3.14) 207 | assert list(ll) == [42, 3.14, "fnord", [0, 1, 2]] 208 | assert list(ll.iternodes()) == [n1, nx, n2, n3] 209 | assert ll.find_node(n1) == 0 210 | assert ll.find_node(n2) == 2 211 | assert ll.find_node(n3) == 3 212 | assert ll.find_node(nx) == 1 213 | assert ll.start is n1 214 | assert ll.end is n3 215 | assert n1.prev is None 216 | assert n1.next is nx 217 | assert nx.prev is n1 218 | assert nx.next is n2 219 | assert n2.prev is nx 220 | assert n2.next is n3 221 | assert n3.prev is n2 222 | assert n3.next is None 223 | 224 | 225 | def test_linked_list_insert_after_middle(): 226 | ll = LinkedList() 227 | n1 = ll.append(42) 228 | n2 = ll.append("fnord") 229 | n3 = ll.append([0, 1, 2]) 230 | nx = n2.insert_after(3.14) 231 | assert list(ll) == [42, "fnord", 3.14, [0, 1, 2]] 232 | assert list(ll.iternodes()) == [n1, n2, nx, n3] 233 | assert ll.find_node(n1) == 0 234 | assert ll.find_node(n2) == 1 235 | assert ll.find_node(n3) == 3 236 | assert ll.find_node(nx) == 2 237 | assert ll.start is n1 238 | assert ll.end is n3 239 | assert n1.prev is None 240 | assert n1.next is n2 241 | assert n2.prev is n1 242 | assert n2.next is nx 243 | assert nx.prev is n2 244 | assert nx.next is n3 245 | assert n3.prev is nx 246 | assert n3.next is None 247 | 248 | 249 | def test_linked_list_insert_after_last(): 250 | ll = LinkedList() 251 | n1 = ll.append(42) 252 | n2 = ll.append("fnord") 253 | n3 = ll.append([0, 1, 2]) 254 | nx = n3.insert_after(3.14) 255 | assert list(ll) == [42, "fnord", [0, 1, 2], 3.14] 256 | assert list(ll.iternodes()) == [n1, n2, n3, nx] 257 | assert ll.find_node(n1) == 0 258 | assert ll.find_node(n2) == 1 259 | assert ll.find_node(n3) == 2 260 | assert ll.find_node(nx) == 3 261 | assert ll.start is n1 262 | assert ll.end is nx 263 | assert n1.prev is None 264 | assert n1.next is n2 265 | assert n2.prev is n1 266 | assert n2.next is n3 267 | assert n3.prev is n2 268 | assert n3.next is nx 269 | assert nx.prev is n3 270 | assert nx.next is None 271 | 272 | 273 | @pytest.mark.parametrize( 274 | "s,lines", 275 | [ 276 | ("", []), 277 | ("foobar", ["foobar"]), 278 | ("foo\n", ["foo\n"]), 279 | ("foo\r", ["foo\r"]), 280 | ("foo\r\n", ["foo\r\n"]), 281 | ("foo\n\r", ["foo\n", "\r"]), 282 | ("foo\nbar", ["foo\n", "bar"]), 283 | ("foo\rbar", ["foo\r", "bar"]), 284 | ("foo\r\nbar", ["foo\r\n", "bar"]), 285 | ("foo\n\rbar", ["foo\n", "\r", "bar"]), 286 | ( 287 | "Why\vare\fthere\x1Cso\x1Ddang\x1Emany\x85line\u2028separator\u2029" 288 | "characters?", 289 | [ 290 | "Why\vare\fthere\x1Cso\x1Ddang\x1Emany\x85line\u2028separator\u2029" 291 | "characters?" 292 | ], 293 | ), 294 | ], 295 | ) 296 | def test_ascii_splitlines(s, lines): 297 | assert ascii_splitlines(s) == lines 298 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,typing,py38,py39,py310,py311,py312,py313,pypy3 3 | skip_missing_interpreters = True 4 | isolated_build = True 5 | minversion = 3.3.0 6 | 7 | [testenv] 8 | setenv = 9 | LC_ALL=en_US.UTF-8 10 | TZ=EST5EDT,M3.2.0,M11.1.0 11 | deps = 12 | coverage 13 | pytest 14 | pytest-mock 15 | python-dateutil 16 | commands = 17 | coverage erase 18 | coverage run -m pytest {posargs} --doctest-modules --pyargs javaproperties 19 | coverage run -m pytest {posargs} test 20 | coverage combine 21 | coverage report 22 | 23 | [testenv:lint] 24 | skip_install = True 25 | deps = 26 | flake8 27 | flake8-bugbear 28 | flake8-builtins 29 | flake8-unused-arguments 30 | commands = 31 | flake8 src test 32 | 33 | [testenv:typing] 34 | deps = 35 | mypy 36 | commands = 37 | mypy src 38 | 39 | [pytest] 40 | filterwarnings = 41 | error 42 | # 43 | ignore:.*utcfromtimestamp.* is deprecated:DeprecationWarning:dateutil 44 | 45 | [coverage:run] 46 | branch = True 47 | parallel = True 48 | source = javaproperties 49 | 50 | [coverage:paths] 51 | source = 52 | src 53 | .tox/**/site-packages 54 | 55 | [coverage:report] 56 | precision = 2 57 | show_missing = True 58 | exclude_lines = 59 | pragma: no cover 60 | @overload 61 | 62 | [flake8] 63 | doctests = True 64 | extend-exclude = build/,dist/,test/data,venv/ 65 | max-doc-length = 100 66 | max-line-length = 100 67 | unused-arguments-ignore-stub-functions = True 68 | extend-select = B901,B902,B950 69 | ignore = A003,A005,B005,E203,E262,E266,E501,E704,U101,W503 70 | 71 | [isort] 72 | atomic = True 73 | classes = IO 74 | force_sort_within_sections = True 75 | honor_noqa = True 76 | lines_between_sections = 0 77 | profile = black 78 | reverse_relative = True 79 | sort_relative_in_force_sorted_sections = True 80 | src_paths = src 81 | 82 | [testenv:docs] 83 | basepython = python3 84 | deps = -rdocs/requirements.txt 85 | changedir = docs 86 | commands = sphinx-build -E -W -b html . _build/html 87 | --------------------------------------------------------------------------------