├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .travis.yml ├── Changes.rst ├── LICENSE ├── Makefile ├── README.rst ├── common.mk ├── docs ├── Makefile ├── conf.py └── index.rst ├── pyproject.toml ├── test └── test.py └── tweak └── __init__.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kislyuk] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-20.04, ubuntu-latest 8 | strategy: 9 | max-parallel: 8 10 | matrix: 11 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install 20 | run: make install 21 | - name: Test 22 | run: make test 23 | isort: 24 | runs-on: ubuntu-22.04 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: isort/isort-action@v1.1.0 28 | ruff: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: astral-sh/ruff-action@v1 33 | - uses: astral-sh/ruff-action@v1 34 | with: 35 | args: "format --check" 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | pypi-publish: 10 | name: Build and upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: release 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | - run: pip install build 19 | - run: python -m build 20 | - name: Publish package distributions to PyPI 21 | uses: pypa/gh-action-pypi-publish@release/v1 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Reminder: 2 | # - A leading slash means the pattern is anchored at the root. 3 | # - No leading slash means the pattern matches at any depth. 4 | 5 | # Python files 6 | *.pyc 7 | __pycache__/ 8 | .tox/ 9 | *.egg-info/ 10 | /build/ 11 | /dist/ 12 | /.eggs/ 13 | 14 | # Sphinx documentation 15 | /docs/_build/ 16 | 17 | # IDE project files 18 | /.pydevproject 19 | 20 | # vim python-mode plugin 21 | /.ropeproject 22 | 23 | # IntelliJ IDEA / PyCharm project files 24 | /.idea 25 | /*.iml 26 | 27 | # JS/node/npm/web dev files 28 | node_modules 29 | npm-debug.log 30 | 31 | # OS X metadata files 32 | .DS_Store 33 | 34 | # Python coverage 35 | .coverage 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | - 3.5 6 | - 3.6 7 | - pypy 8 | - pypy3 9 | 10 | before_install: 11 | - pip install --quiet codecov pyyaml 12 | 13 | install: 14 | - make install 15 | 16 | script: 17 | - make test 18 | 19 | after_success: 20 | - bash <(curl -s https://codecov.io/bash) 21 | 22 | sudo: false 23 | -------------------------------------------------------------------------------- /Changes.rst: -------------------------------------------------------------------------------- 1 | Changes for v1.0.4 (2021-10-26) 2 | =============================== 3 | 4 | - Python 3.10 support 5 | 6 | Changes for v1.0.3 (2019-07-12) 7 | =============================== 8 | 9 | - Save only when contents don’t match 10 | 11 | Changes for v1.0.2 (2019-02-11) 12 | =============================== 13 | 14 | - Allow saving in Config subclasses with YAML 15 | 16 | Changes for v1.0.1 (2019-02-11) 17 | =============================== 18 | 19 | - Prevent yaml from using flow style 20 | 21 | Changes for v1.0.0 (2018-09-04) 22 | =============================== 23 | 24 | Declare project stable 25 | 26 | Changes for v0.6.7 (2018-01-21) 27 | =============================== 28 | 29 | - Disregard save\_on\_exit in non-root config objects (fixup) 30 | 31 | Changes for v0.6.6 (2018-01-21) 32 | =============================== 33 | 34 | - Disregard save\_on\_exit in non-root config objects 35 | 36 | Changes for v0.6.5 (2018-01-21) 37 | =============================== 38 | 39 | - Disregard save\_on\_exit in non-root config objects 40 | 41 | Changes for v0.6.4 (2017-11-19) 42 | =============================== 43 | 44 | Fix logic error introduced in 9e6a025, add test 45 | 46 | Changes for v0.6.3 (2017-11-16) 47 | =============================== 48 | 49 | - Fix broken release 50 | 51 | Changes for v0.6.2 (2017-11-16) 52 | =============================== 53 | 54 | WIP 55 | 56 | Changes for v0.6.1 (2017-11-16) 57 | =============================== 58 | 59 | - Fix broken release 60 | 61 | Changes for v0.6.0 (2017-11-16) 62 | =============================== 63 | 64 | - Follow getattr protocol and add pickle compatibility 65 | 66 | - Add user\_config\_dir 67 | 68 | Changes for v0.5.1 (2017-01-30) 69 | =============================== 70 | 71 | - Raise error if yaml import fails 72 | 73 | Changes for v0.5.0 (2017-01-30) 74 | =============================== 75 | 76 | - Tolerate array merge operator failures when ingesting 77 | 78 | Version 0.4.0 (2016-11-15) 79 | -------------------------- 80 | - Add support for include directives 81 | 82 | Version 0.3.3 (2016-06-14) 83 | -------------------------- 84 | - Add config_files to public interface 85 | 86 | Version 0.3.2 (2016-04-12) 87 | -------------------------- 88 | - Treat *_CONFIG_FILE as colon delimited 89 | 90 | Version 0.3.1 (2016-04-12) 91 | -------------------------- 92 | - Fix bug in saving to a relative path 93 | 94 | Version 0.3.0 (2016-04-12) 95 | -------------------------- 96 | - Add config hierarchy merging and array append functionality 97 | 98 | Version 0.2.0 (2016-04-12) 99 | -------------------------- 100 | - Add ability to ingest config filename via *_CONFIG_FILE environment variable. 101 | 102 | Version 0.1.2 (2016-03-20) 103 | -------------------------- 104 | - Add basic logging support. 105 | 106 | Version 0.1.1 (2016-03-11) 107 | -------------------------- 108 | - Fix save_on_exit=False behavior. 109 | 110 | Version 0.1.0 (2016-03-11) 111 | -------------------------- 112 | - YAML support. 113 | 114 | Version 0.0.2 (2016-03-04) 115 | -------------------------- 116 | - Initial release. 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright [yyyy] [name of copyright owner] 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test_deps: 2 | pip install coverage flake8 wheel 3 | 4 | lint: test_deps 5 | flake8 $$(python setup.py --name) test 6 | 7 | test: test_deps lint 8 | coverage run --source=$$(python setup.py --name) ./test/test.py 9 | 10 | init_docs: 11 | cd docs; sphinx-quickstart 12 | 13 | docs: 14 | $(MAKE) -C docs html 15 | 16 | install: clean 17 | pip install wheel pyyaml 18 | python setup.py bdist_wheel 19 | pip install --upgrade dist/*.whl 20 | 21 | clean: 22 | -rm -rf build dist 23 | -rm -rf *.egg-info 24 | 25 | .PHONY: lint test test_deps docs install clean 26 | 27 | include common.mk 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | tweak: Application configuration engine 2 | ======================================= 3 | Tweak is a Python helper class to ingest and serialize app-specific configuration. 4 | 5 | Tweak provides a self-contained (no dependencies outside the standard library), Python 2 and 3 compatible configuration 6 | manager. It automatically saves and restores your application's configuration in your user home directory. It uses JSON or 7 | (optionally) YAML for serialization. It supports dict-like methods and access semantics, hierarchical configuration sources, 8 | and array merge operators for layering configuration options (see below). 9 | 10 | Installation 11 | ------------ 12 | If your package does not permit dependency management, you can copy the ``Config`` class directly into your 13 | application from https://github.com/kislyuk/tweak/blob/master/tweak/__init__.py. Otherwise: 14 | 15 | :: 16 | 17 | pip install tweak 18 | 19 | Synopsis 20 | -------- 21 | 22 | .. code-block:: python 23 | 24 | from tweak import Config 25 | 26 | config = Config() 27 | config.host, config.port = "example.com", 9000 28 | config.nested_config = {} 29 | config.nested_config.foo = True 30 | 31 | After restarting your application:: 32 | 33 | config = Config() 34 | print(config) 35 | >>> {'host': 'example.com', 'port': 9000, 'nested_config': {'foo': True}} 36 | 37 | Using an ``argparse.Namespace`` object returned by ``argparse.parse_args()``:: 38 | 39 | parser = argparse.ArgumentParser() 40 | ... 41 | args = parser.parse_args() 42 | if args.foo is not None: 43 | config.foo = args.foo 44 | elif "foo" not in config: 45 | raise Exception("foo unconfigured") 46 | 47 | config.update(vars(args)) 48 | 49 | Using YAML:: 50 | 51 | config = Config(use_yaml=True) 52 | ... 53 | 54 | Pass ``Config(save_on_exit=False)`` to disable automatic configuration saving on Python shutdown (this is useful if you 55 | only want to read the config, never write it, or if you want to call ``config.save()`` manually). Pass 56 | ``Config(autosave=True)`` to make ``save()`` run any time an assignment happens to a config object. 57 | 58 | Configuration ingestion order 59 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 60 | Tweak supports ingesting configuration from a configurable array of sources. Each source is a JSON or YAML file. 61 | Configuration sources that follow the first source update the configuration using recursive dictionary merging. Sources are 62 | enumerated in the following order: 63 | 64 | - Site-wide configuration source, ``/etc/NAME/config.(yml|json)`` 65 | - User configuration source, ``~/.config/NAME/config.(yml|json)`` 66 | - Any sources listed in the colon-delimited variable ``NAME_CONFIG_FILE`` 67 | 68 | Array merge operators 69 | ~~~~~~~~~~~~~~~~~~~~~ 70 | 71 | When loading a chain of configuration sources, Tweak uses recursive dictionary merging to combine the 72 | sources. Additionally, when the original config value is a list, Tweak supports array manipulation operators:: 73 | 74 | In [1]: from tweak import Config 75 | 76 | In [2]: c = Config() 77 | 78 | In [3]: c.update(x=[1, 2, 3]) 79 | 80 | In [4]: c 81 | Out[4]: {'x': [1, 2, 3]} 82 | 83 | In [5]: c.update(x={"$append": 4}) 84 | 85 | In [6]: c 86 | Out[6]: {'x': [1, 2, 3, 4]} 87 | 88 | In [7]: c.update(x={"$extend": [5, 6]}) 89 | 90 | In [8]: c 91 | Out[8]: {'x': [1, 2, 3, 4, 5, 6]} 92 | 93 | In [9]: c.update(x={"$insert": {0: 0}}) 94 | 95 | In [10]: c 96 | Out[10]: {'x': [0, 1, 2, 3, 4, 5, 6]} 97 | 98 | In [11]: c.update(x={"$extendleft": [-2, -1]}) 99 | 100 | In [12]: c 101 | Out[12]: {'x': [-2, -1, 0, 1, 2, 3, 4, 5, 6]} 102 | 103 | In [13]: c.update(x={"$remove": 0}) 104 | 105 | In [14]: c 106 | Out[14]: {'x': [-2, -1, 1, 2, 3, 4, 5, 6]} 107 | 108 | Each operator (``$append``, ``$extend``, ``$insert``, ``$extendleft``, ``$remove``) must be the only key in the 109 | dictionary representing the update, and the value being updated must be a list. For example, in the following set of two 110 | YAML files, the second file extends the list in the first file. 111 | 112 | ``/etc/NAME/config.yml``:: 113 | 114 | x: 115 | - y 116 | - z 117 | 118 | ``~/.config/NAME/config.yml``:: 119 | 120 | x: 121 | $extend: 122 | - a 123 | - b 124 | 125 | Include directives 126 | ~~~~~~~~~~~~~~~~~~ 127 | 128 | The optional ``Config(allow_includes=True)`` keyword argument can be used to trigger processing of include directives in 129 | config files. For each config source file ingested, a top level ``include`` key can contain a string or array of 130 | strings. Each of these strings will be globbed and ingested before the file contianing the directive (e.g. ``{"include": 131 | "config.d/*"}`` to ingest a directory of config files). 132 | 133 | Authors 134 | ------- 135 | * Andrey Kislyuk 136 | 137 | Links 138 | ----- 139 | * `Project home page (GitHub) `_ 140 | * `Documentation (Read the Docs) `_ 141 | * `Package distribution (PyPI) `_ 142 | 143 | Bugs 144 | ~~~~ 145 | Please report bugs, issues, feature requests, etc. on `GitHub `_. 146 | 147 | License 148 | ------- 149 | Licensed under the terms of the `Apache License, Version 2.0 `_. 150 | 151 | .. image:: https://travis-ci.org/kislyuk/tweak.png 152 | :target: https://travis-ci.org/kislyuk/tweak 153 | .. image:: https://img.shields.io/coveralls/kislyuk/tweak.svg 154 | :target: https://coveralls.io/r/kislyuk/tweak?branch=master 155 | .. image:: https://img.shields.io/pypi/v/tweak.svg 156 | :target: https://pypi.python.org/pypi/tweak 157 | .. image:: https://img.shields.io/pypi/l/tweak.svg 158 | :target: https://pypi.python.org/pypi/tweak 159 | .. image:: https://readthedocs.org/projects/tweak/badge/?version=latest 160 | :target: https://tweak.readthedocs.io/ 161 | -------------------------------------------------------------------------------- /common.mk: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash -eo pipefail 2 | 3 | release-major: 4 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v@{[$$1+1]}.0.0"')) 5 | $(MAKE) release 6 | 7 | release-minor: 8 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.@{[$$2+1]}.0"')) 9 | $(MAKE) release 10 | 11 | release-patch: 12 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.$$2.@{[$$3+1]}"')) 13 | $(MAKE) release 14 | 15 | release: 16 | @if ! git diff --cached --exit-code; then echo "Commit staged files before proceeding"; exit 1; fi 17 | @if [[ -z $$TAG ]]; then echo "Use release-{major,minor,patch}"; exit 1; fi 18 | @if ! type -P pandoc; then echo "Please install pandoc"; exit 1; fi 19 | @if ! type -P sponge; then echo "Please install moreutils"; exit 1; fi 20 | @if ! type -P gh; then echo "Please install gh"; exit 1; fi 21 | git pull 22 | git clean -x --force argcomplete 23 | TAG_MSG=$$(mktemp); \ 24 | echo "# Changes for ${TAG} ($$(date +%Y-%m-%d))" > $$TAG_MSG; \ 25 | git log --pretty=format:%s $$(git describe --abbrev=0)..HEAD >> $$TAG_MSG; \ 26 | $${EDITOR:-emacs} $$TAG_MSG; \ 27 | if [[ -f Changes.md ]]; then cat $$TAG_MSG <(echo) Changes.md | sponge Changes.md; git add Changes.md; fi; \ 28 | if [[ -f Changes.rst ]]; then cat <(pandoc --from markdown --to rst $$TAG_MSG) <(echo) Changes.rst | sponge Changes.rst; git add Changes.rst; fi; \ 29 | git commit -m ${TAG}; \ 30 | git tag --annotate --file $$TAG_MSG ${TAG} 31 | git push --follow-tags 32 | $(MAKE) install 33 | gh release create ${TAG} dist/*.whl --notes="$$(git tag --list ${TAG} -n99 | perl -pe 's/^\S+\s*// if $$. == 1' | sed 's/^\s\s\s\s//')" 34 | 35 | .PHONY: release 36 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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 http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Tweak.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Tweak.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Tweak" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Tweak" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Tweak documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Nov 17 11:55:06 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath('..')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.viewcode', 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The encoding of source files. 45 | # source_encoding = 'utf-8-sig' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'Tweak' 52 | copyright = '2015, Andrey Kislyuk' 53 | author = 'Andrey Kislyuk' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '1.0.0' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '1.0.0' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | # today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | # today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | # default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | # add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | # add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | # show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | # modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | # keep_warnings = False 104 | 105 | # If true, `todo` and `todoList` produce output, else they produce nothing. 106 | todo_include_todos = False 107 | 108 | 109 | # -- Options for HTML output ---------------------------------------------- 110 | 111 | # The theme to use for HTML and HTML Help pages. See the documentation for 112 | # a list of builtin themes. 113 | html_theme = 'default' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | # html_theme_options = {} 119 | 120 | # Add any paths that contain custom themes here, relative to this directory. 121 | # html_theme_path = [] 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | # html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | # html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | # html_logo = None 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | # html_favicon = None 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # Add any extra paths that contain custom files (such as robots.txt or 145 | # .htaccess) here, relative to this directory. These files are copied 146 | # directly to the root of the documentation. 147 | # html_extra_path = [] 148 | 149 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 150 | # using the given strftime format. 151 | # html_last_updated_fmt = '%b %d, %Y' 152 | 153 | # If true, SmartyPants will be used to convert quotes and dashes to 154 | # typographically correct entities. 155 | # html_use_smartypants = True 156 | 157 | # Custom sidebar templates, maps document names to template names. 158 | # html_sidebars = {} 159 | 160 | # Additional templates that should be rendered to pages, maps page names to 161 | # template names. 162 | # html_additional_pages = {} 163 | 164 | # If false, no module index is generated. 165 | # html_domain_indices = True 166 | 167 | # If false, no index is generated. 168 | # html_use_index = True 169 | 170 | # If true, the index is split into individual pages for each letter. 171 | # html_split_index = False 172 | 173 | # If true, links to the reST sources are added to the pages. 174 | # html_show_sourcelink = True 175 | 176 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 177 | # html_show_sphinx = True 178 | 179 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 180 | # html_show_copyright = True 181 | 182 | # If true, an OpenSearch description file will be output, and all pages will 183 | # contain a tag referring to it. The value of this option must be the 184 | # base URL from which the finished HTML is served. 185 | # html_use_opensearch = '' 186 | 187 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 188 | # html_file_suffix = None 189 | 190 | # Language to be used for generating the HTML full-text search index. 191 | # Sphinx supports the following languages: 192 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 193 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 194 | # html_search_language = 'en' 195 | 196 | # A dictionary with options for the search language support, empty by default. 197 | # Now only 'ja' uses this config value 198 | # html_search_options = {'type': 'default'} 199 | 200 | # The name of a javascript file (relative to the configuration directory) that 201 | # implements a search results scorer. If empty, the default will be used. 202 | # html_search_scorer = 'scorer.js' 203 | 204 | # Output file base name for HTML help builder. 205 | htmlhelp_basename = 'Tweakdoc' 206 | 207 | # -- Options for LaTeX output --------------------------------------------- 208 | 209 | latex_elements = { 210 | # The paper size ('letterpaper' or 'a4paper'). 211 | #'papersize': 'letterpaper', 212 | # The font size ('10pt', '11pt' or '12pt'). 213 | #'pointsize': '10pt', 214 | # Additional stuff for the LaTeX preamble. 215 | #'preamble': '', 216 | # Latex figure (float) alignment 217 | #'figure_align': 'htbp', 218 | } 219 | 220 | # Grouping the document tree into LaTeX files. List of tuples 221 | # (source start file, target name, title, 222 | # author, documentclass [howto, manual, or own class]). 223 | latex_documents = [ 224 | (master_doc, 'Tweak.tex', 'Tweak Documentation', 'Andrey Kislyuk', 'manual'), 225 | ] 226 | 227 | # The name of an image file (relative to this directory) to place at the top of 228 | # the title page. 229 | # latex_logo = None 230 | 231 | # For "manual" documents, if this is true, then toplevel headings are parts, 232 | # not chapters. 233 | # latex_use_parts = False 234 | 235 | # If true, show page references after internal links. 236 | # latex_show_pagerefs = False 237 | 238 | # If true, show URL addresses after external links. 239 | # latex_show_urls = False 240 | 241 | # Documents to append as an appendix to all manuals. 242 | # latex_appendices = [] 243 | 244 | # If false, no module index is generated. 245 | # latex_domain_indices = True 246 | 247 | 248 | # -- Options for manual page output --------------------------------------- 249 | 250 | # One entry per manual page. List of tuples 251 | # (source start file, name, description, authors, manual section). 252 | man_pages = [(master_doc, 'tweak', 'Tweak Documentation', [author], 1)] 253 | 254 | # If true, show URL addresses after external links. 255 | # man_show_urls = False 256 | 257 | 258 | # -- Options for Texinfo output ------------------------------------------- 259 | 260 | # Grouping the document tree into Texinfo files. List of tuples 261 | # (source start file, target name, title, author, 262 | # dir menu entry, description, category) 263 | texinfo_documents = [ 264 | (master_doc, 'Tweak', 'Tweak Documentation', author, 'Tweak', 'One line description of project.', 'Miscellaneous'), 265 | ] 266 | 267 | # Documents to append as an appendix to all manuals. 268 | # texinfo_appendices = [] 269 | 270 | # If false, no module index is generated. 271 | # texinfo_domain_indices = True 272 | 273 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 274 | # texinfo_show_urls = 'footnote' 275 | 276 | # If true, do not generate a @detailmenu in the "Top" node's menu. 277 | # texinfo_no_detailmenu = False 278 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | API documentation 4 | ================= 5 | 6 | .. automodule:: tweak 7 | :members: 8 | 9 | Release Notes 10 | ============= 11 | .. include:: ../Changes.rst 12 | 13 | 14 | Table of Contents 15 | ================= 16 | 17 | .. toctree:: 18 | :maxdepth: 5 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tweak" 3 | description = "Application configuration engine" 4 | readme = "README.rst" 5 | requires-python = ">=3.8" 6 | license = { text = "Apache Software License" } 7 | authors = [{ name = "Andrey Kislyuk"}, {email = "kislyuk@gmail.com" }] 8 | maintainers = [{ name = "Andrey Kislyuk"}, {email = "kislyuk@gmail.com" }] 9 | dynamic = ["version"] 10 | classifiers = [ 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: Apache Software License", 13 | "Operating System :: MacOS :: MacOS X", 14 | "Operating System :: POSIX", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Programming Language :: Python :: Implementation :: CPython", 24 | "Programming Language :: Python :: Implementation :: PyPy", 25 | "Development Status :: 5 - Production/Stable", 26 | "Topic :: Software Development", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | 30 | [project.optional-dependencies] 31 | test = ["coverage", "wheel", "ruff", "mypy"] 32 | 33 | [project.urls] 34 | "Homepage"= "https://github.com/kislyuk/tweak" 35 | "Documentation"= "https://kislyuk.github.io/tweak" 36 | "Source Code"= "https://github.com/kislyuk/tweak" 37 | "Issue Tracker"= "https://github.com/kislyuk/tweak/issues" 38 | "Change Log"= "https://github.com/kislyuk/tweak/blob/develop/Changes.rst" 39 | 40 | [build-system] 41 | requires = ["hatchling", "hatch-vcs"] 42 | build-backend = "hatchling.build" 43 | 44 | [tool.hatch.version] 45 | source = "vcs" 46 | 47 | [tool.black] 48 | line-length = 120 49 | exclude = ".*/version.py" 50 | skip-string-normalization = true 51 | 52 | [tool.isort] 53 | profile = "black" 54 | line_length = 120 55 | skip = ".*/version.py" 56 | 57 | [tool.ruff] 58 | line-length = 120 59 | per-file-ignores = {"tweak/__init__.py" = ["F401"]} 60 | 61 | [tool.ruff.format] 62 | quote-style = "preserve" 63 | 64 | [tool.mypy] 65 | check_untyped_defs = true 66 | disallow_incomplete_defs = true 67 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import logging 5 | import os 6 | import pickle 7 | import sys 8 | import tempfile 9 | import unittest 10 | 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 12 | 13 | from tweak import Config # noqa 14 | 15 | 16 | class TestTweak(unittest.TestCase): 17 | def setUp(self): 18 | logging.basicConfig(level="DEBUG") 19 | 20 | def test_basic_statements(self): 21 | config = Config() 22 | print(config) 23 | config.host, config.port = "example.com", 9000 24 | config.nested_config = {} 25 | config.nested_config.foo = True 26 | config.nested_config.bar = 9000 27 | print(config) 28 | if "token" not in config: 29 | config["token"] = "т" 30 | 31 | import argparse 32 | 33 | parser = argparse.ArgumentParser(description=__doc__) 34 | for arg in "verbose quiet failfast catch buffer".split(): 35 | parser.add_argument("-" + arg[0], "--" + arg, nargs="?") 36 | parser.add_argument("--foo") 37 | parser.add_argument("--bar") 38 | args = parser.parse_args([]) 39 | config.update(vars(args)) 40 | print(config) 41 | 42 | config = Config(save_on_exit=True, autosave=False, use_yaml=True) 43 | config.foo = "bar" 44 | config.nested_config = {} 45 | config.nested_config.foo = True 46 | config.nested_config.bar = 9001 47 | config.nested_config.baz = "т" 48 | 49 | def test_basic_statements2(self): 50 | config = Config() 51 | config.test = 1 52 | config.test2 = True 53 | config.test3 = None 54 | config.test4 = dict(x=1, y=2) 55 | print(config.test4.x) 56 | config.test4.x = "тест" 57 | print(config.test4.x) 58 | config.test4.save() 59 | print(config) 60 | 61 | def test_picklable(self): 62 | config = Config() 63 | config.foo = 1 64 | config = pickle.loads(pickle.dumps(config)) 65 | self.assertEqual(config.foo, 1) 66 | 67 | def test_merge(self): 68 | config = Config() 69 | config.update({}) 70 | config.update({"m": {"i": 1}}) 71 | self.assertEqual(config["m"], {"i": 1}) 72 | config.update({"m": {"j": 2}}) 73 | self.assertEqual(config["m"], {"i": 1, "j": 2}) 74 | config.update({"m": {"j": [7, 8]}}) 75 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8]}) 76 | config.update({"m": {"k": []}}) 77 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": []}) 78 | config.update({"m": {"k": {"$append": "v"}}}) 79 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": ["v"]}) 80 | config.update({"m": {"k": {"$append": "v"}}}) 81 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": ["v", "v"]}) 82 | config.update({"m": {"k": {"$extend": ["z", "z"]}}}) 83 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": ["v", "v", "z", "z"]}) 84 | config.update({"m": {"k": {"$extendleft": ["z", "z"]}}}) 85 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": ["z", "z", "v", "v", "z", "z"]}) 86 | config.update({"m": {"k": {"$insert": {0: 5}}}}) 87 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": [5, "z", "z", "v", "v", "z", "z"]}) 88 | config.update({"m": {"k": {"$remove": "z"}}}) 89 | self.assertEqual(config["m"], {"i": 1, "j": [7, 8], "k": [5, "z", "v", "v", "z", "z"]}) 90 | 91 | def test_ingest(self): 92 | with tempfile.NamedTemporaryFile("w") as cf1, tempfile.NamedTemporaryFile("w") as cf2: 93 | json.dump(dict(x="foo", y="bar", z={}), cf1) 94 | cf1.flush() 95 | json.dump(dict(x=list(range(8)), z={None: None}, t=4.5), cf2) 96 | cf2.flush() 97 | os.environ["TWEAK_TEST_CONFIG_FILE"] = ":".join([cf1.name, cf2.name]) 98 | config = Config("TWEAK_TEST") 99 | self.assertEqual(dict(config), {'y': 'bar', 'x': list(range(8)), 'z': {'null': None}, 't': 4.5}) 100 | self.assertEqual(len(config.config_files), 4) 101 | 102 | config = Config("TWEAK_TEST", use_yaml=True) 103 | self.assertEqual(dict(config), {'y': 'bar', 'x': list(range(8)), 'z': {'null': None}, 't': 4.5}) 104 | self.assertEqual(len(config.config_files), 4) 105 | 106 | def test_ingest_with_merge_operator_failure(self): 107 | with tempfile.NamedTemporaryFile("w") as cf1, tempfile.NamedTemporaryFile("w") as cf2: 108 | json.dump(dict(x="foo", y="bar", z={}), cf1) 109 | cf1.flush() 110 | json.dump(dict(x="bar", i={"$extend": [1, 2]}, t={"$append": 3}), cf2) 111 | cf2.flush() 112 | os.environ["TWEAK_TEST_CONFIG_FILE"] = ":".join([cf1.name, cf2.name]) 113 | config = Config("TWEAK_TEST") 114 | self.assertEqual(dict(config), {'x': 'bar', 'y': 'bar', 'z': {}}) 115 | self.assertEqual(len(config.config_files), 4) 116 | 117 | def test_include(self): 118 | with tempfile.NamedTemporaryFile("w") as cf1, tempfile.NamedTemporaryFile("w") as cf2: 119 | json.dump(dict(x="foo", y="bar", z={}), cf1) 120 | cf1.flush() 121 | incl_expr = os.path.basename(cf1.name) + "*" 122 | json.dump(dict(x=list(range(8)), z={None: None}, t=4.5, include=incl_expr), cf2) 123 | cf2.flush() 124 | os.environ["TWEAK_TEST_CONFIG_FILE"] = cf2.name 125 | config = Config("TWEAK_TEST", allow_includes=True) 126 | self.assertEqual(dict(config), {'y': 'bar', 'x': list(range(8)), 'z': {'null': None}, 't': 4.5}) 127 | self.assertEqual(len(config.config_files), 3) 128 | config = Config("TWEAK_TEST") 129 | self.assertEqual(dict(config), {'x': list(range(8)), 'z': {'null': None}, 't': 4.5, 'include': incl_expr}) 130 | self.assertEqual(len(config.config_files), 3) 131 | 132 | 133 | if __name__ == '__main__': 134 | unittest.main() 135 | -------------------------------------------------------------------------------- /tweak/__init__.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import errno 3 | import glob 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | from collections import OrderedDict 9 | from collections.abc import Mapping, MutableMapping 10 | 11 | 12 | class Config(MutableMapping): 13 | """ 14 | Provides a self-contained (no dependencies outside the standard library), Python 2 and 3 compatible configuration 15 | manager. Automatically saves and restores your application's configuration in your user home directory. Uses JSON 16 | or optionally YAML for serialization. Supports dict-like methods and access semantics. 17 | 18 | Examples: 19 | 20 | config = Config() 21 | config.host, config.port = "example.com", 9000 22 | config.nested_config = {} 23 | config.nested_config.foo = True 24 | 25 | After restarting your application: 26 | config = Config() 27 | print(config) 28 | 29 | >>> {'host': 'example.com', 'port': 9000, 'nested_config': {'foo': True}} 30 | """ 31 | 32 | _site_config_home = "/etc" 33 | _user_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) 34 | _logger = logging.getLogger(__name__) 35 | 36 | def __init__( 37 | self, 38 | name=os.path.basename(__file__), 39 | save_on_exit=True, 40 | autosave=False, 41 | use_yaml=False, 42 | allow_includes=False, 43 | _parent=None, 44 | _data=None, 45 | ): 46 | """ 47 | :param name: 48 | Name of the application that this config belongs to. This will be used as the name of the config directory. 49 | :param save_on_exit: If True, save() will be called at Python interpreter exit (using an atexit handler). 50 | :param autosave: If True, save() will be called after each attribute assignment. 51 | :param use_yaml: 52 | If True, the config file will be interpreted as YAML; otherwise, as JSON. Requires the PyYAML optional 53 | dependency to be installed. 54 | """ 55 | self._name, self._autosave, self._use_yaml = name, autosave, use_yaml 56 | self._allow_includes = allow_includes 57 | if save_on_exit and _parent is None: 58 | atexit.register(self.save) 59 | self._parent = _parent 60 | if self._parent is None: 61 | self._data = {} 62 | for config_file in self.config_files: 63 | try: 64 | with open(config_file) as fh: 65 | self._load(fh) 66 | except Exception as e: 67 | if isinstance(e, ImportError): 68 | raise 69 | self._logger.debug(e) 70 | else: 71 | self._data = _data 72 | 73 | @property 74 | def config_files(self): 75 | config_files = [ 76 | os.path.join(self._site_config_home, self._name, "config.yml" if self._use_yaml else "config.json"), 77 | os.path.join(self._user_config_home, self._name, "config.yml" if self._use_yaml else "config.json"), 78 | ] 79 | config_var = self._name.upper() + "_CONFIG_FILE" 80 | if config_var in os.environ: 81 | config_files.extend(os.environ[config_var].split(":")) 82 | return config_files 83 | 84 | @property 85 | def user_config_dir(self): 86 | return os.path.join(self._user_config_home, self._name) 87 | 88 | def update(self, *args, **kwargs): 89 | updates = OrderedDict() 90 | updates.update(*args, **kwargs) 91 | for k, v in updates.items(): 92 | if isinstance(v, Mapping): 93 | try: 94 | if len(v) == 1 and list(v.keys())[0] == "$append": 95 | self[k].append(list(v.values())[0]) 96 | elif len(v) == 1 and list(v.keys())[0] == "$extend": 97 | self[k].extend(list(v.values())[0]) 98 | elif len(v) == 1 and list(v.keys())[0] == "$insert": 99 | for position, value in list(v.values())[0].items(): 100 | self[k].insert(position, value) 101 | elif len(v) == 1 and list(v.keys())[0] == "$extendleft": 102 | self[k][0:0] = list(v.values())[0] 103 | elif len(v) == 1 and list(v.keys())[0] == "$remove": 104 | self[k].remove(list(v.values())[0]) 105 | else: 106 | if k not in self: 107 | self[k] = {} 108 | self[k].update(v) 109 | except Exception as e: 110 | self._logger.debug(e) 111 | else: 112 | self[k] = updates[k] 113 | 114 | def _parse(self, stream): 115 | if self._use_yaml: 116 | import yaml 117 | 118 | class ConfigLoader(yaml.SafeLoader): 119 | def construct_mapping(loader, node): 120 | loader.flatten_mapping(node) 121 | return self._as_config(yaml.SafeLoader.construct_mapping(loader, node)) 122 | 123 | ConfigLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, ConfigLoader.construct_mapping) 124 | return yaml.load(stream, Loader=ConfigLoader) or {} 125 | else: 126 | return json.load(stream, object_hook=self._as_config) 127 | 128 | def _load(self, stream): 129 | contents = self._parse(stream) 130 | if self._allow_includes and "include" in contents: 131 | includes = contents["include"] if isinstance(contents["include"], (list, tuple)) else [contents["include"]] 132 | for include in includes: 133 | for include_file in glob.glob(os.path.join(os.path.dirname(stream.name), include)): 134 | try: 135 | with open(include_file) as fh: 136 | self._load(fh) 137 | except Exception as e: 138 | self._logger.debug(e) 139 | del contents["include"] 140 | self.update(contents) 141 | self._logger.info("Loaded configuration from %s", stream.name) 142 | 143 | def _dump(self, stream=None): 144 | if self._use_yaml: 145 | import yaml 146 | 147 | class OrderedDumper(yaml.SafeDumper): 148 | pass 149 | 150 | def config_representer(dumper, obj): 151 | return dumper.represent_mapping(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, obj._data.items()) 152 | 153 | OrderedDumper.add_representer(self.__class__, config_representer) 154 | return yaml.dump(self._data, stream=stream, default_flow_style=False, Dumper=OrderedDumper) 155 | elif stream: 156 | return json.dump(self._data, stream, default=lambda obj: obj._data) 157 | return json.dumps(self._data, default=lambda obj: obj._data) 158 | 159 | def _as_config(self, d): 160 | if isinstance(d, MutableMapping): 161 | return self.__class__(autosave=self._autosave, _parent=self, _data=d) 162 | return d 163 | 164 | def save(self, mode=0o600): 165 | """ 166 | Serialize the config data to the user home directory. 167 | 168 | :param mode: The octal Unix mode (permissions) for the config file. 169 | """ 170 | if self._parent is not None: 171 | self._parent.save(mode=mode) 172 | else: 173 | contents = self._dump() 174 | config_dir = os.path.dirname(os.path.abspath(self.config_files[-1])) 175 | try: 176 | with open(self.config_files[-1]) as fh: 177 | if fh.read() == contents: 178 | self._logger.debug("Config file %s unchanged", self.config_files[-1]) 179 | return 180 | except Exception: 181 | pass 182 | try: 183 | os.makedirs(config_dir) 184 | except OSError as e: 185 | if not (e.errno == errno.EEXIST and os.path.isdir(config_dir)): 186 | raise 187 | with open(self.config_files[-1], "wb" if sys.version_info < (3, 0) else "w") as fh: 188 | fh.write(contents) 189 | os.chmod(self.config_files[-1], mode) 190 | self._logger.debug("Saved config to %s", self.config_files[-1]) 191 | 192 | def __getitem__(self, item): 193 | if item not in self._data: 194 | raise KeyError(item) 195 | return self._data[item] 196 | 197 | def __setitem__(self, key, value): 198 | self._data[key] = self._as_config(value) 199 | if self._autosave: 200 | self.save() 201 | 202 | def __getattr__(self, attr): 203 | if attr not in self._data: 204 | raise AttributeError(attr) 205 | return self._data[attr] 206 | 207 | def __setattr__(self, attr, value): 208 | if attr.startswith("_"): 209 | object.__setattr__(self, attr, value) 210 | else: 211 | self.__setitem__(attr, value) 212 | 213 | def __delitem__(self, key): 214 | del self._data[key] 215 | 216 | def __iter__(self): 217 | for item in self._data: 218 | yield item 219 | 220 | def __len__(self): 221 | return len(self._data) 222 | 223 | def __repr__(self): 224 | return repr(self._data) 225 | 226 | def __setstate__(self, state): 227 | self.__dict__ = state 228 | --------------------------------------------------------------------------------