├── src └── ini2toml │ ├── py.typed │ ├── drivers │ ├── __init__.py │ ├── configparser.py │ ├── lite_toml.py │ ├── configupdater.py │ └── plain_builtins.py │ ├── __main__.py │ ├── __init__.py │ ├── api.py │ ├── plugins │ ├── toml_incompatibilities.py │ ├── profile_independent_tasks.py │ ├── coverage.py │ ├── best_effort.py │ ├── isort.py │ ├── mypy.py │ ├── __init__.py │ └── pytest.py │ ├── errors.py │ ├── profile.py │ ├── types.py │ ├── translator.py │ └── base_translator.py ├── docs ├── _static │ ├── .gitignore │ └── custom-adjustments.css ├── contributing.rst ├── readme.rst ├── authors.rst ├── changelog.rst ├── license.rst ├── requirements.txt ├── index.rst ├── Makefile ├── public_api_docs.py └── setuptools_pep621.rst ├── .isort.cfg ├── AUTHORS.rst ├── tests ├── examples │ ├── dynamic_extras │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── dynamic_extras_mixed │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── setuptools_scm │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── zipp │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── setuptools_docs │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── plumbum │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── virtualenv │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── pluggy │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── pyscaffold │ │ ├── LICENSE.txt │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── flask │ │ ├── LICENSE.rst │ │ ├── setup.cfg │ │ └── pyproject.toml │ ├── django │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml │ └── pandas │ │ ├── LICENSE │ │ ├── setup.cfg │ │ └── pyproject.toml ├── conftest.py ├── plugins │ ├── test_profile_independent_tasks.py │ ├── test_best_effort.py │ ├── test_toml_incompatibilities.py │ ├── test_isort.py │ ├── test_mypy.py │ ├── test_coverage.py │ └── test_pytest.py ├── drivers │ ├── test_configparser.py │ ├── test_configupdater.py │ ├── test_lite_toml.py │ ├── test_plain_builtins.py │ └── test_full_toml.py ├── test_intermediate_repr.py ├── test_plugins.py ├── test_cli.py ├── test_translator.py ├── test_examples.py └── test_transformations.py ├── setup.cfg ├── .projections.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .readthedocs.yml ├── .coveragerc ├── .gitignore ├── NOTICE.txt ├── .pre-commit-config.yaml ├── pyproject.toml └── tox.ini /src/ini2toml/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ini2toml/drivers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- 1 | # Empty directory 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. _readme: 2 | .. include:: ../README.rst 3 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. _authors: 2 | .. include:: ../AUTHORS.rst 3 | -------------------------------------------------------------------------------- /src/ini2toml/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import run 2 | 3 | run() 4 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | .. include:: ../CHANGELOG.rst 3 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | known_first_party = ini2toml 4 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. _license: 2 | 3 | ======= 4 | License 5 | ======= 6 | 7 | .. include:: ../LICENSE.txt 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | * Anderson Bravalheri 6 | * Michał Górny 7 | -------------------------------------------------------------------------------- /tests/examples/dynamic_extras/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = hello 3 | version = 42 4 | 5 | [options] 6 | install_requires = file: requirements.txt 7 | 8 | [options.extras_require] 9 | dev = file: dev-requirements.txt 10 | -------------------------------------------------------------------------------- /tests/examples/dynamic_extras_mixed/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = hello 3 | version = 42 4 | 5 | [options] 6 | install_requires = file: requirements.txt 7 | 8 | [options.extras_require] 9 | dev = file: dev-requirements.txt 10 | other = 11 | platformdirs 12 | rich 13 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy conftest.py for ini2toml. 3 | 4 | If you don't know what this is for, just leave it empty. 5 | Read more about conftest.py under: 6 | - https://docs.pytest.org/en/stable/fixture.html 7 | - https://docs.pytest.org/en/stable/writing_plugins.html 8 | """ 9 | 10 | # import pytest 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements file for ReadTheDocs, check .readthedocs.yml. 2 | # To build the module reference correctly, make sure every external package 3 | # under `install_requires` in `setup.cfg` is also listed here! 4 | furo>=2021.10.9 5 | sphinx>=3.2.1 6 | sphinx-argparse>=0.3.1 7 | sphinx-copybutton 8 | sphinx-jsonschema>=1.16.11 9 | sphinxemoji 10 | -------------------------------------------------------------------------------- /src/ini2toml/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | # Change here if project is renamed and does not equal the package name 5 | dist_name = __name__ 6 | __version__ = version(dist_name) 7 | except PackageNotFoundError: # pragma: no cover 8 | __version__ = "unknown" 9 | finally: 10 | del version, PackageNotFoundError 11 | -------------------------------------------------------------------------------- /tests/examples/dynamic_extras/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "hello" 7 | version = "42" 8 | dynamic = ["dependencies", "optional-dependencies"] 9 | 10 | [tool.setuptools] 11 | include-package-data = false 12 | 13 | [tool.setuptools.dynamic] 14 | dependencies = {file = ["requirements.txt"]} 15 | 16 | [tool.setuptools.dynamic.optional-dependencies] 17 | dev = {file = ["dev-requirements.txt"]} 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Some sane defaults for the code style checker flake8 3 | max_line_length = 88 4 | extend_ignore = E203, W503, W291 5 | # ^ Black-compatible 6 | # E203 and W503 have edge cases handled by black 7 | exclude = 8 | .tox 9 | build 10 | dist 11 | .eggs 12 | docs/conf.py 13 | 14 | [pyscaffold] 15 | # PyScaffold's parameters when the project was created. 16 | # This will be used when updating. Do not change! 17 | version = 4.1.4 18 | package = ini2toml 19 | extensions = 20 | cirrus 21 | pre_commit 22 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.py": { 3 | "autoformat": true, 4 | "textwidth": 88 5 | }, 6 | "src/ini2toml/*/__init__.py" : { 7 | "alternate": "tests/test_{basename}.py", 8 | "type": "source" 9 | }, 10 | "src/ini2toml/*.py" : { 11 | "alternate": "tests/{dirname}/test_{basename}.py", 12 | "type": "source" 13 | }, 14 | "tests/**/test_*.py" : { 15 | "alternate": [ 16 | "src/ini2toml/{dirname}/{basename}.py", 17 | "src/ini2toml/{dirname}/{basename}/__init__.py" 18 | ], 19 | "type": "test" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/examples/dynamic_extras_mixed/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "hello" 7 | version = "42" 8 | dynamic = ["dependencies", "optional-dependencies"] 9 | 10 | [tool.setuptools] 11 | include-package-data = false 12 | 13 | [tool.setuptools.dynamic] 14 | dependencies = {file = ["requirements.txt"]} 15 | 16 | [tool.setuptools.dynamic.optional-dependencies] 17 | dev = {file = ["dev-requirements.txt"]} 18 | other = [ 19 | "platformdirs", 20 | "rich", 21 | ] 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # Build documentation with MkDocs 12 | #mkdocs: 13 | # configuration: mkdocs.yml 14 | 15 | # Optionally build your docs in additional formats such as PDF 16 | # formats: 17 | # - pdf 18 | 19 | build: 20 | os: ubuntu-22.04 21 | tools: 22 | python: "3.11" 23 | 24 | python: 25 | install: 26 | - requirements: docs/requirements.txt 27 | - {path: ., method: pip} 28 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = ini2toml 5 | omit = 6 | */ini2toml/api.py 7 | */ini2toml/__main__.py 8 | 9 | [paths] 10 | source = 11 | src/ 12 | */site-packages/ 13 | 14 | [report] 15 | # Regexes for lines to exclude from consideration 16 | exclude_lines = 17 | \.\.\. 18 | 19 | # Have to re-enable the standard pragma 20 | pragma: no cover 21 | 22 | # Don't complain about missing debug-only code: 23 | def __repr__ 24 | if self\.debug 25 | 26 | # Don't complain if tests don't hit defensive assertion code: 27 | raise AssertionError 28 | raise NotImplementedError 29 | 30 | # Don't complain if non-runnable code isn't run: 31 | if 0: 32 | if __name__ == .__main__.: 33 | -------------------------------------------------------------------------------- /src/ini2toml/drivers/configparser.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from types import MappingProxyType 3 | from typing import Mapping 4 | 5 | from ..types import IntermediateRepr 6 | 7 | EMPTY: Mapping = MappingProxyType({}) 8 | 9 | 10 | def parse(text: str, opts: Mapping = EMPTY) -> IntermediateRepr: 11 | cfg = ConfigParser(**opts) 12 | cfg.read_string(text) 13 | irepr = IntermediateRepr() 14 | for name, section in cfg.items(): 15 | if name == "DEFAULT": 16 | continue 17 | irepr.append(name, translate_section(section)) 18 | return irepr 19 | 20 | 21 | def translate_section(section: Mapping): 22 | irepr = IntermediateRepr() 23 | for name, value in section.items(): 24 | irepr.append(name, value) 25 | return irepr 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | *.cfg 6 | !.isort.cfg 7 | !setup.cfg 8 | *.orig 9 | *.log 10 | *.pot 11 | __pycache__/* 12 | .cache/* 13 | .*.swp 14 | */.ipynb_checkpoints/* 15 | .DS_Store 16 | 17 | # Project files 18 | .ropeproject 19 | .project 20 | .pydevproject 21 | .settings 22 | .idea 23 | .vscode 24 | tags 25 | 26 | # Package files 27 | *.egg 28 | *.eggs/ 29 | .installed.cfg 30 | *.egg-info 31 | 32 | # Unittest and coverage 33 | htmlcov/* 34 | .coverage 35 | .coverage.* 36 | .tox 37 | junit*.xml 38 | coverage.xml 39 | .pytest_cache/ 40 | 41 | # Build and docs folder/files 42 | build/* 43 | dist/* 44 | sdist/* 45 | docs/api/* 46 | docs/_rst/* 47 | docs/_build/* 48 | cover/* 49 | MANIFEST 50 | 51 | # Per-project virtualenvs 52 | .venv*/ 53 | .conda*/ 54 | -------------------------------------------------------------------------------- /src/ini2toml/drivers/lite_toml.py: -------------------------------------------------------------------------------- 1 | """This module serves as a compatibility layer between API-compatible 2 | TOML parsers/serialisers. 3 | It makes it easy to swap between implementations for testing (by means of search and 4 | replace). 5 | """ 6 | 7 | try: 8 | from tomli_w import dumps 9 | except ImportError: # pragma: no cover 10 | # Let's try another API-compatible popular library as a last hope 11 | from toml import dumps # type: ignore[import-untyped,no-redef] 12 | 13 | from ..types import IntermediateRepr 14 | from . import plain_builtins 15 | 16 | __all__ = [ 17 | "convert", 18 | ] 19 | 20 | 21 | def convert(irepr: IntermediateRepr) -> str: 22 | text = dumps(plain_builtins.convert(irepr)) 23 | return text.strip() + "\n" # ensure terminating newline (POSIX requirement) 24 | -------------------------------------------------------------------------------- /tests/plugins/test_profile_independent_tasks.py: -------------------------------------------------------------------------------- 1 | from inspect import cleandoc 2 | 3 | from ini2toml.plugins.profile_independent_tasks import ( 4 | normalise_newlines, 5 | remove_empty_table_headers, 6 | ) 7 | 8 | 9 | def test_terminating_line(): 10 | assert normalise_newlines("a") == "a\n" 11 | assert normalise_newlines("a\n") == "a\n" 12 | assert normalise_newlines("a\n\n") == "a\n" 13 | 14 | 15 | def test_remove_empty_table_headers(): 16 | text = """ 17 | [tools] 18 | [tools.setuptools] 19 | 20 | [tools.setuptools.package] 21 | 22 | 23 | [tools.setuptools.package.find] 24 | where = "src" 25 | """ 26 | expected = """ 27 | [tools.setuptools.package.find] 28 | where = "src" 29 | """ 30 | 31 | assert remove_empty_table_headers(cleandoc(text)) == cleandoc(expected) 32 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | ini2toml 3 | ======== 4 | 5 | **ini2toml** is a command line tool and Python library for automatically 6 | converting |ini_cfg|_ files into a TOML_ equivalent. 7 | 8 | 9 | Contents 10 | ======== 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | Overview 16 | setuptools_pep621 17 | 18 | .. toctree:: 19 | :caption: Project 20 | :maxdepth: 2 21 | 22 | Contributions & Help 23 | dev-guide 24 | license 25 | authors 26 | 27 | .. toctree:: 28 | :maxdepth: 1 29 | 30 | Module Reference 31 | changelog 32 | 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | 41 | .. |ini_cfg| replace:: ``.ini/.cfg`` 42 | 43 | .. _ini_cfg: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure 44 | .. _TOML: https://toml.io/en/ 45 | -------------------------------------------------------------------------------- /tests/examples/setuptools_scm/LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /tests/examples/zipp/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Jason R. Coombs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/examples/setuptools_docs/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Jason R. Coombs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/examples/plumbum/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Tomer Filiba (tomerfiliba@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/examples/virtualenv/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-202x The virtualenv developers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/examples/pluggy/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 holger krekel (rather uses bitbucket/hpk42) 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 | -------------------------------------------------------------------------------- /tests/plugins/test_best_effort.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from ini2toml.drivers.full_toml import loads 6 | from ini2toml.plugins import best_effort 7 | from ini2toml.translator import Translator 8 | 9 | 10 | @pytest.fixture 11 | def translate(): 12 | translator = Translator(plugins=[best_effort.activate]) 13 | return lambda text: translator.translate(text, "best_effort") 14 | 15 | 16 | @pytest.fixture 17 | def ini2tomlobj(translate): 18 | return lambda text: loads(translate(text)) 19 | 20 | 21 | def test_best_effort(ini2tomlobj): 22 | example = """\ 23 | [section] 24 | a = 1 25 | b = 0.42 26 | c = false 27 | d = on 28 | f = 29 | a 30 | b 31 | e = 32 | a=1 33 | b=2 34 | [nested.section] 35 | value = string 36 | """ 37 | doc = ini2tomlobj(dedent(example)) 38 | assert doc["section"]["a"] == 1 39 | assert doc["section"]["b"] == 0.42 40 | assert doc["section"]["c"] is False 41 | assert doc["section"]["d"] is True 42 | assert doc["section"]["f"] == ["a", "b"] 43 | assert doc["section"]["e"] == {"a": "1", "b": "2"} 44 | assert doc["nested"]["section"]["value"] == "string" 45 | -------------------------------------------------------------------------------- /tests/examples/pyscaffold/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present, PyScaffold contributors 4 | Copyright (c) 2014-2018 Blue Yonder GmbH 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/ini2toml/api.py: -------------------------------------------------------------------------------- 1 | """Public API available for general usage. 2 | 3 | In addition to the classes and functions "exported" by this module, the following are 4 | also part of the public API: 5 | 6 | - The public members of the :mod:`~ini2toml.types` module. 7 | - The public members of the :mod:`~ini2toml.errors` module. 8 | - The ``activate`` function in each submodule of the :obj:`~ini2toml.plugins` package 9 | 10 | Please notice there might be classes of similar names exported by both ``api`` and 11 | ``types``. When this happens, the classes in ``types`` are not concrete implementations, 12 | but instead act as :class:`protocols ` (i.e. abstract descriptions 13 | for checking `structural polymorphism`_ during static analysis). 14 | These should be preferred when writing type hints and signatures. 15 | 16 | Plugin authors can also use functions exported by :mod:`~ini2toml.transformations`. 17 | 18 | .. _structural polymorphism: https://www.python.org/dev/peps/pep-0544/ 19 | """ 20 | 21 | from .base_translator import BaseTranslator 22 | from .translator import FullTranslator, LiteTranslator, Translator 23 | 24 | __all__ = [ 25 | "BaseTranslator", 26 | "FullTranslator", 27 | "LiteTranslator", 28 | "Translator", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | AUTODOCDIR = api 11 | 12 | # User-friendly check for sphinx-build 13 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1) 14 | $(error "The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/") 15 | endif 16 | 17 | .PHONY: help clean Makefile 18 | 19 | # Put it first so that "make" without argument is like "make help". 20 | help: 21 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 22 | 23 | clean: 24 | rm -rf $(BUILDDIR)/* $(AUTODOCDIR) 25 | 26 | # Catch-all target: route all unknown targets to Sphinx using the new 27 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 28 | %: Makefile 29 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 30 | -------------------------------------------------------------------------------- /tests/examples/setuptools_docs/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = my_package 3 | version = attr: src.VERSION 4 | description = My package description 5 | long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst 6 | keywords = one, two 7 | license = BSD 3-Clause License 8 | classifiers = 9 | Framework :: Django 10 | License :: OSI Approved :: BSD License 11 | Programming Language :: Python :: 3 12 | Programming Language :: Python :: 3.5 13 | 14 | [options] 15 | zip_safe = False 16 | include_package_data = True 17 | packages = find: 18 | scripts = 19 | bin/first.py 20 | bin/second.py 21 | install_requires = 22 | requests 23 | importlib; python_version == "2.6" 24 | 25 | [options.package_data] 26 | * = *.txt, *.rst 27 | hello = *.msg 28 | 29 | [options.entry_points] 30 | console_scripts = 31 | executable-name = package.module:function 32 | 33 | [options.extras_require] 34 | pdf = ReportLab>=1.2; RXP 35 | rest = docutils>=0.3; pack ==1.1, ==1.3 36 | 37 | [options.packages.find] 38 | exclude = 39 | src.subpackage1 40 | src.subpackage2 41 | 42 | [options.data_files] 43 | /etc/my_package = 44 | site.d/00_default.conf 45 | host.d/00_default.conf 46 | data = data/img/logo.png, data/svg/icon.svg 47 | fonts = data/fonts/*.ttf, data/fonts/*.otf 48 | -------------------------------------------------------------------------------- /tests/drivers/test_configparser.py: -------------------------------------------------------------------------------- 1 | from ini2toml.drivers import configparser as lib 2 | from ini2toml.types import IntermediateRepr as IR 3 | 4 | example_cfg = """\ 5 | [section1] 6 | # comment 7 | value = 42 # int value 8 | 9 | [section2] 10 | float-value = 1.5 11 | boolean-value = false 12 | other value = 13 | 1, 2, 3, # 1st line comment 14 | # 2nd line comment 15 | other-boolean-value = true 16 | 17 | # comment between options 18 | string-value = string # comment 19 | 20 | [section2.another value] 21 | a = 1 22 | b = 2 # 1st line comment 23 | c = 3 24 | d = 4 # 2nd line comment 25 | """ 26 | 27 | 28 | example_parsed = IR( 29 | { 30 | "section1": IR(value="42 # int value"), 31 | "section2": IR( 32 | { 33 | "float-value": "1.5", 34 | "boolean-value": "false", 35 | "other value": "\n1, 2, 3, # 1st line comment", 36 | "other-boolean-value": "true", 37 | "string-value": "string # comment", 38 | } 39 | ), 40 | "section2.another value": IR( 41 | a="1", b="2 # 1st line comment", c="3", d="4 # 2nd line comment" 42 | ), 43 | } 44 | ) 45 | 46 | 47 | def test_parse(): 48 | assert lib.parse(example_cfg.strip(), {}) == example_parsed 49 | -------------------------------------------------------------------------------- /tests/examples/zipp/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = zipp 3 | author = Jason R. Coombs 4 | author_email = jaraco@jaraco.com 5 | description = Backport of pathlib-compatible object wrapper for zip files 6 | long_description = file:README.rst 7 | url = https://github.com/jaraco/zipp 8 | classifiers = 9 | Development Status :: 5 - Production/Stable 10 | Intended Audience :: Developers 11 | License :: OSI Approved :: MIT License 12 | Programming Language :: Python :: 3 13 | Programming Language :: Python :: 3 :: Only 14 | 15 | [options] 16 | packages = find_namespace: 17 | py_modules = zipp 18 | include_package_data = true 19 | python_requires = >=3.6 20 | install_requires = 21 | 22 | [options.packages.find] 23 | exclude = 24 | build* 25 | dist* 26 | docs* 27 | tests* 28 | 29 | [options.extras_require] 30 | testing = 31 | # upstream 32 | pytest >= 6 33 | pytest-checkdocs >= 2.4 34 | pytest-flake8 35 | pytest-black >= 0.3.7; \ 36 | # workaround for jaraco/skeleton#22 37 | python_implementation != "PyPy" 38 | pytest-cov 39 | pytest-mypy; \ 40 | # workaround for jaraco/skeleton#22 41 | python_implementation != "PyPy" 42 | pytest-enabler >= 1.0.1 43 | 44 | # local 45 | jaraco.itertools 46 | func-timeout 47 | 48 | docs = 49 | # upstream 50 | sphinx 51 | jaraco.packaging >= 8.2 52 | rst.linker >= 1.9 53 | 54 | # local 55 | 56 | [options.entry_points] 57 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | 'ini2toml' is licensed under the MPL-2.0 license, with the following copyright notice: 2 | 3 | Copyright (c) 2021, Anderson Bravalheri 4 | 5 | see the LICENSE.txt file for details. 6 | 7 | A few extra files are collocated in this code base ('tests/examples') exclusively for testing 8 | purposes during development: 9 | 10 | - 'django/setup.cfg' from https://github.com/django/django, licensed under BSD-3-Clause 11 | - 'flask/setup.cfg' from https://github.com/pallets/flask, licensed under BSD-3-Clause 12 | - 'pandas/setup.cfg' from https://github.com/pandas-dev/pandas, licensed under BSD-3-Clause 13 | - 'pluggy/setup.cfg' from https://github.com/pytest-dev/pytest, licensed under MIT 14 | - 'plumbum/setup.cfg' from https://github.com/tomerfiliba/plumbum, licensed under MIT 15 | - 'pyscaffold/setup.cfg' from https://github.com/pyscaffold/pyscaffold, licensed under MIT 16 | - 'setuptools_docs/**/*.cfg' from https://github.com/pypa/setuptools, licensed under MIT 17 | - 'setuptools_scm/setup.cfg' from https://github.com/pypa/setuptools_scm, licensed under MIT 18 | - 'virtualenv/setup.cfg' from https://github.com/pypa/virtualenv, licensed under MIT 19 | - 'zipp/setup.cfg' from https://github.com/jaraco/zipp, licensed under MIT 20 | 21 | These files are not part of the 'ini2toml' project and not meant 22 | for distribution (as part of the 'ini2toml' software package). 23 | The original licenses for each one of these files can be found inside the 24 | respective directory under 'tests/examples'. 25 | -------------------------------------------------------------------------------- /tests/examples/flask/LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright 2010 Pallets 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 21 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 24 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 25 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 26 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 27 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /tests/examples/setuptools_docs/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "my_package" 7 | description = "My package description" 8 | keywords = ["one", "two"] 9 | license = {text = "BSD 3-Clause License"} 10 | classifiers = [ 11 | "Framework :: Django", 12 | "License :: OSI Approved :: BSD License", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.5", 15 | ] 16 | dynamic = ["readme", "version"] 17 | dependencies = [ 18 | "requests", 19 | 'importlib; python_version == "2.6"', 20 | ] 21 | 22 | [project.optional-dependencies] 23 | pdf = ["ReportLab>=1.2", "RXP"] 24 | rest = ["docutils>=0.3", "pack ==1.1, ==1.3"] 25 | 26 | [project.scripts] 27 | executable-name = "package.module:function" 28 | 29 | [tool.setuptools] 30 | zip-safe = false 31 | include-package-data = true 32 | script-files = [ 33 | "bin/first.py", 34 | "bin/second.py", 35 | ] 36 | 37 | [tool.setuptools.package-data] 38 | "*" = ["*.txt", "*.rst"] 39 | hello = ["*.msg"] 40 | 41 | [tool.setuptools.packages.find] 42 | exclude = [ 43 | "src.subpackage1", 44 | "src.subpackage2", 45 | ] 46 | namespaces = false 47 | 48 | [tool.setuptools.data-files] 49 | "/etc/my_package" = [ 50 | "site.d/00_default.conf", 51 | "host.d/00_default.conf", 52 | ] 53 | data = ["data/img/logo.png", "data/svg/icon.svg"] 54 | fonts = ["data/fonts/*.ttf", "data/fonts/*.otf"] 55 | 56 | [tool.setuptools.dynamic] 57 | readme = {file = ["README.rst", "CHANGELOG.rst", "LICENSE.rst"]} 58 | version = {attr = "src.VERSION"} 59 | -------------------------------------------------------------------------------- /tests/examples/zipp/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "zipp" 7 | authors = [{name = "Jason R. Coombs", email = "jaraco@jaraco.com"}] 8 | description = "Backport of pathlib-compatible object wrapper for zip files" 9 | readme = "README.rst" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3 :: Only", 16 | ] 17 | urls = {Homepage = "https://github.com/jaraco/zipp"} 18 | requires-python = ">=3.6" 19 | dependencies = [] 20 | dynamic = ["version"] 21 | 22 | [project.optional-dependencies] 23 | testing = [ 24 | # upstream 25 | "pytest >= 6", 26 | "pytest-checkdocs >= 2.4", 27 | "pytest-flake8", 28 | 'pytest-black >= 0.3.7; python_implementation != "PyPy"', # workaround for jaraco/skeleton#22 29 | "pytest-cov", 30 | 'pytest-mypy; python_implementation != "PyPy"', # workaround for jaraco/skeleton#22 31 | "pytest-enabler >= 1.0.1", 32 | # local 33 | "jaraco.itertools", 34 | "func-timeout", 35 | ] 36 | docs = [ 37 | # upstream 38 | "sphinx", 39 | "jaraco.packaging >= 8.2", 40 | "rst.linker >= 1.9", 41 | # local 42 | ] 43 | 44 | [tool.setuptools] 45 | py-modules = ["zipp"] 46 | include-package-data = true 47 | 48 | [tool.setuptools.packages.find] 49 | exclude = [ 50 | "build*", 51 | "dist*", 52 | "docs*", 53 | "tests*", 54 | ] 55 | namespaces = true 56 | -------------------------------------------------------------------------------- /tests/drivers/test_configupdater.py: -------------------------------------------------------------------------------- 1 | from ini2toml.drivers import configupdater as lib 2 | from ini2toml.types import CommentKey 3 | from ini2toml.types import IntermediateRepr as IR 4 | from ini2toml.types import WhitespaceKey 5 | 6 | example_cfg = """\ 7 | [section1] 8 | # comment 9 | value = 42 # int value 10 | 11 | [section2] 12 | float-value = 1.5 13 | boolean-value = false 14 | other value = 15 | 1, 2, 3, # 1st line comment 16 | # 2nd line comment 17 | other-boolean-value = true 18 | 19 | # comment between options 20 | string-value = string # comment 21 | 22 | [section2.another value] 23 | a = 1 24 | b = 2 # 1st line comment 25 | c = 3 26 | d = 4 # 2nd line comment 27 | """ 28 | 29 | 30 | example_parsed = IR( 31 | { 32 | "section1": IR( 33 | {CommentKey(): "comment", "value": "42 # int value", WhitespaceKey(): "\n"} 34 | ), 35 | "section2": IR( 36 | { 37 | "float-value": "1.5", 38 | "boolean-value": "false", 39 | "other value": "\n1, 2, 3, # 1st line comment\n# 2nd line comment", 40 | "other-boolean-value": "true", 41 | WhitespaceKey(): "\n", 42 | CommentKey(): "comment between options", 43 | "string-value": "string # comment", 44 | WhitespaceKey(): "\n", 45 | } 46 | ), 47 | "section2.another value": IR( 48 | a="1", b="2 # 1st line comment", c="3", d="4 # 2nd line comment" 49 | ), 50 | } 51 | ) 52 | 53 | 54 | def test_parse(): 55 | assert lib.parse(example_cfg.strip(), {}) == example_parsed 56 | -------------------------------------------------------------------------------- /tests/examples/pluggy/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pluggy 3 | description = plugin and hook calling mechanisms for python 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | license = MIT 7 | platforms = unix, linux, osx, win32 8 | author = Holger Krekel 9 | author_email = holger@merlinux.eu 10 | url = https://github.com/pytest-dev/pluggy 11 | classifiers = 12 | Development Status :: 6 - Mature 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: MIT License 15 | Operating System :: POSIX 16 | Operating System :: Microsoft :: Windows 17 | Operating System :: MacOS :: MacOS X 18 | Topic :: Software Development :: Testing 19 | Topic :: Software Development :: Libraries 20 | Topic :: Utilities 21 | Programming Language :: Python :: Implementation :: CPython 22 | Programming Language :: Python :: Implementation :: PyPy 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3 :: Only 25 | Programming Language :: Python :: 3.6 26 | Programming Language :: Python :: 3.7 27 | Programming Language :: Python :: 3.8 28 | Programming Language :: Python :: 3.9 29 | Programming Language :: Python :: 3.10 30 | 31 | [options] 32 | packages = 33 | pluggy 34 | install_requires = 35 | importlib-metadata>=0.12;python_version<"3.8" 36 | python_requires = >=3.6 37 | package_dir = 38 | =src 39 | setup_requires = 40 | setuptools-scm 41 | [options.extras_require] 42 | dev = 43 | pre-commit 44 | tox 45 | testing = 46 | pytest 47 | pytest-benchmark 48 | 49 | [devpi:upload] 50 | formats=sdist.tgz,bdist_wheel 51 | -------------------------------------------------------------------------------- /tests/examples/django/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Django Software Foundation and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/plugins/test_toml_incompatibilities.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from ini2toml.drivers import configparser, configupdater 6 | from ini2toml.plugins import toml_incompatibilities as plugin 7 | from ini2toml.translator import Translator 8 | 9 | EXAMPLE_FLAKE8 = """ 10 | [flake8] 11 | # Some sane defaults for the code style checker flake8 12 | # black compatibility 13 | max_line_length = 88 14 | # E203 and W503 have edge cases handled by black 15 | extend_ignore = E203, W503 16 | exclude = 17 | src/pyscaffold/contrib 18 | .tox 19 | build 20 | dist 21 | .eggs 22 | docs/conf.py 23 | """ 24 | 25 | EXAMPLE_DEVPI = """ 26 | [devpi:upload] 27 | # Options for the devpi: PyPI server and packaging tool 28 | # VCS export must be deactivated since we are using setuptools-scm 29 | no_vcs = 1 30 | formats = bdist_wheel 31 | """ 32 | 33 | EXAMPLES = { 34 | "flake8": (".flake8", "flake8", EXAMPLE_FLAKE8), 35 | "flake8-setup.cfg": ("setup.cfg", "flake8", EXAMPLE_FLAKE8), 36 | "devpi-setup.cfg": ("setup.cfg", "devpi:upload", EXAMPLE_DEVPI), 37 | } 38 | 39 | 40 | @pytest.mark.parametrize("example", EXAMPLES.keys()) 41 | @pytest.mark.parametrize("convert", (configparser.parse, configupdater.parse)) 42 | def test_log_warnings(example, convert, caplog): 43 | """ini2toml should display a warning via the logging system""" 44 | profile, section, content = EXAMPLES[example] 45 | translator = Translator(plugins=[plugin.activate], ini_loads_fn=convert) 46 | caplog.clear() 47 | with caplog.at_level(logging.WARNING): 48 | translator.translate(content, profile) 49 | expected = plugin._warning_text(profile, repr(section)) 50 | assert expected in caplog.text 51 | -------------------------------------------------------------------------------- /tests/plugins/test_isort.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import tomli 4 | 5 | from ini2toml.drivers import full_toml, lite_toml 6 | from ini2toml.plugins import isort 7 | from ini2toml.translator import Translator 8 | 9 | 10 | def test_isort(): 11 | example = """\ 12 | [{section}] 13 | profile = black 14 | order_by_type = false 15 | src_paths=isort,test 16 | known_first_party = ini2toml 17 | combine_as_imports = true 18 | default_section = THIRDPARTY 19 | include_trailing_comma = true 20 | line_length = 79 21 | multi_line_output = 5 22 | """ 23 | expected_template = """\ 24 | [{section}] 25 | profile = "black" 26 | order_by_type = false 27 | src_paths = ["isort", "test"] 28 | known_first_party = ["ini2toml"] 29 | combine_as_imports = true 30 | default_section = "THIRDPARTY" 31 | include_trailing_comma = true 32 | line_length = 79 33 | multi_line_output = 5 34 | """ 35 | for convert in (lite_toml.convert, full_toml.convert): 36 | translator = Translator(plugins=[isort.activate], toml_dumps_fn=convert) 37 | for file, section in [(".isort.cfg", "settings"), ("setup.cfg", "isort")]: 38 | expected = dedent(expected_template.format(section=section)).strip() 39 | out = translator.translate(dedent(example).format(section=section), file) 40 | print("expected=\n" + expected + "\n***") 41 | print("out=\n" + out) 42 | try: 43 | assert expected in out 44 | except AssertionError: 45 | # At least the Python-equivalents when parsing should be the same 46 | assert tomli.loads(expected) == tomli.loads(out) 47 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/toml_incompatibilities.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from inspect import cleandoc 3 | from typing import List, TypeVar 4 | 5 | from ..types import IntermediateRepr, Translator 6 | 7 | R = TypeVar("R", bound=IntermediateRepr) 8 | 9 | _logger = logging.getLogger(__package__) 10 | 11 | _FLAKE8_SECTIONS = ["flake8", "flake8-rst", "flake8:local-plugins"] 12 | 13 | _KNOWN_INCOMPATIBILITIES = { 14 | "setup.cfg": [*_FLAKE8_SECTIONS, "devpi:upload"], 15 | ".flake8": _FLAKE8_SECTIONS, 16 | } 17 | 18 | 19 | def activate(translator: Translator): 20 | for name, sections in _KNOWN_INCOMPATIBILITIES.items(): 21 | fn = ReportIncompatibleSections(name, sections) 22 | translator[name].intermediate_processors.insert(0, fn) 23 | 24 | 25 | class ReportIncompatibleSections: 26 | """Remove well-know incompatible sections.""" 27 | 28 | def __init__(self, profile: str, sections: List[str]): 29 | self._profile = profile 30 | self._sections = sections 31 | 32 | def __call__(self, cfg: R) -> R: 33 | invalid = [section for section in self._sections if section in cfg] 34 | if invalid: 35 | sections = ", ".join(repr(x) for x in invalid) 36 | _logger.warning(_warning_text(self._profile, sections)) 37 | return cfg 38 | 39 | 40 | def _warning_text(profile: str, sections: str) -> str: 41 | msg = f""" 42 | Sections {sections} ({profile!r}) may be problematic. 43 | 44 | It might be the case TOML files are not supported by the relevant tools, 45 | or that 'ini2toml' just lacks a plugin for this kind of configuration. 46 | 47 | Please review the generated output and remove these sections if necessary. 48 | """ 49 | return cleandoc(msg) + "\n" 50 | -------------------------------------------------------------------------------- /docs/_static/custom-adjustments.css: -------------------------------------------------------------------------------- 1 | /** 2 | * The code in this module is mostly borrowed/adapted from PyScaffold and was originally 3 | * published under the MIT license 4 | * The original PyScaffold license can be found in 'tests/examples/pyscaffold' 5 | */ 6 | 7 | /* .row-odd td { */ 8 | /* background-color: #f3f6f6 !important; */ 9 | /* } */ 10 | 11 | article .align-center:not(table) { 12 | display: block; 13 | } 14 | 15 | dl:not([class]) dt { 16 | color: var(--color-brand-content); 17 | } 18 | 19 | ol > li::marker { 20 | /* font-weight: bold; */ 21 | color: var(--color-foreground-muted); 22 | } 23 | 24 | blockquote { 25 | background-color: var(--color-sidebar-background); 26 | border-left: solid 0.2rem var(--color-foreground-border); 27 | padding-left: 1rem; 28 | } 29 | 30 | blockquote p:first-child { 31 | margin-top: 0.1rem; 32 | } 33 | 34 | blockquote p:last-child { 35 | margin-bottom: 0.1rem; 36 | } 37 | 38 | .mobile-header, 39 | .mobile-header.scrolled { 40 | border-bottom: solid 1px var(--color-background-border); 41 | box-shadow: none; 42 | } 43 | 44 | .section[id$="package"] h1 { 45 | color: var(--color-brand-content); 46 | } 47 | 48 | .section[id^="module"] h2 { 49 | color: var(--color-brand-primary); 50 | background-color: var(--color-brand-muted); 51 | border-top: solid 0.2rem var(--color-brand-primary); 52 | padding: 0.2rem 0.5rem; 53 | /* font-family: var(--font-stack--monospace); */ 54 | } 55 | 56 | .section[id^="module"] h2:last-child { 57 | display: none; 58 | } 59 | 60 | .sidebar-tree .current-page > .reference { 61 | background: var(--color-brand-muted); 62 | } 63 | 64 | .py.class, 65 | .py.exception, 66 | .py.function, 67 | .py.data { 68 | border-top: solid 0.2rem var(--color-brand-muted); 69 | } 70 | -------------------------------------------------------------------------------- /tests/examples/pandas/LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team 4 | All rights reserved. 5 | 6 | Copyright (c) 2011-2021, Open source contributors. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | * Neither the name of the copyright holder nor the names of its 19 | contributors may be used to endorse or promote products derived from 20 | this software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 23 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 24 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 25 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 26 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 27 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 28 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 30 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /tests/plugins/test_mypy.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import tomli 4 | 5 | from ini2toml.drivers import full_toml, lite_toml 6 | from ini2toml.plugins import mypy 7 | from ini2toml.translator import Translator 8 | 9 | 10 | def test_mypy(): 11 | example = """\ 12 | [mypy] 13 | python_version = 2.7 14 | warn_return_any = True 15 | warn_unused_configs = True 16 | plugins = mypy_django_plugin.main, returns.contrib.mypy.returns_plugin 17 | [mypy-mycode.foo.*] 18 | disallow_untyped_defs = True 19 | [mypy-mycode.bar] 20 | warn_return_any = False 21 | [mypy-somelibrary,some_other_library] 22 | ignore_missing_imports = True 23 | """ 24 | expected = """\ 25 | [mypy] 26 | python_version = "2.7" 27 | warn_return_any = true 28 | warn_unused_configs = true 29 | plugins = ["mypy_django_plugin.main", "returns.contrib.mypy.returns_plugin"] 30 | 31 | [[mypy.overrides]] 32 | module = ["mycode.foo.*"] 33 | disallow_untyped_defs = true 34 | 35 | [[mypy.overrides]] 36 | module = ["mycode.bar"] 37 | warn_return_any = false 38 | 39 | [[mypy.overrides]] 40 | module = ["somelibrary", "some_other_library"] 41 | ignore_missing_imports = true 42 | """ 43 | for convert in (lite_toml.convert, full_toml.convert): 44 | translator = Translator(plugins=[mypy.activate], toml_dumps_fn=convert) 45 | out = translator.translate(dedent(example), "mypy.ini").strip() 46 | expected = dedent(expected).strip() 47 | print("expected=\n" + expected + "\n***") 48 | print("out=\n" + out) 49 | try: 50 | assert expected == out 51 | except AssertionError: 52 | # At least the Python-equivalents when parsing should be the same 53 | assert tomli.loads(expected) == tomli.loads(out) 54 | -------------------------------------------------------------------------------- /tests/test_intermediate_repr.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ini2toml.intermediate_repr import Commented, CommentedKV 4 | from ini2toml.intermediate_repr import IntermediateRepr as IR 5 | 6 | 7 | class TestInteremediateRepr: 8 | def test_init(self): 9 | with pytest.raises(ValueError): 10 | # elements and order must have the same elements 11 | IR({"a": 1, "b": 2}, ["a", "c"]) 12 | 13 | def test_eq(self): 14 | assert IR({"a": 1}, inline_comment="hello world") != IR({"a": 1}) 15 | 16 | def test_rename(self): 17 | irepr = IR({"a": 1, "b": 2}) 18 | with pytest.raises(KeyError): 19 | # Cannot rename to an existing key 20 | irepr.rename("a", "b") 21 | 22 | irepr.rename("a", "c") 23 | assert irepr == IR({"c": 1, "b": 2}) 24 | 25 | with pytest.raises(KeyError): 26 | # Cannot rename to a missing key unless explicitly ignoring 27 | irepr.rename("a", "b") 28 | 29 | irepr.rename("a", "d", ignore_missing=True) 30 | assert irepr == IR({"c": 1, "b": 2}) 31 | 32 | def test_insert(self): 33 | irepr = IR({"a": 1, "b": 2}) 34 | with pytest.raises(KeyError): 35 | # Cannot insert an existing key 36 | irepr.insert(0, "a", 3) 37 | 38 | def test_copy(self): 39 | irepr = IR({"a": 1, "b": 2}) 40 | other = irepr.copy() 41 | other["a"] = 3 42 | assert irepr != other 43 | assert other["a"] == 3 44 | assert irepr["a"] == 1 45 | 46 | 47 | class TestCommentedKV: 48 | def test_find(self): 49 | v = CommentedKV([Commented([("a", 1), ("b", 2)]), Commented([("c", 3)])]) 50 | assert v.find("a") == (0, 0) 51 | assert v.find("b") == (0, 1) 52 | assert v.find("c") == (1, 0) 53 | assert v.find("d") is None 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^docs/conf.py' 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | exclude: 'test_(.*)\.py$' 9 | - id: check-added-large-files 10 | - id: check-ast 11 | - id: check-json 12 | - id: check-merge-conflict 13 | - id: check-symlinks 14 | - id: check-toml 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: debug-statements 18 | - id: end-of-file-fixer 19 | - id: requirements-txt-fixer 20 | - id: mixed-line-ending 21 | args: ['--fix=lf'] 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: v3.19.1 25 | hooks: 26 | - id: pyupgrade 27 | args: [--py38-plus] 28 | 29 | - repo: https://github.com/PyCQA/autoflake 30 | rev: v2.3.1 31 | hooks: 32 | - id: autoflake 33 | args: [ 34 | --in-place, 35 | --remove-all-unused-imports, 36 | --remove-unused-variables, 37 | ] 38 | 39 | - repo: https://github.com/PyCQA/isort 40 | rev: 6.0.1 41 | hooks: 42 | - id: isort 43 | 44 | - repo: https://github.com/psf/black-pre-commit-mirror 45 | rev: 25.1.0 46 | hooks: 47 | - id: black 48 | language_version: python3 49 | 50 | - repo: https://github.com/asottile/blacken-docs 51 | rev: 1.19.1 52 | hooks: 53 | - id: blacken-docs 54 | additional_dependencies: [black] 55 | 56 | - repo: https://github.com/codespell-project/codespell 57 | rev: v2.4.1 58 | hooks: 59 | - id: codespell 60 | args: [--write-changes] 61 | additional_dependencies: ["tomli; python_version<'3.11'"] 62 | 63 | - repo: https://github.com/PyCQA/flake8 64 | rev: 7.2.0 65 | hooks: 66 | - id: flake8 67 | additional_dependencies: [flake8-bugbear>=23.2.13] 68 | 69 | - repo: https://github.com/abravalheri/validate-pyproject 70 | rev: v0.24.1 71 | hooks: 72 | - id: validate-pyproject 73 | -------------------------------------------------------------------------------- /tests/drivers/test_lite_toml.py: -------------------------------------------------------------------------------- 1 | from ini2toml.drivers import lite_toml as lib 2 | from ini2toml.types import Commented, CommentedKV, CommentedList, CommentKey 3 | from ini2toml.types import IntermediateRepr as IR 4 | from ini2toml.types import WhitespaceKey 5 | 6 | example_toml = """\ 7 | [section1] 8 | value = 42 9 | 10 | [section2] 11 | float-value = 1.5 12 | boolean-value = false 13 | "other value" = [ 14 | 1, 15 | 2, 16 | 3, 17 | ] 18 | other-boolean-value = true 19 | string-value = "string" 20 | list-value = [ 21 | true, 22 | false, 23 | ] 24 | 25 | [section2."another value"] 26 | a = 1 27 | b = 2 28 | c = 3 29 | d = 4 30 | 31 | [section3.nested] 32 | x = "y" 33 | z = "w" 34 | """ 35 | 36 | 37 | example_parsed = IR( 38 | section1=IR({CommentKey(): "comment", "value": Commented(42, "int value")}), 39 | section2=IR( 40 | { 41 | "float-value": 1.5, 42 | "boolean-value": False, 43 | "other value": CommentedList( 44 | [ 45 | Commented([1, 2, 3], "1st line comment"), 46 | Commented(comment="2nd line comment"), 47 | ] 48 | ), 49 | "other-boolean-value": True, 50 | WhitespaceKey(): "", 51 | CommentKey(): "comment between options", 52 | "another value": CommentedKV( 53 | [ 54 | Commented([("a", 1), ("b", 2)], "1st line comment"), 55 | Commented([("c", 3), ("d", 4)], "2nd line comment"), 56 | ] 57 | ), 58 | "string-value": Commented("string", "comment"), 59 | "list-value": [True, False], 60 | } 61 | ), 62 | section3=IR(nested=IR(x="y", z=Commented("w", "nested"))), 63 | ) 64 | 65 | 66 | def test_convert(): 67 | assert lib.convert(example_parsed) == example_toml 68 | -------------------------------------------------------------------------------- /tests/drivers/test_plain_builtins.py: -------------------------------------------------------------------------------- 1 | from ini2toml.drivers import plain_builtins as lib 2 | from ini2toml.types import Commented, CommentedKV, CommentedList, CommentKey 3 | from ini2toml.types import IntermediateRepr as IR 4 | from ini2toml.types import WhitespaceKey 5 | 6 | example_output = { 7 | "section1": {"value": 42}, 8 | "section2": { 9 | "float-value": 1.5, 10 | "boolean-value": False, 11 | "other value": [1, 2, 3], 12 | "other-boolean-value": True, 13 | "string-value": "string", 14 | "list-value": [True, False], 15 | "another value": {"a": 1, "b": 2, "c": 3, "d": 4}, 16 | }, 17 | "section3": {"nested": {"x": "y", "z": "w"}}, 18 | } 19 | 20 | 21 | example_parsed = IR( 22 | section1=IR({CommentKey(): "comment", "value": Commented(42, "int value")}), 23 | section2=IR( 24 | { 25 | "float-value": 1.5, 26 | "boolean-value": False, 27 | "other value": CommentedList( 28 | [ 29 | Commented([1, 2, 3], "1st line comment"), 30 | Commented(comment="2nd line comment"), 31 | ] 32 | ), 33 | "other-boolean-value": True, 34 | WhitespaceKey(): "", 35 | CommentKey(): "comment between options", 36 | "another value": CommentedKV( 37 | [ 38 | Commented([("a", 1), ("b", 2)], "1st line comment"), 39 | Commented([("c", 3), ("d", 4)], "2nd line comment"), 40 | ] 41 | ), 42 | "string-value": Commented("string", "comment"), 43 | "list-value": [True, False], 44 | } 45 | ), 46 | section3=IR(nested=IR(x="y", z=Commented("w", "nested"))), 47 | ) 48 | 49 | 50 | def test_convert(): 51 | assert lib.convert(example_parsed) == example_output 52 | -------------------------------------------------------------------------------- /tests/examples/pluggy/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=61.2", 4 | "setuptools-scm", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "pluggy" 10 | description = "plugin and hook calling mechanisms for python" 11 | license = {text = "MIT"} 12 | authors = [{name = "Holger Krekel", email = "holger@merlinux.eu"}] 13 | classifiers = [ 14 | "Development Status :: 6 - Mature", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: POSIX", 18 | "Operating System :: Microsoft :: Windows", 19 | "Operating System :: MacOS :: MacOS X", 20 | "Topic :: Software Development :: Testing", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Utilities", 23 | "Programming Language :: Python :: Implementation :: CPython", 24 | "Programming Language :: Python :: Implementation :: PyPy", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.6", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | ] 33 | urls = {Homepage = "https://github.com/pytest-dev/pluggy"} 34 | requires-python = ">=3.6" 35 | dependencies = ['importlib-metadata>=0.12;python_version<"3.8"'] 36 | dynamic = ["version"] 37 | 38 | [project.readme] 39 | file = "README.rst" 40 | content-type = "text/x-rst" 41 | 42 | [project.optional-dependencies] 43 | dev = [ 44 | "pre-commit", 45 | "tox", 46 | ] 47 | testing = [ 48 | "pytest", 49 | "pytest-benchmark", 50 | ] 51 | 52 | [tool.setuptools] 53 | packages = ["pluggy"] 54 | package-dir = {"" = "src"} 55 | platforms = ["unix", "linux", "osx", "win32"] 56 | include-package-data = false 57 | 58 | [tool.devpi.upload] 59 | formats = "sdist.tgz,bdist_wheel" 60 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/profile_independent_tasks.py: -------------------------------------------------------------------------------- 1 | """Profile-independent tasks implemented via *profile augmentation*.""" 2 | 3 | import re 4 | from functools import wraps 5 | from typing import Callable 6 | 7 | from ..types import Profile, Translator 8 | 9 | DUPLICATED_NEWLINES = re.compile(r"\n+", re.M) 10 | TABLE_START = re.compile(r"^\[(.*)\]", re.M) 11 | EMPTY_TABLES = re.compile(r"^\[(.*)\]\n+\[(\1\.(?:.*))\]", re.M) 12 | MISSING_TERMINATING_LINE = re.compile(r"(? str: 36 | """Make sure every table is preceded by an empty newline, but remove them elsewhere 37 | in the output TOML document. 38 | Also ensure a terminating newline is present for best POSIX tool compatibility. 39 | """ 40 | text = DUPLICATED_NEWLINES.sub(r"\n", text) 41 | text = TABLE_START.sub(r"\n[\1]", text) 42 | return MISSING_TERMINATING_LINE.sub("\n", text) 43 | 44 | 45 | def remove_empty_table_headers(text: str) -> str: 46 | """Remove empty TOML table headers""" 47 | prev_text = "" 48 | while text != prev_text: 49 | prev_text = text 50 | text = EMPTY_TABLES.sub(r"[\2]", text).strip() 51 | return text 52 | 53 | 54 | def ensure_terminating_newlines(text: str) -> str: 55 | return text.strip() + "\n" 56 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/coverage.py: -------------------------------------------------------------------------------- 1 | # based on https://coverage.readthedocs.io/en/stable/config.html 2 | from collections.abc import MutableMapping 3 | from functools import partial, update_wrapper 4 | from typing import TypeVar 5 | 6 | from ..transformations import coerce_scalar, split_list 7 | from ..types import Transformation as T 8 | from ..types import Translator 9 | 10 | M = TypeVar("M", bound=MutableMapping) 11 | 12 | 13 | def activate(translator: Translator): 14 | plugin = Coverage() 15 | profile = translator[".coveragerc"] 16 | 17 | fn = partial(plugin.process_values, prefix="") 18 | update_wrapper(fn, plugin.process_values) 19 | profile.intermediate_processors.append(fn) 20 | profile.help_text = plugin.__doc__ or "" 21 | 22 | for file in ("setup.cfg", "tox.ini"): 23 | translator[file].intermediate_processors.append(plugin.process_values) 24 | 25 | 26 | class Coverage: 27 | """Convert settings to 'pyproject.toml' equivalent""" 28 | 29 | PREFIX = "coverage:" 30 | SECTIONS = ("run", "paths", "report", "html", "xml", "json") 31 | LIST_VALUES = ( 32 | "exclude_lines", 33 | "concurrency", 34 | "disable_warnings", 35 | "debug", 36 | "include", 37 | "omit", 38 | "plugins", 39 | "source", 40 | "source_pkgs", 41 | "partial_branches", 42 | ) 43 | 44 | def process_values(self, doc: M, sections=SECTIONS, prefix=PREFIX) -> M: 45 | for name in sections: 46 | candidates = [ 47 | doc.get(f"{prefix}{name}"), 48 | doc.get("tool", {}).get("coverage", {}).get(name), 49 | doc.get(("tool", "coverage"), {}).get(name), 50 | doc.get(("tool", "coverage", name)), 51 | ] 52 | for section in candidates: 53 | if section: 54 | self.process_section(section) 55 | return doc 56 | 57 | def process_section(self, section: M): 58 | for field in section: 59 | fn: T = split_list if field in self.LIST_VALUES else coerce_scalar 60 | section[field] = fn(section[field]) 61 | -------------------------------------------------------------------------------- /tests/drivers/test_full_toml.py: -------------------------------------------------------------------------------- 1 | from ini2toml.drivers import full_toml as lib 2 | from ini2toml.plugins.profile_independent_tasks import remove_empty_table_headers 3 | from ini2toml.types import Commented, CommentedKV, CommentedList, CommentKey 4 | from ini2toml.types import IntermediateRepr as IR 5 | from ini2toml.types import WhitespaceKey 6 | 7 | example_toml = """\ 8 | [section1] 9 | # comment 10 | value = 42 # int value 11 | 12 | [section2] 13 | float-value = 1.5 14 | boolean-value = false 15 | "other value" = [ 16 | 1, 2, 3, # 1st line comment 17 | # 2nd line comment 18 | ] 19 | other-boolean-value = true 20 | 21 | # comment between options 22 | string-value = "string" # comment 23 | list-value = [true, false] 24 | 25 | [section2."another value"] 26 | a = 1 27 | b = 2 # 1st line comment 28 | c = 3 29 | d = 4 # 2nd line comment 30 | 31 | [section3.nested] 32 | x = "y" 33 | z = "w" # nested 34 | """ 35 | 36 | 37 | example_parsed = IR( 38 | section1=IR({CommentKey(): "comment", "value": Commented(42, "int value")}), 39 | section2=IR( 40 | { 41 | "float-value": 1.5, 42 | "boolean-value": False, 43 | "other value": CommentedList( 44 | [ 45 | Commented([1, 2, 3], "1st line comment"), 46 | Commented(comment="2nd line comment"), 47 | ] 48 | ), 49 | "other-boolean-value": True, 50 | WhitespaceKey(): "", 51 | CommentKey(): "comment between options", 52 | "another value": CommentedKV( 53 | [ 54 | Commented([("a", 1), ("b", 2)], "1st line comment"), 55 | Commented([("c", 3), ("d", 4)], "2nd line comment"), 56 | ] 57 | ), 58 | "string-value": Commented("string", "comment"), 59 | "list-value": [True, False], 60 | } 61 | ), 62 | section3=IR(nested=IR(x="y", z=Commented("w", "nested"))), 63 | ) 64 | 65 | 66 | def test_convert(): 67 | converted = lib.convert(example_parsed) 68 | assert remove_empty_table_headers(converted) == example_toml.strip() 69 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | # The code in this file is mostly borrowed/adapted from PyScaffold and was originally 2 | # published under the MIT license. 3 | # The original PyScaffold license can be found in 'tests/examples/pyscaffold' 4 | 5 | 6 | import pytest 7 | 8 | from ini2toml import plugins 9 | from ini2toml.plugins import ENTRYPOINT_GROUP, EntryPoint, ErrorLoadingPlugin 10 | 11 | EXISTING = ( 12 | "setuptools_pep621", 13 | "best_effort", 14 | "isort", 15 | "coverage", 16 | "pytest", 17 | "mypy", 18 | "independent_tasks", 19 | ) 20 | 21 | 22 | def test_load_from_entry_point__error(): 23 | # This module does not exist, so Python will have some trouble loading it 24 | # EntryPoint(name, value, group) 25 | entry = "mypkg.SOOOOO___fake___:activate" 26 | fake = EntryPoint("fake", entry, ENTRYPOINT_GROUP) 27 | with pytest.raises(ErrorLoadingPlugin): 28 | plugins.load_from_entry_point(fake) 29 | 30 | 31 | def is_entry_point(ep): 32 | return all(hasattr(ep, attr) for attr in ("name", "load")) 33 | 34 | 35 | def test_iterate_entry_points(): 36 | plugin_iter = plugins.iterate_entry_points() 37 | assert hasattr(plugin_iter, "__iter__") 38 | pluging_list = list(plugin_iter) 39 | assert all([is_entry_point(e) for e in pluging_list]) 40 | name_list = [e.name for e in pluging_list] 41 | for ext in EXISTING: 42 | assert ext in name_list 43 | 44 | 45 | def test_list_from_entry_points(): 46 | # Should return a list with all the plugins registered in the entrypoints 47 | pluging_list = plugins.list_from_entry_points() 48 | orig_len = len(pluging_list) 49 | isort_count = len([e for e in pluging_list if "isort" in str(e.__module__)]) 50 | assert all(callable(e) for e in pluging_list) 51 | plugin_names = " ".join(str(e.__module__) for e in pluging_list) 52 | for example in EXISTING: 53 | assert example in plugin_names 54 | 55 | # a filtering function can be passed to avoid loading plugins that are not needed 56 | pluging_list = plugins.list_from_entry_points(filtering=lambda e: e.name != "isort") 57 | plugin_names = " ".join(str(e.__module__) for e in pluging_list) 58 | assert len(pluging_list) == orig_len - isort_count 59 | assert "isort" not in plugin_names 60 | -------------------------------------------------------------------------------- /tests/examples/plumbum/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = plumbum 3 | description = Plumbum: shell combinators library 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | url = https://plumbum.readthedocs.io 7 | author = Tomer Filiba 8 | author_email = tomerfiliba@gmail.com 9 | license = MIT 10 | license_file = LICENSE 11 | platforms = POSIX, Windows 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | License :: OSI Approved :: MIT License 15 | Operating System :: Microsoft :: Windows 16 | Operating System :: POSIX 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Programming Language :: Python :: 3.9 23 | Programming Language :: Python :: 3.10 24 | Topic :: Software Development :: Build Tools 25 | Topic :: System :: Systems Administration 26 | keywords = 27 | path, 28 | local, 29 | remote, 30 | ssh, 31 | shell, 32 | pipe, 33 | popen, 34 | process, 35 | execution, 36 | color, 37 | cli 38 | provides = plumbum 39 | 40 | [options] 41 | packages = find: 42 | install_requires = 43 | pywin32;platform_system=='Windows' and platform_python_implementation!="PyPy" 44 | python_requires = >=3.6 45 | 46 | [options.packages.find] 47 | exclude = 48 | tests 49 | 50 | [options.extras_require] 51 | dev = 52 | paramiko 53 | psutil 54 | pytest>=6.0 55 | pytest-cov 56 | pytest-mock 57 | pytest-timeout 58 | docs = 59 | Sphinx>=4.0.0 60 | sphinx-rtd-theme>=1.0.0 61 | ssh = 62 | paramiko 63 | 64 | [options.package_data] 65 | plumbum.cli = i18n/*/LC_MESSAGES/*.mo 66 | 67 | [coverage:run] 68 | branch = True 69 | relative_files = True 70 | source_pkgs = 71 | plumbum 72 | omit = 73 | *ipython*.py 74 | *__main__.py 75 | *_windows.py 76 | 77 | [coverage:report] 78 | exclude_lines = 79 | pragma: no cover 80 | def __repr__ 81 | raise AssertionError 82 | raise NotImplementedError 83 | if __name__ == .__main__.: 84 | 85 | [flake8] 86 | max-complexity = 50 87 | extend-ignore = E203, E501, E722, B950, E731 88 | select = C,E,F,W,B,B9 89 | 90 | [codespell] 91 | ignore-words-list = ans,switchs,hart,ot,twoo,fo 92 | skip = *.po 93 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/best_effort.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import partial 3 | from typing import TypeVar 4 | 5 | from ..transformations import split_comment, split_kv_pairs, split_list, split_scalar 6 | from ..types import HiddenKey, IntermediateRepr, Translator 7 | 8 | M = TypeVar("M", bound=IntermediateRepr) 9 | 10 | _SECTION_SPLITTER = re.compile(r"\.|:|\\") 11 | _KEY_SEP = "=" 12 | 13 | 14 | def activate(translator: Translator): 15 | profile = translator["best_effort"] 16 | plugin = BestEffort() 17 | profile.intermediate_processors.append(plugin.process_values) 18 | profile.help_text = plugin.__doc__ or "" 19 | 20 | 21 | class BestEffort: 22 | """Guess option value conversion based on the string format""" 23 | 24 | def __init__( 25 | self, 26 | key_sep=_KEY_SEP, 27 | section_splitter=_SECTION_SPLITTER, 28 | ): 29 | self.key_sep = key_sep 30 | self.section_splitter = section_splitter 31 | self.split_dict = partial(split_kv_pairs, key_sep=key_sep) 32 | 33 | def process_values(self, doc: M) -> M: 34 | doc_items = list(doc.items()) 35 | for name, section in doc_items: 36 | doc[name] = self.apply_best_effort_to_section(section) 37 | # Separate nested sections 38 | if self.section_splitter.search(name): 39 | keys = tuple(self.section_splitter.split(name)) 40 | doc.rename(name, keys) 41 | return doc 42 | 43 | def apply_best_effort_to_section(self, section: M) -> M: 44 | options = list(section.items()) 45 | # Convert option values: 46 | for field, value in options: 47 | self.apply_best_effort(section, field, value) 48 | return section 49 | 50 | def apply_best_effort(self, container: M, field: str, value: str): 51 | if isinstance(field, HiddenKey): 52 | return 53 | if not isinstance(value, str): 54 | return 55 | lines = value.splitlines() 56 | if len(lines) > 1: 57 | if self.key_sep in value: 58 | container[field] = self.split_dict(value) 59 | else: 60 | container[field] = split_list(value) 61 | elif field.endswith("version"): 62 | container[field] = split_comment(value) 63 | else: 64 | container[field] = split_scalar(value) 65 | -------------------------------------------------------------------------------- /tests/examples/django/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Django 3 | version = attr: django.__version__ 4 | url = https://www.djangoproject.com/ 5 | author = Django Software Foundation 6 | author_email = foundation@djangoproject.com 7 | description = A high-level Python web framework that encourages rapid development and clean, pragmatic design. 8 | long_description = file: README.rst 9 | license = BSD-3-Clause 10 | classifiers = 11 | Development Status :: 2 - Pre-Alpha 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: BSD License 16 | Operating System :: OS Independent 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3 :: Only 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Topic :: Internet :: WWW/HTTP 23 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 24 | Topic :: Internet :: WWW/HTTP :: WSGI 25 | Topic :: Software Development :: Libraries :: Application Frameworks 26 | Topic :: Software Development :: Libraries :: Python Modules 27 | project_urls = 28 | Documentation = https://docs.djangoproject.com/ 29 | Release notes = https://docs.djangoproject.com/en/stable/releases/ 30 | Funding = https://www.djangoproject.com/fundraising/ 31 | Source = https://github.com/django/django 32 | Tracker = https://code.djangoproject.com/ 33 | 34 | [options] 35 | python_requires = >=3.8 36 | packages = find: 37 | include_package_data = true 38 | zip_safe = false 39 | install_requires = 40 | asgiref >= 3.3.2 41 | backports.zoneinfo; python_version<"3.9" 42 | sqlparse >= 0.2.2 43 | tzdata; sys_platform == 'win32' 44 | 45 | [options.entry_points] 46 | console_scripts = 47 | django-admin = django.core.management:execute_from_command_line 48 | 49 | [options.extras_require] 50 | argon2 = argon2-cffi >= 19.1.0 51 | bcrypt = bcrypt 52 | 53 | [bdist_rpm] 54 | doc_files = docs extras AUTHORS INSTALL LICENSE README.rst 55 | install_script = scripts/rpm-install.sh 56 | 57 | [flake8] 58 | exclude = build,.git,.tox,./tests/.env 59 | ignore = W504,W601 60 | max-line-length = 119 61 | 62 | [isort] 63 | combine_as_imports = true 64 | default_section = THIRDPARTY 65 | include_trailing_comma = true 66 | known_first_party = django 67 | line_length = 79 68 | multi_line_output = 5 69 | -------------------------------------------------------------------------------- /tests/plugins/test_coverage.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import tomli 4 | 5 | from ini2toml.drivers import full_toml, lite_toml 6 | from ini2toml.plugins import coverage 7 | from ini2toml.translator import Translator 8 | 9 | 10 | def test_coverage(): 11 | example = """\ 12 | # .coveragerc to control coverage.py 13 | [run] 14 | branch = True 15 | source = ini2toml 16 | # omit = bad_file.py 17 | [paths] 18 | source = 19 | src/ 20 | */site-packages/ 21 | [report] 22 | # Regexes for lines to exclude from consideration 23 | exclude_lines = 24 | # Have to re-enable the standard pragma 25 | pragma: no cover 26 | # Don't complain about missing debug-only code 27 | def __repr__ 28 | # Don't complain if tests don't hit defensive assertion code 29 | raise AssertionError 30 | raise NotImplementedError 31 | # Don't complain if non-runnable code isn't run 32 | if 0: 33 | if __name__ == .__main__.: 34 | """ 35 | expected = """\ 36 | # .coveragerc to control coverage.py 37 | 38 | [run] 39 | branch = true 40 | source = ["ini2toml"] 41 | # omit = bad_file.py 42 | 43 | [paths] 44 | source = [ 45 | "src/", 46 | "*/site-packages/", 47 | ] 48 | 49 | [report] 50 | # Regexes for lines to exclude from consideration 51 | exclude_lines = [ 52 | # Have to re-enable the standard pragma 53 | "pragma: no cover", 54 | # Don't complain about missing debug-only code 55 | "def __repr__", 56 | # Don't complain if tests don't hit defensive assertion code 57 | "raise AssertionError", 58 | "raise NotImplementedError", 59 | # Don't complain if non-runnable code isn't run 60 | "if 0:", 61 | "if __name__ == .__main__.:", 62 | ] 63 | """ 64 | for convert in (full_toml.convert, lite_toml.convert): 65 | translator = Translator(plugins=[coverage.activate], toml_dumps_fn=convert) 66 | out = translator.translate(dedent(example), ".coveragerc").strip() 67 | expected = dedent(expected).strip() 68 | print("expected=\n" + expected + "\n***") 69 | print("out=\n" + out) 70 | try: 71 | assert expected == out 72 | except AssertionError: 73 | # At least the Python-equivalents when parsing should be the same 74 | assert tomli.loads(expected) == tomli.loads(out) 75 | -------------------------------------------------------------------------------- /src/ini2toml/drivers/configupdater.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Mapping 3 | 4 | from configupdater import Comment, ConfigUpdater, Option, Section, Space 5 | 6 | from ..errors import InvalidCfgBlock 7 | from ..transformations import remove_prefixes 8 | from ..types import CommentKey, IntermediateRepr, WhitespaceKey 9 | 10 | EMPTY: Mapping = MappingProxyType({}) 11 | COMMENT_PREFIXES = ("#", ";") 12 | 13 | 14 | def parse(text: str, opts: Mapping = EMPTY) -> IntermediateRepr: 15 | cfg = ConfigUpdater(**opts).read_string(text) 16 | irepr = IntermediateRepr() 17 | 18 | for block in cfg.iter_blocks(): 19 | if isinstance(block, Section): 20 | translate_section(irepr, block, opts) 21 | elif isinstance(block, Comment): 22 | translate_comment(irepr, block, opts) 23 | elif isinstance(block, Space): 24 | translate_space(irepr, block, opts) 25 | else: # pragma: no cover -- not supposed to happen 26 | raise InvalidCfgBlock(block) 27 | 28 | return irepr 29 | 30 | 31 | def translate_section(irepr: IntermediateRepr, item: Section, opts: Mapping): 32 | out = IntermediateRepr() 33 | # Inline comment 34 | cmt = getattr(item, "_raw_comment", "") # TODO: private attr 35 | cmt = remove_prefixes(cmt, opts.get("comment_prefixes", COMMENT_PREFIXES)) 36 | if cmt: 37 | out.inline_comment = cmt 38 | # Children 39 | for block in item.iter_blocks(): 40 | if isinstance(block, Option): 41 | translate_option(out, block, opts) 42 | elif isinstance(block, Comment): 43 | translate_comment(out, block, opts) 44 | elif isinstance(block, Space): 45 | translate_space(out, block, opts) 46 | else: # pragma: no cover -- not supposed to happen 47 | raise InvalidCfgBlock(block) 48 | irepr.append(item.name, out) 49 | 50 | 51 | def translate_option(irepr: IntermediateRepr, item: Option, _opts: Mapping): 52 | irepr.append(item.key, item.value) 53 | 54 | 55 | def translate_comment(irepr: IntermediateRepr, item: Comment, opts: Mapping): 56 | prefixes = opts.get("comment_prefixes", COMMENT_PREFIXES) 57 | for line in str(item).splitlines(): 58 | irepr.append(CommentKey(), remove_prefixes(line, prefixes)) 59 | 60 | 61 | def translate_space(irepr: IntermediateRepr, item: Space, _opts: Mapping): 62 | for line in str(item).splitlines(keepends=True): 63 | irepr.append(WhitespaceKey(), line) 64 | -------------------------------------------------------------------------------- /src/ini2toml/drivers/plain_builtins.py: -------------------------------------------------------------------------------- 1 | """The purpose of this module is to "collapse" the intermediate representation of a TOML 2 | document into Python builtin data types (mainly a composition of :class:`dict`, 3 | :class:`list`, :class:`int`, :class:`float`, :class:`bool`, :class:`str`). 4 | 5 | This is **not a loss-less** process, since comments are not preserved. 6 | """ 7 | 8 | from collections.abc import Mapping, MutableMapping 9 | from functools import singledispatch 10 | from typing import Any, TypeVar 11 | 12 | from ..errors import InvalidTOMLKey 13 | from ..types import Commented, CommentedKV, CommentedList, HiddenKey, IntermediateRepr 14 | 15 | __all__ = [ 16 | "convert", 17 | "collapse", 18 | ] 19 | 20 | M = TypeVar("M", bound=MutableMapping) 21 | 22 | 23 | def convert(irepr: IntermediateRepr) -> dict: 24 | return collapse(irepr) 25 | 26 | 27 | @singledispatch 28 | def collapse(obj): 29 | # Catch all 30 | return obj 31 | 32 | 33 | @collapse.register(Commented) 34 | def _collapse_commented(obj: Commented) -> Any: 35 | return obj.value_or(None) 36 | 37 | 38 | @collapse.register(CommentedList) 39 | def _collapse_commented_list(obj: CommentedList) -> list: 40 | return [collapse(v) for v in obj.as_list()] 41 | 42 | 43 | @collapse.register(CommentedKV) 44 | def _collapse_commented_kv(obj: CommentedKV) -> dict: 45 | return {k: collapse(v) for k, v in obj.as_dict().items()} 46 | 47 | 48 | @collapse.register(Mapping) 49 | def _collapse_mapping(obj: Mapping) -> dict: 50 | return _convert_irepr_to_dict(obj, {}) 51 | 52 | 53 | @collapse.register(list) 54 | def _collapse_list(obj: list) -> list: 55 | return [collapse(e) for e in obj] 56 | 57 | 58 | def _convert_irepr_to_dict(irepr: Mapping, out: M) -> M: 59 | for key, value in irepr.items(): 60 | if isinstance(key, HiddenKey): 61 | continue 62 | elif isinstance(key, tuple): 63 | parent_key, *rest = key 64 | if not isinstance(parent_key, str): 65 | raise InvalidTOMLKey(key) 66 | p = out.setdefault(parent_key, {}) 67 | if not isinstance(p, MutableMapping): 68 | msg = f"Value for `{parent_key}` expected to be Mapping, found {p!r}" 69 | raise ValueError(msg) 70 | nested_key = rest[0] if len(rest) == 1 else tuple(rest) 71 | _convert_irepr_to_dict({nested_key: value}, p) 72 | elif isinstance(key, (int, str)): 73 | out[str(key)] = collapse(value) 74 | return out 75 | -------------------------------------------------------------------------------- /docs/public_api_docs.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | TOC_TEMPLATE = """ 5 | Module Reference 6 | ================ 7 | 8 | The public API of ``ini2toml`` is mainly composed by ``ini2toml.api``. 9 | Users may also find useful to import ``ini2toml.errors`` and 10 | ``ini2toml.types`` when handling exceptions or specifying type hints. 11 | 12 | Plugin developers might try to use ``ini2toml.transformations``, 13 | but they should take the stability of that module with a grain of salt... 14 | 15 | .. toctree:: 16 | :glob: 17 | :maxdepth: 2 18 | 19 | ini2toml.api 20 | ini2toml.errors 21 | ini2toml.types 22 | 23 | .. toctree:: 24 | :maxdepth: 1 25 | 26 | ini2toml.transformations 27 | 28 | .. toctree:: 29 | :caption: Plugins 30 | :glob: 31 | :maxdepth: 1 32 | 33 | plugins/* 34 | """ 35 | 36 | MODULE_TEMPLATE = """ 37 | ``{name}`` 38 | ~~{underline}~~ 39 | 40 | .. automodule:: {name} 41 | :members:{_members} 42 | :undoc-members: 43 | :show-inheritance: 44 | """ 45 | 46 | 47 | def gen_stubs(module_dir: str, output_dir: str): 48 | try_rmtree(output_dir) # Always start fresh 49 | Path(output_dir, "plugins").mkdir(parents=True, exist_ok=True) 50 | for module in iter_public(): 51 | text = module_template(module) 52 | Path(output_dir, f"{module}.rst").write_text(text, encoding="utf-8") 53 | for module in iter_plugins(module_dir): 54 | text = module_template(module, "activate") 55 | Path(output_dir, f"plugins/{module}.rst").write_text(text, encoding="utf-8") 56 | Path(output_dir, "modules.rst").write_text(TOC_TEMPLATE, encoding="utf-8") 57 | 58 | 59 | def iter_public(): 60 | lines = (x.strip() for x in TOC_TEMPLATE.splitlines()) 61 | return (x for x in lines if x.startswith("ini2toml.")) 62 | 63 | 64 | def iter_plugins(module_dir: str): 65 | return ( 66 | f'ini2toml.plugins.{path.with_suffix("").name}' 67 | for path in Path(module_dir, "plugins").iterdir() 68 | if path.is_file() 69 | and path.name not in {".", "..", "__init__.py"} 70 | and not path.name.startswith("_") 71 | ) 72 | 73 | 74 | def try_rmtree(target_dir: str): 75 | try: 76 | shutil.rmtree(target_dir) 77 | except FileNotFoundError: 78 | pass 79 | 80 | 81 | def module_template(name: str, *members: str) -> str: 82 | underline = "~" * len(name) 83 | _members = (" " + ", ".join(members)) if members else "" 84 | return MODULE_TEMPLATE.format(name=name, underline=underline, _members=_members) 85 | -------------------------------------------------------------------------------- /src/ini2toml/errors.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import Callable, List, Mapping, Sequence 3 | 4 | from . import types 5 | 6 | 7 | class UndefinedProfile(ValueError): 8 | """The given profile ('{name}') is not registered with ``ini2toml``. 9 | Are you sure you have the right plugins installed and loaded? 10 | """ 11 | 12 | def __init__(self, name: str, available: Sequence[str]): 13 | msg = self.__class__.__doc__ or "" 14 | super().__init__(msg.format(name=name) + f"Available: {', '.join(available)})") 15 | 16 | @classmethod 17 | def check(cls, name: str, available: List[str]): 18 | """:meta private:""" 19 | if name not in available: 20 | raise cls(name, available) 21 | 22 | 23 | class AlreadyRegisteredAugmentation(ValueError): 24 | """The profile augmentation '{name}' is already registered for '{existing}'. 25 | 26 | Some installed plugins seem to be in conflict with each other, 27 | please check '{new}' and '{existing}'. 28 | If you are the developer behind one of them, please use a different name. 29 | """ 30 | 31 | def __init__(self, name: str, new: Callable, existing: Callable): 32 | existing_id = f"{existing.__module__}.{existing.__qualname__}" 33 | new_id = f"{new.__module__}.{new.__qualname__}" 34 | msg = dedent(self.__class__.__doc__ or "") 35 | super().__init__(msg.format(name=name, new=new_id, existing=existing_id)) 36 | 37 | @classmethod 38 | def check( 39 | cls, name: str, fn: Callable, registry: Mapping[str, types.ProfileAugmentation] 40 | ): 41 | """:meta private:""" 42 | if name in registry: 43 | raise cls(name, fn, registry[name].fn) 44 | 45 | 46 | class InvalidAugmentationName(ValueError): 47 | """Profile augmentations should be valid python identifiers""" 48 | 49 | def __init__(self, name: str): 50 | msg = self.__class__.__doc__ or "" 51 | super().__init__(f"{msg} ({name!r} given)") 52 | 53 | @classmethod 54 | def check(cls, name: str): 55 | if not name.isidentifier(): 56 | raise cls(name) 57 | 58 | 59 | class InvalidTOMLKey(ValueError): 60 | """{key!r} is not a valid key in the intermediate TOML representation""" 61 | 62 | def __init__(self, key): 63 | msg = self.__doc__.format(key=key) 64 | super().__init__(msg) 65 | 66 | 67 | class InvalidCfgBlock(ValueError): # pragma: no cover -- not supposed to happen 68 | """Something is wrong with the provided ``.ini/.cfg`` AST""" 69 | 70 | def __init__(self, block): 71 | super().__init__(f"{block.__class__}: {block}", {"block_object": block}) 72 | -------------------------------------------------------------------------------- /src/ini2toml/profile.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from copy import deepcopy 3 | from typing import Optional, Sequence, TypeVar 4 | 5 | from .types import IntermediateProcessor, ProfileAugmentationFn, TextProcessor 6 | 7 | P = TypeVar("P", bound="Profile") 8 | 9 | 10 | def replace(self: P, **changes) -> P: 11 | """Works similarly to :func:`dataclasses.replace`""" 12 | sig = inspect.signature(self.__class__) 13 | kwargs = {x: getattr(self, x) for x in sig.parameters} 14 | kwargs.update(changes) 15 | return self.__class__(**kwargs) 16 | 17 | 18 | class Profile: 19 | """Profile object that follows the public API defined in 20 | :class:`ini2toml.types.Profile`. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | name: str, 26 | help_text: str = "", 27 | pre_processors: Sequence[TextProcessor] = (), 28 | intermediate_processors: Sequence[IntermediateProcessor] = (), 29 | post_processors: Sequence[TextProcessor] = (), 30 | ini_parser_opts: Optional[dict] = None, 31 | ): 32 | self.name = name 33 | self.help_text = help_text 34 | self.pre_processors = list(pre_processors) 35 | self.intermediate_processors = list(intermediate_processors) 36 | self.post_processors = list(post_processors) 37 | self.ini_parser_opts = ini_parser_opts 38 | 39 | replace = replace 40 | 41 | def _copy(self: P) -> P: 42 | return self.__class__( 43 | name=self.name, 44 | help_text=self.help_text, 45 | pre_processors=self.pre_processors[:], 46 | intermediate_processors=self.intermediate_processors[:], 47 | post_processors=self.post_processors[:], 48 | ini_parser_opts=deepcopy(self.ini_parser_opts), 49 | ) 50 | 51 | 52 | class ProfileAugmentation: 53 | def __init__( 54 | self, 55 | fn: ProfileAugmentationFn, 56 | active_by_default: bool = False, 57 | name: str = "", 58 | help_text: str = "", 59 | ): 60 | self.fn = fn 61 | self.active_by_default = active_by_default 62 | self.name = name or getattr(fn, "__name__", "") 63 | self.help_text = help_text or getattr(fn, "__doc__", "") 64 | 65 | def is_active(self, explicitly_active: Optional[bool] = None) -> bool: 66 | """``explicitly_active`` is a tree-state variable: ``True`` if the user 67 | explicitly asked for the augmentation, ``False`` if the user explicitly denied 68 | the augmentation, or ``None`` otherwise. 69 | """ 70 | activation = explicitly_active 71 | return activation is True or (activation is None and self.active_by_default) 72 | -------------------------------------------------------------------------------- /tests/examples/django/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "Django" 7 | authors = [{name = "Django Software Foundation", email = "foundation@djangoproject.com"}] 8 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 9 | readme = "README.rst" 10 | license = {text = "BSD-3-Clause"} 11 | classifiers = [ 12 | "Development Status :: 2 - Pre-Alpha", 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Topic :: Internet :: WWW/HTTP", 24 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 25 | "Topic :: Internet :: WWW/HTTP :: WSGI", 26 | "Topic :: Software Development :: Libraries :: Application Frameworks", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | requires-python = ">=3.8" 30 | dependencies = [ 31 | "asgiref >= 3.3.2", 32 | 'backports.zoneinfo; python_version<"3.9"', 33 | "sqlparse >= 0.2.2", 34 | "tzdata; sys_platform == 'win32'", 35 | ] 36 | dynamic = ["version"] 37 | 38 | [project.urls] 39 | Homepage = "https://www.djangoproject.com/" 40 | Documentation = "https://docs.djangoproject.com/" 41 | "Release notes" = "https://docs.djangoproject.com/en/stable/releases/" 42 | Funding = "https://www.djangoproject.com/fundraising/" 43 | Source = "https://github.com/django/django" 44 | Tracker = "https://code.djangoproject.com/" 45 | 46 | [project.optional-dependencies] 47 | argon2 = ["argon2-cffi >= 19.1.0"] 48 | bcrypt = ["bcrypt"] 49 | 50 | [project.scripts] 51 | django-admin = "django.core.management:execute_from_command_line" 52 | 53 | [tool.setuptools] 54 | include-package-data = true 55 | zip-safe = false 56 | 57 | [tool.setuptools.packages] 58 | find = {namespaces = false} 59 | 60 | [tool.setuptools.dynamic] 61 | version = {attr = "django.__version__"} 62 | 63 | [tool.distutils.bdist_rpm] 64 | doc-files = "docs extras AUTHORS INSTALL LICENSE README.rst" 65 | install-script = "scripts/rpm-install.sh" 66 | 67 | [tool.flake8] 68 | exclude = "build,.git,.tox,./tests/.env" 69 | ignore = "W504,W601" 70 | max-line-length = "119" 71 | 72 | [tool.isort] 73 | combine_as_imports = true 74 | default_section = "THIRDPARTY" 75 | include_trailing_comma = true 76 | known_first_party = ["django"] 77 | line_length = 79 78 | multi_line_output = 5 79 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/isort.py: -------------------------------------------------------------------------------- 1 | # https://pycqa.github.io/isort/docs/configuration/config_files 2 | from collections.abc import Mapping, MutableMapping 3 | from functools import partial, update_wrapper 4 | from typing import Set, TypeVar 5 | 6 | from ..transformations import coerce_scalar, kebab_case, split_list 7 | from ..types import Transformation as T 8 | from ..types import Translator 9 | 10 | M = TypeVar("M", bound=MutableMapping) 11 | 12 | 13 | def activate(translator: Translator): 14 | plugin = ISort() 15 | profile = translator[".isort.cfg"] 16 | fn = partial(plugin.process_values, section_name="settings") 17 | update_wrapper(fn, plugin.process_values) 18 | profile.intermediate_processors.append(fn) 19 | profile.help_text = plugin.__doc__ or "" 20 | 21 | for file in ("setup.cfg", "tox.ini"): 22 | translator[file].intermediate_processors.append(plugin.process_values) 23 | 24 | 25 | class ISort: 26 | """Convert settings to 'pyproject.toml' equivalent""" 27 | 28 | FIELDS = { 29 | "force_to_top", 30 | "treat_comments_as_code", 31 | "no_lines_before", 32 | "forced_separate", 33 | "sections", 34 | "length_sort_sections", 35 | "sources", 36 | "constants", 37 | "classes", 38 | "variables", 39 | "namespace_packages", 40 | "add_imports", 41 | "remove_imports", 42 | } 43 | 44 | FIELD_ENDS = ["skip", "glob", "paths", "exclusions", "plugins"] 45 | FIELD_STARTS = ["known", "extra"] 46 | 47 | # dicts? ["import_headings", "git_ignore", "know_other"] 48 | 49 | def find_list_options(self, section: Mapping) -> Set[str]: 50 | dynamic_fields = ( 51 | field 52 | for field in section 53 | if isinstance(field, str) 54 | and ( 55 | any(field.startswith(s) for s in self.FIELD_STARTS) 56 | or any(field.endswith(s) for s in self.FIELD_ENDS) 57 | ) 58 | ) 59 | fields = {*self.FIELDS, *dynamic_fields} 60 | return {*fields, *map(kebab_case, fields)} 61 | 62 | def process_values(self, doc: M, section_name="isort") -> M: 63 | candidates = [ 64 | doc.get(section_name), 65 | doc.get(("tool", section_name)), 66 | doc.get("tool", {}).get(section_name), 67 | ] 68 | for section in candidates: 69 | if section: 70 | self.process_section(section) 71 | return doc 72 | 73 | def process_section(self, section: M): 74 | list_options = self.find_list_options(section) 75 | for field in section: 76 | fn: T = split_list if field in list_options else coerce_scalar 77 | section[field] = fn(section[field]) 78 | -------------------------------------------------------------------------------- /tests/examples/plumbum/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "plumbum" 7 | description = "Plumbum: shell combinators library" 8 | authors = [{name = "Tomer Filiba", email = "tomerfiliba@gmail.com"}] 9 | license = {text = "MIT"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: Microsoft :: Windows", 14 | "Operating System :: POSIX", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Topic :: Software Development :: Build Tools", 23 | "Topic :: System :: Systems Administration", 24 | ] 25 | keywords = [ 26 | "path", 27 | "local", 28 | "remote", 29 | "ssh", 30 | "shell", 31 | "pipe", 32 | "popen", 33 | "process", 34 | "execution", 35 | "color", 36 | "cli", 37 | ] 38 | urls = {Homepage = "https://plumbum.readthedocs.io"} 39 | requires-python = ">=3.6" 40 | dependencies = ["pywin32;platform_system=='Windows' and platform_python_implementation!=\"PyPy\""] 41 | dynamic = ["version"] 42 | 43 | [project.readme] 44 | file = "README.rst" 45 | content-type = "text/x-rst" 46 | 47 | [project.optional-dependencies] 48 | dev = [ 49 | "paramiko", 50 | "psutil", 51 | "pytest>=6.0", 52 | "pytest-cov", 53 | "pytest-mock", 54 | "pytest-timeout", 55 | ] 56 | docs = [ 57 | "Sphinx>=4.0.0", 58 | "sphinx-rtd-theme>=1.0.0", 59 | ] 60 | ssh = ["paramiko"] 61 | 62 | [tool.setuptools] 63 | platforms = ["POSIX", "Windows"] 64 | provides = ["plumbum"] 65 | license-files = ["LICENSE"] 66 | include-package-data = false 67 | 68 | [tool.setuptools.packages.find] 69 | exclude = ["tests"] 70 | namespaces = false 71 | 72 | [tool.setuptools.package-data] 73 | "plumbum.cli" = ["i18n/*/LC_MESSAGES/*.mo"] 74 | 75 | [tool.coverage.run] 76 | branch = true 77 | relative_files = true 78 | source_pkgs = ["plumbum"] 79 | omit = [ 80 | "*ipython*.py", 81 | "*__main__.py", 82 | "*_windows.py", 83 | ] 84 | 85 | [tool.coverage.report] 86 | exclude_lines = [ 87 | "pragma: no cover", 88 | "def __repr__", 89 | "raise AssertionError", 90 | "raise NotImplementedError", 91 | "if __name__ == .__main__.:", 92 | ] 93 | 94 | [tool.flake8] 95 | max-complexity = "50" 96 | extend-ignore = "E203, E501, E722, B950, E731" 97 | select = "C,E,F,W,B,B9" 98 | 99 | [tool.codespell] 100 | ignore-words-list = "ans,switchs,hart,ot,twoo,fo" 101 | skip = "*.po" 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2", "setuptools_scm[toml]>=5"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ini2toml" 7 | description = "Automatically conversion of .ini/.cfg files to TOML equivalents" 8 | authors = [{name = "Anderson Bravalheri", email = "andersonbravalheri@gmail.com"}] 9 | readme ="README.rst" 10 | license = {text = "MPL-2.0"} 11 | requires-python = ">=3.8" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Programming Language :: Python", 15 | ] 16 | dependencies = [ 17 | "packaging>=20.7", 18 | "setuptools>=60", # local setuptools._distutils is the default 19 | ] 20 | dynamic = ["version"] 21 | 22 | [project.urls] 23 | Homepage = "https://github.com/abravalheri/ini2toml/" 24 | Documentation = "https://ini2toml.readthedocs.io/" 25 | Source = "https://github.com/abravalheri/ini2toml" 26 | Tracker = "https://github.com/abravalheri/ini2toml/issues" 27 | Changelog = "https://ini2toml.readthedocs.io/en/latest/changelog.html" 28 | Download = "https://pypi.org/project/ini2toml/#files" 29 | 30 | [project.optional-dependencies] 31 | full = [ 32 | "configupdater>=3.0.1,<4", 33 | "tomlkit>=0.10.0,<2", 34 | ] 35 | lite = [ 36 | "tomli-w>=0.4.0,<2", 37 | ] 38 | all = [ 39 | "configupdater>=3.0.1,<4", 40 | "tomlkit>=0.10.0,<2", 41 | "tomli-w>=0.4.0,<2", 42 | ] 43 | testing = [ 44 | "isort", 45 | "setuptools", 46 | "tomli", 47 | "pytest", 48 | "pytest-cov", 49 | "pytest-xdist", 50 | "pytest-randomly", 51 | "validate-pyproject>=0.6,<2", 52 | ] 53 | 54 | [project.scripts] 55 | ini2toml = "ini2toml.cli:run" 56 | 57 | [project.entry-points."ini2toml.processing"] 58 | setuptools_pep621 = "ini2toml.plugins.setuptools_pep621:activate" 59 | best_effort = "ini2toml.plugins.best_effort:activate" 60 | isort = "ini2toml.plugins.isort:activate" 61 | coverage = "ini2toml.plugins.coverage:activate" 62 | pytest = "ini2toml.plugins.pytest:activate" 63 | mypy = "ini2toml.plugins.mypy:activate" 64 | independent_tasks = "ini2toml.plugins.profile_independent_tasks:activate" 65 | toml_incompatibilities = "ini2toml.plugins.toml_incompatibilities:activate" 66 | 67 | [tool.setuptools_scm] 68 | version_scheme = "no-guess-dev" 69 | 70 | [tool.pytest.ini_options] 71 | addopts = """ 72 | --import-mode importlib 73 | --cov ini2toml 74 | --cov-report term-missing 75 | --doctest-modules 76 | --strict-markers 77 | --verbose 78 | """ 79 | norecursedirs = ["dist", "build", ".*"] 80 | testpaths = ["src", "tests"] 81 | 82 | [tool.codespell] 83 | skip = "tests/examples/*" 84 | ignore-words-list = "thirdparty" 85 | 86 | [tool.mypy] 87 | pretty = true 88 | show_error_codes = true 89 | show_error_context = true 90 | show_traceback = true 91 | ignore_missing_imports = true 92 | warn_redundant_casts = true 93 | warn_unused_ignores = true 94 | warn_unused_configs = true 95 | # Add here plugins 96 | # plugins = mypy_django_plugin.main, returns.contrib.mypy.returns_plugin 97 | -------------------------------------------------------------------------------- /tests/plugins/test_pytest.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | import tomli 5 | 6 | from ini2toml.drivers import full_toml, lite_toml 7 | from ini2toml.plugins.pytest import activate 8 | from ini2toml.translator import FullTranslator 9 | 10 | EXAMPLES = { 11 | "simple": { 12 | "example": """\ 13 | [pytest] 14 | minversion = 6.0 15 | addopts = -ra -q --cov ini2toml 16 | testpaths = tests 17 | python_files = test_*.py check_*.py example_*.py 18 | required_plugins = pytest-django>=3.0.0,<4.0.0 pytest-html pytest-xdist>=1.0.0 19 | norecursedirs = 20 | dist 21 | build 22 | .tox 23 | filterwarnings= 24 | error 25 | ignore:Please use `dok_matrix` from the `scipy\\.sparse` namespace, the `scipy\\.sparse\\.dok` namespace is deprecated.:DeprecationWarning 26 | """, # noqa 27 | "expected": """\ 28 | [pytest] 29 | [pytest.ini_options] 30 | minversion = "6.0" 31 | addopts = "-ra -q --cov ini2toml" 32 | testpaths = ["tests"] 33 | python_files = ["test_*.py", "check_*.py", "example_*.py"] 34 | required_plugins = ["pytest-django>=3.0.0,<4.0.0", "pytest-html", "pytest-xdist>=1.0.0"] 35 | norecursedirs = [ 36 | "dist", 37 | "build", 38 | ".tox", 39 | ] 40 | filterwarnings = [ 41 | "error", 42 | 'ignore:Please use `dok_matrix` from the `scipy\\.sparse` namespace, the `scipy\\.sparse\\.dok` namespace is deprecated.:DeprecationWarning', 43 | ] 44 | """, # noqa 45 | }, 46 | "multiline-addopts": { 47 | "example": """\ 48 | [pytest] 49 | addopts = -ra -q 50 | # comment 51 | --cov ini2toml 52 | """, 53 | "expected": '''\ 54 | [pytest.ini_options] 55 | addopts = """ 56 | -ra -q 57 | --cov ini2toml""" 58 | ''', 59 | }, 60 | } 61 | 62 | 63 | @pytest.mark.parametrize("example_name", EXAMPLES.keys()) 64 | @pytest.mark.parametrize("convert", [full_toml.convert, lite_toml.convert]) 65 | def test_pytest(example_name, convert): 66 | example = EXAMPLES[example_name]["example"] 67 | expected = EXAMPLES[example_name]["expected"] 68 | translator = FullTranslator(plugins=[activate], toml_dumps_fn=convert) 69 | out = translator.translate(dedent(example), "pytest.ini").strip() 70 | expected = dedent(expected).strip() 71 | print("expected=\n" + expected + "\n***") 72 | print("out=\n" + out) 73 | try: 74 | assert expected in out 75 | except AssertionError: 76 | # At least the Python-equivalents when parsing should be the same 77 | assert tomli.loads(expected) == tomli.loads(out) 78 | -------------------------------------------------------------------------------- /tests/examples/setuptools_scm/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = setuptools_scm 3 | description = the blessed package to manage your versions by scm tags 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | url = https://github.com/pypa/setuptools_scm/ 7 | author = Ronny Pfannschmidt 8 | author_email = opensource@ronnypfannschmidt.de 9 | license = MIT 10 | license_file = LICENSE 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: MIT License 15 | Programming Language :: Python 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Topic :: Software Development :: Libraries 23 | Topic :: Software Development :: Version Control 24 | Topic :: System :: Software Distribution 25 | Topic :: Utilities 26 | 27 | [options] 28 | packages = find: 29 | install_requires = 30 | packaging>=20.0 31 | setuptools 32 | tomli>=1.0.0 # keep in sync 33 | python_requires = >=3.6 34 | package_dir = 35 | =src 36 | zip_safe = true 37 | 38 | [options.packages.find] 39 | where = src 40 | 41 | [options.entry_points] 42 | distutils.setup_keywords = 43 | use_scm_version = setuptools_scm.integration:version_keyword 44 | setuptools.file_finders = 45 | setuptools_scm = setuptools_scm.integration:find_files 46 | setuptools.finalize_distribution_options = 47 | setuptools_scm = setuptools_scm.integration:infer_version 48 | setuptools_scm.files_command = 49 | .hg = setuptools_scm.file_finder_hg:hg_find_files 50 | .git = setuptools_scm.file_finder_git:git_find_files 51 | setuptools_scm.local_scheme = 52 | node-and-date = setuptools_scm.version:get_local_node_and_date 53 | node-and-timestamp = setuptools_scm.version:get_local_node_and_timestamp 54 | dirty-tag = setuptools_scm.version:get_local_dirty_tag 55 | no-local-version = setuptools_scm.version:get_no_local_node 56 | setuptools_scm.parse_scm = 57 | .hg = setuptools_scm.hg:parse 58 | .git = setuptools_scm.git:parse 59 | setuptools_scm.parse_scm_fallback = 60 | .hg_archival.txt = setuptools_scm.hg:parse_archival 61 | PKG-INFO = setuptools_scm.hacks:parse_pkginfo 62 | pip-egg-info = setuptools_scm.hacks:parse_pip_egg_info 63 | setup.py = setuptools_scm.hacks:fallback_version 64 | setuptools_scm.version_scheme = 65 | guess-next-dev = setuptools_scm.version:guess_next_dev_version 66 | post-release = setuptools_scm.version:postrelease_version 67 | python-simplified-semver = setuptools_scm.version:simplified_semver_version 68 | release-branch-semver = setuptools_scm.version:release_branch_semver_version 69 | no-guess-dev = setuptools_scm.version:no_guess_dev_version 70 | calver-by-date = setuptools_scm.version:calver_by_date 71 | 72 | [options.extras_require] 73 | toml = 74 | setuptools>=42 75 | tomli>=1.0.0 76 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/mypy.py: -------------------------------------------------------------------------------- 1 | # https://mypy.readthedocs.io/en/stable/config_file.html 2 | import string 3 | from collections.abc import MutableMapping, MutableSequence 4 | from functools import partial 5 | from typing import List, TypeVar, cast 6 | 7 | from ..transformations import coerce_scalar, split_list 8 | from ..types import IntermediateRepr 9 | from ..types import Transformation as T 10 | from ..types import Translator 11 | 12 | M = TypeVar("M", bound=MutableMapping) 13 | R = TypeVar("R", bound=IntermediateRepr) 14 | 15 | list_comma = partial(split_list, sep=",") 16 | 17 | 18 | def activate(translator: Translator): 19 | plugin = Mypy() 20 | for file in ("setup.cfg", "mypy.ini", ".mypy.ini"): 21 | translator[file].intermediate_processors.append(plugin.process_values) 22 | 23 | translator["mypy.ini"].help_text = plugin.__doc__ or "" 24 | 25 | 26 | class Mypy: 27 | """Convert settings to 'pyproject.toml' equivalent""" 28 | 29 | LIST_VALUES = ( 30 | "files", 31 | "always_false", 32 | "disable_error_code", 33 | "plugins", 34 | ) 35 | 36 | DONT_TOUCH = ("python_version",) 37 | 38 | def process_values(self, doc: M) -> M: 39 | for parent in (doc, doc.get("tool", {})): 40 | for key in list(parent.keys()): # need to be eager: we will delete elements 41 | key_ = key[-1] if isinstance(key, tuple) else key 42 | if not isinstance(key_, str): 43 | continue 44 | name = key_.strip('"' + string.whitespace) 45 | if name.startswith("mypy-"): 46 | overrides = self.get_or_create_overrides(parent) 47 | self.process_overrides(parent.pop(key), overrides, name) 48 | elif name == "mypy": 49 | self.process_options(parent[key]) 50 | return doc 51 | 52 | def process_overrides(self, section: R, overrides: MutableSequence, name: str) -> R: 53 | section = self.process_options(section) 54 | modules = [n.replace("mypy-", "") for n in name.split(",")] 55 | self.add_overrided_modules(section, name, modules) 56 | overrides.append(section) 57 | return section 58 | 59 | def process_options(self, section: M) -> M: 60 | for field in section: 61 | if field in self.DONT_TOUCH: 62 | continue 63 | fn: T = split_list if field in self.LIST_VALUES else coerce_scalar 64 | section[field] = fn(section[field]) 65 | return section 66 | 67 | def get_or_create_overrides(self, parent: MutableMapping) -> MutableSequence: 68 | mypy = parent.setdefault("mypy", IntermediateRepr()) 69 | return cast(MutableSequence, mypy.setdefault("overrides", [])) 70 | 71 | def add_overrided_modules(self, section: R, name: str, modules: List[str]): 72 | if not isinstance(section, IntermediateRepr): 73 | raise ValueError(f"Expecting section {name} to be an IntermediateRepr") 74 | if "module" not in section: 75 | section.insert(0, "module", modules) 76 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox configuration file 2 | # Read more under https://tox.wiki/ 3 | # THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! 4 | 5 | [tox] 6 | minversion = 3.24 7 | envlist = default 8 | isolated_build = True 9 | 10 | 11 | [testenv] 12 | description = Invoke pytest to run automated tests 13 | setenv = 14 | TOXINIDIR = {toxinidir} 15 | passenv = 16 | HOME 17 | SETUPTOOLS_* 18 | extras = 19 | all 20 | testing 21 | experimental 22 | # Overwrite dependency just while the latest version is not published: 23 | # deps = 24 | # validate-pyproject @ git+https://github.com/abravalheri/validate-pyproject@main#egg=validate-pyproject 25 | commands = 26 | pytest {posargs} 27 | 28 | 29 | [testenv:lint] 30 | description = Perform static analysis and style checks 31 | skip_install = True 32 | deps = pre-commit 33 | passenv = 34 | HOMEPATH 35 | PROGRAMDATA 36 | SETUPTOOLS_* 37 | commands = 38 | pre-commit run --all-files {posargs:--show-diff-on-failure} 39 | 40 | 41 | [testenv:typecheck] 42 | description = Invoke mypy to typecheck the source code 43 | changedir = {toxinidir} 44 | passenv = 45 | TERM 46 | # ^ ensure colors 47 | extras = 48 | all 49 | testing 50 | experimental 51 | deps = 52 | mypy 53 | commands = 54 | python -m mypy {posargs:src} 55 | 56 | 57 | [testenv:{build,clean}] 58 | description = 59 | build: Build the package in isolation according to PEP517, see https://github.com/pypa/build 60 | clean: Remove old distribution files and temporary build artifacts (./build and ./dist) 61 | # https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it 62 | skip_install = True 63 | changedir = {toxinidir} 64 | deps = 65 | build: build[virtualenv] 66 | passenv = 67 | SETUPTOOLS_* 68 | commands = 69 | clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' 70 | clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' 71 | build: python -m build {posargs} 72 | 73 | 74 | [testenv:{docs,doctests,linkcheck}] 75 | description = 76 | docs: Invoke sphinx-build to build the docs 77 | doctests: Invoke sphinx-build to run doctests 78 | linkcheck: Check for broken links in the documentation 79 | setenv = 80 | DOCSDIR = {toxinidir}/docs 81 | BUILDDIR = {toxinidir}/docs/_build 82 | docs: BUILD = html 83 | doctests: BUILD = doctest 84 | linkcheck: BUILD = linkcheck 85 | passenv = 86 | SETUPTOOLS_* 87 | deps = 88 | -r {toxinidir}/docs/requirements.txt 89 | # ^ requirements.txt shared with Read The Docs 90 | commands = 91 | sphinx-build -v -T -j auto --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} 92 | 93 | 94 | [testenv:publish] 95 | description = 96 | Publish the package you have been developing to a package index server. 97 | By default, it uses testpypi. If you really want to publish your package 98 | to be publicly accessible in PyPI, use the `-- --repository pypi` option. 99 | skip_install = True 100 | changedir = {toxinidir} 101 | passenv = 102 | TWINE_USERNAME 103 | TWINE_PASSWORD 104 | TWINE_REPOSITORY 105 | TWINE_REPOSITORY_URL 106 | deps = twine 107 | commands = 108 | python -m twine check dist/* 109 | python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* 110 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # The code in this module is mostly borrowed/adapted from PyScaffold and was originally 2 | # published under the MIT license 3 | # The original PyScaffold license can be found in 'tests/examples/pyscaffold' 4 | 5 | from importlib.metadata import EntryPoint, entry_points 6 | from textwrap import dedent 7 | from typing import Any, Callable, Iterable, List, Optional, cast 8 | 9 | from .. import __version__ 10 | from ..types import Plugin 11 | 12 | ENTRYPOINT_GROUP = "ini2toml.processing" 13 | 14 | 15 | def iterate_entry_points(group=ENTRYPOINT_GROUP) -> Iterable[EntryPoint]: 16 | """Produces a generator yielding an EntryPoint object for each plugin registered 17 | via `setuptools`_ entry point mechanism. 18 | 19 | This method can be used in conjunction with :obj:`load_from_entry_point` to 20 | filter the plugins before actually loading them. 21 | 22 | 23 | .. _setuptools: https://setuptools.pypa.io/en/latest/userguide/entry_point.html 24 | """ # noqa 25 | entries = entry_points() 26 | if hasattr(entries, "select"): 27 | # The select method was introduced in importlib_metadata 3.9/3.10 28 | # and the previous dict interface was declared deprecated 29 | select = cast(Any, getattr(entries, "select")) # typecheck gymnastic # noqa 30 | entries_: Iterable[EntryPoint] = select(group=group) 31 | else: 32 | # TODO: Once Python 3.10 becomes the oldest version supported, this fallback 33 | # and conditional statement can be removed. 34 | entries_ = (plugin for plugin in entries.get(group, [])) 35 | return sorted(entries_, key=lambda e: e.name) 36 | 37 | 38 | def load_from_entry_point(entry_point: EntryPoint) -> Plugin: 39 | """Carefully load the plugin, raising a meaningful message in case of errors""" 40 | try: 41 | return entry_point.load() 42 | except Exception as ex: 43 | raise ErrorLoadingPlugin(entry_point=entry_point) from ex 44 | 45 | 46 | def list_from_entry_points( 47 | group: str = ENTRYPOINT_GROUP, 48 | filtering: Callable[[EntryPoint], bool] = lambda _: True, 49 | ) -> List[Plugin]: 50 | """Produces a list of plugin objects for each plugin registered 51 | via `setuptools`_ entry point mechanism. 52 | 53 | Args: 54 | group: name of the setuptools' entry_point group where plugins is being 55 | registered 56 | filtering: function returning a boolean deciding if the entry point should be 57 | loaded and included (or not) in the final list. A ``True`` return means the 58 | plugin should be included. 59 | 60 | .. _setuptools: https://setuptools.pypa.io/en/latest/userguide/entry_point.html 61 | """ # noqa 62 | return [ 63 | load_from_entry_point(e) for e in iterate_entry_points(group) if filtering(e) 64 | ] 65 | 66 | 67 | class ErrorLoadingPlugin(RuntimeError): 68 | """There was an error loading '{plugin}'. 69 | Please make sure you have installed a version of the plugin that is compatible 70 | with {package} {version}. You can also try uninstalling it. 71 | """ 72 | 73 | def __init__(self, plugin: str = "", entry_point: Optional[EntryPoint] = None): 74 | if entry_point and not plugin: 75 | plugin = getattr(entry_point, "module", entry_point.name) 76 | 77 | sub = dict(package=__package__, version=__version__, plugin=plugin) 78 | msg = dedent(self.__doc__ or "").format(**sub).splitlines() 79 | super().__init__(f"{msg[0]}\n{' '.join(msg[1:])}") 80 | -------------------------------------------------------------------------------- /tests/examples/setuptools_scm/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "setuptools_scm" 7 | description = "the blessed package to manage your versions by scm tags" 8 | authors = [{name = "Ronny Pfannschmidt", email = "opensource@ronnypfannschmidt.de"}] 9 | license = {text = "MIT"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Software Development :: Version Control", 23 | "Topic :: System :: Software Distribution", 24 | "Topic :: Utilities", 25 | ] 26 | urls = {Homepage = "https://github.com/pypa/setuptools_scm/"} 27 | requires-python = ">=3.6" 28 | dependencies = [ 29 | "packaging>=20.0", 30 | "setuptools", 31 | "tomli>=1.0.0", # keep in sync 32 | ] 33 | dynamic = ["version"] 34 | 35 | [project.readme] 36 | file = "README.rst" 37 | content-type = "text/x-rst" 38 | 39 | [project.entry-points."distutils.setup_keywords"] 40 | use_scm_version = "setuptools_scm.integration:version_keyword" 41 | 42 | [project.entry-points."setuptools.file_finders"] 43 | setuptools_scm = "setuptools_scm.integration:find_files" 44 | 45 | [project.entry-points."setuptools.finalize_distribution_options"] 46 | setuptools_scm = "setuptools_scm.integration:infer_version" 47 | 48 | [project.entry-points."setuptools_scm.files_command"] 49 | ".hg" = "setuptools_scm.file_finder_hg:hg_find_files" 50 | ".git" = "setuptools_scm.file_finder_git:git_find_files" 51 | 52 | [project.entry-points."setuptools_scm.local_scheme"] 53 | node-and-date = "setuptools_scm.version:get_local_node_and_date" 54 | node-and-timestamp = "setuptools_scm.version:get_local_node_and_timestamp" 55 | dirty-tag = "setuptools_scm.version:get_local_dirty_tag" 56 | no-local-version = "setuptools_scm.version:get_no_local_node" 57 | 58 | [project.entry-points."setuptools_scm.parse_scm"] 59 | ".hg" = "setuptools_scm.hg:parse" 60 | ".git" = "setuptools_scm.git:parse" 61 | 62 | [project.entry-points."setuptools_scm.parse_scm_fallback"] 63 | ".hg_archival.txt" = "setuptools_scm.hg:parse_archival" 64 | PKG-INFO = "setuptools_scm.hacks:parse_pkginfo" 65 | pip-egg-info = "setuptools_scm.hacks:parse_pip_egg_info" 66 | "setup.py" = "setuptools_scm.hacks:fallback_version" 67 | 68 | [project.entry-points."setuptools_scm.version_scheme"] 69 | guess-next-dev = "setuptools_scm.version:guess_next_dev_version" 70 | post-release = "setuptools_scm.version:postrelease_version" 71 | python-simplified-semver = "setuptools_scm.version:simplified_semver_version" 72 | release-branch-semver = "setuptools_scm.version:release_branch_semver_version" 73 | no-guess-dev = "setuptools_scm.version:no_guess_dev_version" 74 | calver-by-date = "setuptools_scm.version:calver_by_date" 75 | 76 | [project.optional-dependencies] 77 | toml = [ 78 | "setuptools>=42", 79 | "tomli>=1.0.0", 80 | ] 81 | 82 | [tool.setuptools] 83 | package-dir = {"" = "src"} 84 | zip-safe = true 85 | license-files = ["LICENSE"] 86 | include-package-data = false 87 | 88 | [tool.setuptools.packages.find] 89 | where = ["src"] 90 | namespaces = false 91 | -------------------------------------------------------------------------------- /tests/examples/flask/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Flask 3 | version = attr: flask.__version__ 4 | url = https://palletsprojects.com/p/flask 5 | project_urls = 6 | Donate = https://palletsprojects.com/donate 7 | Documentation = https://flask.palletsprojects.com/ 8 | Changes = https://flask.palletsprojects.com/changes/ 9 | Source Code = https://github.com/pallets/flask/ 10 | Issue Tracker = https://github.com/pallets/flask/issues/ 11 | Twitter = https://twitter.com/PalletsTeam 12 | Chat = https://discord.gg/pallets 13 | license = BSD-3-Clause 14 | author = Armin Ronacher 15 | author_email = armin.ronacher@active-4.com 16 | maintainer = Pallets 17 | maintainer_email = contact@palletsprojects.com 18 | description = A simple framework for building complex web applications. 19 | long_description = file: README.rst 20 | long_description_content_type = text/x-rst 21 | classifiers = 22 | Development Status :: 5 - Production/Stable 23 | Environment :: Web Environment 24 | Framework :: Flask 25 | Intended Audience :: Developers 26 | License :: OSI Approved :: BSD License 27 | Operating System :: OS Independent 28 | Programming Language :: Python 29 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 30 | Topic :: Internet :: WWW/HTTP :: WSGI 31 | Topic :: Internet :: WWW/HTTP :: WSGI :: Application 32 | Topic :: Software Development :: Libraries :: Application Frameworks 33 | 34 | [options] 35 | packages = find: 36 | package_dir = = src 37 | include_package_data = true 38 | python_requires = >= 3.6 39 | # Dependencies are in setup.py for GitHub's dependency graph. 40 | 41 | [options.packages.find] 42 | where = src 43 | 44 | [options.entry_points] 45 | console_scripts = 46 | flask = flask.cli:main 47 | 48 | [tool:pytest] 49 | testpaths = tests 50 | filterwarnings = 51 | error 52 | 53 | [coverage:run] 54 | branch = True 55 | source = 56 | flask 57 | tests 58 | 59 | [coverage:paths] 60 | source = 61 | src 62 | */site-packages 63 | 64 | [flake8] 65 | # B = bugbear 66 | # E = pycodestyle errors 67 | # F = flake8 pyflakes 68 | # W = pycodestyle warnings 69 | # B9 = bugbear opinions 70 | # ISC = implicit-str-concat 71 | select = B, E, F, W, B9, ISC 72 | ignore = 73 | # slice notation whitespace, invalid 74 | E203 75 | # import at top, too many circular import fixes 76 | E402 77 | # line length, handled by bugbear B950 78 | E501 79 | # bare except, handled by bugbear B001 80 | E722 81 | # bin op line break, invalid 82 | W503 83 | # up to 88 allowed by bugbear B950 84 | max-line-length = 80 85 | per-file-ignores = 86 | # __init__ module exports names 87 | src/flask/__init__.py: F401 88 | 89 | [mypy] 90 | files = src/flask 91 | python_version = 3.6 92 | allow_redefinition = True 93 | disallow_subclassing_any = True 94 | # disallow_untyped_calls = True 95 | # disallow_untyped_defs = True 96 | # disallow_incomplete_defs = True 97 | no_implicit_optional = True 98 | local_partial_types = True 99 | # no_implicit_reexport = True 100 | strict_equality = True 101 | warn_redundant_casts = True 102 | warn_unused_configs = True 103 | warn_unused_ignores = True 104 | # warn_return_any = True 105 | # warn_unreachable = True 106 | 107 | [mypy-asgiref.*] 108 | ignore_missing_imports = True 109 | 110 | [mypy-blinker.*] 111 | ignore_missing_imports = True 112 | 113 | [mypy-dotenv.*] 114 | ignore_missing_imports = True 115 | 116 | [mypy-cryptography.*] 117 | ignore_missing_imports = True 118 | -------------------------------------------------------------------------------- /src/ini2toml/plugins/pytest.py: -------------------------------------------------------------------------------- 1 | # https://docs.pytest.org/en/latest/reference/reference.html#configuration-options 2 | # https://docs.pytest.org/en/latest/reference/customize.html#config-file-formats 3 | import logging 4 | from collections.abc import MutableMapping 5 | from functools import partial 6 | from typing import TypeVar, Union 7 | 8 | from ..transformations import coerce_scalar, remove_comments, split_comment, split_list 9 | from ..types import Commented, IntermediateRepr, Translator 10 | 11 | R = TypeVar("R", bound=IntermediateRepr) 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | _split_spaces = partial(split_list, sep=" ") 16 | _split_lines = partial(split_list, sep="\n") 17 | # ^ most of the list values in pytest use whitespace separators, 18 | # but markers/filterwarnings are a special case. 19 | 20 | 21 | def activate(translator: Translator): 22 | plugin = Pytest() 23 | for file in ("setup.cfg", "tox.ini", "pytest.ini"): 24 | translator[file].intermediate_processors.append(plugin.process_values) 25 | translator["pytest.ini"].help_text = plugin.__doc__ or "" 26 | 27 | 28 | class Pytest: 29 | """Convert settings to 'pyproject.toml' ('ini_options' table)""" 30 | 31 | LINE_SEPARATED_LIST_VALUES = ( 32 | "markers", 33 | "filterwarnings", 34 | ) 35 | SPACE_SEPARATED_LIST_VALUES = ( 36 | "norecursedirs", 37 | "python_classes", 38 | "python_files", 39 | "python_functions", 40 | "required_plugins", 41 | "testpaths", 42 | "usefixtures", 43 | ) 44 | 45 | DONT_TOUCH = ("minversion",) 46 | 47 | def process_values(self, doc: R) -> R: 48 | candidates = [ 49 | (("pytest", "ini_options"), "pytest", doc), 50 | (("tool", "pytest", "ini_options"), "tool:pytest", doc), 51 | (("tool", "pytest", "ini_options"), ("tool", "pytest"), doc), 52 | (("pytest", "ini_options"), "pytest", doc.get("tool", {})), 53 | ] 54 | for new_key, old_key, parent in candidates: 55 | section = parent.get(old_key) 56 | if section: 57 | self.process_section(section) 58 | parent.rename(old_key, new_key) 59 | return doc 60 | 61 | def process_section(self, section: MutableMapping): 62 | for field in section: 63 | if field in self.DONT_TOUCH: 64 | continue 65 | if field in self.LINE_SEPARATED_LIST_VALUES: 66 | section[field] = _split_lines(section[field]) 67 | elif field in self.SPACE_SEPARATED_LIST_VALUES: 68 | section[field] = _split_spaces(section[field]) 69 | elif hasattr(self, f"_process_{field}"): 70 | section[field] = getattr(self, f"_process_{field}")(section[field]) 71 | else: 72 | section[field] = coerce_scalar(section[field]) 73 | 74 | def _process_addopts(self, content: str) -> Union[Commented[str], str]: 75 | # pytest-dev/pytest#12228: pytest maintainers recommend addopts as string. 76 | # However, it cannot handle embedded comments, so we have to strip them. 77 | 78 | if "\n" not in content: 79 | # It is easy to handle inline comments for a single line. 80 | return split_comment(content) 81 | 82 | if "#" not in content: 83 | return content 84 | 85 | msg = ( 86 | "Stripping comments from `tool.pytest.ini_options.addopts`.\n" 87 | "This field is recommended to be a string, however it cannot " 88 | "contain embedded comments (ref: pytest-dev/pytest#12228)." 89 | ) 90 | _logger.warning(msg) 91 | 92 | return remove_comments(content) 93 | -------------------------------------------------------------------------------- /src/ini2toml/types.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections.abc import Mapping, MutableMapping 3 | from typing import TYPE_CHECKING, Any, Callable, List, Optional, TypeVar, Union 4 | 5 | from .intermediate_repr import ( 6 | KV, 7 | Commented, 8 | CommentedKV, 9 | CommentedList, 10 | CommentKey, 11 | HiddenKey, 12 | IntermediateRepr, 13 | Key, 14 | WhitespaceKey, 15 | ) 16 | 17 | if sys.version_info <= (3, 8): # pragma: no cover 18 | # TODO: Import directly when `python_requires = >= 3.8` 19 | if TYPE_CHECKING: 20 | from typing_extensions import Protocol 21 | else: 22 | # Not a real replacement but allows getting rid of the dependency 23 | from abc import ABC as Protocol 24 | else: # pragma: no cover 25 | from typing import Protocol 26 | 27 | 28 | R = TypeVar("R", bound=IntermediateRepr) 29 | T = TypeVar("T") 30 | M = TypeVar("M", bound=MutableMapping) 31 | 32 | Scalar = Union[int, float, bool, str] # TODO: missing time and datetime 33 | CoerceFn = Callable[[str], T] 34 | Transformation = Union[Callable[[str], Any], Callable[[M], M]] 35 | 36 | TextProcessor = Callable[[str], str] 37 | IntermediateProcessor = Callable[[R], R] 38 | 39 | 40 | IniLoadsFn = Callable[[str, Mapping], IntermediateRepr] 41 | IReprCollapseFn = Callable[[IntermediateRepr], T] 42 | TomlDumpsFn = IReprCollapseFn[str] 43 | 44 | 45 | class CLIChoice(Protocol): 46 | """:meta private:""" 47 | 48 | name: str 49 | help_text: str 50 | 51 | 52 | class Profile(Protocol): 53 | name: str 54 | help_text: str 55 | pre_processors: List[TextProcessor] 56 | intermediate_processors: List[IntermediateProcessor] 57 | post_processors: List[TextProcessor] 58 | 59 | 60 | class ProfileAugmentation(Protocol): 61 | active_by_default: bool 62 | name: str 63 | help_text: str 64 | 65 | def fn(self, profile: Profile): ... 66 | 67 | def is_active(self, explicitly_active: Optional[bool] = None) -> bool: 68 | """``explicitly_active`` is a tree-state variable: ``True`` if the user 69 | explicitly asked for the augmentation, ``False`` if the user explicitly denied 70 | the augmentation, or ``None`` otherwise. 71 | """ 72 | ... 73 | 74 | 75 | class Translator(Protocol): 76 | def __getitem__(self, profile_name: str) -> Profile: 77 | """Create and register (and return) a translation :class:`Profile` 78 | (or return a previously registered one) (see :ref:`core-concepts`). 79 | """ 80 | ... 81 | 82 | def augment_profiles( 83 | self, 84 | fn: "ProfileAugmentationFn", 85 | active_by_default: bool = False, 86 | name: str = "", 87 | help_text: str = "", 88 | ): 89 | """Register a profile augmentation function (see :ref:`core-concepts`). 90 | The keyword ``name`` and ``help_text`` can be used to customise the description 91 | featured in ``ini2toml``'s CLI, but when these arguments are not given (or empty 92 | strings), ``name`` is taken from ``fn.__name__`` and ``help_text`` is taken from 93 | ``fn.__doc__`` (docstring). 94 | """ 95 | ... 96 | 97 | 98 | Plugin = Callable[[Translator], None] 99 | ProfileAugmentationFn = Callable[[Profile], None] 100 | 101 | 102 | __all__ = [ 103 | "CLIChoice", 104 | "CommentKey", 105 | "Commented", 106 | "CommentedKV", 107 | "CommentedList", 108 | "HiddenKey", 109 | "IniLoadsFn", 110 | "IntermediateProcessor", 111 | "IntermediateRepr", 112 | "Key", 113 | "KV", 114 | "Plugin", 115 | "Profile", 116 | "ProfileAugmentation", 117 | "ProfileAugmentationFn", 118 | "TextProcessor", 119 | "Translator", 120 | "TomlDumpsFn", 121 | "WhitespaceKey", 122 | "Scalar", 123 | "CoerceFn", 124 | "Transformation", 125 | ] 126 | -------------------------------------------------------------------------------- /tests/examples/flask/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "Flask" 7 | license = {text = "BSD-3-Clause"} 8 | authors = [{name = "Armin Ronacher", email = "armin.ronacher@active-4.com"}] 9 | maintainers = [{name = "Pallets", email = "contact@palletsprojects.com"}] 10 | description = "A simple framework for building complex web applications." 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Environment :: Web Environment", 14 | "Framework :: Flask", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 20 | "Topic :: Internet :: WWW/HTTP :: WSGI", 21 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 22 | "Topic :: Software Development :: Libraries :: Application Frameworks", 23 | ] 24 | requires-python = ">= 3.6" 25 | dynamic = ["version"] 26 | 27 | [project.urls] 28 | Homepage = "https://palletsprojects.com/p/flask" 29 | Donate = "https://palletsprojects.com/donate" 30 | Documentation = "https://flask.palletsprojects.com/" 31 | Changes = "https://flask.palletsprojects.com/changes/" 32 | "Source Code" = "https://github.com/pallets/flask/" 33 | "Issue Tracker" = "https://github.com/pallets/flask/issues/" 34 | Twitter = "https://twitter.com/PalletsTeam" 35 | Chat = "https://discord.gg/pallets" 36 | 37 | [project.readme] 38 | file = "README.rst" 39 | content-type = "text/x-rst" 40 | 41 | [project.scripts] 42 | flask = "flask.cli:main" 43 | 44 | [tool.setuptools] 45 | package-dir = {"" = "src"} 46 | include-package-data = true 47 | # Dependencies are in setup.py for GitHub's dependency graph. 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["src"] 51 | namespaces = false 52 | 53 | [tool.setuptools.dynamic] 54 | version = {attr = "flask.__version__"} 55 | 56 | [tool.pytest.ini_options] 57 | testpaths = ["tests"] 58 | filterwarnings = ["error"] 59 | 60 | [tool.coverage.run] 61 | branch = true 62 | source = [ 63 | "flask", 64 | "tests", 65 | ] 66 | 67 | [tool.coverage.paths] 68 | source = [ 69 | "src", 70 | "*/site-packages", 71 | ] 72 | 73 | [tool.flake8] 74 | # B = bugbear 75 | # E = pycodestyle errors 76 | # F = flake8 pyflakes 77 | # W = pycodestyle warnings 78 | # B9 = bugbear opinions 79 | # ISC = implicit-str-concat 80 | select = "B, E, F, W, B9, ISC" 81 | ignore = """ 82 | # slice notation whitespace, invalid 83 | E203 84 | # import at top, too many circular import fixes 85 | E402 86 | # line length, handled by bugbear B950 87 | E501 88 | # bare except, handled by bugbear B001 89 | E722 90 | # bin op line break, invalid 91 | W503""" 92 | # up to 88 allowed by bugbear B950 93 | max-line-length = "80" 94 | per-file-ignores = """ 95 | # __init__ module exports names 96 | src/flask/__init__.py: F401""" 97 | 98 | [tool.mypy] 99 | files = ["src/flask"] 100 | python_version = "3.6" 101 | allow_redefinition = true 102 | disallow_subclassing_any = true 103 | # disallow_untyped_calls = True 104 | # disallow_untyped_defs = True 105 | # disallow_incomplete_defs = True 106 | no_implicit_optional = true 107 | local_partial_types = true 108 | # no_implicit_reexport = True 109 | strict_equality = true 110 | warn_redundant_casts = true 111 | warn_unused_configs = true 112 | warn_unused_ignores = true 113 | # warn_return_any = True 114 | # warn_unreachable = True 115 | 116 | [[tool.mypy.overrides]] 117 | module = ["asgiref.*"] 118 | ignore_missing_imports = true 119 | 120 | [[tool.mypy.overrides]] 121 | module = ["blinker.*"] 122 | ignore_missing_imports = true 123 | 124 | [[tool.mypy.overrides]] 125 | module = ["dotenv.*"] 126 | ignore_missing_imports = true 127 | 128 | [[tool.mypy.overrides]] 129 | module = ["cryptography.*"] 130 | ignore_missing_imports = true 131 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | # Avoid using all the resources/limits available by checking only 6 | # relevant branches and tags. Other branches can be checked via PRs. 7 | # branches: [main] 8 | tags: ['v[0-9]*', '[0-9]+.[0-9]+*'] # Match tags that resemble a version 9 | pull_request: 10 | paths: 11 | - '.github/workflows/ci.yml' # Only runs when the file itself is modified. 12 | workflow_dispatch: # Allow manually triggering the workflow 13 | schedule: 14 | # Run roughly every 15 days at 00:00 UTC 15 | # (useful to check if updates on dependencies break the package) 16 | - cron: '0 0 1,16 * *' 17 | 18 | concurrency: 19 | group: >- 20 | ${{ github.workflow }}-${{ github.ref_type }}- 21 | ${{ github.event.pull_request.number || github.sha }} 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | prepare: 26 | runs-on: ubuntu-latest 27 | outputs: 28 | wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: {fetch-depth: 0} # deep clone for setuptools-scm 32 | - uses: actions/setup-python@v5 33 | with: {python-version: "3.10"} 34 | - uses: astral-sh/setup-uv@v6 35 | - name: Run static analysis and format checkers 36 | run: >- 37 | uvx --with tox-uv 38 | tox -e lint,typecheck 39 | - name: Build package distribution files 40 | run: >- 41 | uvx --with tox-uv 42 | tox -e clean,build 43 | - name: Record the path of wheel distribution 44 | id: wheel-distribution 45 | run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT 46 | - name: Store the distribution files for use in other stages 47 | # `tests` and `publish` will use the same pre-built distributions, 48 | # so we make sure to release the exact same package that was tested 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: python-distribution-files 52 | path: dist/ 53 | retention-days: 1 54 | 55 | test: 56 | needs: prepare 57 | strategy: 58 | matrix: 59 | python: 60 | - "3.8" # oldest Python supported by either PSF or GHA 61 | - "3.x" # newest Python that is stable 62 | platform: 63 | - ubuntu-latest 64 | - macos-latest 65 | - windows-latest 66 | runs-on: ${{ matrix.platform }} 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: actions/setup-python@v5 70 | with: 71 | python-version: ${{ matrix.python }} 72 | - uses: astral-sh/setup-uv@v6 73 | - name: Retrieve pre-built distribution files 74 | uses: actions/download-artifact@v4 75 | with: {name: python-distribution-files, path: dist/} 76 | - name: Run tests 77 | run: >- 78 | uvx --with tox-uv 79 | tox 80 | --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' 81 | -- -rFEx --durations 10 --color yes 82 | - name: Generate coverage report 83 | run: pipx run coverage lcov -o coverage.lcov 84 | - name: Upload partial coverage report 85 | uses: coverallsapp/github-action@v2 86 | with: 87 | path-to-lcov: coverage.lcov 88 | github-token: ${{ secrets.github_token }} 89 | flag-name: ${{ matrix.platform }} - py${{ matrix.python }} 90 | parallel: true 91 | 92 | finalize: 93 | needs: test 94 | runs-on: ubuntu-latest 95 | steps: 96 | - name: Finalize coverage report 97 | uses: coverallsapp/github-action@v2 98 | with: 99 | github-token: ${{ secrets.GITHUB_TOKEN }} 100 | parallel-finished: true 101 | 102 | publish: 103 | needs: finalize 104 | if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 105 | runs-on: ubuntu-latest 106 | steps: 107 | - uses: actions/checkout@v4 108 | - uses: actions/setup-python@v5 109 | with: {python-version: "3.10"} 110 | - uses: astral-sh/setup-uv@v6 111 | - name: Retrieve pre-built distribution files 112 | uses: actions/download-artifact@v4 113 | with: {name: python-distribution-files, path: dist/} 114 | - name: Publish Package 115 | env: 116 | # See: https://pypi.org/help/#apitoken 117 | TWINE_REPOSITORY: pypi 118 | TWINE_USERNAME: __token__ 119 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 120 | run: >- 121 | uvx --with tox-uv 122 | tox -e publish 123 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from ini2toml import cli 8 | from ini2toml.plugins import ErrorLoadingPlugin 9 | from ini2toml.profile import Profile, ProfileAugmentation 10 | 11 | 12 | def test_guess_profile(caplog): 13 | available = ["best_effort", "setup.cfg", "mypy.ini", "pyscaffold/default.cfg"] 14 | with caplog.at_level(logging.DEBUG): 15 | assert cli.guess_profile("best_effort", "setup.cfg", available) == "best_effort" 16 | assert caplog.text == "" 17 | caplog.clear() 18 | with caplog.at_level(logging.DEBUG): 19 | assert cli.guess_profile(None, "myproj/setup.cfg", available) == "setup.cfg" 20 | assert "Profile not explicitly set" in caplog.text 21 | assert "'setup.cfg' inferred" in caplog.text 22 | caplog.clear() 23 | with caplog.at_level(logging.DEBUG): 24 | profile = cli.guess_profile(None, "~/.config/pyscaffold/default.cfg", available) 25 | assert profile == "pyscaffold/default.cfg" 26 | assert "Profile not explicitly set" in caplog.text 27 | assert "'pyscaffold/default.cfg' inferred" in caplog.text 28 | caplog.clear() 29 | with caplog.at_level(logging.DEBUG): 30 | profile = cli.guess_profile(None, "myfile.ini", available) 31 | assert "Profile not explicitly set" in caplog.text 32 | assert "using 'best_effort'" in caplog.text 33 | 34 | 35 | def _profile(name, help_text="help_text"): 36 | return Profile(name, help_text) 37 | 38 | 39 | def _aug(name, active, help_text="help_text"): 40 | return ProfileAugmentation(lambda x: x, active, name, help_text) 41 | 42 | 43 | def test_parse_args(tmp_path): 44 | file = tmp_path / "file.ini" 45 | file.touch() 46 | args = f"{file} -p setup.cfg -D hello world -E ehllo owrld".split() 47 | profiles = [_profile(n) for n in "setup.cfg default.cfg".split()] 48 | aug = [_aug(n, True) for n in "hello world other".split()] 49 | aug += [_aug(n, False) for n in "ehllo owrld".split()] 50 | params = cli.parse_args(args, profiles, aug) 51 | assert params.profile == "setup.cfg" 52 | assert params.active_augmentations == { 53 | "hello": False, 54 | "world": False, 55 | "ehllo": True, 56 | "owrld": True, 57 | } 58 | assert "other" not in params.active_augmentations 59 | 60 | 61 | def test_critical_logging_sets_log_level_on_error(monkeypatch, caplog): 62 | spy = MagicMock() 63 | monkeypatch.setattr(sys, "argv", ["-vv"]) 64 | monkeypatch.setattr(logging, "basicConfig", spy) 65 | with pytest.raises(ValueError): 66 | with cli.critical_logging(): 67 | raise ValueError 68 | _args, kwargs = spy.call_args 69 | assert kwargs["level"] == logging.DEBUG 70 | 71 | 72 | def test_critical_logging_does_nothing_if_no_argv(monkeypatch, caplog): 73 | spy = MagicMock() 74 | monkeypatch.setattr(sys, "argv", []) 75 | monkeypatch.setattr(logging, "basicConfig", spy) 76 | with pytest.raises(ValueError): 77 | with cli.critical_logging(): 78 | raise ValueError 79 | assert spy.call_args is None 80 | 81 | 82 | def test_exceptions2exit(): 83 | with pytest.raises(SystemExit): 84 | with cli.exceptions2exit(): 85 | raise ValueError 86 | 87 | 88 | def test_early_plugin_error(monkeypatch): 89 | err = MagicMock(side_effect=ErrorLoadingPlugin("fake")) 90 | monkeypatch.setattr("ini2toml.translator.list_all_plugins", err) 91 | 92 | # Remove all attached handlers, so `logging.basicConfig` can work 93 | for logger in (logging.getLogger(), logging.getLogger(cli.__package__)): 94 | monkeypatch.setattr(logger, "handlers", []) 95 | 96 | assert logger.getEffectiveLevel() in {logging.NOTSET, logging.WARNING} 97 | 98 | monkeypatch.setattr("sys.argv", ["ini2toml", "-vv"]) 99 | with pytest.raises(SystemExit): 100 | cli.run() 101 | 102 | assert logger.getEffectiveLevel() == logging.DEBUG 103 | 104 | 105 | def test_help(capsys): 106 | with pytest.raises(SystemExit): 107 | cli.run(["--help"]) 108 | out, _ = capsys.readouterr() 109 | assert len(out) > 0 110 | text = " ".join(x.strip() for x in out.splitlines()) 111 | expected_profile_desc = """\ 112 | conversion. Available profiles: 113 | - 'best_effort': guess option value conversion based on the string format. 114 | - '.coveragerc': convert settings to 'pyproject.toml' equivalent. 115 | - 'setup.cfg': convert settings to 'pyproject.toml' based on :pep:`621`. 116 | - '.isort.cfg': convert settings to 'pyproject.toml' equivalent. 117 | - 'mypy.ini': convert settings to 'pyproject.toml' equivalent. 118 | - 'pytest.ini': convert settings to 'pyproject.toml' ('ini_options' table). 119 | """ 120 | expected = " ".join(x.strip() for x in expected_profile_desc.splitlines()) 121 | print(out) 122 | assert expected in text 123 | -------------------------------------------------------------------------------- /tests/examples/virtualenv/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = virtualenv 3 | description = Virtual Python Environment builder 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | url = https://virtualenv.pypa.io/ 7 | author = Bernat Gabor 8 | author_email = gaborjbernat@gmail.com 9 | maintainer = Bernat Gabor 10 | maintainer_email = gaborjbernat@gmail.com 11 | license = MIT 12 | license_file = LICENSE 13 | platforms = any 14 | classifiers = 15 | Development Status :: 5 - Production/Stable 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: MIT License 18 | Operating System :: MacOS :: MacOS X 19 | Operating System :: Microsoft :: Windows 20 | Operating System :: POSIX 21 | Programming Language :: Python :: 2 22 | Programming Language :: Python :: 2.7 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3.5 25 | Programming Language :: Python :: 3.6 26 | Programming Language :: Python :: 3.7 27 | Programming Language :: Python :: 3.8 28 | Programming Language :: Python :: 3.9 29 | Programming Language :: Python :: 3.10 30 | Programming Language :: Python :: Implementation :: CPython 31 | Programming Language :: Python :: Implementation :: PyPy 32 | Topic :: Software Development :: Libraries 33 | Topic :: Software Development :: Testing 34 | Topic :: Utilities 35 | keywords = virtual, environments, isolated 36 | project_urls = 37 | Source=https://github.com/pypa/virtualenv 38 | Tracker=https://github.com/pypa/virtualenv/issues 39 | 40 | [options] 41 | packages = find: 42 | install_requires = 43 | backports.entry_points_selectable>=1.0.4 44 | distlib>=0.3.1,<1 45 | filelock>=3.0.0,<4 46 | platformdirs>=2,<3 47 | six>=1.9.0,<2 # keep it >=1.9.0 as it may cause problems on LTS platforms 48 | importlib-metadata>=0.12;python_version<"3.8" 49 | importlib-resources>=1.0;python_version<"3.7" 50 | pathlib2>=2.3.3,<3;python_version < '3.4' and sys.platform != 'win32' 51 | python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* 52 | package_dir = 53 | =src 54 | zip_safe = True 55 | 56 | [options.packages.find] 57 | where = src 58 | 59 | [options.entry_points] 60 | console_scripts = 61 | virtualenv=virtualenv.__main__:run_with_catch 62 | virtualenv.activate = 63 | bash = virtualenv.activation.bash:BashActivator 64 | cshell = virtualenv.activation.cshell:CShellActivator 65 | batch = virtualenv.activation.batch:BatchActivator 66 | fish = virtualenv.activation.fish:FishActivator 67 | powershell = virtualenv.activation.powershell:PowerShellActivator 68 | python = virtualenv.activation.python:PythonActivator 69 | nushell = virtualenv.activation.nushell:NushellActivator 70 | virtualenv.create = 71 | venv = virtualenv.create.via_global_ref.venv:Venv 72 | cpython3-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix 73 | cpython3-win = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows 74 | cpython2-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Posix 75 | cpython2-mac-framework = virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython2macOsFramework 76 | cpython3-mac-framework = virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsFramework 77 | cpython2-win = virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Windows 78 | pypy2-posix = virtualenv.create.via_global_ref.builtin.pypy.pypy2:PyPy2Posix 79 | pypy2-win = virtualenv.create.via_global_ref.builtin.pypy.pypy2:Pypy2Windows 80 | pypy3-posix = virtualenv.create.via_global_ref.builtin.pypy.pypy3:PyPy3Posix 81 | pypy3-win = virtualenv.create.via_global_ref.builtin.pypy.pypy3:Pypy3Windows 82 | virtualenv.discovery = 83 | builtin = virtualenv.discovery.builtin:Builtin 84 | virtualenv.seed = 85 | pip = virtualenv.seed.embed.pip_invoke:PipInvoke 86 | app-data = virtualenv.seed.embed.via_app_data.via_app_data:FromAppData 87 | 88 | [options.extras_require] 89 | docs = 90 | proselint>=0.10.2 91 | sphinx>=3 92 | sphinx-argparse>=0.2.5 93 | sphinx-rtd-theme>=0.4.3 94 | towncrier>=19.9.0rc1 95 | testing = 96 | coverage>=4 97 | coverage_enable_subprocess>=1 98 | flaky>=3 99 | pytest>=4 100 | pytest-env>=0.6.2 101 | pytest-freezegun>=0.4.1 102 | pytest-mock>=2 103 | pytest-randomly>=1 104 | pytest-timeout>=1 105 | packaging>=20.0;python_version>"3.4" 106 | 107 | [options.package_data] 108 | virtualenv.activation.bash = *.sh 109 | virtualenv.activation.batch = *.bat 110 | virtualenv.activation.cshell = *.csh 111 | virtualenv.activation.fish = *.fish 112 | virtualenv.activation.nushell = *.nu 113 | virtualenv.activation.powershell = *.ps1 114 | virtualenv.seed.wheels.embed = *.whl 115 | 116 | [sdist] 117 | formats = gztar 118 | 119 | [bdist_wheel] 120 | universal = true 121 | 122 | [tool:pytest] 123 | markers = 124 | slow 125 | junit_family = xunit2 126 | addopts = --tb=auto -ra --showlocals --no-success-flaky-report 127 | env = 128 | PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command 129 | PYTHONIOENCODING=utf-8 130 | -------------------------------------------------------------------------------- /tests/test_translator.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from ini2toml.errors import UndefinedProfile 6 | from ini2toml.plugins import profile_independent_tasks 7 | from ini2toml.profile import Profile, ProfileAugmentation 8 | from ini2toml.translator import FullTranslator, LiteTranslator, Translator 9 | 10 | 11 | def test_simple_example(): 12 | example = """\ 13 | # comment 14 | 15 | [section1] 16 | option1 = value 17 | option2 = value # option comments are considered part of the value 18 | 19 | # comment 20 | [section2] # inline comment 21 | # comment 22 | option3 = value 23 | [section3] 24 | """ 25 | # Obs: TOML always add a space before a new section 26 | expected = """\ 27 | # comment 28 | 29 | [section1] 30 | option1 = "value" 31 | option2 = "value # option comments are considered part of the value" 32 | 33 | # comment 34 | 35 | [section2] # inline comment 36 | # comment 37 | option3 = "value" 38 | 39 | [section3] 40 | """ 41 | translator = Translator(plugins=[]) 42 | # ensure profile exists 43 | translator["simple"] 44 | out = translator.translate(dedent(example), "simple") 45 | print(out) 46 | assert out == dedent(expected) 47 | 48 | 49 | def test_parser_opts(): 50 | example = """\ 51 | : comment 52 | 53 | [section1] 54 | option1 - value 55 | option2 - value : option comments are considered part of the value 56 | 57 | : comment 58 | [section2] : inline comment 59 | : comment 60 | option3 - value 61 | [section3] 62 | """ 63 | # Obs: TOML always add a space before a new section 64 | expected = """\ 65 | # comment 66 | 67 | [section1] 68 | option1 = "value" 69 | option2 = "value : option comments are considered part of the value" 70 | 71 | # comment 72 | 73 | [section2] # inline comment 74 | # comment 75 | option3 = "value" 76 | 77 | [section3] 78 | """ 79 | 80 | parser_opts = {"comment_prefixes": (":",), "delimiters": ("-",)} 81 | translator = Translator(plugins=[], ini_parser_opts=parser_opts) 82 | # ensure profile exists 83 | translator["simple"] 84 | out = translator.translate(dedent(example), "simple") 85 | print(out) 86 | assert out == dedent(expected) 87 | 88 | 89 | def test_undefined_profile(): 90 | translator = Translator() 91 | with pytest.raises(UndefinedProfile): 92 | translator.translate("", "!!--UNDEFINED ??? PROFILE--!!") 93 | 94 | 95 | simple_setupcfg = """\ 96 | [metadata] 97 | summary = Automatically translates .cfg/.ini files into TOML 98 | author_email = example@example 99 | license-file = LICENSE.txt 100 | long_description_content_type = text/x-rst; charset=UTF-8 101 | home_page = https://github.com/abravalheri/ini2toml/ 102 | classifier = Development Status :: 4 - Beta 103 | platform = any 104 | """ 105 | 106 | 107 | def test_reuse_object(): 108 | """Make sure the same translator object can be reused multiple times""" 109 | profile = Profile("setup.cfg") 110 | augmentations = [] 111 | for task in ("normalise_newlines", "remove_empty_table_headers"): 112 | fn = getattr(profile_independent_tasks, task) 113 | aug = ProfileAugmentation( 114 | profile_independent_tasks.post_process(fn), active_by_default=True 115 | ) 116 | augmentations.append(aug) 117 | 118 | translator = Translator( 119 | profiles=[profile], plugins=(), profile_augmentations=augmentations 120 | ) 121 | active_augmentations = {aug.name: True for aug in augmentations} 122 | for _ in range(5): 123 | out = translator.translate(simple_setupcfg, "setup.cfg", active_augmentations) 124 | assert len(out) > 0 125 | processors = [ 126 | *profile.post_processors, 127 | *profile.intermediate_processors, 128 | *profile.pre_processors, 129 | ] 130 | deduplicated = {getattr(p, "__name__", ""): p for p in processors} 131 | # Make sure there is no duplication in the processors 132 | assert len(processors) == len(deduplicated) 133 | 134 | 135 | def test_deduplicate_plugins(): 136 | plugins = [ 137 | profile_independent_tasks.activate, 138 | profile_independent_tasks.activate, 139 | ] 140 | translator = Translator(plugins=plugins) 141 | assert len(translator.plugins) == 1 142 | 143 | 144 | minimal_example = """\ 145 | # comment 146 | 147 | [section1] 148 | option1 = value 149 | option2 = value # option comments are considered part of the value 150 | """ 151 | 152 | 153 | def test_lite_translator(): 154 | parser_opts = {"inline_comment_prefixes": ["#"]} 155 | translator = LiteTranslator(plugins=[], ini_parser_opts=parser_opts) 156 | # ensure profile exists 157 | translator["simple"] 158 | out = translator.translate(dedent(minimal_example), "simple") 159 | assert "# comment" not in out 160 | assert "part of the value" not in out 161 | 162 | 163 | def test_full_translator(): 164 | translator = FullTranslator(plugins=[]) 165 | # ensure profile exists 166 | translator["simple"] 167 | out = translator.translate(dedent(minimal_example), "simple") 168 | assert "# comment" in out 169 | assert "part of the value" in out 170 | -------------------------------------------------------------------------------- /tests/examples/virtualenv/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "virtualenv" 7 | description = "Virtual Python Environment builder" 8 | authors = [{name = "Bernat Gabor", email = "gaborjbernat@gmail.com"}] 9 | maintainers = [{name = "Bernat Gabor", email = "gaborjbernat@gmail.com"}] 10 | license = {text = "MIT"} 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: MacOS :: MacOS X", 16 | "Operating System :: Microsoft :: Windows", 17 | "Operating System :: POSIX", 18 | "Programming Language :: Python :: 2", 19 | "Programming Language :: Python :: 2.7", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.5", 22 | "Programming Language :: Python :: 3.6", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | "Programming Language :: Python :: Implementation :: PyPy", 29 | "Topic :: Software Development :: Libraries", 30 | "Topic :: Software Development :: Testing", 31 | "Topic :: Utilities", 32 | ] 33 | keywords = ["virtual", "environments", "isolated"] 34 | requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 35 | dependencies = [ 36 | "backports.entry_points_selectable>=1.0.4", 37 | "distlib>=0.3.1,<1", 38 | "filelock>=3.0.0,<4", 39 | "platformdirs>=2,<3", 40 | "six>=1.9.0,<2", # keep it >=1.9.0 as it may cause problems on LTS platforms 41 | 'importlib-metadata>=0.12;python_version<"3.8"', 42 | 'importlib-resources>=1.0;python_version<"3.7"', 43 | "pathlib2>=2.3.3,<3;python_version < '3.4' and sys.platform != 'win32'", 44 | ] 45 | dynamic = ["version"] 46 | 47 | [project.readme] 48 | file = "README.md" 49 | content-type = "text/markdown" 50 | 51 | [project.urls] 52 | Homepage = "https://virtualenv.pypa.io/" 53 | Source = "https://github.com/pypa/virtualenv" 54 | Tracker = "https://github.com/pypa/virtualenv/issues" 55 | 56 | [project.entry-points."virtualenv.activate"] 57 | bash = "virtualenv.activation.bash:BashActivator" 58 | cshell = "virtualenv.activation.cshell:CShellActivator" 59 | batch = "virtualenv.activation.batch:BatchActivator" 60 | fish = "virtualenv.activation.fish:FishActivator" 61 | powershell = "virtualenv.activation.powershell:PowerShellActivator" 62 | python = "virtualenv.activation.python:PythonActivator" 63 | nushell = "virtualenv.activation.nushell:NushellActivator" 64 | 65 | [project.entry-points."virtualenv.create"] 66 | venv = "virtualenv.create.via_global_ref.venv:Venv" 67 | cpython3-posix = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix" 68 | cpython3-win = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows" 69 | cpython2-posix = "virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Posix" 70 | cpython2-mac-framework = "virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython2macOsFramework" 71 | cpython3-mac-framework = "virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsFramework" 72 | cpython2-win = "virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Windows" 73 | pypy2-posix = "virtualenv.create.via_global_ref.builtin.pypy.pypy2:PyPy2Posix" 74 | pypy2-win = "virtualenv.create.via_global_ref.builtin.pypy.pypy2:Pypy2Windows" 75 | pypy3-posix = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:PyPy3Posix" 76 | pypy3-win = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:Pypy3Windows" 77 | 78 | [project.entry-points."virtualenv.discovery"] 79 | builtin = "virtualenv.discovery.builtin:Builtin" 80 | 81 | [project.entry-points."virtualenv.seed"] 82 | pip = "virtualenv.seed.embed.pip_invoke:PipInvoke" 83 | app-data = "virtualenv.seed.embed.via_app_data.via_app_data:FromAppData" 84 | 85 | [project.optional-dependencies] 86 | docs = [ 87 | "proselint>=0.10.2", 88 | "sphinx>=3", 89 | "sphinx-argparse>=0.2.5", 90 | "sphinx-rtd-theme>=0.4.3", 91 | "towncrier>=19.9.0rc1", 92 | ] 93 | testing = [ 94 | "coverage>=4", 95 | "coverage_enable_subprocess>=1", 96 | "flaky>=3", 97 | "pytest>=4", 98 | "pytest-env>=0.6.2", 99 | "pytest-freezegun>=0.4.1", 100 | "pytest-mock>=2", 101 | "pytest-randomly>=1", 102 | "pytest-timeout>=1", 103 | 'packaging>=20.0;python_version>"3.4"', 104 | ] 105 | 106 | [project.scripts] 107 | virtualenv = "virtualenv.__main__:run_with_catch" 108 | 109 | [tool.setuptools] 110 | package-dir = {"" = "src"} 111 | zip-safe = true 112 | platforms = ["any"] 113 | license-files = ["LICENSE"] 114 | include-package-data = false 115 | 116 | [tool.setuptools.packages.find] 117 | where = ["src"] 118 | namespaces = false 119 | 120 | [tool.setuptools.package-data] 121 | "virtualenv.activation.bash" = ["*.sh"] 122 | "virtualenv.activation.batch" = ["*.bat"] 123 | "virtualenv.activation.cshell" = ["*.csh"] 124 | "virtualenv.activation.fish" = ["*.fish"] 125 | "virtualenv.activation.nushell" = ["*.nu"] 126 | "virtualenv.activation.powershell" = ["*.ps1"] 127 | "virtualenv.seed.wheels.embed" = ["*.whl"] 128 | 129 | [tool.distutils.sdist] 130 | formats = "gztar" 131 | 132 | [tool.distutils.bdist_wheel] 133 | universal = true 134 | 135 | [tool.pytest.ini_options] 136 | markers = ["slow"] 137 | junit_family = "xunit2" 138 | addopts = "--tb=auto -ra --showlocals --no-success-flaky-report" 139 | env = """ 140 | PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command 141 | PYTHONIOENCODING=utf-8""" 142 | -------------------------------------------------------------------------------- /src/ini2toml/translator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Mapping, Optional, Sequence 3 | 4 | from . import types # Structural/Abstract types 5 | from .base_translator import EMPTY, BaseTranslator 6 | from .plugins import list_from_entry_points as list_all_plugins 7 | 8 | _logger = logging.getLogger(__name__) 9 | 10 | 11 | # TODO: Once MyPy/other type checkers handle ``partial`` and ``partialmethod`` 12 | # we probably can use them to simply the implementations in this module. 13 | 14 | 15 | class Translator(BaseTranslator[str]): 16 | """``Translator`` is the main public Python API exposed by the ``ini2toml``, 17 | to convert strings representing ``.ini/.cfg`` files into the ``TOML`` syntax. 18 | 19 | It follows the public API defined in :class:`ini2toml.types.Translator`, and is very 20 | similar to :class:`~ini2toml.base_translator.BaseTranslator`. 21 | The main difference is that ``Translator`` will try to automatically figure out the 22 | values for ``plugins``, ``ini_loads_fn`` and ``toml_dumps_fn`` when they are not 23 | passed, based on the installed Python packages (and available `entry-points`_), 24 | while ``BaseTranslator`` requires the user to explicitly set these parameters. 25 | 26 | For most of the users ``Translator`` is recommended over ``BaseTranslator``. 27 | Most of the times ``Translator`` (or its deterministic variants ``LiteTranslator``, 28 | ``FullTranslator``) is recommended over ``BaseTranslator``. 29 | 30 | See :class:`~ini2toml.base_translator.BaseTranslator` for a description of the 31 | instantiation parameters. 32 | 33 | .. _entry-points: https://packaging.python.org/specifications/entry-points/ 34 | """ 35 | 36 | def __init__( 37 | self, 38 | plugins: Optional[Sequence[types.Plugin]] = None, 39 | profiles: Sequence[types.Profile] = (), 40 | profile_augmentations: Sequence[types.ProfileAugmentation] = (), 41 | ini_parser_opts: Mapping = EMPTY, 42 | ini_loads_fn: Optional[types.IniLoadsFn] = None, 43 | toml_dumps_fn: Optional[types.TomlDumpsFn] = None, 44 | ): 45 | super().__init__( 46 | ini_loads_fn=ini_loads_fn or _discover_ini_loads_fn(), 47 | toml_dumps_fn=toml_dumps_fn or _discover_toml_dumps_fn(), 48 | plugins=list_all_plugins() if plugins is None else plugins, 49 | ini_parser_opts=ini_parser_opts, 50 | profiles=profiles, 51 | profile_augmentations=profile_augmentations, 52 | ) 53 | 54 | 55 | def _discover_ini_loads_fn() -> types.IniLoadsFn: 56 | try: 57 | from .drivers.configupdater import parse 58 | except ImportError: # pragma: no cover 59 | from .drivers.configparser import parse 60 | 61 | return parse 62 | 63 | 64 | def _discover_toml_dumps_fn() -> types.TomlDumpsFn: 65 | try: 66 | from .drivers.full_toml import convert 67 | except ImportError: # pragma: no cover 68 | try: 69 | from .drivers.lite_toml import convert 70 | except ImportError: 71 | msg = "Please install either `ini2toml[full]` or `ini2toml[lite]`" 72 | _logger.warning(f"{msg}. `ini2toml` (alone) is not valid.") 73 | raise 74 | 75 | return convert 76 | 77 | 78 | class LiteTranslator(Translator): 79 | """Similar to ``Translator``, but instead of trying to figure out ``ini_loads_fn`` 80 | and ``toml_dumps_fn`` is will always try to use the ``lite`` flavour 81 | (ignoring comments). 82 | """ 83 | 84 | def __init__( 85 | self, 86 | plugins: Optional[Sequence[types.Plugin]] = None, 87 | profiles: Sequence[types.Profile] = (), 88 | profile_augmentations: Sequence[types.ProfileAugmentation] = (), 89 | ini_parser_opts: Mapping = EMPTY, 90 | ini_loads_fn: Optional[types.IniLoadsFn] = None, 91 | toml_dumps_fn: Optional[types.TomlDumpsFn] = None, 92 | ): 93 | if ini_loads_fn: 94 | parse = ini_loads_fn 95 | else: 96 | from .drivers.configparser import parse 97 | 98 | if toml_dumps_fn: 99 | convert = toml_dumps_fn 100 | else: 101 | from .drivers.lite_toml import convert 102 | 103 | super().__init__( 104 | ini_loads_fn=parse, 105 | toml_dumps_fn=convert, 106 | plugins=plugins, 107 | ini_parser_opts=ini_parser_opts, 108 | profiles=profiles, 109 | profile_augmentations=profile_augmentations, 110 | ) 111 | 112 | 113 | class FullTranslator(Translator): 114 | """Similar to ``Translator``, but instead of trying to figure out ``ini_loads_fn`` 115 | and ``toml_dumps_fn`` is will always try to use the ``full`` flavour 116 | (best effort to maintain comments). 117 | """ 118 | 119 | def __init__( 120 | self, 121 | plugins: Optional[Sequence[types.Plugin]] = None, 122 | profiles: Sequence[types.Profile] = (), 123 | profile_augmentations: Sequence[types.ProfileAugmentation] = (), 124 | ini_parser_opts: Mapping = EMPTY, 125 | ini_loads_fn: Optional[types.IniLoadsFn] = None, 126 | toml_dumps_fn: Optional[types.TomlDumpsFn] = None, 127 | ): 128 | if ini_loads_fn: 129 | parse = ini_loads_fn 130 | else: 131 | from .drivers.configupdater import parse 132 | 133 | if toml_dumps_fn: 134 | convert = toml_dumps_fn 135 | else: 136 | from .drivers.full_toml import convert 137 | 138 | super().__init__( 139 | ini_loads_fn=parse, 140 | toml_dumps_fn=convert, 141 | plugins=plugins, 142 | ini_parser_opts=ini_parser_opts, 143 | profiles=profiles, 144 | profile_augmentations=profile_augmentations, 145 | ) 146 | -------------------------------------------------------------------------------- /tests/examples/pandas/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pandas 3 | description = Powerful data structures for data analysis, time series, and statistics 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | url = https://pandas.pydata.org 7 | author = The Pandas Development Team 8 | author_email = pandas-dev@python.org 9 | license = BSD-3-Clause 10 | license_file = LICENSE 11 | platforms = any 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Environment :: Console 15 | Intended Audience :: Science/Research 16 | License :: OSI Approved :: BSD License 17 | Operating System :: OS Independent 18 | Programming Language :: Cython 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Topic :: Scientific/Engineering 25 | project_urls = 26 | Bug Tracker = https://github.com/pandas-dev/pandas/issues 27 | Documentation = https://pandas.pydata.org/pandas-docs/stable 28 | Source Code = https://github.com/pandas-dev/pandas 29 | 30 | [options] 31 | packages = find: 32 | install_requires = 33 | numpy>=1.18.5 34 | python-dateutil>=2.8.1 35 | pytz>=2020.1 36 | python_requires = >=3.8 37 | include_package_data = True 38 | zip_safe = False 39 | 40 | [options.entry_points] 41 | pandas_plotting_backends = 42 | matplotlib = pandas:plotting._matplotlib 43 | 44 | [options.extras_require] 45 | test = 46 | hypothesis>=5.5.3 47 | pytest>=6.0 48 | pytest-xdist>=1.31 49 | 50 | [options.package_data] 51 | * = templates/*, _libs/**/*.dll 52 | 53 | [build_ext] 54 | inplace = True 55 | 56 | [options.packages.find] 57 | include = pandas, pandas.* 58 | 59 | # See the docstring in versioneer.py for instructions. Note that you must 60 | # re-run 'versioneer.py setup' after changing this section, and commit the 61 | # resulting files. 62 | [versioneer] 63 | VCS = git 64 | style = pep440 65 | versionfile_source = pandas/_version.py 66 | versionfile_build = pandas/_version.py 67 | tag_prefix = v 68 | parentdir_prefix = pandas- 69 | 70 | [flake8] 71 | max-line-length = 88 72 | ignore = 73 | # space before : (needed for how black formats slicing) 74 | E203, 75 | # line break before binary operator 76 | W503, 77 | # line break after binary operator 78 | W504, 79 | # module level import not at top of file 80 | E402, 81 | # do not assign a lambda expression, use a def 82 | E731, 83 | # found modulo formatter (incorrect picks up mod operations) 84 | S001, 85 | # controversial 86 | B005, 87 | # controversial 88 | B006, 89 | # controversial 90 | B007, 91 | # controversial 92 | B008, 93 | # setattr is used to side-step mypy 94 | B009, 95 | # getattr is used to side-step mypy 96 | B010, 97 | # tests use assert False 98 | B011, 99 | # tests use comparisons but not their returned value 100 | B015, 101 | # false positives 102 | B301 103 | exclude = 104 | doc/sphinxext/*.py, 105 | doc/build/*.py, 106 | doc/temp/*.py, 107 | .eggs/*.py, 108 | versioneer.py, 109 | # exclude asv benchmark environments from linting 110 | env 111 | per-file-ignores = 112 | # private import across modules 113 | pandas/tests/*:PDF020 114 | # pytest.raises without match= 115 | pandas/tests/extension/*:PDF009 116 | # os.remove 117 | doc/make.py:PDF008 118 | # import from pandas._testing 119 | pandas/testing.py:PDF014 120 | 121 | 122 | [flake8-rst] 123 | max-line-length = 84 124 | bootstrap = 125 | import numpy as np 126 | import pandas as pd 127 | # avoiding error when importing again numpy or pandas 128 | np 129 | # (in some cases we want to do it to show users) 130 | pd 131 | ignore = 132 | # space before : (needed for how black formats slicing) 133 | E203, 134 | # module level import not at top of file 135 | E402, 136 | # line break before binary operator 137 | W503, 138 | # Classes/functions in different blocks can generate those errors 139 | # expected 2 blank lines, found 0 140 | E302, 141 | # expected 2 blank lines after class or function definition, found 0 142 | E305, 143 | # We use semicolon at the end to avoid displaying plot objects 144 | # statement ends with a semicolon 145 | E703, 146 | # comparison to none should be 'if cond is none:' 147 | E711, 148 | exclude = 149 | doc/source/development/contributing_docstring.rst, 150 | # work around issue of undefined variable warnings 151 | # https://github.com/pandas-dev/pandas/pull/38837#issuecomment-752884156 152 | doc/source/getting_started/comparison/includes/*.rst 153 | 154 | [codespell] 155 | ignore-words-list = ba,blocs,coo,hist,nd,sav,ser 156 | ignore-regex = https://(\w+\.)+ 157 | 158 | [coverage:run] 159 | branch = True 160 | omit = 161 | */tests/* 162 | pandas/_typing.py 163 | pandas/_version.py 164 | plugins = Cython.Coverage 165 | source = pandas 166 | 167 | [coverage:report] 168 | ignore_errors = False 169 | show_missing = True 170 | omit = 171 | pandas/_version.py 172 | # Regexes for lines to exclude from consideration 173 | exclude_lines = 174 | # Have to re-enable the standard pragma 175 | pragma: no cover 176 | 177 | # Don't complain about missing debug-only code: 178 | def __repr__ 179 | if self\.debug 180 | 181 | # Don't complain if tests don't hit defensive assertion code: 182 | raise AssertionError 183 | raise NotImplementedError 184 | AbstractMethodError 185 | 186 | # Don't complain if non-runnable code isn't run: 187 | if 0: 188 | if __name__ == .__main__.: 189 | if TYPE_CHECKING: 190 | 191 | [coverage:html] 192 | directory = coverage_html_report 193 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import re 2 | from itertools import chain 3 | from pathlib import Path 4 | 5 | import pytest 6 | import tomli 7 | from validate_pyproject.api import Validator 8 | from validate_pyproject.errors import ValidationError 9 | 10 | from ini2toml import cli 11 | from ini2toml.drivers import configparser, full_toml, lite_toml 12 | from ini2toml.translator import FullTranslator, LiteTranslator 13 | 14 | 15 | def examples(): 16 | here = Path(".").resolve() 17 | parent = Path(__file__).parent / "examples" 18 | for folder in parent.glob("*/"): 19 | cfg = chain(folder.glob("*.cfg"), folder.glob("*.ini")) 20 | toml = folder.glob("*.toml") 21 | for orig in cfg: 22 | expected = orig.with_suffix(".toml") 23 | if expected.is_file(): 24 | yield str(orig.relative_to(here)), str(expected.relative_to(here)) 25 | else: 26 | try: 27 | yield str(orig.relative_to(here)), str(next(toml).relative_to(here)) 28 | except: # noqa 29 | print(f"Missing TOML file to compare to {orig}") 30 | raise 31 | 32 | 33 | @pytest.fixture(scope="module") 34 | def validate(): 35 | """Use ``validate-pyproject`` to validate the generated TOML""" 36 | return Validator() 37 | 38 | 39 | @pytest.mark.filterwarnings("ignore::DeprecationWarning") 40 | @pytest.mark.parametrize(("original", "expected"), list(examples())) 41 | def test_examples_api(original, expected, validate): 42 | translator = FullTranslator() 43 | available_profiles = list(translator.profiles.keys()) 44 | profile = cli.guess_profile(None, original, available_profiles) 45 | orig = Path(original) 46 | 47 | # Make sure file ends in a newline (requirement for posix text files) 48 | out = translator.translate(orig.read_text(encoding="utf-8"), profile) 49 | assert out.endswith("\n") 50 | 51 | expected_text = Path(expected).read_text(encoding="utf-8") 52 | assert out == expected_text 53 | 54 | # Make sure they can be parsed 55 | dict_equivalent = tomli.loads(out) 56 | assert dict_equivalent == tomli.loads(expected_text) 57 | try: 58 | assert validate(remove_deprecated(dict_equivalent)) is not None 59 | except ValidationError as ex: 60 | if "optional-dependencies" not in str(ex): 61 | # For the time being both `setuptools` and `validate-pyproject` 62 | # are not prepared to deal with mixed dynamic dependencies 63 | raise 64 | 65 | 66 | COMMENT_LINE = re.compile(r"^\s*#[^\n]*\n", re.M) 67 | INLINE_COMMENT = re.compile(r'#\s[^\n"]*$', re.M) 68 | 69 | 70 | @pytest.mark.filterwarnings("ignore::DeprecationWarning") 71 | @pytest.mark.parametrize(("original", "expected"), list(examples())) 72 | def test_examples_api_lite(original, expected, validate): 73 | opts = {"ini_loads_fn": configparser.parse, "toml_dumps_fn": lite_toml.convert} 74 | translator = LiteTranslator(**opts) 75 | available_profiles = list(translator.profiles.keys()) 76 | profile = cli.guess_profile(None, original, available_profiles) 77 | # We cannot compare "flake8" sections (currently not handled) 78 | # (ConfigParser automatically strips comments, contrary to ConfigUpdater) 79 | orig = Path(original) 80 | 81 | # Make sure file ends in a newline (requirement for posix text files) 82 | translated = translator.translate(orig.read_text(encoding="utf-8"), profile) 83 | assert translated.endswith("\n") 84 | 85 | out = remove_flake8_from_toml(translated) 86 | expected_text = remove_flake8_from_toml(Path(expected).read_text(encoding="utf-8")) 87 | 88 | # At least the Python-equivalents should be the same when parsing 89 | dict_equivalent = tomli.loads(out) 90 | assert dict_equivalent == tomli.loads(expected_text) 91 | try: 92 | assert validate(remove_deprecated(dict_equivalent)) is not None 93 | except ValidationError as ex: 94 | if "optional-dependencies" not in str(ex): 95 | # For the time being both `setuptools` and `validate-pyproject` 96 | # are not prepared to deal with mixed dynamic dependencies 97 | raise 98 | 99 | without_comments = COMMENT_LINE.sub("", expected_text) 100 | without_comments = INLINE_COMMENT.sub("", without_comments) 101 | try: 102 | assert out == without_comments 103 | except AssertionError: 104 | # We can ignore some minor formatting differences, as long as the parsed 105 | # dict is the same 106 | pass 107 | 108 | 109 | @pytest.mark.filterwarnings("ignore::DeprecationWarning") 110 | @pytest.mark.parametrize(("original", "expected"), list(examples())) 111 | def test_examples_cli(original, expected, capsys): 112 | cli.run([original]) 113 | (out, err) = capsys.readouterr() 114 | 115 | # Make sure file ends in a newline (requirement for posix text files) 116 | assert out.endswith("\n") 117 | 118 | expected_text = Path(expected).read_text(encoding="utf-8") 119 | assert out == expected_text 120 | 121 | # Make sure they can be parsed 122 | assert tomli.loads(out) == tomli.loads(expected_text) 123 | 124 | 125 | def remove_flake8_from_toml(text: str) -> str: 126 | # full_toml should not change any formatting, just remove the 127 | # parts we don't want 128 | doc = full_toml.loads(text) 129 | tool = doc.get("tool", {}) 130 | for key in list(tool.keys()): # eager to allow dictionary modifications 131 | if key.startswith("flake8"): 132 | tool.pop(key) 133 | return full_toml.dumps(doc) 134 | 135 | 136 | def remove_deprecated(dict_equivalent): 137 | setuptools = dict_equivalent.get("tool", {}).get("setuptools", {}) 138 | # The usage of ``data-files`` is deprecated in setuptools and not supported by pip 139 | setuptools.pop("data-files", None) 140 | return dict_equivalent 141 | -------------------------------------------------------------------------------- /tests/examples/pandas/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pandas" 7 | description = "Powerful data structures for data analysis, time series, and statistics" 8 | authors = [{name = "The Pandas Development Team", email = "pandas-dev@python.org"}] 9 | license = {text = "BSD-3-Clause"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Environment :: Console", 13 | "Intended Audience :: Science/Research", 14 | "License :: OSI Approved :: BSD License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Cython", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Topic :: Scientific/Engineering", 23 | ] 24 | requires-python = ">=3.8" 25 | dependencies = [ 26 | "numpy>=1.18.5", 27 | "python-dateutil>=2.8.1", 28 | "pytz>=2020.1", 29 | ] 30 | dynamic = ["version"] 31 | 32 | [project.readme] 33 | file = "README.md" 34 | content-type = "text/markdown" 35 | 36 | [project.urls] 37 | Homepage = "https://pandas.pydata.org" 38 | "Bug Tracker" = "https://github.com/pandas-dev/pandas/issues" 39 | Documentation = "https://pandas.pydata.org/pandas-docs/stable" 40 | "Source Code" = "https://github.com/pandas-dev/pandas" 41 | 42 | [project.entry-points.pandas_plotting_backends] 43 | matplotlib = "pandas:plotting._matplotlib" 44 | 45 | [project.optional-dependencies] 46 | test = [ 47 | "hypothesis>=5.5.3", 48 | "pytest>=6.0", 49 | "pytest-xdist>=1.31", 50 | ] 51 | 52 | [tool.setuptools] 53 | include-package-data = true 54 | zip-safe = false 55 | platforms = ["any"] 56 | license-files = ["LICENSE"] 57 | 58 | [tool.setuptools.package-data] 59 | "*" = ["templates/*", "_libs/**/*.dll"] 60 | 61 | [tool.setuptools.packages.find] 62 | include = ["pandas", "pandas.*"] 63 | # See the docstring in versioneer.py for instructions. Note that you must 64 | # re-run 'versioneer.py setup' after changing this section, and commit the 65 | # resulting files. 66 | namespaces = false 67 | 68 | [tool.distutils.build_ext] 69 | inplace = true 70 | 71 | [tool.versioneer] 72 | vcs = "git" 73 | style = "pep440" 74 | versionfile_source = "pandas/_version.py" 75 | versionfile_build = "pandas/_version.py" 76 | tag_prefix = "v" 77 | parentdir_prefix = "pandas-" 78 | 79 | [tool.flake8] 80 | max-line-length = "88" 81 | ignore = """ 82 | # space before : (needed for how black formats slicing) 83 | E203, 84 | # line break before binary operator 85 | W503, 86 | # line break after binary operator 87 | W504, 88 | # module level import not at top of file 89 | E402, 90 | # do not assign a lambda expression, use a def 91 | E731, 92 | # found modulo formatter (incorrect picks up mod operations) 93 | S001, 94 | # controversial 95 | B005, 96 | # controversial 97 | B006, 98 | # controversial 99 | B007, 100 | # controversial 101 | B008, 102 | # setattr is used to side-step mypy 103 | B009, 104 | # getattr is used to side-step mypy 105 | B010, 106 | # tests use assert False 107 | B011, 108 | # tests use comparisons but not their returned value 109 | B015, 110 | # false positives 111 | B301""" 112 | exclude = """ 113 | doc/sphinxext/*.py, 114 | doc/build/*.py, 115 | doc/temp/*.py, 116 | .eggs/*.py, 117 | versioneer.py, 118 | # exclude asv benchmark environments from linting 119 | env""" 120 | per-file-ignores = """ 121 | # private import across modules 122 | pandas/tests/*:PDF020 123 | # pytest.raises without match= 124 | pandas/tests/extension/*:PDF009 125 | # os.remove 126 | doc/make.py:PDF008 127 | # import from pandas._testing 128 | pandas/testing.py:PDF014""" 129 | 130 | [tool.flake8-rst] 131 | max-line-length = "84" 132 | bootstrap = """ 133 | import numpy as np 134 | import pandas as pd 135 | # avoiding error when importing again numpy or pandas 136 | np 137 | # (in some cases we want to do it to show users) 138 | pd""" 139 | ignore = """ 140 | # space before : (needed for how black formats slicing) 141 | E203, 142 | # module level import not at top of file 143 | E402, 144 | # line break before binary operator 145 | W503, 146 | # Classes/functions in different blocks can generate those errors 147 | # expected 2 blank lines, found 0 148 | E302, 149 | # expected 2 blank lines after class or function definition, found 0 150 | E305, 151 | # We use semicolon at the end to avoid displaying plot objects 152 | # statement ends with a semicolon 153 | E703, 154 | # comparison to none should be 'if cond is none:' 155 | E711,""" 156 | exclude = """ 157 | doc/source/development/contributing_docstring.rst, 158 | # work around issue of undefined variable warnings 159 | # https://github.com/pandas-dev/pandas/pull/38837#issuecomment-752884156 160 | doc/source/getting_started/comparison/includes/*.rst""" 161 | 162 | [tool.codespell] 163 | ignore-words-list = "ba,blocs,coo,hist,nd,sav,ser" 164 | ignore-regex = 'https://(\w+\.)+' 165 | 166 | [tool.coverage.run] 167 | branch = true 168 | omit = [ 169 | "*/tests/*", 170 | "pandas/_typing.py", 171 | "pandas/_version.py", 172 | ] 173 | plugins = ["Cython.Coverage"] 174 | source = ["pandas"] 175 | 176 | [tool.coverage.report] 177 | ignore_errors = false 178 | show_missing = true 179 | omit = ["pandas/_version.py"] 180 | # Regexes for lines to exclude from consideration 181 | exclude_lines = [ 182 | # Have to re-enable the standard pragma 183 | "pragma: no cover", 184 | # Don't complain about missing debug-only code: 185 | "def __repr__", 186 | 'if self\.debug', 187 | # Don't complain if tests don't hit defensive assertion code: 188 | "raise AssertionError", 189 | "raise NotImplementedError", 190 | "AbstractMethodError", 191 | # Don't complain if non-runnable code isn't run: 192 | "if 0:", 193 | "if __name__ == .__main__.:", 194 | "if TYPE_CHECKING:", 195 | ] 196 | 197 | [tool.coverage.html] 198 | directory = "coverage_html_report" 199 | -------------------------------------------------------------------------------- /tests/test_transformations.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from textwrap import dedent 3 | 4 | import pytest 5 | 6 | from ini2toml import transformations as lib 7 | from ini2toml.drivers.configupdater import parse as loads 8 | from ini2toml.drivers.full_toml import convert as dumps 9 | 10 | 11 | def test_coerce_bool(): 12 | for value in ("true", "1", "yes", "on"): 13 | assert lib.coerce_bool(value) is True 14 | assert lib.coerce_bool(value.upper()) is True 15 | 16 | for value in ("false", "0", "no", "off", "none", "null", "nil"): 17 | assert lib.coerce_bool(value) is False 18 | assert lib.coerce_bool(value.upper()) is False 19 | 20 | with pytest.raises(ValueError): 21 | lib.coerce_bool("3") 22 | 23 | 24 | def test_split_comment(): 25 | example = "1 # comment" 26 | assert lib.split_comment(example) == lib.Commented("1", "comment") 27 | assert lib.split_comment(example, int) == lib.Commented(1, "comment") 28 | item = lib.split_comment(" # comment only") 29 | assert item.comment_only() 30 | 31 | 32 | def test_split_list(): 33 | example = "1, 2, 3 # comment" 34 | assert lib.split_list(example) == [lib.Commented(["1", "2", "3"], "comment")] 35 | example = "1, 2, 3 # comment" 36 | expected = [lib.Commented([1, 2, 3], "comment")] 37 | assert lib.split_list(example, coerce_fn=int) == expected 38 | example = " 1, 2, 3 # comment\n 4, 5, 6 # comment\n" 39 | expected = [ 40 | lib.Commented([1, 2, 3], "comment"), 41 | lib.Commented([4, 5, 6], "comment"), 42 | ] 43 | assert lib.split_list(example, coerce_fn=int) == expected 44 | 45 | 46 | def test_split_kv_pairs(): 47 | example = "a=1, b=2, c=3 # comment" 48 | expected = [lib.Commented([("a", "1"), ("b", "2"), ("c", "3")], "comment")] 49 | assert lib.split_kv_pairs(example) == expected 50 | 51 | expected = [lib.Commented([("a", 1), ("b", 2), ("c", 3)], "comment")] 52 | assert lib.split_kv_pairs(example, coerce_fn=int) == expected 53 | 54 | example = " a=1, b = 2, c =3 # comment\n d=4, e=5, f=6 # comment\n" 55 | expected = [ 56 | lib.Commented([("a", 1), ("b", 2), ("c", 3)], "comment"), 57 | lib.Commented([("d", 4), ("e", 5), ("f", 6)], "comment"), 58 | ] 59 | assert lib.split_kv_pairs(example, coerce_fn=int) == expected 60 | 61 | 62 | # The following tests are more of "integration tests" since they also use drivers 63 | 64 | 65 | def apply(container, field, fn): 66 | container[field] = fn(container[field]) 67 | return container 68 | 69 | 70 | def test_application(): 71 | example = """\ 72 | [table] 73 | option1 = 1 74 | option2 = value # comment 75 | 76 | # commented single line compound value 77 | option3 = 1, 2, 3 # comment 78 | option4 = a=1, b=2, c=3 # comment 79 | 80 | # commented multiline compound value 81 | option5 = 82 | a=1 # comment 83 | b=2, c=3 # comment 84 | option5.1 = 85 | # header comment 86 | b=2, c=3 # comment 87 | option6 = 88 | 1 89 | 2 # comment 90 | 3 91 | option7 = 92 | # comment 93 | 1 94 | 2 95 | 96 | # No subsplit dangling 97 | option8 = 98 | 1, 2 99 | 3 100 | option9 = 101 | 1, 2 102 | 3 103 | option10 = 104 | a=1 105 | b=2, c=3 106 | """ 107 | 108 | doc = loads(dedent(example)) 109 | split_int = partial(lib.split_list, coerce_fn=int) 110 | split_kv_int = partial(lib.split_kv_pairs, coerce_fn=int) 111 | dangling_list_no_subsplit = partial(lib.split_list, subsplit_dangling=False) 112 | dangling_kv_no_subsplit = partial(lib.split_kv_pairs, subsplit_dangling=False) 113 | 114 | doc["table"] = apply(doc["table"], "option1", int) 115 | expected = "option1 = 1" 116 | assert expected in dumps(doc) 117 | 118 | # assert len(_trailing_nl()) == 1 119 | 120 | doc["table"] = apply(doc["table"], "option2", lib.split_comment) 121 | expected = 'option2 = "value" # comment' 122 | assert expected in dumps(doc) 123 | 124 | doc["table"] = apply(doc["table"], "option3", split_int) 125 | expected = "option3 = [1, 2, 3] # comment" 126 | assert expected in dumps(doc) 127 | 128 | doc["table"] = apply(doc["table"], "option4", split_kv_int) 129 | expected = "option4 = {a = 1, b = 2, c = 3} # comment" 130 | assert expected in dumps(doc) 131 | 132 | doc["table"] = apply(doc["table"], "option5", split_kv_int) 133 | expected = """\ 134 | [table.option5] 135 | a = 1 # comment 136 | b = 2 137 | c = 3 # comment 138 | """ 139 | assert dedent(expected) in dumps(doc) 140 | 141 | doc["table"] = apply(doc["table"], "option5.1", split_kv_int) 142 | expected = """\ 143 | [table."option5.1"] 144 | # header comment 145 | b = 2 146 | c = 3 # comment 147 | """ 148 | assert dedent(expected) in dumps(doc) 149 | 150 | doc["table"] = apply(doc["table"], "option6", split_int) 151 | expected = """\ 152 | option6 = [ 153 | 1, 154 | 2, # comment 155 | 3, 156 | ] 157 | """ 158 | assert dedent(expected) in dumps(doc) 159 | 160 | doc["table"] = apply(doc["table"], "option7", split_int) 161 | expected = """\ 162 | option7 = [ 163 | # comment 164 | 1, 165 | 2, 166 | ] 167 | """ 168 | assert dedent(expected) in dumps(doc) 169 | 170 | doc["table"] = apply(doc["table"], "option8", dangling_list_no_subsplit) 171 | expected = """\ 172 | option8 = [ 173 | "1, 2", 174 | "3", 175 | ] 176 | """ 177 | assert dedent(expected) in dumps(doc) 178 | 179 | doc["table"] = apply(doc["table"], "option9", split_int) 180 | expected = """\ 181 | option9 = [ 182 | 1, 2, 183 | 3, 184 | ] 185 | """ 186 | assert dedent(expected) in dumps(doc) 187 | 188 | doc["table"] = apply(doc["table"], "option10", dangling_kv_no_subsplit) 189 | expected = """\ 190 | option10 = {a = "1", b = "2, c=3"} 191 | """ 192 | print(dumps(doc)) 193 | assert dedent(expected) in dumps(doc) 194 | -------------------------------------------------------------------------------- /tests/examples/pyscaffold/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = PyScaffold 3 | description = Template tool for putting up the scaffold of a Python project 4 | author = Florian Wilhelm 5 | author_email = Florian.Wilhelm@gmail.com 6 | license = MIT 7 | url = https://github.com/pyscaffold/pyscaffold/ 8 | project_urls = 9 | Documentation = https://pyscaffold.org/ 10 | Source = https://github.com/pyscaffold/pyscaffold/ 11 | Tracker = https://github.com/pyscaffold/pyscaffold/issues 12 | Changelog = https://pyscaffold.org/en/latest/changelog.html 13 | Conda-Forge = https://anaconda.org/conda-forge/pyscaffold 14 | Download = https://pypi.org/project/PyScaffold/#files 15 | Twitter = https://twitter.com/PyScaffold 16 | long_description = file: README.rst 17 | long_description_content_type = text/x-rst; charset=UTF-8 18 | platforms = any 19 | classifiers = 20 | Development Status :: 5 - Production/Stable 21 | Topic :: Utilities 22 | Programming Language :: Python 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3 :: Only 25 | Environment :: Console 26 | Intended Audience :: Developers 27 | License :: OSI Approved :: MIT License 28 | Operating System :: POSIX :: Linux 29 | Operating System :: Unix 30 | Operating System :: MacOS 31 | Operating System :: Microsoft :: Windows 32 | 33 | [options] 34 | zip_safe = False 35 | packages = find_namespace: 36 | python_requires = >=3.6 37 | include_package_data = True 38 | package_dir = 39 | =src # all the packages under the src folder 40 | install_requires = 41 | importlib-metadata; python_version<"3.8" 42 | appdirs>=1.4.4,<2 43 | configupdater>=3.0,<4 44 | setuptools>=46.1.0 45 | setuptools_scm>=5 46 | tomlkit>=0.7.0,<2 47 | packaging>=20.7 48 | 49 | # packaging is versioned by year, not SemVer 50 | 51 | # Notes about setuptools versions: 52 | # - 40.1: required for `find_namespace` 53 | # - 45: required for `setuptools_scm` v6 54 | # However we specify a higher version so we encourage users to update the 55 | # version they have installed... 56 | 57 | [options.packages.find] 58 | where = src 59 | exclude = 60 | tests 61 | 62 | [options.extras_require] 63 | # Add here additional requirements for extra features, like: 64 | all = 65 | pyscaffoldext-markdown>=0.4 66 | pyscaffoldext-custom-extension>=0.6 67 | pyscaffoldext-dsproject>=0.5 68 | pyscaffoldext-django>=0.1.1 69 | pyscaffoldext-cookiecutter>=0.1 70 | pyscaffoldext-travis>=0.3 71 | virtualenv 72 | pre-commit 73 | md = 74 | pyscaffoldext-markdown>=0.4 75 | ds = 76 | pyscaffoldext-dsproject>=0.5 77 | # Add here test dependencies (used by tox) 78 | testing = 79 | setuptools 80 | tomlkit # as dependency in `-e fast` 81 | certifi # tries to prevent certificate problems on windows 82 | tox # system tests use tox inside tox 83 | build # system tests use it to build projects 84 | pre-commit # system tests run pre-commit 85 | sphinx # system tests build docs 86 | flake8 # system tests run flake8 87 | virtualenv # virtualenv as dependency for the venv extension in `-e fast` 88 | pytest 89 | pytest-cov 90 | pytest-shutil 91 | pytest-virtualenv 92 | pytest-fixture-config 93 | pytest-xdist 94 | # We keep pytest-xdist in the test dependencies, so the developer can 95 | # easily opt-in for distributed tests by adding, for example, the `-n 15` 96 | # arguments in the command-line. 97 | 98 | [options.entry_points] 99 | console_scripts = 100 | putup = pyscaffold.cli:run 101 | pyscaffold.cli = 102 | config = pyscaffold.extensions.config:Config 103 | interactive = pyscaffold.extensions.interactive:Interactive 104 | venv = pyscaffold.extensions.venv:Venv 105 | namespace = pyscaffold.extensions.namespace:Namespace 106 | no_skeleton = pyscaffold.extensions.no_skeleton:NoSkeleton 107 | pre_commit = pyscaffold.extensions.pre_commit:PreCommit 108 | no_tox = pyscaffold.extensions.no_tox:NoTox 109 | gitlab = pyscaffold.extensions.gitlab_ci:GitLab 110 | cirrus = pyscaffold.extensions.cirrus:Cirrus 111 | no_pyproject = pyscaffold.extensions.no_pyproject:NoPyProject 112 | 113 | [tool:pytest] 114 | # Options for pytest: 115 | # Specify command line options as you would do when invoking pytest directly. 116 | # e.g. --cov-report html (or xml) for html/xml output or --junit-xml junit.xml 117 | # in order to write a coverage file that can be read by Jenkins. 118 | # CAUTION: --cov flags may prohibit setting breakpoints while debugging. 119 | # Comment those flags to avoid this pytest issue. 120 | addopts = --cov pyscaffold --cov-config .coveragerc --cov-report term-missing 121 | --verbose 122 | # In order to use xdist, the developer can add, for example, the following 123 | # arguments: 124 | # --dist=load --numprocesses=auto 125 | norecursedirs = 126 | dist 127 | build 128 | .tox 129 | testpaths = tests 130 | markers = 131 | only: for debugging purposes, a single, failing, test can be required to run 132 | slow: mark tests as slow (deselect with '-m "not slow"') 133 | system: mark system tests 134 | original_logger: do not isolate logger in specific tests 135 | no_fake_config_dir: avoid the autofixture fake_config_dir to take effect 136 | requires_src: tests that require the raw source of PyScaffold and assume our default CI environment 137 | log_level = DEBUG 138 | log_cli = True 139 | log_cli_level = CRITICAL 140 | junit_family = xunit2 141 | 142 | [devpi:upload] 143 | # Options for the devpi: PyPI server and packaging tool 144 | # VCS export must be deactivated since we are using setuptools-scm 145 | no_vcs = 1 146 | formats = bdist_wheel 147 | 148 | [flake8] 149 | # Some sane defaults for the code style checker flake8 150 | # black compatibility 151 | max_line_length = 88 152 | # E203 and W503 have edge cases handled by black 153 | extend_ignore = E203, W503 154 | exclude = 155 | src/pyscaffold/contrib 156 | .tox 157 | build 158 | dist 159 | .eggs 160 | docs/conf.py 161 | 162 | [mypy] 163 | ignore_missing_imports = True 164 | pretty = True 165 | show_error_codes = True 166 | show_error_context = True 167 | show_traceback = True 168 | -------------------------------------------------------------------------------- /tests/examples/pyscaffold/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "PyScaffold" 7 | description = "Template tool for putting up the scaffold of a Python project" 8 | authors = [{name = "Florian Wilhelm", email = "Florian.Wilhelm@gmail.com"}] 9 | license = {text = "MIT"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Topic :: Utilities", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: POSIX :: Linux", 20 | "Operating System :: Unix", 21 | "Operating System :: MacOS", 22 | "Operating System :: Microsoft :: Windows", 23 | ] 24 | requires-python = ">=3.6" 25 | dependencies = [ 26 | 'importlib-metadata; python_version<"3.8"', 27 | "appdirs>=1.4.4,<2", 28 | "configupdater>=3.0,<4", 29 | "setuptools>=46.1.0", 30 | "setuptools_scm>=5", 31 | "tomlkit>=0.7.0,<2", 32 | "packaging>=20.7", 33 | # packaging is versioned by year, not SemVer 34 | # Notes about setuptools versions: 35 | # - 40.1: required for `find_namespace` 36 | # - 45: required for `setuptools_scm` v6 37 | # However we specify a higher version so we encourage users to update the 38 | # version they have installed... 39 | ] 40 | dynamic = ["version"] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/pyscaffold/pyscaffold/" 44 | Documentation = "https://pyscaffold.org/" 45 | Source = "https://github.com/pyscaffold/pyscaffold/" 46 | Tracker = "https://github.com/pyscaffold/pyscaffold/issues" 47 | Changelog = "https://pyscaffold.org/en/latest/changelog.html" 48 | Conda-Forge = "https://anaconda.org/conda-forge/pyscaffold" 49 | Download = "https://pypi.org/project/PyScaffold/#files" 50 | Twitter = "https://twitter.com/PyScaffold" 51 | 52 | [project.readme] 53 | file = "README.rst" 54 | content-type = "text/x-rst; charset=UTF-8" 55 | 56 | [project.optional-dependencies] 57 | # Add here additional requirements for extra features, like: 58 | all = [ 59 | "pyscaffoldext-markdown>=0.4", 60 | "pyscaffoldext-custom-extension>=0.6", 61 | "pyscaffoldext-dsproject>=0.5", 62 | "pyscaffoldext-django>=0.1.1", 63 | "pyscaffoldext-cookiecutter>=0.1", 64 | "pyscaffoldext-travis>=0.3", 65 | "virtualenv", 66 | "pre-commit", 67 | ] 68 | md = ["pyscaffoldext-markdown>=0.4"] 69 | ds = ["pyscaffoldext-dsproject>=0.5"] 70 | # Add here test dependencies (used by tox) 71 | testing = [ 72 | "setuptools", 73 | "tomlkit", # as dependency in `-e fast` 74 | "certifi", # tries to prevent certificate problems on windows 75 | "tox", # system tests use tox inside tox 76 | "build", # system tests use it to build projects 77 | "pre-commit", # system tests run pre-commit 78 | "sphinx", # system tests build docs 79 | "flake8", # system tests run flake8 80 | "virtualenv", # virtualenv as dependency for the venv extension in `-e fast` 81 | "pytest", 82 | "pytest-cov", 83 | "pytest-shutil", 84 | "pytest-virtualenv", 85 | "pytest-fixture-config", 86 | "pytest-xdist", 87 | # We keep pytest-xdist in the test dependencies, so the developer can 88 | # easily opt-in for distributed tests by adding, for example, the `-n 15` 89 | # arguments in the command-line. 90 | ] 91 | 92 | [project.entry-points."pyscaffold.cli"] 93 | config = "pyscaffold.extensions.config:Config" 94 | interactive = "pyscaffold.extensions.interactive:Interactive" 95 | venv = "pyscaffold.extensions.venv:Venv" 96 | namespace = "pyscaffold.extensions.namespace:Namespace" 97 | no_skeleton = "pyscaffold.extensions.no_skeleton:NoSkeleton" 98 | pre_commit = "pyscaffold.extensions.pre_commit:PreCommit" 99 | no_tox = "pyscaffold.extensions.no_tox:NoTox" 100 | gitlab = "pyscaffold.extensions.gitlab_ci:GitLab" 101 | cirrus = "pyscaffold.extensions.cirrus:Cirrus" 102 | no_pyproject = "pyscaffold.extensions.no_pyproject:NoPyProject" 103 | 104 | [project.scripts] 105 | putup = "pyscaffold.cli:run" 106 | 107 | [tool.setuptools] 108 | zip-safe = false 109 | include-package-data = true 110 | package-dir = {"" = "src"} # all the packages under the src folder 111 | platforms = ["any"] 112 | 113 | [tool.setuptools.packages.find] 114 | where = ["src"] 115 | exclude = ["tests"] 116 | namespaces = true 117 | 118 | [tool.pytest.ini_options] 119 | # Options for pytest: 120 | # Specify command line options as you would do when invoking pytest directly. 121 | # e.g. --cov-report html (or xml) for html/xml output or --junit-xml junit.xml 122 | # in order to write a coverage file that can be read by Jenkins. 123 | # CAUTION: --cov flags may prohibit setting breakpoints while debugging. 124 | # Comment those flags to avoid this pytest issue. 125 | addopts = """ 126 | --cov pyscaffold --cov-config .coveragerc --cov-report term-missing 127 | --verbose""" 128 | # In order to use xdist, the developer can add, for example, the following 129 | # arguments: 130 | # --dist=load --numprocesses=auto 131 | norecursedirs = [ 132 | "dist", 133 | "build", 134 | ".tox", 135 | ] 136 | testpaths = ["tests"] 137 | markers = [ 138 | "only: for debugging purposes, a single, failing, test can be required to run", 139 | "slow: mark tests as slow (deselect with '-m \"not slow\"')", 140 | "system: mark system tests", 141 | "original_logger: do not isolate logger in specific tests", 142 | "no_fake_config_dir: avoid the autofixture fake_config_dir to take effect", 143 | "requires_src: tests that require the raw source of PyScaffold and assume our default CI environment", 144 | ] 145 | log_level = "DEBUG" 146 | log_cli = true 147 | log_cli_level = "CRITICAL" 148 | junit_family = "xunit2" 149 | 150 | [tool.devpi.upload] 151 | # Options for the devpi: PyPI server and packaging tool 152 | # VCS export must be deactivated since we are using setuptools-scm 153 | no_vcs = "1" 154 | formats = "bdist_wheel" 155 | 156 | [tool.flake8] 157 | # Some sane defaults for the code style checker flake8 158 | # black compatibility 159 | max_line_length = "88" 160 | # E203 and W503 have edge cases handled by black 161 | extend_ignore = "E203, W503" 162 | exclude = """ 163 | src/pyscaffold/contrib 164 | .tox 165 | build 166 | dist 167 | .eggs 168 | docs/conf.py""" 169 | 170 | [tool.mypy] 171 | ignore_missing_imports = true 172 | pretty = true 173 | show_error_codes = true 174 | show_error_context = true 175 | show_traceback = true 176 | -------------------------------------------------------------------------------- /docs/setuptools_pep621.rst: -------------------------------------------------------------------------------- 1 | ================================= 2 | Setuptools and ``pyproject.toml`` 3 | ================================= 4 | 5 | In the Python software ecosystem packaging and distributing software have 6 | historically been a difficult topic. 7 | Nevertheless, the community has been served well by :pypi:`setuptools` as the *de facto* 8 | standard for creating distributable Python software packages. 9 | 10 | In recent years, however, other needs and opportunities fostered the emergence 11 | of some alternatives. 12 | During this packaging renaissance, :pep:`621` was proposed (and accepted) 13 | to standardise package metadata/configuration done by developers, in a single 14 | file format shared by all the packaging alternatives. 15 | It makes use of a TOML_ file, ``pyproject.toml``, which is, unfortunately, 16 | significantly different from `setuptools own original declarative configuration file`_, 17 | ``setup.cfg``, and therefore its adoption by :pypi:`setuptools` requires mapping 18 | concepts between these two files. 19 | 20 | :pep:`621` covers most of the information expected from a ``setup.cfg`` file, 21 | but there are parameters specific to :pypi:`setuptools` without an obvious equivalent. 22 | The :doc:`setuptools docs ` covers 23 | the remaining options using the ``[tool.setuptools]`` TOML table. 24 | Based on this description, the automatic translation proposed by ``ini2toml`` 25 | works like the following: 26 | 27 | - Any field without an obvious equivalent in :pep:`621` is stored in the 28 | ``[tool.setuptools]`` TOML table, regardless if it comes from the 29 | ``[metadata]`` or ``[options]`` sections in ``setup.cfg``. 30 | 31 | - ``[options.*]`` sections in ``setup.cfg`` are translated to sub-tables of 32 | ``[tool.setuptools]`` in ``pyproject.toml``. For example:: 33 | 34 | [options.package_data] => [tool.setuptools.package-data] 35 | 36 | - Field and subtables in ``[tool.setuptools]`` have the ``_`` character 37 | replaced by ``-`` in their keys, to follow the conventions set in :pep:`517` 38 | and :pep:`621`. 39 | 40 | - ``setup.cfg`` directives (e.g. fields starting with ``attr:`` or ``file:``) 41 | can be transformed into a (potentially inline) TOML table, for example:: 42 | 43 | 'file: description.rst' => {file = "description.rst"} 44 | 45 | Note, however, that these directives are not allowed to be used directly 46 | under the ``project`` table. Instead, ``ini2toml`` will rely on ``dynamic``, 47 | as explained below. 48 | Also note that for some fields (e.g. ``readme``), ``ini2toml`` 49 | might try to automatically convert the directive into values accepted by 50 | :pep:`621` (for complex scenarios ``dynamic`` might still be used). 51 | 52 | - Instead of requiring a separated/dedicated section to specify parameters, the 53 | directives ``find:`` and ``find_namespace:`` just use a nested table: 54 | ``tool.setuptools.packages.find``. 55 | Moreover, two quality of life improvements are added: the ``where`` option 56 | takes a list of strings (instead of a single directory) and the boolean 57 | ``namespaces`` option is added (``namespaces = true`` is equivalent to 58 | ``find_namespace:`` and ``namespaces = false`` is equivalent to ``find:``). 59 | For example: 60 | 61 | .. code-block:: ini 62 | 63 | # setup.cfg 64 | [options] 65 | package = find_namespace: 66 | [options.packages.find] 67 | where = src 68 | exclude = 69 | tests 70 | 71 | .. code-block:: toml 72 | 73 | # pyproject.toml 74 | [tool.setuptools.packages.find] 75 | where = ["src"] 76 | exclude = ["tests"] 77 | namespaces = true 78 | 79 | - Fields set up to be dynamically resolved by :pypi:`setuptools` via directives, that 80 | cannot be directly represented by following :pep:`621` (or other complementary standards) 81 | (e.g. ``version = attr: module.attribute`` or ``classifiers = file: classifiers.txt``), 82 | are listed as ``dynamic`` under the ``[project]`` table. 83 | The configurations for how :pypi:`setuptools` fill those fields are stored 84 | under the ``[tool.setuptools.dynamic]`` table. For example: 85 | 86 | .. code-block:: ini 87 | 88 | # setup.cfg 89 | [metadata] 90 | version = attr: module.attribute 91 | classifiers = file: classifiers.txt 92 | 93 | [options] 94 | entry_points = file: entry-points.txt 95 | 96 | .. code-block:: toml 97 | 98 | # pyproject.toml 99 | [project] 100 | dynamic = ["version", "classifiers", "entry-points", "scripts", "gui-scripts"] 101 | 102 | [tool.setuptools.dynamic] 103 | version = {attr = "module.attribute"} 104 | classifiers = {file = "classifiers.txt"} 105 | entry-points = {file = "entry-points.txt"} 106 | 107 | There is a special case for dynamic ``entry-points``, ``scripts`` and ``gui-scripts``: 108 | while these 3 fields should be listed under ``project.dynamic``, only 109 | ``tool.setuptools.dynamic.entry-point`` is allowed. ``scripts`` and 110 | ``gui-scripts`` should be directly derived from `entry-points file`_. 111 | 112 | - The ``options.scripts`` field is renamed to ``script-files`` and resides 113 | inside the ``tool.setuptools`` table. This is done to avoid confusion with 114 | the ``project.scripts`` field defined by :pep:`621`. 115 | 116 | - When not present in the original config file, ``include_package_data`` is 117 | explicitly added with the ``False`` value to the translated TOML. 118 | This happens because in ``setup.cfg`` the default value for 119 | ``inclue_package_data`` is ``False``, but in ``pyproject.toml`` the default 120 | value is ``True``. 121 | This change was mentioned by some members of the community as a nice quality 122 | of life improvement. 123 | 124 | - The ``metadata.license_files`` field in ``setup.cfg`` is not translated to 125 | ``project.license.file`` in ``pyproject.toml``, even when a single file is 126 | given. The reason behind this choice is that ``project.license.file`` is 127 | meant to be used in a different way than ``metadata.license_files`` when 128 | generating `core metadata`_ (the first is read and expanded into the 129 | ``License`` core metadata field, the second is added as a path - relative to 130 | the project root - as the ``License-file`` core metadata field). This might 131 | change in the future if :pep:`639` is accepted. Meanwhile, 132 | ``metadata.license_files`` is translated to ``tool.setuptools.license-files``. 133 | 134 | 135 | .. _TOML: https://toml.io/en/ 136 | .. _setuptools own original declarative configuration file: https://setuptools.pypa.io/en/latest/userguide/declarative_config.html 137 | .. _entry-points file: https://packaging.python.org/en/latest/specifications/entry-points/ 138 | .. _core metadata: https://packaging.python.org/en/latest/specifications/core-metadata/ 139 | -------------------------------------------------------------------------------- /src/ini2toml/base_translator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import reduce 3 | from types import MappingProxyType 4 | from typing import Dict, Generic, List, Mapping, Sequence, TypeVar, cast 5 | 6 | from . import types # Structural/Abstract types 7 | from .errors import ( 8 | AlreadyRegisteredAugmentation, 9 | InvalidAugmentationName, 10 | UndefinedProfile, 11 | ) 12 | from .profile import Profile, ProfileAugmentation 13 | from .transformations import apply 14 | 15 | T = TypeVar("T") 16 | EMPTY = MappingProxyType({}) # type: ignore 17 | 18 | 19 | class BaseTranslator(Generic[T]): 20 | """Translator object that follows the public API defined in 21 | :class:`ini2toml.types.Translator`. See :doc:`/dev-guide` for a quick explanation of 22 | concepts such as plugins, profiles, profile augmentations, etc. 23 | 24 | Arguments 25 | --------- 26 | 27 | ini_loads_fn: 28 | function to convert the ``.ini/.cfg`` file into an :class:`intermediate 29 | representation ` object. 30 | Possible values for this argument include: 31 | 32 | - :func:`ini2toml.drivers.configparser.parse` (when comments can be simply 33 | removed) 34 | - :func:`ini2toml.drivers.configupdater.parse` (when you wish to preserve 35 | comments in the TOML output) 36 | 37 | toml_dumps_fn: 38 | function to convert the :class:`intermediate representation 39 | ` object into (ideally) 40 | a TOML string. 41 | If you don't exactly need a TOML string (maybe you want your TOML to 42 | be represented by :class:`bytes` or simply the equivalent :obj:`dict`) you can 43 | also pass a ``Callable[[IntermediateRepr], T]`` function for any desired ``T``. 44 | 45 | Possible values for this argument include: 46 | 47 | - :func:`ini2toml.drivers.lite_toml.convert` (when comments can be simply 48 | removed) 49 | - :func:`ini2toml.drivers.full_toml.convert` (when you wish to preserve 50 | comments in the TOML output) 51 | - :func:`ini2toml.drivers.plain_builtins.convert` (when you wish to retrieve a 52 | :class:`dict` equivalent to the TOML, instead of string with the TOML syntax) 53 | 54 | plugins: 55 | list of plugins activation functions. By default no plugin will be activated. 56 | profiles: 57 | list of profile objects, by default no profile will be pre-loaded (plugins can 58 | still add them). 59 | profile_augmentations: 60 | list of profile augmentations. By default no profile augmentation will be 61 | preloaded (plugins can still add them) 62 | ini_parser_opts: 63 | syntax options for parsing ``.ini/.cfg`` files (see 64 | :mod:`~configparser.ConfigParser` and :mod:`~configupdater.ConfigUpdater`). 65 | By default it uses the standard configuration of the selected parser (depending 66 | on the choice of ``ini_loads_fn``). 67 | 68 | Tip 69 | --- 70 | 71 | Most of the times the usage of :class:`~ini2toml.translator.Translator` 72 | (or its deterministic variants ``LiteTranslator``, ``FullTranslator``) is preferred 73 | over :class:`~ini2toml.base_translator.BaseTranslator` (unless you are vendoring 74 | ``ini2toml`` and wants to reduce the number of files included in your project). 75 | """ 76 | 77 | profiles: Dict[str, types.Profile] 78 | plugins: List[types.Plugin] 79 | 80 | def __init__( 81 | self, 82 | ini_loads_fn: types.IniLoadsFn, 83 | toml_dumps_fn: types.IReprCollapseFn[T], 84 | plugins: Sequence[types.Plugin] = (), 85 | profiles: Sequence[types.Profile] = (), 86 | profile_augmentations: Sequence[types.ProfileAugmentation] = (), 87 | ini_parser_opts: Mapping = EMPTY, 88 | ): 89 | self.plugins = _deduplicate_plugins(plugins) 90 | self.ini_parser_opts = ini_parser_opts 91 | self.profiles = {p.name: p for p in profiles} 92 | self.augmentations: Dict[str, types.ProfileAugmentation] = { 93 | (p.name or p.fn.__name__): p for p in profile_augmentations 94 | } 95 | 96 | self._loads_fn = ini_loads_fn 97 | self._dumps_fn = toml_dumps_fn 98 | 99 | for activate in self.plugins: 100 | activate(self) 101 | 102 | def loads(self, text: str) -> types.IntermediateRepr: 103 | return self._loads_fn(text, self.ini_parser_opts) 104 | 105 | def dumps(self, irepr: types.IntermediateRepr) -> T: 106 | return self._dumps_fn(irepr) 107 | 108 | def __getitem__(self, profile_name: str) -> types.Profile: 109 | """Retrieve an existing profile (or create a new one).""" 110 | if profile_name not in self.profiles: 111 | profile = Profile(profile_name) 112 | if self.ini_parser_opts: 113 | profile = profile.replace(ini_parser_opts=self.ini_parser_opts) 114 | self.profiles[profile_name] = profile 115 | return self.profiles[profile_name] 116 | 117 | def augment_profiles( 118 | self, 119 | fn: types.ProfileAugmentationFn, 120 | active_by_default: bool = False, 121 | name: str = "", 122 | help_text: str = "", 123 | ): 124 | """Register a profile augmentation function to be called after the 125 | profile is selected and before the actual translation (see :doc:`/dev-guide`). 126 | """ 127 | name = (name or fn.__name__).strip() 128 | InvalidAugmentationName.check(name) 129 | AlreadyRegisteredAugmentation.check(name, fn, self.augmentations) 130 | help_text = help_text or fn.__doc__ or "" 131 | obj = ProfileAugmentation(fn, active_by_default, name, help_text) 132 | self.augmentations[name] = obj 133 | 134 | def _add_augmentations( 135 | self, profile: types.Profile, explicit_activation: Mapping[str, bool] = EMPTY 136 | ) -> types.Profile: 137 | for aug in self.augmentations.values(): 138 | if aug.is_active(explicit_activation.get(aug.name)): 139 | aug.fn(profile) 140 | return profile 141 | 142 | def translate( 143 | self, 144 | ini: str, 145 | profile_name: str, 146 | active_augmentations: Mapping[str, bool] = EMPTY, 147 | ) -> T: 148 | UndefinedProfile.check(profile_name, list(self.profiles.keys())) 149 | profile = cast(Profile, self[profile_name])._copy() 150 | # ^--- avoid permanent changes and conflicts with duplicated augmentation 151 | self._add_augmentations(profile, active_augmentations) 152 | 153 | ini = reduce(apply, profile.pre_processors, ini) 154 | irepr = self.loads(ini) 155 | irepr = reduce(apply, profile.intermediate_processors, irepr) 156 | toml = self.dumps(irepr) 157 | return reduce(apply, profile.post_processors, toml) 158 | 159 | 160 | def _deduplicate_plugins(plugins: Sequence[types.Plugin]) -> List[types.Plugin]: 161 | deduplicated = {_plugin_name(p): p for p in plugins} 162 | return list(deduplicated.values()) 163 | 164 | 165 | def _plugin_name(plugin: types.Plugin) -> str: 166 | mod = inspect.getmodule(plugin) 167 | modname = getattr(mod, "__name__", str(mod)) 168 | name = getattr(plugin, "__qualname__", getattr(plugin, "__name__", "**plugin**")) 169 | return f"{modname}:{name}" 170 | --------------------------------------------------------------------------------