Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.15.1 - 2026-10-20
Added
anycfg.user_config_path(name=None)andanycfg.system_config_path(name=None): the platform's configuration directory, ornameinside it, as apathlib.Path—%APPDATA%/%PROGRAMDATA%on Windows,$XDG_CONFIG_HOME(else~/.config) //etcelsewhere. They create nothing.anycfg.default_configs(name): the system and user files calledname, user last, withNonefor one that does not exist, to pass straight to a load:load(*default_configs("app.toml"), default={}).anycfg.load(*sources)andaload(*sources)take several sources and merge their documents in order, later over earlier, with the newmerge=(a strategy asLoadertakes it,"simple"by default).Noneis skipped as an absent layer, and the newdefault=is the result when every source isNone. Each source still gives exactly one document. One source behaves as before, includingload(None)raisingTypeError.
0.15.0 - 2026-10-20
Changed
- A
.cfgfile is no longer INI. It is read in the format it names — a#!<format>first line (removed before parsing) or a format's own marker such as%YAML— and as TOML when it names none, through the newcfgformat;dump(x, "a.cfg")writes TOML. The INI format lost its.cfgsuffix and itscfgalias. What to do: start an INI.cfgfile with#!ini, or load it withformat="ini". tomliis a dependency below Python 3.11, so TOML always reads; thetomlextra now installs onlytomli-w, the writer.
0.14.0 - 2026-10-18
The project is now anycfg: the distribution (pip install anycfg), the
import package (import anycfg), the repository, the entry-point groups
(anycfg.formats, anycfg.sources) and the ANYCFG_CONFINE_TO environment
variable all take the new name; YACONFIGLIB_CONFINE_TO is not read.
yaconfiglib 0.13.1 remains the last release under the old name.
This release replaces the public API. Every entry point, keyword and backend class changed; the migration guide in the documentation maps each old name to its replacement.
Changed
load/dumpchoose the format from the file extension;dumpsneedsformat=, andloadstakes it or a document that names its own.dump(obj, "a.json")writes JSON (it always wrote YAML), sodumpthenloadround-trips. A name no format claims raisesUnsupportedFormatErrorbefore the file is opened, where it used to fall back to YAML for streams andloads. What to do: passformat="yaml"toloads/dumps(or start the document with#!yaml), and name files by their format.- A
stris always a location.loads()takes content; a"#!name\n..."string given toload()as content is gone — pass it toloads(), or useanycfg.sources.Text(data)in a layered load. ConfigLoaderis nowLoader, keyword-only, and no longer a backend..load(*sources)merges;.load_all(*sources)yields every document of every source, including each document of a---YAML stream. Settings are the same set for the constructor and per call, and an unknown keyword raisesTypeErrorinstead of being forwarded to a backend and ignored. Format options go informat_options={"json": {...}}, or as keywords to the one-document functions, where they are checked against what the format accepts.- The trust policy is a
Policyobject, untrusted by default. Commands and template files are refused unlessPolicy(allow_commands=True)/Policy(allow_templates=True)(orPolicy.trusted()); interpolation is sandboxed by default.allow_commands=,sandbox=,strict=andconfine_to=move intoPolicy. What to do: configuration you wrote yourself loads withLoader(policy=Policy.trusted()). - Results are plain
dict/list. Passwrap=TrueforDotAccessibleDict. - Merge strategies are names (
"simple","substitute","deep", or one added withregister_merge), or any callable.MergeMethod,ConfigLoaderMergeMethodand integer values are gone;Last/List/Hashbecamecollect="last","list","hash"withkey=. Nonein an overriding document means "no opinion" in every strategy;simpleused to replace with it. Remove a key withDELETE(!deletein YAML).load_asisload(..., into=Model)andhydrate(Model, data), on the same engine astyped_merge:"false"becomesFalse,"8080"becomes8080,Model | Noneis hydrated on Python 3.10-3.13, and unknown keys are dropped (extra="forbid"raises).intcoercion no longer truncates (1.9raises), and ISO strings becomedate/datetime/time.- Errors. Every parse failure is
ParseError(path,lineno,colno), translated from the parser; it may name a key, section, tag or alias but never a value or a line, and never chains the parser's own error;except yaml.YAMLErrorand the like no longer match.load_error_types()is gone: catch(ConfigError, OSError). Policy refusals arePolicyErrorsubclasses (alsoPermissionError);CommandsDisabledErrorwas aValueError.UnsupportedFormatErroris aValueError, no longer aNotImplementedError;UnknownLoaderErroris merged into it. Template and interpolation failures areTemplateError.anycfg.errorsis nowanycfg.exceptions. - Command sources need a format (
cmd+json://,format=, a#!jsonfirst line, or a script's inner suffix likesecrets.json.sh); output is never sniffed. The scheme must be writtencmd://, notcmd:. A command now has a 60-second default timeout (Policy.command_timeout). Leading indentation of the output is kept. - Environment and dotenv keys keep their case (
lowercase=Truefolds them), and environment values stay strings;Env(..., coerce=True)is gone — types come frominto=. - Globs use
native=False, so**matches the same files on every Python; a bare**pattern loaded nothing on 3.9-3.12. - YAML duplicate keys raise
ParseError.dumpswrites plain data only: mapping and sequence subclasses as plain ones,Enummembers as their values, and anything else raisesConfigTypeErrorinstead of writing a!!python/objecttag thatloadsrefused. - Templates go through tplx, for files and for values. A template file renders
with any engine tplx knows (
app.yaml.j2,app.yaml.liquid,app.yaml.mako) when the policy allows templates; under the default sandbox only the engines tplx can sandbox render (Jinja, Liquid, Mustache, Handlebars, Go, shell, envsubst), and a sandboxed template sees plain data (a path or a date as its text) and includes files only under the confinement roots. Value interpolation usesTemplateInterpolator.interpolate=names the engine —True(Jinja), a tplx engine's name or class,"vars"(${a.b}references,VarsInterpolator, no extra needed) or an interpolator object — and only strings holding that engine's syntax render.interpolator=is gone; the choice is checked when theLoaderis built. AnInterpolatoris now justrender(text, scope), given every string: tplx'sevalis the only syntax check, and a top-level key renders the first time a template reads it.JinjaInterpolator,utils.jinja2and its caches are gone.transform=andkey=take callables; their Jinja-string forms are gone. - Extras:
jinja2andtransformbecametemplates(tplx[jinja]0.2 or later);tomladdstomli-w;allinstalls every extra. Another template engine needs its own tplx extra (tplx[liquid]).
Added
anycfg.formats: theFormatbase class and a registry (register_format,get,for_filename,names), with theanycfg.formatsentry-point group. A plugin's formats are reachable from documents only when named inLoader(formats=...).anycfg.sources:Source,Text,Data,Env,Command, and theanycfg.sourcesentry-point group for schemes.anycfg.testing.FormatContract, the suite every format passes.load_all/loads_all(both withinto=), writers for TOML (tomli-w), INI and dotenv (the INI writer escapes for the interpolation the file will be read with and raisesConfigValueErrorfor anything that would not load back as written),DELETE,register_merge,hydrate,Interpolator,TemplateInterpolator,LoadEvent,BudgetExceededError,MissingDependencyError,TemplateError.Policy.max_includes,max_depth,max_bytes: a load's budget.Policyis a class to subclass:check_path,check_commandandcheck_templatenarrow what its fields allow.anycfg.policieshas mixins that stack (LocalPathsOnly,AllowedSuffixes,AllowedCommands,AllowedEngines), withMixin.using(**settings)andcompose();policy=also takes a list of mixin classes, applied to the default policy in order.- YAML
dialect="1.2"(the YAML 1.2 core schema);Env(coerce=)and the dotenvcoerce=option read values by the same rules or a callable. anycfg.dig(data, *keys, default=None)(andDotAccessibleDict.dig): Puppet'sdigover plain results — keys as given, a miss isdefault.INHERIT(!inherit) and the merge optionnulls="replace", which makesNoneset a key whileINHERITkeeps the earlier value.- Async loading:
aload,aload_all,adump,Loader.aloadandLoader.aload_allread async streams and async iterables of chunks on the event loop (stopping pastmax_bytes) and run the rest of the load in a worker thread.sources.Chunksloads a sync iterable of chunks. - The target type steers the parse. With
into=, YAML keeps each plain scalar it read as a bool, number or date beside its text, and the type decides: astrorDecimalfield getsNO,1.10or0755as written, other fields the read value, or a value built from the text when the read value does not convert. Only the typed build sees the text: merging, includes,transformand duplicate keys behave as withoutinto=.TypedDicttargets (withextra=and required keys) and ordinary classes built from a mapping are hydrated. typed_merge/hydratecoerceLiteral[...],NewType,Set/FrozenSetand aNamedTuplewritten as a mapping. A bool no longer passes as a number,Decimaltakes a float's shortest text, and a failing constructor raisesConfigValueError/ConfigTypeErrorinstead of its own exception.loads()/loads_all()withoutformat=read a document that names its own format — a#!<format>first line (removed before parsing) or a format's marker (YAML's%YAML) — through the newFormat.claims_documentandformats.declared; one that names none raisesUnsupportedFormatError.sources.Texttakes the same route withoutformat=.load()reads one location: a glob matching no file or several raisesConfigValueError, and a list of sources isTypeError.- Type hooks: a classmethod
_load_(cls, value)builds a type from configuration, and_dump_(self)turns it back into plain data.dehydrate()applies them, anddump/dumpscall it, so a dataclass, enum, path orDecimalwrites as plain data anddump(load(path, into=Model), path)round-trips.
Removed
ConfigLoader,ConfigBackendand every backend class,loader=,loader_factory=,path_factory=,DEFAULT_LOADER,load_as,MergeMethod,ConfigLoaderMergeMethod,parse_sources,has_glob_pattern, theanycfg.utilspackage,load_error_types, and backend instances used as PyYAML tag constructors.- Scheme strings no longer reach a URI path layer: a
stris a local path. Pass a pathapiPathobject (anHTTPPath, anSFTPPath) to read a remote source.
Fixed
import anycfgno longer imports PyYAML, Jinja2 or tomllib, and no longer fails whenANYCFG_CONFINE_TOholds a relative path (the load raises instead).- A pathapi
Pathsource is no longer joined tobase_dir, which broke every remote path object. - Interpolation no longer rewrites the caller's data in place, keeps
tupleandsetvalues, renders documents with a non-string top-level key, and raises when a templated key collides with an existing one. - Dotenv drops the spaces after
=, soK= "v"is the quoted valuev. typed_mergereplaces on a shape change instead of failing with a barevars()TypeError.
Security
- An include cycle spelled through
../is detected; it recursed untilRecursionError, or exponentially with errors ignored. deeplist merging compares unhashable items by structure, once per node: a document of aliased lists could make it exponential.typed_merge/hydrate(andinto=) build a node reached twice once, so an aliased document costs its distinct nodes instead of every path through it; data that contains itself raisesConfigValueErrorinstead ofRecursionError.max_bytesstops a file or stream read one 64 KiB chunk past the budget instead of after the whole source is in memory.- An
encodingmust name a text codec; a document's include could namezlib_codecand inflate pastmax_bytes. - A glob pattern is checked against
confine_tobefore the loader asks whether a file of that name exists. - Format selection is a linear suffix lookup; the dotenv name pattern backtracked quadratically on a long, document-controlled name.
- A command's
#!line can select only a document format;#!envreturned the process environment. - Any imported backend subclass was reachable from a document under the hardened settings; registration is now explicit, and plugin formats need the caller's opt-in.
0.13.1 - 2026-10-15
Changed
- The path layer is
pathapi, notpathlib-next.pathlib-nextwas renamed and split: its core is now thepathapidistribution, andpathlib-next0.10.1 is only a deprecation shim over it. The dependency ispathapi>=0.1.0,<0.2, and yaconfiglib no longer installspathlib-next. The paths a load accepts and returns (Path,LocalPath, and theMemPaththat stands for an in-memory document) are nowpathapiclasses. Nothing about how sources resolve, glob or merge changed.
What to do: if your code imports pathlib_next for these types, import them from
pathapi instead (from pathapi import Path, LocalPath, from pathapi.mempath import
MemPath), and depend on pathapi yourself rather than relying on yaconfiglib to
bring pathlib_next. A path object made by pathlib-next 0.9 is not a pathapi
Path, so build such paths with pathapi (or pathlib-next 0.10.1, whose names are
the pathapi classes). A URI source (sftp://, http://) needs the distribution
that provides it (pathapi-uri, sftppath).
0.13.0 - 2026-09-20
Removed
- The stdlib path fallback. Every
from pathlib_next import ...is now unconditional, in all eleven modules that had atry/except ImportErroraround it, and theHAS_PATHLIB_NEXTflag, the temp-file materializer it fed and itsatexitcleanup are gone. pathlib-next is a declared dependency, so a missing one now raisesImportErrorat import time like any other — where before the package imported and then answered differently: a**source expanded to nothing instead of failing. Measured on the current suite with pathlib-next absent: 33 failures and 7 errors, against the 5 on record when the fallback was last examined. ConfigLoader(log_level=...). It accepted a value, warned, and did nothing for one release; passing it now raisesTypeError. Configure theyaconfigliblogger through theloggingmodule.- The
envoriment=alias on the Jinja2 backend, a historical misspelling kept besideenvironment=. Useenvironment=.
Passing path_factory=pathlib.Path remains fully supported, and is better than it was:
see Fixed.
Fixed
- A glob under
path_factory=pathlib.Pathexpands like any other. Such a source was handed to stdlibglobasparent.glob(name), which cannot expand**— it looks inside a directory literally named**— so arecursive=source expanded to nothing at all, silently, whileon_error=andbound_loops=were inert. A stdlib path is now converted to aLocalPathfor the walk and every match converted back with the caller's own factory, so the factory decides the path type a caller receives and never which glob runs. Matches are stillpathlib.Pathinstances.
0.12.0 - 2026-09-17
Security
- Interpolation DEBUG logs no longer include rendered values, which can be secrets a template fetched from the environment. A record now names the key and the template text instead.
!include/!loadare no longer registered on the sharedyaml.SafeLoader. Previously, after any yaconfiglib YAML load, everyyaml.safe_load()call in the same process resolved!include, so an untrusted document parsed by unrelated code could runcmd://commands or read local files. The tags are now registered on a private subclass, and only documents loaded through aConfigLoaderresolve them. Code that relied onyaml.safe_loadresolving!includeafter a yaconfiglib load must load through aConfigLoaderinstead.- An untrusted document can no longer weaken the caller's settings through an
!includemapping. Previously a mapping could setallow_commands: trueorsandbox: falsefor the included file, run arbitrary Python through atransformor%-formkey_factoryexpression, or call a method on the included path through a plainkey_factory(key_factory: unlinkdeleted the file). - A per-call
load(..., allow_commands=False)orload(..., sandbox=True)now also applies to nested!includetargets. Previously the included files were loaded with the instance settings. .j2/.jinja2sources now render in Jinja2's sandbox wheneversandbox=Trueorallow_commands=Falseis in effect, including ones reached through!include(an in-memory#!name.yaml.j2document included the same way). Previously they always rendered in the non-sandboxed default environment, so a template could run arbitrary Python even under both controls. A rendered document that is a command source is refused whileallow_commands=False.
Added
ConfigLoader(confine_to=...)andyaconfiglib.ConfinementError: opt-in confinement of file reads to allowed roots. With it set, an!include(or a top-level source) that resolves outside every root raisesConfinementError— aPermissionError, and so anOSError— before the file is opened, which closes the file-disclosure gap the security guide documented: an untrusted document could otherwise read any file the process can through an absolute path or..traversal. It takes a sequence of roots, oneos.pathsep-separated string (so the value can come from an environment variable),Truemeaningbase_dir, orFalse/Nonefor off;YACONFIGLIB_CONFINE_TOis read only when the argument isNone, and an empty allowlist refuses every local read rather than disabling the control. Containment is decided on the logical path (absolute,..-resolved, case-folded where the platform is): symlinks are deliberately not resolved, so a link inside a root may point outside it — the boundary is write access to a root. Command sources and in-memory#!documents are exempt. A path object that is not a local filesystem path is refused, being inside no local root — note that ascheme://…string is not one of those with the defaultpath_factory, which builds local paths: it is checked as the relative filename it becomes. Off by default, so no existing load changes — unlessYACONFIGLIB_CONFINE_TOis set in the environment, which confines every loader constructed without an explicitconfine_to=.bound_loops=onConfigLoaderandparse_sources: bound a Windows junction loop — a junction pointing at one of its own ancestors — which unbounded makes a recursive glob walk the loop until the filesystem refuses the path, failing the load withOSError(or, underignore_error, merging the loop's files many times over). Defaults toTrue(see Changed);bound_loops=Falserestores pathlib's unbounded walk. A POSIX directory symlink is never descended by**in the first place, so nothing changes there.yaconfiglib.CommandError(also asubprocess.CalledProcessError) andyaconfiglib.CommandTimeoutError(also asubprocess.TimeoutExpired), so a command failure can be caught either as the stdlib type it always was or as ayaconfiglib.ConfigError.parse_sources(on_error=...): a callback(error, directory) -> boolcalled when glob expansion cannot list a directory. ReturnTrueto skip that directory and keep expanding; anything falsy lets theOSErrorpropagate. It is asked once per directory, and a missing or non-directory parent is not reported at all (it simply matches nothing).yaconfiglib.ConfigErrorand the subclassesConfigValueError,ConfigTypeError,UnsupportedFormatError,UnknownLoaderErrorandCommandsDisabledError. Each is also an instance of the builtin exception the same condition raised before, so existingexcept ValueError/except NotImplementedErrorclauses keep working; a parser's own errors are still raised unwrapped.yaconfiglib.load_error_types()returns the exception types a load can raise for a configuration or I/O reason, for a singleexcept yaconfiglib.load_error_types():clause. It includes PyYAML's, Jinja2's and TOML's base errors only for parsers already imported, so it needs no optional extra.DotAccessibleDictis importable fromyaconfiglib(and listed in__all__), so the typeload()returns can be named in annotations andisinstancechecks.del config.keyremoves that key, mirroringconfig.key = value.DotAccessibleDict.get()accepts a tuple path —get(("metadata", "labels", "app.kubernetes.io/name"))— which reaches keys containing dots, integer keys, and list indexes without any string parsing.yaconfiglib.utils.source.CommandSource, thestrsubclassparse_sourcesyields for a command URI. It exposesscheme,formatandcommand, and answersname,stemandas_posix()with the whole text.- Python 3.14 is tested in CI and declared in the package classifiers.
ini_interpolation(per call) andIniConfig(interpolation=...)choose how%is handled in an INI file:"basic"(the default, unchanged),"extended"for${section:key}references, orNoneto read values verbatim. A logging or alembic formatter value such as%(levelname)-5.5s [%(name)s] %(message)scould not be loaded at all before — it raisedInterpolationSyntaxErrorwith no way to opt out.DotenvBackend(strict=True)and the per-calldotenv_strict=Truemake an unparseable.envline, or a file with no assignment at all, raiseValueError.yaconfiglib.load_as(model_cls, *sources, **options): the top-level form the README and the model guide already showed. It takes several sources, and routes keywords likeyaconfiglib.load(), sostrict/base_dir/mergeconfigure the loader.YamlConfig.load(origin=...): the document that relative!include/!loadpaths resolve against. Defaults to the file being parsed; a rendered.j2template passes its own path.timeout=for command sources, e.g.loader.load("cmd://...", timeout=30): after that many seconds the command and its child processes are killed andsubprocess.TimeoutExpiredis raised. There is still no timeout by default.ConfigLoader.load_all()acceptssandbox=andallow_commands=per call; they also apply to nested!includetargets.
Changed
- A recursive glob now bounds a directory loop by default (
bound_loops=True). A**source that crossed a Windows junction loop used to walk it until the filesystem refused the path, so the load failed withOSError— or, underignore_error, merged the loop's files once per lap. That is now bounded unless you ask for the old walk withbound_loops=False. The default could not be this until pathlib-next 0.9.9: before it, bounding also dropped a directory deliberately reachable under two names, because the bound keyed on every directory seen rather than on the current descent path. The floor moves topathlib-next>=0.9.9for that rule, and a load that relied on a looped tree failing now succeeds instead. from yaconfiglib.utils.merge import *exports only the merge API and the typed-merge helpers; it previously also pulled in the module's internals.ConfigBackendis a regular base class instead of atyping.Protocol.issubclass()/isinstance()checks against it now work — they raisedTypeError— and type checkers no longer reportConfigLoader(),EnvVarBackend(),PythonBackend()or a custom backend as abstract. Registration was already nominal, so dispatch is unchanged; the one behaviour difference is that an object which merely looks like a backend is no longer anisinstanceof it — subclassConfigBackendinstead.yaconfiglib.utilsno longer re-exports the internal namesTandannotations, which a star-import of itsenummodule leaked.- While sniffing a command's output format, only a parse failure now means "try the next
format". Anything else — a disabled command, an error from an
!includeinside the output, aTypeError, a missing file — propagates instead of falling through to another format or to the raw output. Fix the reported error, or passformat=/cmd+<fmt>://to choose the format outright. - With
ConfigLoader(ignore_error=...),load_all()yields a document with the failing value unrendered instead of skipping the document entirely, and an interpolation failure is offered to a predicate once per failing value withphase="interpolate"andkey=its path. To keep failing on template errors, return False from the predicate whenphase == "interpolate". - The
pathlib-nextfloor is now>=0.9.7,<0.10(was>=0.9.6), because glob expansion now uses itsglob(on_error=)hook, added in 0.9.7. - An
ignore_errorpredicate now always receivesphase=,path=andloader=by keyword, in every phase.phaseis"load","include","merge"or"interpolate";load()'s interpolation offer addsresult=andload_all()addsvalue=. The three call sites used to pass different keywords, so a predicate written with explicit parameters raisedTypeErrorin whichever phase it had not been written for. Predicates that accept**contextare unaffected. - A failure skipped by
ignore_error=Trueis logged at WARNING, naming the source, the phase and the error's type. It was logged only at DEBUG, so a blanketignore_error=Truemade failures invisible in a default logging configuration. The line never includes the error's message text, which can quote a configuration line; pass a predicate instead ofTrueto keep chosen skips at DEBUG. - A malformed
!include/!loadmapping with nopathname, or an empty!include [], raisesyaml.constructor.ConstructorErrornaming the file and line. They raised a bareKeyError: 'pathname'and an unpackingValueError, neither of which said where. - The unknown-format and unknown-loader errors now list the loader names
loader=accepts, and the format error readsNo backend reads <path>instead ofNot reader for <path>. Code matching the old text should catchyaconfiglib.UnsupportedFormatError(still aNotImplementedError) oryaconfiglib.UnknownLoaderError(still aValueError) instead. yaconfiglib.dumps()anddump()keep mapping keys in their original order and write non-ASCII characters as-is; they sorted keys and escaped every non-ASCII character, so a written configuration no longer resembled the one that was read. Passsort_keys=Trueorallow_unicode=Falsefor the previous output.dump()to a text file whose encoding is not a UTF codec (for exampleopen(path, "w")on Windows) still escapes non-ASCII, since that codec cannot hold it.yaconfiglib.dumps(..., encoding=...)raisesTypeError; it returnedbytesfrom a function documented to returnstr. Call.encode()on the result, or usedump(obj, fp, encoding=...).yaconfiglib.load(None)raisesTypeErrorandyaconfiglib.load("")raisesValueError; both returnedNone, so an unset config path loaded as an empty configuration. Check the path before calling, or pass it among the sources ofConfigLoader().load(...), which still skips a falsy source.- With
merge="hash", an open file's key is its file stem (settingsforsettings.toml) instead ofstream-<n>. An open.j2file is rendered as a template, like the same file loaded by path. - Assigning or deleting an attribute whose name the class defines (
items,get,copy,keys, ...) now raisesAttributeErrorinstead of silently storing a key that attribute reads could never return — reads of those names resolve to the method, as they must for adictsubclass. Useconfig["items"] = ...for the key, orobject.__setattr__for a real attribute in a subclass. - Values assigned to a loaded configuration after it is built are stored exactly as
given:
config["x"] = {...}keeps a plain dict, rather than that dict becoming dot-accessible on the next read. Wrap it yourself (DotAccessibleDict({...})) if dot access is wanted. .bat/.cmdsources run on Windows only and refuse a path containing%;.ps1requirespwshorpowershelland obeys the machine's execution policy;.shon Windows requiresshonPATH. Each raises a clearValueError/FileNotFoundErrorinstead of failing obscurely.- An in-memory source handed to the command backend must have a script extension
(
.sh,.bat,.ps1,.cmd). Use acmd://source to run a shell command. - A command source's default merge key and its
pathnamein akey_factoryortransformexpression are now its full source text rather than a fragment of it. AHashmerge keyed on the old value has to use the wholecmd+json://...string. CommandBackend.PATHNAME_REGEXmatches script extensions only (.sh,.bat,.ps1,.cmd); scheme recognition moved to the source type.CMD+JSON://(any case) now selects thejsonformat.- A source of an unsupported type raises
ValueErrorbefore any source loads, rather than after the earlier ones have been read. - A directory that cannot be listed during glob expansion (a permission error, say) is
now skipped silently instead of raising: the expansion machinery in
pathlib-next0.9.4+ swallows the error, so it never reachesignore_erroreither. Name a file explicitly if its absence has to be an error. - Glob matches now merge in a fixed order — path components compared by code point — on
every operating system and filesystem, where the order used to be whatever the
directory listing returned. A layered set of globbed files that relied on listing order
may resolve differently; give such files sortable prefixes (
00-base.yaml,99-local.yaml). - Wildcards match dotfiles, following
pathlib. - The
yamlextra now requires PyYAML>=6.0,<7, and thejinja2andtransformextras Jinja2>=3.0,<4. An environment pinned to Jinja2 2.x or to PyYAML below 6.0 no longer resolves: Jinja2 2.x cannot import beside MarkupSafe 2.1 or later, and PyYAML below 6.0 publishes no wheels for Python 3.11+. Raise the pin. - The documentation site is rebuilt from
mainwhenever documentation or source files change, not only when a release is published. Between releases the published site can therefore describe behaviour that is not on PyPI yet. - The source distribution no longer includes
benchmarks/, and files matching*.local.*are excluded from both the source distribution and the wheel. EnvVarBackendwithnested_delimiterraisesValueErrornaming both variables when one variable is a plain value and another nests keys under it (APP_DBalongsideAPP_DB__PORT). Which one won previously depended on the order ofos.environ, so the same environment could produce different configuration. Rename or remove one of them.- On Python 3.9/3.10 the
tomlextra now installstomli, the backport of the standard librarytomllib, instead of the unmaintainedtomlpackage — which is no longer used even when it is installed. TOML 1.0 files therefore parse on 3.9/3.10 exactly as they do on 3.11+: mixed-type arrays load instead of raising, a lowercasezdatetime keeps its UTC offset instead of coming back naive, and offset datetimes can be pickled. Reinstall withpip install "yaconfiglib[toml]"on 3.9/3.10; on 3.11+ the extra installs nothing. Note thattomli2.4+ accepts some TOML 1.1 syntax that 3.11-3.14'stomllibrejects, so a TOML 1.1-only file is not portable across interpreters. .envdouble-quoted values decode\n,\r,\t,\"and\\, so a value that needs a literal backslash (a Windows path) belongs in single quotes, which stay raw.- An unterminated quoted
.envvalue raisesValueErrorinstead of silently swallowing the rest of the file. - A quoted
.envvalue ends at its first matching quote, and only whitespace or a#comment may follow it.KEY='it's here'andKEY="say "hi""used to load whole and are now skipped with a warning — writeKEY="it's here"or escape the inner quotes. - In an unquoted
.envvalue quotes are ordinary characters, soKEY=a "b # c"givesa "b. Quote the whole value to keep the#. - A
.envline that cannot be parsed is skipped with a logged warning rather than in silence. typed_mergecoerces mapping keys through aDict[K, V]hint's key type, soDict[int, str]can be satisfied by JSON, TOML, INI or env sources, which only produce string keys. Keys that differ only by type ("80"and80) now merge into one entry.typed_mergeinto aTypedNamespacesubclass assembles the result without calling__init__, to avoid re-parsing values. If a subclass's__init__did more than apply_parse_<field>hooks, move that work into a hook or into__merge__.typed_mergeparses string booleans for aboolhint:true/yes/on/1andfalse/no/off/0(stripped, case-insensitive). Any other string now raisesValueError, where every non-empty string used to becomeTrue— including"false". Use one of the listed words.typed_mergeraisesTypeErrorwhen a sequence hint is given a string, a mapping or a non-iterable, instead of splitting it into characters or keys. Pass a list.typed_mergeraisesTypeErrorwhen aTuple[...]hint's length does not match the value's, instead of dropping the extra items.typed_merge: aNonesource no longer overrides a value from an earlier source. To clear a field, pass an explicit empty value ("",[],{}) instead ofNone.typed_merge: in a multi-member union, a value that is already an instance of one of the members is kept as it is instead of being coerced through the first member —Union[int, str]fed"8080"now returns"8080", andUnion[str, int]fed5returns5. To force a coercion, narrow the hint to the one type you want (int, notUnion[int, str]); reordering the union members does not restore the old result.is_array()returnsFalseforbytearrayandmemoryview, matchingbytes. Binary buffers are values, so merging replaces them instead of combining them byte-wise.load_asbuilds dataclass fields that are annotated as a dataclass or Pydantic model (includingOptional[...]of one) as instances instead of leaving them as dicts. Code that indexed such a field (cfg.db["host"]) must use attributes (cfg.db.host). Containers of models (List[Model]) are still left as loaded.- Options that
ConfigLoaderitself accepts (merge,encoding,recursive,interpolate, ...) passed toyaconfiglib.load()/loads()now configure the loader, so they also apply to files pulled in with!include— a per-callmerge="deep"now deep-merges an included glob, as the same option onConfigLoader(...)always did. A keyword that no backend reads is ignored rather than rejected, so check the spelling of an option that seems to have no effect. - With
interpolate=True, a bare{{ expr }}keeps the expression's type only when nothing but a trailing newline surrounds it; with leading or trailing spaces the value renders as a string. A YAML|/>block scalar still yields the expression's type. - With
strict=True, a reference cycle between two top-level keys raisesValueError: interpolation reference cycle: a -> b -> ainstead of rendering each value once against the other's current text. - An included file's templates now render in the merged document, so a reference to one
of that file's own keys must go through the key it was included under
(
{{ svc.name }}rather than{{ name }}). - A relative
!include/!loadpath is now resolved relative to the including file — that file's own directory, at every depth,.yaml.j2templates included. Previously it resolved against the loader'sbase_dir(by default the working directory), so a file in a subdirectory could not name its neighbours, and an unrelated file with the same name could be loaded instead. Documents that are not files (loads(),#!strings, streams, command output) still resolve their includes againstbase_dir, and so do the sources you pass toload()yourself. An included source'spathname, as seen bytransformandkey_factory, is now absolute. Include paths written relative tobase_dirfrom a file in a subdirectory must be rewritten relative to that file, or made absolute. - The
!includemapping form accepts onlypathname,encoding,transform,key_factory,default,flatten,merge,merge_optionsandrecursive; other keys are ignored and logged atWARNING.key_factorythere must use the"%<expr>"form. transformand%-formkey_factoryexpressions are evaluated in Jinja2's sandboxed environment wheneversandbox=Trueorallow_commands=Falseis in effect.CommandsDisabledErroris now defined inyaconfiglib.utils.trust; importing it fromyaconfigliboryaconfiglib.loaderkeeps working.- With
strict=True, an undefined variable in a.j2/.jinja2source now raisesUndefinedErrorinstead of rendering as an empty string. - Passing a non-sandboxed
environment=for a.j2/.jinja2source whilesandbox=Trueorallow_commands=Falseis in effect raisesValueError; pass ajinja2.sandbox.SandboxedEnvironmentinstead. - With
inject_env=True, templates receive a read-only snapshot ofos.environasenv. Previouslyenvwas the liveos.environ, so a template could change or delete process environment variables, including ones later command sources saw.
Deprecated
ConfigLoader(log_level=...)(and the same keyword throughyaconfiglib.load()) has no effect and now warns. It has not changed logging since 0.11.0, when setting the level on a shared logger was removed. Configure theyaconfigliblogger with theloggingmodule instead.
Removed
yaconfiglib.utils.getLoggerand theyaconfiglib.utils.Loggerre-export, neither documented nor used anywhere. Uselogging.getLoggerandlogging.Logger.
Fixed
- Under
confine_to=, a glob pattern is checked before it is expanded, and a refused match no longer reports the filename expansion found. Both were disclosure paths rather than read paths: a pattern aimed outside the roots used to be expanded anyway — listing directories outside them — and the refusal then named a real file, so a document could discover filenames by guessing a prefix (!include '/etc/*.yaml'). The pattern's literal base is now required to be inside a root, each match is checked as well (a wildcard can still leave a root after a legal prefix), and a match refusal names the pattern, with the resolved path logged at DEBUG. A refused stream or pattern can now also be skipped byignore_error, which only a refused path could before. - A
confine_to=root must now be an absolute path; anything else raisesConfigTypeErrorinstead of being resolved against the process working directory. That silent resolution was how a typo, a""list entry or a stray space after a separator inYACONFIGLIB_CONFINE_TObecame a root under the working directory —confine_to=[""]allowed the whole of it, and an environment value of"/a/conf: /b/conf"dropped the second root and added a cwd-relative one in its place. Empty entries are now dropped in the sequence form as they always were in the string form, so[""]is an empty allowlist. A whole UNC share is also usable as a root now (\\server\share):os.path.commonpathtreats a bare share as a relative path, so such a root previously refused every file inside it. confine_to=no longer fails open in four ways a caller could reach by accident. Passing it to an existing loader's.load()/.load_all()now raisesConfigTypeErrorinstead of being forwarded to the backend and ignored — it is a constructor setting, and a caller who wrote it there had no confinement and no warning (the module-levelload/loads/load_asroute it correctly and are unchanged). Assigningloader.confine_toafter construction is honoured, where it used to change what the loader reported without changing what it enforced, in both directions. An open stream is now checked against the roots by the file itsnamenames, soload(open(path), confine_to=...)refuses a path outside them; a stream with no real name (sys.stdin, a descriptor, aStringIO) stays exempt. And an!includeinside a command's output is confined: that output is parsed by a fresh loader, which was therefore checking against no roots at all while the same include in a file was refused.- An
!includeinside the output of a top-level command source is read with the load'sencoding=. It was read as UTF-8 whatever the call passed, so a non-UTF-8 include raisedUnicodeDecodeError— with a hint that told the caller to pass the very encoding they had passed. An include inside a command reached through another!includewas already correct, and a mapping-formencoding:on a command include now reaches that command's output and what the output includes. - The merge API type-checks as documented:
ConfigLoaderMergeMethodis seen as a normal enum, soConfigLoaderMergeMethod.Deepandisinstancechecks work (the class is built at runtime byMergeMethod.extend, typedtype[IntEnum], so a checker saw no members);merge="deep"is accepted wherever a strategy is;typed_merge()acceptsOptional[...], unions and generic hints and is typed as returningNonewhen called with no objects;@opaquekeeps the decorated class's type; and importingtyped_merge,OpaqueMerge,opaqueandTypedNamespacefromyaconfiglib.utils.mergesatisfies a strict checker's re-export rule. - Backend types say what they actually are:
Jinja2ConfigLoader.load()is typed as returning the parsed document (it claimedNone), a name-only backend (PATHNAME_REGEX = None) type-checks,YamlConfigacceptsloader_cls=yaml.SafeLoader/CSafeLoaderand any dumper class (the previous hints rejected the backend's own defaults, sinceSafeLoaderdoes not subclassBaseLoader), backenddumps()accepts the data it serializes, andConfigBackend.get_class_by_name()is typed — and now explicitly returns —Nonefor an unknown name. typing.get_type_hints()now works on Python 3.9 — the supported floor — for the whole public API (ConfigLoader,load/loads,parse_sources, the Jinja2 helpers, the merge methods, every backend). Hints usedX | Yandtyping.Self, which 3.9 cannot evaluate, so anything introspecting the API there failed on most of it.- Type checkers now accept the documented calls. Results of
load()/loads()/ConfigLoader.load()/load_all()andDotAccessibleDictattribute access are typedAnyrather thanobject, soconfig.database.hostchecks;loader=accepts a backend instance or a callable;key_factoryaccepts a string at construction and its callable form is the(path, value)callable it is actually called with;loader_factoryis typed as the(path) -> backendcallable it is called as (passing a backend class type-checked but never worked);key_factory/loader_factorycallables are typed as receiving a path or, for a command URI, aCommandSource;ignore_error=predicates are checked against the keywords they receive, so one that accepts only the error — and fails at runtime — is reported; path parameters acceptos.PathLikesuch aspathlib.Path;load_all()accepts the same sources asload(); and parameters defaulting toNoneacceptNone. Code that runs today needs no change, andcast()/# type: ignoreworkarounds for these can go. typed_merge()andload_as()errors name the field path and the model (... [at db.port (AppConfig)], andports[1]for a list item), readable aserror.config_keyanderror.config_model. A coercion failure reported only the value, which in a large configuration could have been any field. Exception types are unchanged, andtyped_merge's own refusals are nowyaconfiglib.ConfigErrorsubclasses as well.load_as()says when no source was loaded, instead of giving a glob that matched nothing the same message as a document of the wrong shape. That message now also names the model and the type actually loaded:load_as(AppConfig) needs a mapping, got list.- A failing command source's error message now ends with the tail of the command's stderr — the reason the tool gave — where before it said only "returned non-zero exit status N" and the reason was reachable only by inspecting the exception. stdout is never included, since it is the payload. stderr from a command that succeeded is logged at DEBUG instead of being dropped.
- When no output format is given, an
!includethat fails inside a command's output now raises instead of being treated as "this format does not fit". A missing secrets file used to be sniffed past, and the document came back empty. - A parse failure now names the command and every format tried, and chains the parser's
own error as
__cause__; it said onlyFailed to parse command output as [...]with no cause. - With
ignore_error, one failing template no longer deletes the rest of its section. Only that value is left unrendered, with its template text as written, and everything else in the document still renders; previously the failing key and every sibling in its container were dropped, and the load reported success. Interpolation errors also name the key they happened at (... [at database.password], andservers[0].hostfor a list), readable aserror.config_key. - A directory that glob expansion cannot list is no longer skipped in silence: the
OSErroris raised, andignore_erroris consulted for it withphase="glob"andpath=that directory. Previously such a directory vanished from the expansion without an error and without reachingignore_error, so a whole layer of configuration could disappear — and a glob could not be used to assert that a layer was read. Skipping one unreadable directory still loads the rest of the pattern. - Load errors now name the file that failed. This covers YAML (which reported
in "<unicode string>", so a failing member of a glob was unidentifiable), JSON, TOML, INI (the full path, not just the basename), a file the encoding cannot decode (with a hint naming a codec to try),.j2templates (which reportedFile "<unknown>"), files reached through!include(naming the including file and line, at every depth), and a failure while merging a source. The exception type and identity are unchanged, soexceptclauses andignore_errorpredicates behave exactly as before; the same details are readable aserror.config_source,error.config_framesanderror.config_key. - Tuples are written as plain YAML lists. They were
!!python/tuple, which this library's own loader refuses — and interpolating a bare{{ expr }}can produce one, so a config could dump to something it could not read back. yaconfiglib.dump(obj, path, encoding=...)writes in that encoding; it raisedTypeError: write() argument must be str, not bytes.dump()now also accepts binary file objects (open(path, "wb"),io.BytesIO,gzip.open) and pathlib-next paths such asMemPath, which raisedNotImplementedError: __fspath__. A binary target withencoding="utf-16"gets a single BOM; it used to get one before every written chunk, which did not load back.yaconfiglib.loads()honors a first line such as#!settings.tomlthat names a recognized format, asConfigLoader.load()does; the line was read as a YAML comment and the document misparsed. A first line no backend recognizes (a real shebang, a script name) is still content.yaconfiglib.loads(data, encoding=...)accepts bytes in any codec, includingutf-8-sig,utf-16andutf-32; it raised aTypeErrorabout paths.bytearrayis accepted too, and any other type now raisesTypeErrornaming whatloads()expects.yaconfiglib.load(open("settings.toml")), binary"rb"files, and.env,.iniand.jsonfile objects now parse with the backend matching the file's name. Every open file was parsed as YAML, so a TOML or.envfile came back as one string and JSON exponents as strings. A file whose name no backend recognizes (<stdin>,.gz) still parses as YAML; passloader=to choose.- File-like objects that are not
ioclasses —codecs.open(),tempfile.SpooledTemporaryFileon Python 3.9/3.10, custom readers — are read as streams. They were iterated line by line, and each line was loaded as a file path, glob or command, so a file listing other file names loaded those files instead of itself. - YAML strings and text streams holding Windows line endings (
\r\n) no longer gain a blank line per line break on Windows. Block (|), folded (>) and multi-line quoted values now load exactly as they do from a file. - A string or text-stream source with characters the loader's
encoding=cannot represent (for example Japanese text withencoding="cp1252") now loads instead of raisingUnicodeEncodeError. The document is stored as UTF-8 and read back as UTF-8. parse_sources()withoutencoding=stores in-memory text as UTF-8 on every platform; it used the locale codec (cp1252 on Windows), so a document with non-Latin-1 characters failed to load there and could not load at all.- An in-memory document whose
#!nameline ends in\r\nnow loads; it raisedNo backend reads name(then wordedNot reader for name). ConfigLoader.load()of a bytes document in a BOM or UTF-16/UTF-32encoding=now loads; it raisedValueError: not enough values to unpack.- A missing attribute on a loaded configuration raises
AttributeErrornaming the real class, with no chainedKeyErrorbehind it. copy(),|and|=on a loaded configuration keep returning a dot-accessible mapping instead of a plaindict, and|defers to the other operand (NotImplemented) when it is not a mapping.DotAccessibleDict.get()returns the default for a missing non-string key instead of raisingTypeError: argument of type 'int' is not iterable.- A dotted
get()returns an explicitnullleaf asNone, matching top-levelget()and attribute access; only anullor missing intermediate yields the default.get("db.password")onpassword: nullno longer looks like an absent key. - Dotted paths index lists and tuples:
get("servers.0.host"). A digit segment on a mapping is still the string key, soget("codes.0")is unambiguous, and a negative or out-of-range index returns the default. - Reading a configuration no longer modifies it.
config.get("missing", {})used to store that default under the missing key, and every attribute orget()read of a nested mapping replaced the stored value with a new wrapper — soconfig["db"] is config.dbcould be false, a held reference went stale, and two YAML anchor aliases stopped being the same object. Nested mappings are now converted once, when the result is built, and a miss returns the default without inserting it. - Mappings inside lists and tuples are dot-accessible, as are
List-merge results and each document fromload_all(). Previously only a top-level dict was wrapped, soconfig.services[0].nameraisedAttributeError. - Loading no longer aliases the objects a backend returned: writing to a loaded result
cannot reach back into a
PythonBackendsource or an included document. - Command output that does not decode with the requested codec (default
utf-8) raises a clear error namingencoding=, instead of silently replacing bytes withU+FFFD. A non-ASCII secret from a tool writing another code page used to arrive corrupted, and the failure surfaced only wherever that value was later used. Passencoding=— for exampleoemon Windows forcmd,.bator PowerShell output. A command that exits non-zero still raisesCalledProcessErrorwith its output attached, decoded leniently. - Script files run through the right interpreter on each platform: a
.ps1runs PowerShell instead of whatever the file association opens, a POSIX.shneeds no execute bit (it falls back to/bin/sh), and a script path containing a space,&,^or quotes neither breaks nor injects a second command — script files are launched withshell=False, so their names are no longer shell syntax. A relative script path always runs the file in the current directory rather than searchingPATH. - An in-memory
#!name.shdocument, and agen.sh.j2/gen.bat.j2template, run their own (rendered) body. They previously ran whatever program of that name the shell happened to resolve. cmd://,exec://andsh://text reaches the shell exactly as written. A path factory used to rewrite it first: on Windows every/became\, and on POSIX//,/./and trailing slashes were collapsed — so a secrets-manager URL, a relative path argument, or evenprint(10/4)in an inline script arrived corrupted.- A file whose name merely starts with
sh:,cmd:orexec:loads by its extension instead of being executed. Only a string source can be a command; a path object is always a file. - A
Hashmerge logs a warning when two sources produce the same key — for exampleservices/*/config.yaml, whose stems are allconfig, where only the last document was kept and nothing said so. The later document still wins; passkey_factory(such aslambda path, value: path.parent.name) to keep every source. - The same file named two ways now loads once:
conf/app.yaml,./conf/app.yaml,conf/../conf/app.yamland, on a case-insensitive filesystem,conf/App.yamlare one source. Duplicate detection compares normalized absolute paths instead of the string each source was spelled with. - A file named explicitly no longer loads twice when a glob in the same call also matches
it. The explicit name wins and keeps its position, so both
load("base.yaml", "*.yaml")andload("*.yaml", "local.yaml")layer as intended, and two overlapping globs yield each file once. - Glob characters in
base_dir, in an existing file's name, or in a Windows extended-length (\\?\) prefix no longer turn a path into a pattern. Abase_dircalledproj [v2]works, a file really namedz[1].jsonloads as itself, andhas_glob_pattern()looks only outside a path's anchor. - A glob that matches a directory skips it instead of failing with
NotImplementedErrorfor the directory, soenvs/*loads the files underenvs/. - Sources given as a
pathlib.Path(or any otheros.PathLike, including aPurePathor an object that just defines__fspath__) now load instead of raisingValueError: unable to handle arg ... of type <class 'pathlib.WindowsPath'>. They get the samebase_dir,path_factoryand glob handling as the equivalent string, sostr(path)workarounds can be dropped. - Glob sources work again with current
pathlib-next. 0.9.4 removed theglob("")spelling this package used to expand a pattern, for parity withpathlib, so every glob source and everyrecursive=load raisedValueError: Unacceptable pattern: ''on 0.9.4 and 0.9.5. It now usesglob(None), the supported form, and requirespathlib-next>=0.9.6— where that form was added. Upgradepathlib-nextif a pin holds it below 0.9.6. - Without an importable Jinja2,
transform=,key_factory='%...'andinterpolate=TrueraiseImportErrornamingyaconfiglib[jinja2]and the original import error — even underignore_error=True, since the check runs before any source is read. The first two previously raisedAttributeError: 'NoneType' object has no attribute 'eval', or quietly returnedNonewhen errors were being ignored. Install the extra, or catchImportError. - The security guide now states that
!includecan read any local file the process can (absolute paths and..are not confined tobase_dir), even withallow_commands=Falseandsandbox=True: those controls stop code execution and template injection, not file disclosure. Do not return or log an untrusted config verbatim. - The security guide no longer claims the
pythonbackend executes Python; it passes a caller-supplied object through unchanged. - Corrected the
PythonBackendexamples in the backends guide and the class docstring. Passing the backend positionally raisedValueError, andloader=PythonBackend(...)alongside a file silently ignored the file. Load the in-memory object on its own and merge it with the file result, as the documentation now shows. - A lowercase
prefixnow matches on Windows, where environment names are case-insensitive andos.environupper-cases them;prefix="app_"used to find nothing there. - A variable equal to the prefix (
APP_withprefix="APP_") no longer produces an empty-string key. - When a format's optional dependency is missing, the "No backend reads ..." and "Unknown configuration format/loader" errors name the extra to install and the underlying import error, instead of only reporting an unknown format.
.cfgfiles are detected as INI, which the API reference already stated. A.cfgfile previously raisedNotImplementedError, so a glob loaded withignore_error=Trueskipped it silently and now merges it in.- An INI file containing only
[DEFAULT]logs a warning explaining that those keys are inherited by other sections rather than returned, and how to load them as a section. It used to load as an empty mapping, indistinguishable from an empty file. - Command output with no
format=,+fmtor#!fmtshebang is sniffed as documented. INI output keeps its sections, where it used to be flattened as dotenv and lose keys;KEY=valueoutput is parsed as dotenv, where it used to come back as one folded string; and output no format accepts comes back as the raw stdout string, where it used to be an empty mapping. While sniffing, YAML is accepted only for a mapping or a list, so output that only YAML read as a scalar — a bare date such as2026-09-15,yesor~— now comes back as the raw string; usecmd+yaml://to force a YAML scalar. - A multi-line quoted
.envvalue loads whole. A PEM key or any other value spanning several lines was truncated at the first line break. - An apostrophe in an unquoted
.envvalue no longer disables comment stripping, soKEY=it's here # commentgivesit's here. .envkeys containing.or-are kept instead of silently dropping the line.- A
.envvalue containing a form feed, vertical tab,\x1c-\x1e, U+0085, U+2028 or U+2029 is no longer cut at that character: only a newline ends an entry. A Windows-1252 file read withencoding="latin-1"therefore keeps such a value whole — though byte0x85then arrives as U+0085, so read those files withencoding="cp1252". - JSON, TOML, INI and
.envfiles saved with a UTF-8 byte-order mark now load. The mark previously dropped the first.envvariable, prefixed the first TOML key with an invisible character, and made JSON and INI raise. TomlConfig,JsonConfig,IniConfigandJinja2ConfigLoaderaccept astrpath and an omittedencoding, so a backend instance can be registered directly as a PyYAML tag constructor (!toml,!json,!ini), which is how the documentation says to use them. They also acceptpath_factory=, like the other file backends.- Files whose name ends in another format's suffix after
.envare parsed by that format's backend:app.env.yamlas YAML,settings.env.jsonas JSON,x.env.tomlas TOML,x.env.inias INI, and.env.j2orconfig.env.yaml.j2rendered as a template first. They previously loaded as dotenv, which meant an empty or flattened result..env,*.envand staged names such as.env.localand.env.development.localare still dotenv, andloader="dotenv"reads any name as dotenv. typed_mergebuilds a mapping target positionally, so non-string keys no longer fail with "keywords must be strings", an abstractMapping[...]hint no longer raises "Can't instantiate abstract class", and adefaultdicttarget keeps itsdefault_factoryinstead of losing it.typed_mergeleaves a dataclass'sfield(init=False)names out of the constructor call, where passing them raised "unexpected keyword argument".typed_mergeapplies a_parse_<field>hook exactly once per value. ATypedNamespacesource was parsed again while being collected and a third time by the target's constructor, so any parser that is not idempotent — the documented comma-splitting example included — failed or corrupted the value.typed_mergehandles the sequence hints that broke in 0.11.1, when the element coercion branch first became reachable: abstractSequence[...]/MutableSequence[...]hints (which raised "Can't instantiate abstract class"),NamedTuplehints (rebuilt through their field types instead of being handed a generator),range, and lists holding either.typed_mergecoerces a heterogeneousTuple[int, str]hint by position instead of applying the first type argument to every element.typed_mergeskipsNonesources at every level instead of merging them: anOptional[...]field set toNoneby a later source no longer crashes (vars(None)) or replaces the earlier value withNone,'None'orFalse.typed_mergehonours atyping.Anyhint on Python 3.11+, whereAnybecame a class and reached the coercion path (isinstance() with typing.Any). Directly, as a type argument, or as a union member,Anytakes the last object unchanged.typed_mergestripsAnnotated[X, ...]and coerces throughX; the annotation used to be returned uncoerced.typed_mergeresolves type hints one field at a time when the whole set cannot be resolved, so a single unresolvable forward reference no longer leaves every sibling field uncoerced.OpaqueMerge,opaqueand any__merge__classmethod are honoured throughOptional[...],Union[...]and parameterized generics, not only on a direct class hint.- A
Unionhint whose first member isNone(Union[None, int]) no longer merges throughNoneTypeand fails. - An error raised inside a custom merge strategy's
init()propagates instead of being swallowed as "this strategy has noinit", which silently skipped the hook. ListandHashwork on an enum built withConfigLoaderMergeMethod.extend(...). The seed value is now chosen by member name, where an identity check against the built-in enum failed for every extension (ListraisedAttributeError,Hashreturned an unkeyed document).ConfigLoaderMergeMethodmembers can be pickled — for example passed to a worker process. The generated enum now records the module it is bound in, sopicklecan find it again.load(default=...)is documented as what the code does: it is returned only when no source loads, and is never merged into a loaded document.Deeplist extension no longer treatsTrue/Falseas duplicates of1/0, or1.0as a duplicate of1: an item is already present only if an existing one has the same type and compares equal. It also keeps the new source's order when that source mixes mappings with other items; mappings used to be moved to the end of the list.Simple, the default strategy, replaces a list with the later list when two top-level list documents (or two lists passed directly) merge, as the strategy table, the module docstring and the API header all said. It used to replace positionally and keep the earlier list's extra tail, so[a, b, c]overridden by[x]gave[x, b, c]. Lists under a mapping key already replaced and are unchanged. Write the full list in the override if you relied on the old result.DeepandSubstituteno longer raiseTypeErroron ordinary configuration values they had no rule for — dates, datetimes, times,Decimal, sets, enum members, objects a backend produced. Two YAML files with an unquotedrelease: 2024-01-01used to fail the whole load, and withignore_error=Truethe entire override file was discarded instead.Deeplist extension keeps items of those types.holidays: [2024-12-25]overridden byholidays: [2025-12-25]now holds both dates; the override used to be dropped.- A type-changing override replaces instead of raising: a mapping overridden by a number,
a string by a list, a list by a mapping. A mapping overridden by a list that is not a
non-empty list of mappings becomes that list, so
section: []clears it (it used to keep the mapping, or raise for[42]). A non-empty list of mappings still folds into the mapping. - Overriding a key under a YAML anchor or a
<<:merge key no longer rewrites the sibling keys that share it.DeepandSubstitutemerged into their left-hand argument, so an override for one environment silently changed every environment that shared the mapping. The strategies are now copy-on-write and never modify their arguments — use the return value, asConfigLoaderalways has. - A self-referencing mapping now merges instead of raising
RecursionError, and a node aliased in both inputs stays one object in the result. Simpleno longer raisesTypeErrorwhen merging into a read-only mapping whose keys overlap the override.ConfigLoader(log_level=<any int>)no longer raisesValueErrorfor a value that is not one of theLogLevelmembers.flatten=Trueskips empty documents instead of failing inside a comprehension — aconf.dglob may hold an empty file — and reports any other member it cannot flatten with aTypeErrornaming that member.yaconfiglib.dumps()/dump()write a loaded configuration as a plain YAML mapping thatyaconfiglib.load()and other YAML tools read back. Previously the result carried a!!python/object/new:...DotAccessibleDicttag that the library's own safe loader refused. Passing your ownDumper=/dumper_cls=keeps PyYAML's default handling.load_asno longer fails on a document key namedself, keepsInitVarvalues, and no longer imports pydantic when it is not already imported (it probessys.modulesinstead, which is exact: a class can only subclassBaseModelif pydantic is loaded).yaconfiglib.load()andloads()pass backend options through instead of raisingTypeError:json_decoder_options,ini_default_section,environment=and any other option a backend reads now reach it. They used to be rejected because the helpers matched keywords against a hand-written list.envis available when rendering.j2/.jinja2sources withinject_env=True, as the templating guide's example shows. Previously it raisedUndefinedError: 'env' is undefined.utils.jinja2.compile()andeval()honourglobals=on every call. The first call's globals were baked into the cached template, so a later call with different globals got the first call's values.- The compiled-template caches are safe to use from several threads: a lookup can no
longer fail with
KeyErrorwhen another thread evicts an entry at the same moment. - A
.j2/.jinja2file with no format extension in front of the suffix (config.j2) now raisesNotImplementedErrornaming the template and the expectedname.<format>.j2form, before the file is read. The error used to name a stripped path that does not exist, or an unrelated temporary file. - Values pulled in with
!include/!loadare now rendered together with the document that includes them wheninterpolate=True: an included file's templates can refer to the including document's keys, and an escaped literal such as{{ '{{ x }}' }}in an included file is no longer rendered a second time. References between templated values resolve fully in any order (logs: "{{ base }}/logs",err: "{{ logs }}/err"); previously a literal{{ base }}could survive into the result. - An
encoding=passed toConfigLoader.load(),ConfigLoader.load_all()oryaconfiglib.load()now applies, at every depth, to the files, templates and commands pulled in with!include/!loadthat do not set their ownencoding. Previously those targets were read with the loader's default, which raisedUnicodeDecodeErroror silently garbled non-ASCII text; the same happened to includes inside an included command's output underConfigLoader(encoding=...). An include whose real encoding differs from the one passed needs its ownencoding:in the mapping form. - Interpolating YAML that reuses anchors (
&name/*name) no longer slows down exponentially with nesting depth: each shared node is interpolated once, and every alias refers to the same result. A seven-level document that took about 4 seconds now loads in well under a tenth of a second. - Command sources run with stdin closed. Previously a command that read stdin hung the load or consumed the calling process's input.
- An include cycle (
a.yamlincludingb.yamlincludinga.yaml) now raisesValueError: include cycle: ...naming the chain. Previously it recursed untilRecursionError, or withignore_error=Truereturned a deeply nested partial result.
0.11.2 - 2026-08-16
Changed
- Bounded the
pathlib-nextdependency to>=0.9.0,<0.10(previously unversioned). It carried no bounds at all, so a resolver was free to install a release predating thePathname/MemPathsurface thatbackends/andutils/source.pyimport, or a future minor that changes it. Nothing in the package uses API added after 0.9.0, so the floor sits at the start of the series rather than at its newest patch.
0.11.1 - 2026-08-16
Fixed
sandbox=Trueno longer breaks bare{{ expr }}values. The type-preserving expression path captured its result with_meta.__setitem__(...), an underscore attribute access that Jinja2'sSandboxedEnvironmentrefuses — so every pure-expression config value raisedSecurityErrorunder the 0.11.0sandboxcontrol, while mixed-text templates ("hello {{ name }}") worked. The capture is now a plain callable bound as a render name; SSTI payloads still raiseSecurityErroras before.- The
recursiveoption now actually recurses.ConfigLoader(recursive=True)and the per-callload(recursive=...)override were documented but never forwarded toparse_sources, making both silent no-ops;load_all()now honors the instance setting too. Default (False) glob behavior is unchanged. - Deep merge with
mergelists=Trueno longer drops non-overlapping dicts. A positionally-matched dict from the right-hand list that shared no key with its counterpart was removed from the pending set before the overlap check, so it was neither merged nor appended —Deep([{"k": 1}], [{"z": 9}], mergelists=True)returned[{'k': 1}]instead of[{'k': 1}, {'z': 9}]. Silent data loss. typed_mergenow actually reads parametrized-generic arguments. Three defects shared one root cause — type args were taken from the originalclsinstead of the union-unwrapped origin, and the sequence branch tested a class object with instance checks so it could never run.Dict[str, int]coerced nothing ({"a": "1"}stayed a string),List[str]returned its elements uncoerced, andOptional[Dict[str, int]]pickedNoneTypeas the value type and raisedTypeError: NoneType takes no arguments. Unparametrizeddict/listhints andstr/bytesare unaffected.- The Jinja2 backend no longer crashes when
pathlib_nextis absent. Its ImportError fallback setMemPath = Noneandload()called it anyway, so every.j2/.jinja2source raisedTypeError: 'NoneType' object is not callable— despite the class docstring promising "a real temp file whenpathlib_nextis unavailable". It now materializes a tracked temp file, reusing the mechanismutils/source.pyalready used for in-memory sources, and keeps the rendered basename so backend auto-detection still resolvessettings.yaml.j2to the YAML backend.
0.11.0 - 2026-07-20
Changed
- Narrowed
typed_merge'sget_type_hintsfallback to(TypeError, NameError, AttributeError); the loader/command broad error boundaries that feed theignore_errorpredicate now log the swallowed error at DEBUG and are documented as deliberate. - Faster interpolation. A string with no Jinja delimiter (
{{/{%/{#) — the common case for config values — now returns unchanged without a cache lookup orTemplate.render. The template/expression caches also switched from clear-everything-at-capacity to LRU eviction, and their env keys are weakref-guarded so a recycledid(env)can't return a render bound to a dead environment.parse_sourcesduplicate detection is O(1) per source (set) instead of O(n²) (list).
Added
allow_commandscontrol.ConfigLoader(allow_commands=False)(or perload()call) refuses to execute a command source —cmd://,exec://,sh://,*+fmt://, script-extension files, and any reached via!include— raisingCommandsDisabledErrorinstead. Use it when loading configuration you do not fully trust. Defaults to permissive (commands allowed).sandboxcontrol.ConfigLoader(sandbox=True)runs interpolation in Jinja2'sSandboxedEnvironment, blocking attribute-traversal (SSTI) attacks from untrusted config values. Defaults off.- New
docs/guide/security.mddocumenting the trust model and both controls.
Fixed
!includenow routes through the loader driving the current parse. The include constructor is registered once per YAML loader class, capturing the firstConfigLoader; nested!include/!loadpreviously inherited that first loader's settings (base_dir, merge, and — critically —allow_commands), leaking state across loaders. Includes now use the loader actually performing the load.ConfigLoader.load(merge_options=...)no longer permanently overwrites the instance'smerge_options— the documented per-call override was leaking into every subsequentload().- Backend resolution is now deterministic: recursive backend discovery returns a
definition-ordered list instead of a
set, so when two backends' patterns both match a path, the first-registered one reliably wins (previously hash-order dependent). - Constructing a
ConfigLoader(including the import-time default instance) no longer callssetLevel()on the module logger — library code no longer mutates global logging state. - Stream and unnamed in-memory sources each get a unique virtual path; previously
every stream materialized to the same
MemPath("stream"), so resolving sources up front left all of them holding the last stream's content. CommandBackenddecodes command output as UTF-8 (or an explicitencoding=) instead of the locale codec, which mangled UTF-8 output on Windows; temp-file fallback sources are cleaned up at exit and their suffixes sanitized.
0.10.0 - 2026-07-18
Added
OpaqueMergemixin,opaqueclass decorator, andTypedNamespacebase for customizingtyped_merge, all exported from the package root.OpaqueMerge/opaquemark a type opaque (last object wins, no field introspection — for a fully-built config object or one with factory-function field hints);TypedNamespaceapplies_parse_<field>coercers at construction. The__merge__and_parse_<field>extension hooks are now documented in the merging guide.
Changed
- YAML
!include/!loadauto-registration now logs aWARNINGwhen it overrides a constructor already registered on the loader class, so a redundant manualyaml.add_constructor("!include", ...)is visible rather than silently replaced.
0.9.8 - 2026-07-18
Added
typed_mergeis now exported from the top-levelyaconfiglibpackage (from yaconfiglib import typed_merge), alongsideMergeMethod/ConfigLoaderMergeMethod. It was previously only reachable via the internalyaconfiglib.utils.mergepath.
Fixed
typed_mergeno longer raisesTypeError: issubclass() arg 1 must be a classwhen a field's resolved type hint is a non-class callable (e.g. anipaddress-style factory function such asnetutils.IPNetwork). Such a hint is now treated as an opaque coercer: the last value wins, coerced through the callable when it accepts the value, otherwise returned unchanged. Class hints are unaffected.
0.9.7 - 2026-07-14
Documentation
- Added docstrings across
ConfigBackendand every built-in backend (YAML, TOML, JSON, INI, dotenv, env, command, python, jinja2), and filled inConfigLoader's constructor,load,load_all, andparse_sources. - Rebuilt the docs site:
docs/index.mdis now a full landing page instead of a README redirect stub; added task-oriented guide pages (backends,merging,templating,includes,models) underdocs/guide/; split the flat API reference into per-module pages (api/loader.md,api/backends.md,api/utils.md).
0.9.6 - 2026-07-14
Added
- Added optional nested environment variable loading and scalar coercion to
EnvVarBackendvianested_delimiterandcoerce. - Expanded benchmarks with focused suites for source parsing, merge behavior, Jinja/load interpolation, dot-access lookup, and env backend loading.
Changed
- Optimized
DotAccessibleDict.get()dotted traversal to avoid exception-converting attribute lookup on hot paths.
Fixed
- Fixed
.envparsing so whitespace-delimited inline comments are stripped while hashes inside quoted or unquoted values are preserved. - Defined the missing
ConfigLoader.load_astype variable so runtime type-hint introspection succeeds. - Fixed
Jinja2ConfigLoadersoenvironment=is honored, the backend is discoverable asjinja2, and rendered in-memory configs are parsed without rejoining the parent base directory. - Fixed
ConfigLoader.load()somerge_optionsare passed to merge implementations, includingmergelists=True. - Added an explicit
TypeErrorforflatten=Trueon scalar merged results instead of leaving an unbound local failure path. - Fixed duplicate path tracking, nested iterable option propagation, and stdlib
base_dirglob fallback behavior inparse_sources().
0.9.5 - 2026-07-13
Changed
- Documentation: Replaced AGENTS-specific development and release references in
README.mdwith concrete local install/test commands and a concise release workflow summary for contributors.
0.9.4 - 2026-07-11
Changed
- Performance: Optimized
utils/source.pyby precompiling command matching regex at the module level and avoiding path splitting allocations inhas_glob_pattern(yielding a ~58% speedup in glob checks). - Performance: Optimized
loader.pyandutils/jinja2.pyby caching compiled templates/expressions and reusing the Jinja Environment inload_all(up to 30x faster template interpolation and 8x fasterload_allsequential loads). - Performance: Optimized
utils/merge.pyby fast-pathing standard collection types inis_arrayand bypassing positional dictionary comprehensions in_deep_listswhen positional merging is disabled (yielding a ~19-35% speedup in deep merges).
Fixed
- Fixed
UnboundLocalErrorinsideload_allerror handling path when a load failure occurred before the local variablevaluewas bound.
0.9.3 - 2026-07-11
Fixed
- Fixed false-positive glob pattern detection for
cmd://andexec://sources whose command arguments contained glob metacharacters (e.g.[,]). Paths identified as command sources now bypass the glob check and are yielded directly.
0.9.2 - 2026-07-11
Fixed
- Replaced Python 3.10+
match/casestatement inloader.pywithif/eliffor Python 3.9 compatibility. - Replaced runtime
|pipe union syntax inutils/source.py(SourceLike) withtyping.Unionfor Python 3.9 compatibility.
0.9.1 - 2026-07-11
Fixed
- Corrected dependency declaration from
pathlib_nextto canonical PyPI namepathlib-next.
0.9.0 - 2026-07-11
Added
- Pluggable backend registry with custom backends:
DotenvBackend(parsing.envfiles),EnvVarBackend(queryingos.environwith prefix filtering), andPythonBackend(direct dict injection). - New
CommandBackendsupporting command and shell script execution (cmd://,exec://,.sh,.bat, etc.) with format overrides and shebang-based routing (#!json/#!yaml). - Top-level standard library API parity (
load,loads,dump,dumps) with the newly implementeddumpfunction. - Optional Pydantic model validation (
load_as) with fallback support to dataclasses. - Dot-notation dictionary access wrapper (
DotAccessibleDict) for deep configuration values. - Jinja2 environment features: environment auto-injection (
env.KEY) and strict interpolation mode (strict=True). - Comprehensive unit tests in
tests/test_v2_features.py.
Changed
- API Modernization: Renamed
configloaderargument toloaderacross the public API surface (load,loads, and backends). - YAML Inclusion: Idempotent auto-registration of
!includeand!loadconstructor tags in SafeLoader, now dynamically delegating to the parentConfigLoaderinstance to support nested TOML/JSON/command loads. - Compatibility: Replaced Python 3.10+ specific features to support Python 3.9+, and updated pyproject.toml / CI workflows.
- pathlib_next: Made
pathlib_nextan optional dependency with standard library pathlib, tempfile, and glob fallback behaviors. - Code Organization: Decoupled
typed_mergelogic into its ownutils/typing_merge.pymodule. - License: Changed the license from GNU GPLv3 to MIT License.
- Refactoring: Completely rewrote
utils/merge.pyandutils/jinja2.pyin a clean-room implementation, removing all legacy code dependencies (GPL3 taint fromhiyapycoandyamlinclude). - Error Handling: Improved error handling across
loader.pyandutils/source.py, replacing bareexcept Exception:blocks with targeted exception capturing. - Logging: Adopted idiomatic module-level loggers instead of passing logger instances down the call stack.
- Typing: Adopted modern PEP 604 union types and improved static analysis compatibility in merge functions.
Removed
- Legacy references and code blocks tied to
hiyapyco.