├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── configue ├── __init__.py ├── configue_loader.py ├── exceptions.py ├── file_loader.py ├── py.typed ├── root_loader.py └── utils.py ├── pyproject.toml └── tests ├── __init__.py ├── external_module.py ├── test_configue.py ├── test_file_1.yml ├── test_file_2.yml └── test_file_3.yml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | release: 6 | types: 7 | - created 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -e '.[dev]' 26 | - name: Test with pytest 27 | run: | 28 | py.test --cov-report xml --cov-report term --cov=configue ./tests 29 | - name: Codecov 30 | if: matrix.python-version == '3.10' 31 | uses: codecov/codecov-action@v5 32 | with: 33 | fail_ci_if_error: true 34 | token: ${{ secrets.CODECOV_TOKEN }} 35 | 36 | lint: 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install -e '.[dev]' 52 | - name: Lint with black 53 | run: black . --check -l 120 54 | - name: Lint with mypy 55 | run: mypy --install-types --non-interactive . 56 | - name: Lint with pylint 57 | run: | 58 | pylint configue 59 | pylint tests --disable=too-many-instance-attributes,no-self-use,similarities,too-many-public-methods 60 | 61 | deploy: 62 | needs: [test, lint] 63 | runs-on: ubuntu-latest 64 | if: ${{ github.event_name == 'release' }} 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Set up Python 69 | uses: actions/setup-python@v5 70 | with: 71 | python-version: '3.x' 72 | - name: Install dependencies 73 | run: | 74 | python -m pip install --upgrade pip 75 | pip install -e '.[dev]' 76 | - name: Build and publish 77 | env: 78 | TWINE_USERNAME: __token__ 79 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 80 | run: | 81 | sed -i "s|version = \"0.0.0\"|version = \"${GITHUB_REF##*/}\"|g" pyproject.toml 82 | python -m build 83 | twine upload dist/* 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Linux template 3 | *~ 4 | 5 | # temporary files which can be created if a process still has a handle open of a deleted file 6 | .fuse_hidden* 7 | 8 | # KDE directory preferences 9 | .directory 10 | 11 | # Linux trash folder which might appear on any partition or disk 12 | .Trash-* 13 | 14 | # .nfs files are created when an open file is removed but is still being accessed 15 | .nfs* 16 | ### VirtualEnv template 17 | # Virtualenv 18 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 19 | .Python 20 | [Bb]in 21 | [Ii]nclude 22 | [Ll]ib 23 | [Ll]ib64 24 | [Ll]ocal 25 | [Ss]cripts 26 | pyvenv.cfg 27 | .venv 28 | pip-selfcheck.json 29 | ### SublimeText template 30 | # Cache files for Sublime Text 31 | *.tmlanguage.cache 32 | *.tmPreferences.cache 33 | *.stTheme.cache 34 | 35 | # Workspace files are user-specific 36 | *.sublime-workspace 37 | 38 | # Project files should be checked into the repository, unless a significant 39 | # proportion of contributors will probably not be using Sublime Text 40 | # *.sublime-project 41 | 42 | # SFTP configuration file 43 | sftp-config.json 44 | 45 | # Package control specific files 46 | Package Control.last-run 47 | Package Control.ca-list 48 | Package Control.ca-bundle 49 | Package Control.system-ca-bundle 50 | Package Control.cache/ 51 | Package Control.ca-certs/ 52 | Package Control.merged-ca-bundle 53 | Package Control.user-ca-bundle 54 | oscrypto-ca-bundle.crt 55 | bh_unicode_properties.cache 56 | 57 | # Sublime-github package stores a github token in this file 58 | # https://packagecontrol.io/packages/sublime-github 59 | GitHub.sublime-settings 60 | ### Python template 61 | # Byte-compiled / optimized / DLL files 62 | __pycache__/ 63 | *.py[cod] 64 | *$py.class 65 | 66 | # C extensions 67 | *.so 68 | 69 | # Distribution / packaging 70 | .Python 71 | build/ 72 | develop-eggs/ 73 | dist/ 74 | downloads/ 75 | eggs/ 76 | .eggs/ 77 | lib/ 78 | lib64/ 79 | parts/ 80 | sdist/ 81 | var/ 82 | wheels/ 83 | *.egg-info/ 84 | .installed.cfg 85 | *.egg 86 | MANIFEST 87 | 88 | # PyInstaller 89 | # Usually these files are written by a python script from a template 90 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 91 | *.manifest 92 | *.spec 93 | 94 | # Installer logs 95 | pip-log.txt 96 | pip-delete-this-directory.txt 97 | 98 | # Unit test / coverage reports 99 | htmlcov/ 100 | .tox/ 101 | .coverage 102 | .coverage.* 103 | .cache 104 | nosetests.xml 105 | coverage.xml 106 | *.cover 107 | .hypothesis/ 108 | 109 | # Translations 110 | *.mo 111 | *.pot 112 | 113 | # Django stuff: 114 | *.log 115 | .static_storage/ 116 | .media/ 117 | local_settings.py 118 | 119 | # Flask stuff: 120 | instance/ 121 | .webassets-cache 122 | 123 | # Scrapy stuff: 124 | .scrapy 125 | 126 | # Sphinx documentation 127 | docs/_build/ 128 | 129 | # PyBuilder 130 | target/ 131 | 132 | # Jupyter Notebook 133 | .ipynb_checkpoints 134 | 135 | # pyenv 136 | .python-version 137 | 138 | # celery beat schedule file 139 | celerybeat-schedule 140 | 141 | # SageMath parsed files 142 | *.sage.py 143 | 144 | # Environments 145 | .env 146 | .venv 147 | env/ 148 | venv/ 149 | ENV/ 150 | env.bak/ 151 | venv.bak/ 152 | 153 | # Spyder project settings 154 | .spyderproject 155 | .spyproject 156 | 157 | # Rope project settings 158 | .ropeproject 159 | 160 | # mkdocs documentation 161 | /site 162 | 163 | # mypy 164 | .mypy_cache/ 165 | ### Windows template 166 | # Windows thumbnail cache files 167 | Thumbs.db 168 | ehthumbs.db 169 | ehthumbs_vista.db 170 | 171 | # Dump file 172 | *.stackdump 173 | 174 | # Folder config file 175 | [Dd]esktop.ini 176 | 177 | # Recycle Bin used on file shares 178 | $RECYCLE.BIN/ 179 | 180 | # Windows Installer files 181 | *.cab 182 | *.msi 183 | *.msm 184 | *.msp 185 | 186 | # Windows shortcuts 187 | *.lnk 188 | ### JetBrains template 189 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 190 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 191 | 192 | # User-specific stuff: 193 | .idea/**/workspace.xml 194 | .idea/**/tasks.xml 195 | .idea/dictionaries 196 | 197 | # Sensitive or high-churn files: 198 | .idea/**/dataSources/ 199 | .idea/**/dataSources.ids 200 | .idea/**/dataSources.xml 201 | .idea/**/dataSources.local.xml 202 | .idea/**/sqlDataSources.xml 203 | .idea/**/dynamic.xml 204 | .idea/**/uiDesigner.xml 205 | 206 | # Gradle: 207 | .idea/**/gradle.xml 208 | .idea/**/libraries 209 | 210 | # CMake 211 | cmake-build-debug/ 212 | 213 | # Mongo Explorer plugin: 214 | .idea/**/mongoSettings.xml 215 | 216 | ## File-based project format: 217 | *.iws 218 | 219 | ## Plugin-specific files: 220 | 221 | # IntelliJ 222 | out/ 223 | 224 | # mpeltonen/sbt-idea plugin 225 | .idea_modules/ 226 | 227 | # JIRA plugin 228 | atlassian-ide-plugin.xml 229 | 230 | # Cursive Clojure plugin 231 | .idea/replstate.xml 232 | 233 | # Crashlytics plugin (for Android Studio and IntelliJ) 234 | com_crashlytics_export_strings.xml 235 | crashlytics.properties 236 | crashlytics-build.properties 237 | fabric.properties 238 | ### macOS template 239 | # General 240 | .DS_Store 241 | .AppleDouble 242 | .LSOverride 243 | 244 | # Icon must end with two \r 245 | Icon 246 | 247 | # Thumbnails 248 | ._* 249 | 250 | # Files that might appear in the root of a volume 251 | .DocumentRevisions-V100 252 | .fseventsd 253 | .Spotlight-V100 254 | .TemporaryItems 255 | .Trashes 256 | .VolumeIcon.icns 257 | .com.apple.timemachine.donotpresent 258 | 259 | # Directories potentially created on remote AFP share 260 | .AppleDB 261 | .AppleDesktop 262 | Network Trash Folder 263 | Temporary Items 264 | .apdisk 265 | 266 | # PyCharm 267 | .idea/ 268 | 269 | .pytest_cache 270 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=0 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=no 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.8 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=yes 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=snake_case 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=snake_case 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=15 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=1 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format=LF 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=120 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=new 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable= 425 | locally-disabled, 426 | logging-fstring-interpolation, 427 | missing-class-docstring, 428 | missing-function-docstring, 429 | missing-module-docstring, 430 | suppressed-message, 431 | too-few-public-methods, 432 | 433 | # Enable the message, report, category or checker with the given id(s). You can 434 | # either give multiple identifier separated by comma (,) or put this option 435 | # multiple time (only on the command line, not in the configuration file where 436 | # it should appear only once). See also the "--disable" option for examples. 437 | enable=all 438 | 439 | 440 | [METHOD_ARGS] 441 | 442 | # List of qualified names (i.e., library.method) which require a timeout 443 | # parameter e.g. 'requests.api.get,requests.api.post' 444 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 445 | 446 | 447 | [MISCELLANEOUS] 448 | 449 | # List of note tags to take in consideration, separated by a comma. 450 | notes=FIXME, 451 | XXX, 452 | TODO 453 | 454 | # Regular expression of note tags to take in consideration. 455 | notes-rgx= 456 | 457 | 458 | [REFACTORING] 459 | 460 | # Maximum number of nested blocks for function / method body 461 | max-nested-blocks=5 462 | 463 | # Complete name of functions that never returns. When checking for 464 | # inconsistent-return-statements if a never returning function is called then 465 | # it will be considered as an explicit return statement and no message will be 466 | # printed. 467 | never-returning-functions=sys.exit,argparse.parse_error 468 | 469 | 470 | [REPORTS] 471 | 472 | # Python expression which should return a score less than or equal to 10. You 473 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 474 | # 'convention', and 'info' which contain the number of messages in each 475 | # category, as well as 'statement' which is the total number of statements 476 | # analyzed. This score is used by the global evaluation report (RP0004). 477 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 478 | 479 | # Template used to display messages. This is a python new-style format string 480 | # used to format the message information. See doc for all details. 481 | msg-template= 482 | 483 | # Set the output format. Available formats are: text, parseable, colorized, 484 | # json2 (improved json format), json (old json format) and msvs (visual 485 | # studio). You can also give a reporter class, e.g. 486 | # mypackage.mymodule.MyReporterClass. 487 | #output-format= 488 | 489 | # Tells whether to display a full report or only the messages. 490 | reports=no 491 | 492 | # Activate the evaluation score. 493 | score=yes 494 | 495 | 496 | [SIMILARITIES] 497 | 498 | # Comments are removed from the similarity computation 499 | ignore-comments=yes 500 | 501 | # Docstrings are removed from the similarity computation 502 | ignore-docstrings=yes 503 | 504 | # Imports are removed from the similarity computation 505 | ignore-imports=yes 506 | 507 | # Signatures are removed from the similarity computation 508 | ignore-signatures=yes 509 | 510 | # Minimum lines number of a similarity. 511 | min-similarity-lines=10 512 | 513 | 514 | [SPELLING] 515 | 516 | # Limits count of emitted suggestions for spelling mistakes. 517 | max-spelling-suggestions=4 518 | 519 | # Spelling dictionary name. No available dictionaries : You need to install 520 | # both the python package and the system dependency for enchant to work. 521 | spelling-dict= 522 | 523 | # List of comma separated words that should be considered directives if they 524 | # appear at the beginning of a comment and should not be checked. 525 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 526 | 527 | # List of comma separated words that should not be checked. 528 | spelling-ignore-words= 529 | 530 | # A path to a file that contains the private dictionary; one word per line. 531 | spelling-private-dict-file= 532 | 533 | # Tells whether to store unknown words to the private dictionary (see the 534 | # --spelling-private-dict-file option) instead of raising a message. 535 | spelling-store-unknown-words=no 536 | 537 | 538 | [STRING] 539 | 540 | # This flag controls whether inconsistent-quotes generates a warning when the 541 | # character used as a quote delimiter is used inconsistently within a module. 542 | check-quote-consistency=yes 543 | 544 | # This flag controls whether the implicit-str-concat should generate a warning 545 | # on implicit string concatenation in sequences defined over several lines. 546 | check-str-concat-over-line-jumps=yes 547 | 548 | 549 | [TYPECHECK] 550 | 551 | # List of decorators that produce context managers, such as 552 | # contextlib.contextmanager. Add to this list to register other decorators that 553 | # produce valid context managers. 554 | contextmanager-decorators=contextlib.contextmanager 555 | 556 | # List of members which are set dynamically and missed by pylint inference 557 | # system, and so shouldn't trigger E1101 when accessed. Python regular 558 | # expressions are accepted. 559 | generated-members= 560 | 561 | # Tells whether to warn about missing members when the owner of the attribute 562 | # is inferred to be None. 563 | ignore-none=yes 564 | 565 | # This flag controls whether pylint should warn about no-member and similar 566 | # checks whenever an opaque object is returned when inferring. The inference 567 | # can return multiple potential results while evaluating a Python object, but 568 | # some branches might not be evaluated, which results in partial inference. In 569 | # that case, it might be useful to still emit no-member and other checks for 570 | # the rest of the inferred objects. 571 | ignore-on-opaque-inference=yes 572 | 573 | # List of symbolic message names to ignore for Mixin members. 574 | ignored-checks-for-mixins=no-member, 575 | not-async-context-manager, 576 | not-context-manager, 577 | attribute-defined-outside-init 578 | 579 | # List of class names for which member attributes should not be checked (useful 580 | # for classes with dynamically set attributes). This supports the use of 581 | # qualified names. 582 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 583 | 584 | # Show a hint with possible names when a member name was not found. The aspect 585 | # of finding the hint is based on edit distance. 586 | missing-member-hint=yes 587 | 588 | # The minimum edit distance a name should have in order to be considered a 589 | # similar match for a missing member name. 590 | missing-member-hint-distance=1 591 | 592 | # The total number of similar names that should be taken in consideration when 593 | # showing a hint for a missing member. 594 | missing-member-max-choices=1 595 | 596 | # Regex pattern to define which classes are considered mixins. 597 | mixin-class-rgx=.*[Mm]ixin 598 | 599 | # List of decorators that change the signature of a decorated function. 600 | signature-mutators= 601 | 602 | 603 | [VARIABLES] 604 | 605 | # List of additional names supposed to be defined in builtins. Remember that 606 | # you should avoid defining new builtins when possible. 607 | additional-builtins= 608 | 609 | # Tells whether unused global variables should be treated as a violation. 610 | allow-global-unused-variables=yes 611 | 612 | # List of names allowed to shadow builtins 613 | allowed-redefined-builtins= 614 | 615 | # List of strings which can identify a callback function by name. A callback 616 | # name must start or end with one of those strings. 617 | callbacks=cb_, 618 | _cb 619 | 620 | # A regular expression matching the name of dummy variables (i.e. expected to 621 | # not be used). 622 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 623 | 624 | # Argument names that match this expression will be ignored. 625 | ignored-argument-names=_.*|^ignored_|^unused_ 626 | 627 | # Tells whether we should check for unused import in __init__ files. 628 | init-import=no 629 | 630 | # List of qualified module names which can have objects that can redefine 631 | # builtins. 632 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 633 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 6.0.0 3 | ### Breaking changes 4 | - Remove support for Python 3.8 5 | 6 | ## 5.0.0 7 | ### Breaking changes 8 | - Remove support for Python < 3.8 9 | 10 | ### Features 11 | - `()` can now load object attributes, such as static methods (e.g. `(): module.MyClass.generate`) 12 | 13 | ## 4.2.0 14 | ### Features 15 | - `!ext` can now load object attributes, such as enum members (e.g. `!ext module.MyEnum.MY_VALUE`) 16 | 17 | ## 4.1.1 18 | ### Fixes 19 | - Fixed loading `!import` or `!cfg` tags in a `!cfg` tag 20 | 21 | ## 4.1.0 22 | ### Features 23 | - Added a `logging_config_path` keyword parameter to `configue.load` to set up a logging configuration while loading a 24 | configuration file 25 | 26 | ## 4.0.1 27 | ### Fixes 28 | - Loading a null `!path` will now return `None` instead of raising an Exception 29 | 30 | ## 4.0.0 31 | ### Breaking changes 32 | - The `!env` tag has been removed, environment variables can now be loaded anywhere 33 | - The `cfg://` syntax has been replaced with `!cfg path.to.value` 34 | - `!cfg` now redirects to a path in the current file (instead of the first loaded file) 35 | - Use `!import` to load a path in another file 36 | - The `ext://module.submodule.object` syntax has been replaced with `!ext module.submodule.object` 37 | - Replaced `load_config_from_file` with a `configue.load` function, which is now the main entrypoint of the library 38 | - use `configue.load("/path/to/file.yml")` to load a file 39 | - use `configue.load("/path/to/file.yml", "path.to.section")` to load only a section of a file 40 | - loading a path in a file recursively loads all children (replaced the lazy-loading with path loading), 41 | see README for more details 42 | - Removed the `load_config_from_dict` function 43 | - The `!list` tag has been removed, set your environment variable to `[value_1, value_2]` instead 44 | 45 | ### Migration guide 46 | - Remove all `!env` tags 47 | - Remove all `!list` tags, and replace your environment variables from `value1,value2` to `[value1,value2]` 48 | - Replace `ext://path.to.load` with `!ext path.to.load` 49 | - If you are using `!import` to import a file containing values with `cfg://path.to.load`, replace the `cfg://...` with 50 | `!import:path/to/root/file.yml path.to.load` 51 | - Replace `cfg://path.to.load` with `!cfg path.to.load` 52 | - Replace `configue.load_config_from_file(...)` with `configue.load(...)` 53 | - Replace `configue.load_config_from_file(...)["path"]["to"]["load"]` with `configue.load(..., "path.to.load")` 54 | 55 | ### Features 56 | - The `!import` tag can now specify the path to load in the other file, with the syntax: 57 | `!import:path.to.load path/to/file.yml` 58 | - Dictionaries and lists are now JSON dumpable and picklable 59 | 60 | ### Fixes 61 | - Removed warning about missing environment variables from unloaded paths 62 | 63 | 64 | ## 3.0.5 65 | ### Enhancements 66 | - Added Python 3.10 support 67 | - Updated PyYAML dependency version 68 | 69 | 70 | ## 3.0.4 71 | ### Enhancements 72 | - Added Python 3.9 support 73 | 74 | ### Fixes 75 | - Fixed DeprecationWarning on collections import 76 | 77 | 78 | ## 3.0.3 79 | ### Fixes 80 | - Fixed key errors when loading `cfg://` paths from file 81 | 82 | 83 | ## 3.0.2 84 | ### Fixes 85 | - Fixed top-level object loading 86 | 87 | 88 | ## 3.0.1 89 | ### Fixes 90 | - Fixed PyPI package description 91 | 92 | 93 | ## 3.0.0 94 | ### Breaking changes 95 | - Renamed into `configue` 96 | - Removed json loading 97 | 98 | 99 | ## 2.4.2 100 | ### Enhancements 101 | - Updated dependency: `pyyaml` to `5.1` 102 | 103 | 104 | ## 2.4.1 105 | ### Fixes 106 | - Fixed warnings for missing environment variables appearing when a default was set 107 | 108 | 109 | ## 2.4.0 110 | ### Features 111 | - Added a warning log when an environment variable is missing 112 | 113 | 114 | ## 2.3.0 115 | ### Features 116 | - Using `**kwargs`, `.items()` or `.values()` with a dictionary created with the `ConfigLoader` now converts the value 117 | 118 | ### Fixes 119 | - An error log has been added when a class instantiation fails 120 | 121 | 122 | ## 2.2.3 123 | ### Fixes 124 | - `!path` and `!import` now work as expected when used after importing a file from another folder 125 | 126 | 127 | ## 2.2.2 128 | ### Fixes 129 | - `~` paths are correctly expanded when used with `!path` in yaml files 130 | 131 | 132 | ## 2.2.1 133 | ### Fixes 134 | - Emojis are now supported in yaml files 135 | 136 | 137 | ## 2.2.0 138 | ### Features 139 | - Environment variables can now contain lists in YAML files, use `!list ${my_var}` to create a python list when loading 140 | the file. 141 | 142 | 143 | ## 2.1.0 144 | ### Features 145 | - Environment variables can now be inserted in `!path` values 146 | - Values that contain environment variables are now cast into strings, booleans, ints or floats 147 | - You can use `!import` to concatenate files 148 | 149 | ### Fixes 150 | - Multiple environment variables can now be inserted in the same value 151 | 152 | 153 | ## 2.0.0 154 | ### Breaking changes 155 | - Removed escaped callable syntax `\()` 156 | 157 | ### Fixes 158 | - Fixed a bug where tuples where not converted on access 159 | 160 | 161 | ## 1.3.1 162 | ### Fixes 163 | - Fixed a bug that prevented accessing objects properties with `cfg://` 164 | 165 | 166 | ## 1.3.0 167 | ### Features 168 | - Added environment variables templating with `${var_name-default}` when loading from YAML files 169 | - Added path completion with `!path folder/my_file.txt` when loading from YAML files 170 | (prepends the absolute path to the folder containing the configuration file) 171 | 172 | ### Deprecated 173 | - `load_config_from_json` has been deprecated in favor of `load_config_from_yaml` 174 | - `load_config_from_file` has been deprecated when using JSON files (use YAML files instead) 175 | 176 | 177 | ## 1.2.0 178 | ### Features 179 | - Added callable list support 180 | 181 | 182 | ## 1.1.0 183 | ### Features 184 | - Added `\()` special value to escape callable key 185 | 186 | 187 | ## 1.0.0 188 | ### Features 189 | - Added `load_config_from_file`, `load_config_from_dict`, `load_config_from_yaml_file`, 190 | `load_config_from_json_file` methods 191 | - Added `ConfigLoader` object 192 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome everyone to help us on this project. 4 | 5 | All bug reports, feature requests and questions should be filed at the 6 | [GitHub issues page](https://github.com/illuin-tech/configue/issues). 7 | 8 | You can open your own Pull Request after discussing the need for those changes in an issue. 9 | 10 | All pull requests should: 11 | - Keep the code as clean as possible 12 | - Pass all tests 13 | - Pass the lint check 14 | - Keep the coverage at 100%, with new unit tests for all changes 15 | - Update the `CHANGELOG.md` file with the `Fixes`, `Features` and/or `Breaking changes` that the changes will bring 16 | 17 | ## Local development 18 | Install the development dependencies with `pip install -e ".[dev]"`. 19 | 20 | Run `python -m unitttest discover` to run the tests. 21 | 22 | Run these commands to check the files linting: 23 | ```shell script 24 | black . --check -l 120 25 | pylint configue 26 | pylint tests --disable=too-many-instance-attributes,no-self-use,similarities 27 | ``` 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Illuin Technology 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Configue 2 | ======== 3 | 4 | ![CI](https://github.com/illuin-tech/configue/workflows/CI/badge.svg) 5 | [![codecov](https://codecov.io/gh/illuin-tech/configue/branch/master/graph/badge.svg)](https://codecov.io/gh/illuin-tech/configue) 6 | 7 | A YAML parser with advanced functionalities to ease your application configuration. 8 | 9 | # Who is this library for ? 10 | This library is meant to be used in medium to large-scale applications, that have a lot of parameters to configure. 11 | 12 | Modular applications especially can greatly benefit from using `configue` to easily inject new modules. 13 | 14 | # Installation 15 | 16 | Run `pip install configue` to install from PyPI. 17 | 18 | Run `pip install .` to install from sources. 19 | 20 | This project follows the (Semantic Versioning Specification)[https://semver.org/]. 21 | All breaking changes are described in the [Changelog](CHANGELOG.md). 22 | 23 | # Usage 24 | 25 | ### Basic usage 26 | This library uses [PyYAML](https://github.com/yaml/pyyaml) to parse the YAML files and return the file content. 27 | 28 | ```python 29 | import configue 30 | 31 | 32 | config = configue.load("/path/to/yaml/file.yml") 33 | ``` 34 | 35 | 36 | ### Loading a sub path 37 | If you are not interested in loading the whole file, you can only load a subpath: 38 | ```yaml 39 | # config.yml 40 | some_key: 41 | some_list: 42 | - first_item 43 | - second_item: 44 | item_key: item_value 45 | 46 | not_loaded_key: not_loaded_value 47 | ``` 48 | 49 | ```python 50 | import configue 51 | 52 | config = configue.load("config.yml", "some_key.some_list.1.item_key") 53 | assert config == "item_value" 54 | ``` 55 | 56 | ### Instantiating classes 57 | 58 | Use `()` in your YAML files to instantiate classes: 59 | ```yaml 60 | # config.yml 61 | (): "my_project.MyAwesomeClass" 62 | my_argument: "my_value" 63 | my_other_argument: 64 | (): "my_project.my_module.MyOtherClass" 65 | ``` 66 | 67 | ```python 68 | import configue 69 | from my_project import MyAwesomeClass 70 | from my_project.my_module import MyOtherClass 71 | 72 | 73 | my_instance = configue.load("config.yml") 74 | assert isinstance(my_instance, MyAwesomeClass) 75 | assert my_instance.my_argument == "my_value" 76 | assert isinstance(my_instance.my_other_argument, MyOtherClass) 77 | ``` 78 | 79 | This syntax also works to call functions: 80 | ```yaml 81 | # config.yml 82 | (): "my_project.my_function" 83 | my_argument: "world" 84 | ``` 85 | 86 | ```python 87 | import configue 88 | 89 | def my_function(my_argument: str) -> str: 90 | return "Hello " + my_argument 91 | 92 | my_value = configue.load("config.yml") 93 | assert my_value == "Hello world" 94 | ``` 95 | 96 | ### Loading external variables 97 | 98 | ```yaml 99 | # config.yml 100 | my_argument: !ext my_project.my_module.my_variable 101 | my_argument: !ext my_project.my_module.my_instance.my_attribute 102 | ``` 103 | 104 | When using the `!ext` tag, the value will be imported from the corresponding python module. 105 | 106 | 107 | ### Loading internal variables 108 | 109 | ```yaml 110 | # config.yml 111 | my_object: 112 | my_instance: 113 | (): my_project.MyClass 114 | my_instance_shortcut: !cfg my_object.my_instance 115 | my_attribute_shortcut: !cfg my_object.my_instance.my_attribute 116 | ``` 117 | 118 | When using the `!cfg` tag, the value will be loaded from the same configuration file (useful for a DRY configuration). 119 | 120 | ### Environment variables 121 | 122 | If you want to load an environment variable in your YAML config file, you can use this syntax: 123 | ```yaml 124 | # config.yml 125 | my_key: ${VAR_NAME} 126 | ``` 127 | This will resolve as `"my_value"` if the environment variable `VAR_NAME` is set to this value. 128 | 129 | If you need a default value in case the environment variable is not set: 130 | ```yaml 131 | # config.yml 132 | my_key: ${VAR_NAME-default} 133 | ``` 134 | 135 | You can insert this syntax in the middle of a string: 136 | ```yaml 137 | # config.yml 138 | my_key: prefix${VAR_NAME-default}suffix 139 | ``` 140 | This will resolve as `"prefixmy_value_suffix"` if the value is set, `"prefixdefaultsuffix"` if it is not. 141 | 142 | If your environment variable resolves to a yaml value, it will be cast (unless you are using quotes): 143 | ```yaml 144 | # config.yml 145 | my_key: ${VAR_NAME} 146 | my_quoted_key: "${VAR_NAME}" 147 | ``` 148 | This will resolve as `True` if the value is set to `true`, `yes` or `y`, `None` if the value is set to `~` or `null`. 149 | 150 | 151 | ### Relative paths 152 | 153 | If you want to expand a relative path in your YAML config file: 154 | 155 | ````yaml 156 | # config.yml 157 | my_path: !path my_folder/my_file.txt 158 | ```` 159 | Assuming your file structure looks like this: 160 | ``` 161 | root/ 162 | ├── config.yml 163 | └── my_folder 164 | └── my_file.txt 165 | ``` 166 | 167 | The path is resolved starting from the folder containing the parent yml file, this example will resolve to 168 | `/root/my_folder/my_file.txt` 169 | 170 | Do not start the path with `/` as it will be treated as an absolute path instead. 171 | 172 | You can use environment variables in your file path. 173 | 174 | ### Importing other files 175 | 176 | You can import another file directly in your YAML config file: 177 | 178 | ````yaml 179 | # config.yml 180 | my_import: !import my_folder/my_other_config.yml 181 | ```` 182 | 183 | ```yaml 184 | # my_other_config.yml 185 | some_key: 186 | - var_1 187 | - var_2 188 | ``` 189 | 190 | By default, the path is resolved starting from the folder containing the parent yml file, this example will resolve to 191 | `"my_import": {"some_key": [var_1, var_2]}` 192 | 193 | If you want to import only a section of the file, use the path in the tag suffix `!import:some_key.0` 194 | 195 | Do not start the import path with `/` as it will be treated as an absolute path instead. 196 | 197 | You can use environment variables in your import path. 198 | 199 | ### Logging configuration 200 | 201 | You can load the logging configuration for your application by using the `logging_config_path` parameter: 202 | ```yaml 203 | # config.yml 204 | logging_config: 205 | version: 1 206 | handlers: 207 | console: 208 | class : logging.StreamHandler 209 | stream : ext://sys.stdout 210 | custom_handler: 211 | \(): my_app.CustomHandler 212 | some_param: some_value 213 | level: ERROR 214 | root: 215 | level: INFO 216 | handlers: 217 | - console 218 | 219 | app_config: 220 | some_key: some_value 221 | 222 | not_loaded_key: not_loaded_value 223 | ``` 224 | 225 | ```python 226 | import logging 227 | 228 | import configue 229 | 230 | app_config = configue.load("config.yml", "app_config", logging_config_path="logging_config") 231 | assert app_config == {"some_key": "some_value"} 232 | 233 | logger = logging.getLogger(__name__) 234 | logger.info("Hello world!") # Uses the console handler 235 | ``` 236 | 237 | The logging configuration should follow the format of `logging.config.dictConfig` 238 | (check [the documentation](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) for more 239 | details). 240 | Make sure to escape the constructors with `\()` instead of `()` for handlers, formatters and filters. 241 | 242 | 243 | # Testing 244 | 245 | Install the development dependencies with `pip install -r dev.requirements.txt`. 246 | 247 | Run `python -m unitttest discover` to run the tests. 248 | 249 | Run `pylint configue` to check the files linting. 250 | -------------------------------------------------------------------------------- /configue/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import load 2 | -------------------------------------------------------------------------------- /configue/configue_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from collections.abc import Hashable 5 | from typing import Any, List, Mapping, Union 6 | 7 | import yaml 8 | from yaml.constructor import ConstructorError 9 | 10 | from configue.exceptions import NonCallableError, NotFoundError 11 | 12 | # Matches "${myvar-default}" -> "${", "myvar", "-", "default", "}" 13 | # or "${myvar}" -> "${", "myvar", "", "", "}" 14 | ENV_PATTERN_REGEX = re.compile(r"(\${)(\w+)(-?)((?:(?![^}]*\${)[^}])*)(})") 15 | CONSTRUCTOR_KEY = "()" 16 | ESCAPED_CONSTRUCTOR_KEY = "\\()" 17 | 18 | 19 | class ConfigueLoader(yaml.FullLoader): # pylint: disable=too-many-ancestors 20 | logger = logging.getLogger(__name__) 21 | 22 | def construct_yaml_map(self, node: yaml.MappingNode) -> Any: 23 | mapping: Mapping[Hashable, Any] = self.construct_mapping(node) 24 | if isinstance(mapping, dict) and CONSTRUCTOR_KEY in mapping: 25 | path = mapping.pop(CONSTRUCTOR_KEY) 26 | object_path_elements = path.split(".") 27 | remaining_path_elements: List[str] = [] 28 | while object_path_elements: 29 | try: 30 | cls = self.find_python_name( 31 | ".".join(object_path_elements), 32 | node.start_mark, 33 | unsafe=True, 34 | ) 35 | break 36 | except ConstructorError: 37 | remaining_path_elements.insert(0, object_path_elements.pop(-1)) 38 | else: 39 | raise NotFoundError(f"Could not load element {path} {node.start_mark}") 40 | for path_element in remaining_path_elements: 41 | cls = getattr(cls, path_element) 42 | 43 | if not callable(cls): 44 | raise NonCallableError( 45 | f"Error while constructing a Python instance {node.start_mark}, " 46 | f"expected a callable but found {type(cls)}" 47 | ) 48 | return cls(**mapping) 49 | if isinstance(mapping, dict) and ESCAPED_CONSTRUCTOR_KEY in mapping: 50 | mapping[CONSTRUCTOR_KEY] = mapping.pop(ESCAPED_CONSTRUCTOR_KEY) 51 | return mapping 52 | 53 | def construct_scalar(self, node: Union[yaml.ScalarNode, yaml.MappingNode]) -> Any: 54 | scalar = yaml.FullLoader.construct_scalar(self, node) 55 | if isinstance(node, yaml.MappingNode): # pragma: nocover 56 | return scalar 57 | replaced_value = "" 58 | end_pos = 0 59 | for match in ENV_PATTERN_REGEX.finditer(scalar): 60 | env_var_name, has_default, default_value = match.group(2, 3, 4) 61 | start_pos = match.start(1) 62 | if env_var_name not in os.environ and not has_default: 63 | self.logger.warning(f"Missing environment var: '{env_var_name}', no default is set") 64 | replaced_value += f"{scalar[end_pos:start_pos]}{os.environ.get(env_var_name, default_value)}" 65 | end_pos = match.end(5) 66 | replaced_value += scalar[end_pos:] 67 | if replaced_value == node.value: 68 | return scalar 69 | if node.style in ["'", '"']: 70 | replaced_value = f"{node.style}{replaced_value}{node.style}" 71 | # A variable has been replaced, reload to convert string to number if needed or replace again 72 | return yaml.load(replaced_value, Loader=self.__class__) 73 | -------------------------------------------------------------------------------- /configue/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConfigueError(Exception): 2 | pass 3 | 4 | 5 | class SubPathNotFound(ConfigueError): 6 | pass 7 | 8 | 9 | class NonCallableError(ConfigueError): 10 | pass 11 | 12 | 13 | class ConfigueRecursionError(ConfigueError): 14 | pass 15 | 16 | 17 | class NotFoundError(ConfigueError): 18 | pass 19 | 20 | 21 | class InvalidNodeType(Exception): 22 | pass 23 | -------------------------------------------------------------------------------- /configue/file_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Any, List, Optional, TYPE_CHECKING, Type, Union, cast 4 | 5 | from yaml import Loader, MappingNode, Node, ScalarNode, SequenceNode 6 | from yaml.constructor import ConstructorError 7 | 8 | from .configue_loader import ConfigueLoader 9 | from .exceptions import SubPathNotFound, InvalidNodeType, NotFoundError 10 | 11 | if TYPE_CHECKING: 12 | from .root_loader import RootLoader 13 | 14 | 15 | class FileLoader: 16 | logger = logging.getLogger(__name__) 17 | 18 | def __init__(self, file_path: str, root_loader: "RootLoader") -> None: 19 | self._file_path = file_path 20 | self._root_loader = root_loader 21 | 22 | loader_cls: Type[Loader] = cast( 23 | Type[Loader], 24 | type("CustomLoader", (ConfigueLoader,), {"yaml_loader": self}), 25 | ) 26 | 27 | loader_cls.add_multi_constructor("!import", self._load_import) # type: ignore[no-untyped-call] 28 | loader_cls.add_constructor("!path", self._load_path) 29 | loader_cls.add_constructor("!cfg", self._load_cfg) 30 | loader_cls.add_constructor("!ext", self._load_ext) 31 | loader_cls.add_constructor("tag:yaml.org,2002:map", loader_cls.construct_yaml_map) # type: ignore[type-var] 32 | 33 | with open(self._file_path, encoding="utf-8") as config_file: 34 | self._loader = loader_cls(config_file) 35 | self._root_node = self._loader.get_single_node() 36 | self._loader.dispose() # type: ignore[no-untyped-call] 37 | 38 | def load(self, path: Union[str, List[str]]) -> Any: 39 | if self._root_node is None: 40 | return None 41 | 42 | if isinstance(path, str): 43 | path = path.split(".") 44 | 45 | current_node = self._root_node 46 | is_current_node_loaded = False 47 | for sub_path in path: 48 | if not sub_path: 49 | continue 50 | if not is_current_node_loaded: 51 | try: 52 | current_node = self._get_node_at_sub_path(sub_path, current_node) 53 | except InvalidNodeType: 54 | current_node = self._loader.construct_object( # type: ignore[no-untyped-call] 55 | current_node, 56 | deep=True, 57 | ) 58 | is_current_node_loaded = True 59 | if is_current_node_loaded: 60 | current_node = self._get_element_at_sub_path(sub_path, current_node) 61 | if is_current_node_loaded: 62 | return current_node 63 | return self._loader.construct_object(current_node, deep=True) # type: ignore[no-untyped-call] 64 | 65 | @staticmethod 66 | def _get_node_at_sub_path(sub_path: str, current_node: Node) -> Node: 67 | if isinstance(current_node, SequenceNode): 68 | try: 69 | sub_path_index = int(sub_path) 70 | except ValueError: 71 | raise SubPathNotFound( 72 | f"Could not convert sub_path element {sub_path} to list index {current_node.start_mark}" 73 | ) from None 74 | try: 75 | return cast(Node, current_node.value[sub_path_index]) 76 | except IndexError: 77 | raise SubPathNotFound( 78 | f"Could not find sub_path element {sub_path_index} in list {current_node.start_mark}" 79 | ) from None 80 | elif isinstance(current_node, MappingNode): 81 | for node_key, node_value in current_node.value: 82 | if isinstance(node_key, ScalarNode) and str(node_key.value) == sub_path: 83 | return cast(Node, node_value) 84 | raise SubPathNotFound(f"Could not find sub_path {sub_path} {current_node.start_mark}") 85 | raise InvalidNodeType() 86 | 87 | @staticmethod 88 | def _get_element_at_sub_path(sub_path: str, current_element: Any) -> Any: 89 | try: 90 | sub_path_index: Union[str, int] = int(sub_path) 91 | except ValueError: 92 | sub_path_index = sub_path 93 | try: 94 | return current_element[sub_path_index] 95 | except TypeError: 96 | pass 97 | try: 98 | return getattr(current_element, sub_path) 99 | except AttributeError: 100 | raise SubPathNotFound(f"Could not find sub_path {sub_path} in {current_element}") from None 101 | 102 | def _load_import(self, loader: ConfigueLoader, tag_suffix: str, node: ScalarNode) -> Any: 103 | path = self._load_path(loader, node) 104 | if path is None: 105 | return None 106 | return self._root_loader.load_file(path, tag_suffix[1:]) 107 | 108 | def _load_path(self, loader: ConfigueLoader, node: ScalarNode) -> Optional[str]: 109 | raw_path = loader.construct_scalar(node) 110 | if raw_path is None: 111 | return None 112 | path = os.path.expanduser(raw_path) 113 | return os.path.join(os.path.dirname(self._file_path), path) 114 | 115 | def _load_cfg(self, loader: ConfigueLoader, node: ScalarNode) -> Any: 116 | path = loader.construct_scalar(node) 117 | return self.load(path) 118 | 119 | def _load_ext(self, loader: ConfigueLoader, node: ScalarNode) -> Any: 120 | path = loader.construct_scalar(node) 121 | object_path_elements = path.split(".") 122 | remaining_path_elements: List[str] = [] 123 | while object_path_elements: 124 | try: 125 | loaded_object = loader.find_python_name( 126 | ".".join(object_path_elements), 127 | node.start_mark, 128 | unsafe=True, 129 | ) 130 | break 131 | except ConstructorError: 132 | remaining_path_elements.insert(0, object_path_elements.pop(-1)) 133 | else: 134 | raise NotFoundError(f"Could not load element {path} {node.start_mark}") 135 | remaining_path = ".".join(remaining_path_elements) 136 | if remaining_path: 137 | return self._get_element_at_sub_path(remaining_path, loaded_object) 138 | return loaded_object 139 | -------------------------------------------------------------------------------- /configue/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/illuin-tech/configue/49113af89976d86d7151e9a12aa63eded8ead236/configue/py.typed -------------------------------------------------------------------------------- /configue/root_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | from typing import Any, Dict, List, Optional, Union 4 | 5 | from .file_loader import FileLoader 6 | 7 | 8 | class RootLoader: 9 | logger = logging.getLogger(__name__) 10 | 11 | def __init__(self, file_path: str) -> None: 12 | self._root_file = file_path 13 | self._file_loaders_by_file: Dict[str, FileLoader] = {} 14 | 15 | def load_root_file(self, sub_path: Union[str, List[str]], logging_config_path: Optional[str]) -> Any: 16 | if logging_config_path is not None: 17 | self._load_logging_config(logging_config_path) 18 | return self.load_file(self._root_file, sub_path) 19 | 20 | def _load_logging_config(self, logging_config_path: str) -> None: 21 | logging.captureWarnings(True) 22 | logging_config = self.load_file(self._root_file, logging_config_path) 23 | logging.config.dictConfig(logging_config) 24 | 25 | def load_file(self, file_path: str, sub_path: Union[str, List[str]]) -> Any: 26 | if file_path not in self._file_loaders_by_file: 27 | self._file_loaders_by_file[file_path] = FileLoader(file_path, self) 28 | return self._file_loaders_by_file[file_path].load(sub_path) 29 | -------------------------------------------------------------------------------- /configue/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Union 2 | 3 | from .root_loader import RootLoader 4 | 5 | 6 | def load(file_path: str, sub_path: Union[str, List[str]] = "", *, logging_config_path: Optional[str] = None) -> Any: 7 | """Load configuration from a YAML file. 8 | 9 | :param file_path: Absolute path to the YAML file containing the configuration 10 | :param sub_path: path inside the YAML file to load. List elements are noted as 0-based indexes. 11 | :param logging_config_path: path inside the YAML file that contains the logging configuration. 12 | The format is the same as logging.dictConfig(): 13 | https://docs.python.org/3/library/logging.config.html#logging-config-dictschema 14 | :return: the converting dict corresponding to the file. 15 | 16 | Taking this file as an example: 17 | 18 | top_level_key: 19 | first_level: some_value 20 | other_key: 123 21 | some.dotted.key: dotted.value 22 | some_list: 23 | - item_key: item_value 24 | other_item_key: false 25 | - second_key: ~ 26 | 27 | 28 | Loading the default sub_path (empty string) will return the whole object 29 | { 30 | "top_level_key": { 31 | "first_level": "some_value", 32 | "other_key": 123, 33 | "some.dotted.key": "dotted.value", 34 | "some_list": [{ 35 | "item_key": "item_value", 36 | "other_item_key": False, 37 | }, { 38 | "second_key": None, 39 | }] 40 | } 41 | } 42 | 43 | Loading the sub_path "top_level_key.first_level" will return "some_value". 44 | Loading the sub_path "top_level_key.some_list.0" will return 45 | { 46 | "item_key": "item_value", 47 | "other_item_key": False, 48 | } 49 | Loading the sub_path ["top_level_key", "some.dotted.key"] will return "dotted.value" 50 | """ 51 | 52 | return RootLoader(file_path).load_root_file(sub_path, logging_config_path) 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "configue" 3 | description = "Helpers to load your application configuration from YAML files" 4 | authors = [{ name = "Illuin Technology", email = "contact@illuin.tech" }] 5 | maintainers = [{ name = "Illuin Technology", email = "contact@illuin.tech" }] 6 | requires-python = ">=3.9" 7 | readme = "README.md" 8 | classifiers = [ 9 | "Development Status :: 5 - Production/Stable", 10 | "Intended Audience :: Developers", 11 | "Operating System :: OS Independent", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Topic :: Software Development :: Libraries", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Typing :: Typed" 23 | ] 24 | version = "0.0.0" 25 | dependencies = [ 26 | "pyyaml>=5.1.0,<7.0.0" 27 | ] 28 | 29 | [project.optional-dependencies] 30 | dev = [ 31 | "black==25.1.0", 32 | "build==1.2.2.post1", 33 | "mypy==1.15.0", 34 | "pylint==3.3.7", 35 | "pytest==8.3.5", 36 | "pytest-cov==6.1.1", 37 | "setuptools==80.7.1", 38 | "twine==6.1.0", 39 | "wheel==0.45.1", 40 | ] 41 | 42 | [project.urls] 43 | "Homepage" = "https://github.com/illuin-tech/configue" 44 | "Bug Reports" = "https://github.com/illuin-tech/configue/issues" 45 | "Source" = "https://github.com/illuin-tech/configue/" 46 | 47 | [build-system] 48 | requires = ["setuptools", "wheel"] 49 | build-backend = "setuptools.build_meta" 50 | 51 | [tool.setuptools] 52 | zip-safe = false 53 | platforms = ["any"] 54 | packages.find.include = ["configue", "configue.*"] 55 | package-data.configue = ["py.typed"] 56 | 57 | [tool.coverage] 58 | run.source = ["configue"] 59 | run.branch = true 60 | report.exclude_also = [ 61 | "def __repr__", 62 | "raise NotImplementedError", 63 | "if __name__ == .__main__.:", 64 | "if TYPE_CHECKING:", 65 | ] 66 | 67 | [tool.mypy] 68 | exclude = [ 69 | "env", 70 | ] 71 | strict = true 72 | implicit_reexport = true 73 | ignore_missing_imports = true 74 | 75 | [[tool.mypy.overrides]] 76 | module = "tests.*" 77 | allow_untyped_defs = true 78 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/illuin-tech/configue/49113af89976d86d7151e9a12aa63eded8ead236/tests/__init__.py -------------------------------------------------------------------------------- /tests/external_module.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from logging import Handler, LogRecord 3 | 4 | 5 | class MyObject: 6 | def __init__(self, my_key, my_other_key=None): 7 | self.my_key = my_key 8 | self.my_other_key = my_other_key 9 | 10 | 11 | class CustomHandler(Handler): 12 | def __init__(self, arg): 13 | Handler.__init__(self) 14 | self.arg = arg 15 | 16 | def emit(self, record: LogRecord) -> None: 17 | pass 18 | 19 | 20 | CONSTANT = "constant" 21 | 22 | 23 | class Color(Enum): 24 | RED = "red" 25 | BLUE = "blue" 26 | 27 | 28 | class Static: 29 | @staticmethod 30 | def get_static_value(): 31 | return "foo" 32 | -------------------------------------------------------------------------------- /tests/test_configue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from unittest import TestCase 4 | 5 | import configue 6 | from configue.exceptions import NonCallableError, SubPathNotFound, NotFoundError 7 | from tests.external_module import CONSTANT, MyObject, Color 8 | 9 | 10 | class TestConfigue(TestCase): 11 | def tearDown(self) -> None: 12 | os.environ.pop("ENV_VAR", None) 13 | 14 | def test_load_file_shares_object_instances(self): 15 | result = configue.load(self._get_path("test_file_1.yml"), "key1") 16 | self.assertIs(result["subkey1"], result["subkey4"]["subkey5"]) 17 | self.assertIsInstance(result["subkey1"], dict) 18 | self.assertIs(result["subkey1"], result["subkey4"]["subkey6"]) 19 | self.assertIs(result["subkey2"], result["subkey4"]["subkey7"]) 20 | self.assertIs(result["subkey2"], result["subkey4"]["subkey8"]) 21 | self.assertIsInstance(result["subkey2"], MyObject) 22 | self.assertIs(result["subkey3"], result["subkey4"]["subkey9"]) 23 | self.assertIs(result["subkey3"], result["subkey4"]["subkey10"]) 24 | self.assertIsInstance(result["subkey3"], list) 25 | 26 | def test_load_deep_path(self): 27 | result = configue.load(self._get_path("test_file_1.yml"), "key1.subkey3.1") 28 | self.assertEqual("item2", result) 29 | 30 | def test_load_list_path(self): 31 | result = configue.load(self._get_path("test_file_1.yml"), ["key1", "sub.key.5"]) 32 | self.assertEqual("final_value", result) 33 | 34 | def test_load_with_env_vars(self): 35 | result = configue.load(self._get_path("test_file_1.yml"), "env") 36 | self.assertEqual( 37 | { 38 | "env_key1": None, 39 | "env_key2": None, 40 | "env_key3": "", 41 | "env_key4": None, 42 | "env_key5": "default-value", 43 | "env_key6": 123, 44 | "env_key7": "123", 45 | "env_key8": "prepost", 46 | "env_key9": None, 47 | "env_key10": None, 48 | }, 49 | result, 50 | ) 51 | os.environ["ENV_VAR"] = "my_value" 52 | result = configue.load(self._get_path("test_file_1.yml"), "env") 53 | self.assertEqual( 54 | { 55 | "env_key1": "my_value", 56 | "env_key2": "my_value", 57 | "env_key3": "my_value", 58 | "env_key4": "my_value", 59 | "env_key5": "my_value", 60 | "env_key6": "my_value", 61 | "env_key7": "my_value", 62 | "env_key8": "premy_valuepost", 63 | "env_key9": "my_value", 64 | "env_key10": "my_value", 65 | }, 66 | result, 67 | ) 68 | os.environ["ENV_VAR"] = "321" 69 | result = configue.load(self._get_path("test_file_1.yml"), "env") 70 | self.assertEqual( 71 | { 72 | "env_key1": 321, 73 | "env_key2": 321, 74 | "env_key3": "321", 75 | "env_key4": 321, 76 | "env_key5": 321, 77 | "env_key6": 321, 78 | "env_key7": "321", 79 | "env_key8": "pre321post", 80 | "env_key9": 321, 81 | "env_key10": 321, 82 | }, 83 | result, 84 | ) 85 | os.environ["ENV_VAR"] = "${ENV_VAR_2}-${ENV_VAR_2}" 86 | os.environ["ENV_VAR_2"] = "123" 87 | result = configue.load(self._get_path("test_file_1.yml"), "env") 88 | self.assertEqual( 89 | { 90 | "env_key1": "123-123", 91 | "env_key2": "123-123", 92 | "env_key3": "123-123", 93 | "env_key4": "123-123", 94 | "env_key5": "123-123", 95 | "env_key6": "123-123", 96 | "env_key7": "123-123", 97 | "env_key8": "pre123-123post", 98 | "env_key9": "123-123", 99 | "env_key10": "123-123", 100 | }, 101 | result, 102 | ) 103 | 104 | def test_load_with_imports(self): 105 | os.environ["ENV_VAR"] = "test_file_1" 106 | result = configue.load(self._get_path("test_file_2.yml"), "key1") 107 | self.assertIs(result["value1"], result["value2"]) 108 | self.assertIs(result["value1"], result["value3"]) 109 | self.assertEqual("other_value", result["value1"]["key1"]["subkey1"]["other_key"]) 110 | self.assertEqual("other_value", result["value4"]["other_key"]) 111 | self.assertIsNone(result["value7"]) 112 | 113 | def test_load_without_path(self): 114 | result = configue.load(self._get_path("test_file_1.yml")) 115 | self.assertCountEqual(["key1", "key2", "env"], result.keys()) 116 | 117 | def test_load_with_invalid_class_raises_exception(self): 118 | with self.assertRaises(NonCallableError): 119 | configue.load(self._get_path("test_file_2.yml"), "invalid_class") 120 | 121 | def test_load_invalid_subpath_raises_exception(self): 122 | with self.assertRaises(SubPathNotFound): 123 | configue.load(self._get_path("test_file_1.yml"), "key1.subkey1.other_key.unknown_key") 124 | 125 | with self.assertRaises(SubPathNotFound): 126 | configue.load(self._get_path("test_file_1.yml"), "key1.subkey1.unknown_key") 127 | 128 | with self.assertRaises(SubPathNotFound): 129 | configue.load(self._get_path("test_file_1.yml"), "key1.subkey3.3") 130 | 131 | with self.assertRaises(SubPathNotFound): 132 | configue.load(self._get_path("test_file_1.yml"), "key1.subkey3.unknown_key") 133 | 134 | def test_load_invalid_import_raises_exception(self): 135 | with self.assertRaises(SubPathNotFound): 136 | configue.load(self._get_path("test_file_2.yml"), "invalid_import") 137 | 138 | def test_load_external_value(self): 139 | result = configue.load(self._get_path("test_file_2.yml"), "const") 140 | self.assertEqual(CONSTANT, result) 141 | 142 | def test_null_path(self): 143 | result = configue.load(self._get_path("test_file_2.yml"), "paths") 144 | self.assertIsNone(result["path"]) 145 | self.assertEqual(os.path.expanduser("~"), result["path2"]) 146 | self.assertIsNone(result["path3"]) 147 | 148 | def test_logging_config(self): 149 | configue.load(self._get_path("test_file_2.yml"), "const", logging_config_path="logging_config") 150 | logger = logging.getLogger("test.path") 151 | self.assertEqual(logging.DEBUG, logger.handlers[0].level) 152 | self.assertEqual(logging.ERROR, logger.handlers[1].level) 153 | 154 | def test_load_internal_value_from_other_file(self): 155 | os.environ["ENV_VAR"] = "test_file_1" 156 | result = configue.load(self._get_path("test_file_2.yml"), "key1") 157 | self.assertEqual("other_value", result["value5"]) 158 | self.assertEqual("other_value", result["value6"]) 159 | 160 | def test_ext_enum_loading(self): 161 | result = configue.load(self._get_path("test_file_2.yml"), "enum_loading") 162 | self.assertEqual(Color.RED, result) 163 | 164 | def test_constructor_static_method_loading(self): 165 | result = configue.load(self._get_path("test_file_2.yml"), "static_loading") 166 | self.assertEqual("foo", result) 167 | 168 | def test_constructor_raises_exception_on_constructor_not_found(self): 169 | with self.assertRaises(NotFoundError): 170 | configue.load(self._get_path("test_file_2.yml"), "invalid_loading") 171 | 172 | def test_ext_raises_exception_on_module_not_found(self): 173 | with self.assertRaises(NotFoundError): 174 | configue.load(self._get_path("test_file_2.yml"), "invalid_ext.wrong_module") 175 | 176 | def test_ext_raises_exception_on_submodule_not_found(self): 177 | with self.assertRaises(NotFoundError): 178 | configue.load(self._get_path("test_file_2.yml"), "invalid_ext.wrong_sub_module") 179 | 180 | def test_ext_raises_exception_on_element_not_found(self): 181 | with self.assertRaises(NotFoundError): 182 | configue.load(self._get_path("test_file_2.yml"), "invalid_ext.wrong_element") 183 | 184 | def test_ext_raises_exception_on_property_not_found(self): 185 | with self.assertRaises(NotFoundError): 186 | configue.load(self._get_path("test_file_2.yml"), "invalid_ext.wrong_property") 187 | 188 | def test_loading_empty_file(self): 189 | content = configue.load(self._get_path("test_file_3.yml")) 190 | self.assertIsNone(content) 191 | 192 | @staticmethod 193 | def _get_path(file_name: str) -> str: 194 | return os.path.join(os.path.dirname(__file__), file_name) 195 | -------------------------------------------------------------------------------- /tests/test_file_1.yml: -------------------------------------------------------------------------------- 1 | key1: 2 | subkey1: 3 | other_key: other_value 4 | subkey2: 5 | (): tests.external_module.MyObject 6 | my_key: some_value 7 | subkey3: 8 | - item1 9 | - item2 10 | subkey4: !cfg key2 11 | sub.key.5: final_value 12 | 13 | key2: 14 | subkey5: !cfg key1.subkey1 15 | subkey6: !cfg key1.subkey1 16 | subkey7: !cfg key1.subkey2 17 | subkey8: !cfg key1.subkey2 18 | subkey9: !cfg key1.subkey3 19 | subkey10: !cfg key1.subkey3 20 | 21 | env: 22 | env_key1: ${ENV_VAR} 23 | env_key2: ${ENV_VAR-} 24 | env_key3: "${ENV_VAR-}" 25 | env_key4: ${ENV_VAR-~} 26 | env_key5: ${ENV_VAR-default-value} 27 | env_key6: ${ENV_VAR-123} 28 | env_key7: "${ENV_VAR-123}" 29 | env_key8: pre${ENV_VAR}post 30 | env_key9: | 31 | ${ENV_VAR} 32 | env_key10: > 33 | ${ENV_VAR} 34 | -------------------------------------------------------------------------------- /tests/test_file_2.yml: -------------------------------------------------------------------------------- 1 | key1: 2 | value1: !import test_file_1.yml 3 | value2: !import test_file_1.yml 4 | value3: !import ${ENV_VAR}.yml 5 | value4: !import:key1.subkey1 test_file_1.yml 6 | value5: !cfg key1.value4.other_key 7 | value6: !cfg key1.value2.key1.subkey4.subkey5.other_key 8 | value7: !import ${ENV_PATH-null} 9 | 10 | invalid_class: 11 | (): tests.external_module.CONSTANT 12 | 13 | const: !ext tests.external_module.CONSTANT 14 | paths: 15 | path: !path ${ENV_PATH-null} 16 | path2: !path "${ENV_PATH-~}" 17 | path3: !path ${ENV_PATH-~} 18 | 19 | logging_config: 20 | version: 1 21 | disable_existing_loggers: false 22 | handlers: 23 | console: 24 | class: logging.StreamHandler 25 | level: DEBUG 26 | custom: 27 | \(): tests.external_module.CustomHandler 28 | arg: value 29 | level: ERROR 30 | loggers: 31 | test.path: 32 | handlers: 33 | - console 34 | - custom 35 | 36 | enum_loading: !ext tests.external_module.Color.RED 37 | invalid_ext: 38 | wrong_module: !ext invalid.test.Color.RED 39 | wrong_sub_module: !ext tests.invalid.Color.RED 40 | wrong_element: !ext tests.invalid.Invalid.RED 41 | wrong_property: !ext tests.invalid.Color.GREEN 42 | 43 | static_loading: 44 | (): tests.external_module.Static.get_static_value 45 | 46 | invalid_loading: 47 | (): unknown 48 | -------------------------------------------------------------------------------- /tests/test_file_3.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/illuin-tech/configue/49113af89976d86d7151e9a12aa63eded8ead236/tests/test_file_3.yml --------------------------------------------------------------------------------