Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-10-07
Added
Hiera(confine_locations=True)keeps data file locations inside each level'sdatadir, andHiera(limits=hyera.Limits(...))bounds the nodes a YAML document may yield through aliases (yaml_alias_nodes), the patterns aglobmay expand to through braces (glob_patterns) and the largest value one HOCON substitution may insert (hocon_substitution_size). All are off by default.Backend.limitsgives a backend the activeLimits.hocon_env(HOCONBackend(hocon_env=False)oroptions: {hocon_env: false}) stops a HOCON substitution from reading the process environment.HYERA_MCP_ROOTandHYERA_MCP_BACKENDSlet an operator confine the MCP tool's path arguments and data files to one directory and limit the data functions a hierarchy may name.hyera.testing, a public module for backend authors:lookup_key,data_diganddata_hashcall a backend's hook as the engine does and returnNOT_FOUNDfor a miss, andBackendContractis a suite of checks that a test module subclasses to run against its backend.LookupContext.for_testing(*, scope=None, module_name=None, data=None)builds a context to call a hook with outside a lookup.- A package registers its backends through the entry-point group
hyera.backends; each entry is imported once, on the first registry question and never atimport hyera, and one that fails to import is logged atWARNINGand skipped. Hiera.keys()lists the top-level keys thedata_hashlevels of the instance's scope hold, andHiera.to_dict(*, merge=None)returns each of them with its looked-up value. Alookup_keyordata_diglevel cannot be listed.hyera.lookup(base_config, name, ..., *, facts=None, scope=None), a one-shot lookup that builds aHieraand returns itslookup.- An API reference page for
hyera.exceptions. BackendTimeoutError, aBackendErrorthat is also aTimeoutError, raised whensopsorfacterexceeds its time limit. The child and every process it started are killed.SopsBackend(timeout=...), andSOPS_TIMEOUTinhyera.backends.__all__.options: {hocon_includes: false}on ahocon_datahierarchy entry (or indefaults) selects the stricter include mode; the barehocon_includeskey the README described was never valid hiera.yaml.hyeraexits130on Ctrl-C, with no traceback.- The README lists every difference from Puppet with its own tag, names Puppet 8.10.0 as the reference, and says how far the promise goes: a lookup finds, misses or fails as Puppet's does and returns the same value, while error text, command output text and exit statuses are hyera's own.
Changed
- Breaking: the
hiera.yamlof the construction scope's environment is read by the first lookup that needs it, not byHiera(...); a broken one raisesConfigErrorfrom that lookup. A missing environment still raises at construction. - Breaking:
Backend.get(name)andBackend.new(name)raiseValueError, notBackendError, for a name that is not registered; an unknown function name inhiera.yamlstill raises the sameConfigError.Backend.find,get,newandnamesraiseTypeErrorfor anamethat is not astror akindof another type, andValueErrorfor akindthat is not one of the four namespaces. - Breaking:
Hiera(backends=[5]),explain(..., explain_options=5)and a backend built with aconfthat is not a mapping (YAMLBackend(5)) raiseTypeErrorat the call; they were accepted and failed later or never. - Breaking: a caller's bad argument raises a plain
ValueErrororTypeError, never aHieraError. An unknownmergestrategy or invalid merge options (h.lookup("k", merge="bogus"), onceMergeError), an unparsablevalue_type(h.lookup("k", "Bogus["), onceHieraLookupError) and an emptymergestring (onceTypeError) now raiseValueErrorfromlookup,explain,dig,get,to_dict,h[...]andhyera.lookup, before any data is read. A bad subscript or conversion of ahyera.typesclass (Integer[2, 1],Integer("x")) raisesValueErrorinstead ofHieraLookupError, andfacts_from_facterraisesTypeErrororValueErrorfor a badtimeoutinstead of failing on facter. The same strategy or type in a data file'slookup_optionsorconvert_to, and a validvalue_typethe found value does not match, still raiseMergeErrorandHieraLookupError.explainno longer reports a badmergein its result; it raises. The command still ends a bad--mergeor--typewith one error line and exit status 2. - Breaking:
Hiera.scopeis a read-only property; assigning it raisesAttributeError. Useh.scoped(...)for another scope. - Breaking:
Backend.data_hashtakes a third parameter,data_hash(self, path, options, context), theLookupContextthe other two hooks already receive. A backend that still takes two parameters fails with Python's ownTypeError, reported as aBackendError.context.not_found()insidedata_hashraisesBackendError. - Breaking: an exception of a class outside
hyeraraised insidedata_hash,lookup_keyordata_digbecomes aBackendErrornaming the function and the location, with the original as__cause__and its class name, not its text, in the message. AHieraErrorand anything that is not anExceptionpass through unchanged. Alookup_keyordata_dighook's own exception used to propagate as it was. - Breaking:
Backend,BackendKind,NamePatternanddefault_backendsare defined inhyera.backends._base, andhyera.backendsonly re-exports them: their__module__ishyera.backends._baseand no longerhyera.backends. Every import path is unchanged. - Breaking:
hyera.MergeSpecis renamedhyera.MergeLikeandhyera.types.TypeSpecis renamedhyera.types.TypeLike; the old names are gone. - The
eyamlextra acceptscryptography42.0 and every later release; it was limited to the 50.x series. - A
hiera.yamlwith several schema mismatches reports all of them, in the order and text Puppet gives (version 3, 4 and 5 alike); a version 5 file used to report only the first. A type assertion with aVariant, aStructholding unrecognized keys, anOptionalor aHashwith a non-String key names its mismatches as Puppet does. - The shipped
AGENTS.mdAPI header gives every signature in a code block and ends with the sections Exceptions, Command line, Environment variables and Gotchas; the README's Command line is its own section. - Internal modules are reorganised so none passes 600 lines except
core.py; no public import path, class__module__or signature changed. repr(Hiera(...))is one line naming the class, the base config and the scope's environment (Hiera(config='/etc/hiera.yaml', environment='production')), and never data or scope values.RubySymbol.nameis read-only, and comparing aRubySymbolwith a non-symbol returnsNotImplementedinstead ofFalse; equality, hashing, pickling andcopyare otherwise unchanged.ConfigError,BackendError,InterpolationErrorandMergeErrorare alsoValueError, so a caller'sexcept ValueErrorcatches malformed config, data and interpolation text. Their text,args, pickling and copying are unchanged;HieraLookupErrorandKeyNotFoundErrordo not gain the base.- YAML and JSON documents nested more than 500 levels deep (data files,
--factsfiles,--scopevalues) raiseBackendError("nested too deeply"); a deeply nested YAML document used to end the interpreter on Python 3.9 and raiseRecursionErrorelsewhere. - A YAML mapping whose keys collide only in Python (
1,1.0,true) raisesBackendErrorinstead of silently dropping an entry. - A YAML
<<merge key follows Psych: entries merge in document order, so a merge replaces an earlier explicit key; a quoted or aliased<<merges; a value that is not a mapping or a list of mappings leaves a literal<<key instead of rejecting the file. - A YAML anchor name defined more than once is valid: an alias refers to the latest definition before it.
- YAML parse errors keep their fixed punctuation (
',' or ']'); only quoted source tokens are redacted. - A hierarchy level naming a function that does not exist now raises
"Unable to find ... function named ..." on the first call for a location
that exists, as Puppet does, instead of failing every lookup when the
configuration is read; function names ignore case and a leading
::. - An undefined variable in a version 3 or 4 hierarchy location fails the
lookup under
strict="error"; only version 5 locations are lenient. - An empty
datadiris rejected; a version 5datadiris joined onto the config root before interpolation, so a variable that expands to an absolute path stays under the root; a directory namedhiera.yamlin an environment or module raisesConfigError. - Version 3:
:extension:and:datadir:set to nil mean the default, an extension that is not a string is converted as Ruby does, and the extension is interpolated. HieraLevel.paths()returnslist[str]as annotated;HieraLevel.new()defaultsdatadirto"data"; aHieraLevelwithoptionsis hashable;HieraLevelhas a new fieldlenient_locations.--render-as yamlwrites text that reads back as the value looked up, under PyYAML and Psych alike: strings such as1,000,2001-1-1,.Nanand+.5are quoted, U+0085, U+2028 and U+2029 are escaped, shared values are written out instead of as anchors and aliases, and an empty-string key prints as'': v. Quoting is not byte-identical to Puppet's.--explainreportslookup_optionsacross layers as Puppet does: no node for the global and environment merge, aMerge strategy hashnode overGlobal and EnvironmentandModule NAMEfor a module, each layer once for several keys, and no extraNo such keyline after a module'sdefault_hierarchy; a--mergetype error across layers raises instead of ending the report.- A missing or unreadable
--factsfile exits2with one line, like any other error;load_factsraisesBackendErrorfor it. - A stdout write failure of any kind (a closed pipe on Windows, a full
device) exits
2quietly instead of120. --render-as ''is an error, not the default format.--strictis checked by hyera, not argparse.- The agent help and the MCP tool description declare the real exit codes,
three working examples, and one-line field help that says what omitting a
field means and that
--factsis required;main()leaves the caller'ssys.stdoutandsys.stderrencodings alone. hyera.cliis a package;hyera.cli:main,python -m hyera,python -m hyera.cli,hyera.cli.Lookupandhyera.cli.mainare unchanged.- The
cliextra requiresduho0.7.0 or later, and each option that takes a value is declared so that it takes the next word whatever it looks like. - Over MCP, a call that finds nothing or fails returns an error result whose
last line gives the exit status and its meaning (
exit code: 1 (No value found for the key)); a miss used to return an error result with no text.
Fixed
- With
confine_locations=True, aglobwhose pattern leaves the level'sdatadir(through..in the interpolated value, an absolute value, or a..after a wildcard) is not walked: no directory outside the root is listed, where the matches were only dropped afterwards.HieraLevel.paths(base_path, scope, *, confine=False, limits=None)takes the same two settings as the constructor and applies them. - A YAML
!ruby/encoding NAMEtag loads, as in Puppet: the key that holds the Encoding object fails the lookup and the other keys of the file answer, and a name Ruby does not know fails the file. A scalar tagged with the bare!is resolved like a plain one, so an emptyk: !isnull, not the empty string. - Looking up the reserved key
lookup_optionsis a miss, with--defaultand withexplain, whatever state an environment layer'shiera.yamlis in (a schema error, version 6, a syntax error), as in Puppet. A globalhiera.yamlthat cannot be loaded still raises at construction. Backend.loadraisesBackendError(Unable to read (<path>): <reason>) for a file that is missing or is a directory;FileNotFoundErrorandPermissionErrorleaked out of it.LookupContext.module_nameis the module's name for a hook named in a module'shiera.yaml; it was alwaysNone.- A command-line option value of exactly
--(the knockout prefix--) is kept as the value on every supported Python, in the--opt=--spelling and in an MCP tool call, for every free-text option; the MCP server refused it. - A deep merge of a Hash over a non-Hash (a String at a lower level, say)
merges every key after the first as Puppet does: arrays of the later keys
lose duplicates and honour
knockout_prefix. Aknockout_prefixalso removes a String array item that has the prefix at the start of any line. merge=accepts anyMappingwithstrkeys, not only adict.- A chain of
%{lookup()}or%{alias()}interpolations that is not cyclic but outruns the interpreter's recursion limit, and a value or type expression nested past it, raiseInterpolationErrornaming the keys instead of a rawRecursionError. A chain now resolves to about 80 hops (Puppet: 100), up from 50. - A
lookup_key,data_digordata_hashhook that returns adate,Decimal,bytesorsetraisesBackendError(HieraLookupErrorfor adata_hashentry) naming the function and the type, instead of a bareTypeError; a tuple returned by a hook reads as a list. - A tuple name matches
overrideanddefault_values_hashthrough its dotted form, as the equivalent string does. get()anddig()checkblockandvalue_type, andgetvar()checksdottedandblock, before resolving anything; each raisesTypeError.- A
data_dighook asked for a path with a negative index is a miss, not anIndexError; a segment that follows non-ASCII whitespace before its digits is no longer read as an index. hyera.backends.SOPS_TIMEOUT = nchanges thesopstimeout; it was documented but read from a private copy.- A
sopsfailure keeps its own message (sops executable not found,sops failed (exit n)) instead of being relabelled "Unable to parse", and quotes at most the last 2,000 characters of stderr. - Registering a backend name that a registered pattern already answers to, or
a pattern that matches a registered name, raises
ValueErrornaming both classes. hocon_data: a quoted key such as"ntp::servers"loads without its quote characters, so a class parameter can be written in a HOCON file;k = null xreturns the textnull x; a backslash-u escape in a quoted string is decoded.hocon_data: a plain quoted include, arequired(file(...))include and a value-position include inside a file reached throughinclude file(...)follow the same rules as in the top-level file.- Importing
hyerano longer importspyhoconor replaces its include methods; the guard is installed on hyera's private parser copy the first time a HOCON document is parsed. - Concurrent lookups on one
Hierano longer loselookup_options: the re-entrancy guard belongs to one lookup, not to the instance, and a result composed while it refused a layer is never kept. - A changed module data file is seen by the next lookup, and two module
levels with no location (custom
data_hashfunctions) each serve their own data. - A glob level sees a file that starts matching when its directory was absent, was reached through a literal segment, or gained the file under an existing wildcard directory.
lookup_keyanddata_digresults are kept apart from what a hook stores withcontext.cache(), and live until a file the hook read throughcontext.cached_file_data()changes: aneyaml_lookup_keyfile edited on disk is re-read. A result from a hook that read no file is kept for one lookup (untilclear_cache()withrevalidate=False), so such a hook is called again by the next lookup.pickle,copy.copyandcopy.deepcopyof an instance that looked up a module key work and start with no derived state, so a copy re-reads the disk;clear_cache()on an instance or on one of its views reaches all of them.- Two layers rooted at one directory no longer share cached locations or
providers (an
IndexError, or the wrong layer's data). Hiera.getvar()returns a copy of the scope's value, and the path intern table no longer grows with every distinct candidate path.- Every YAML and JSON loader,
load_factsand--scopereturn data or raiseBackendError: explicitly tagged nodes are built by their kind (!!map [a],!!seq {a: 1},!!omap x), sexagesimal numbers with underscores (1__0:30) read as Ruby does, and aPattern[...]type whose regex Python cannot compile is "not a valid type specification". - Integers over 4300 digits load from YAML and JSON, render in every
--render-asformat and in--explainoutput, and a key segment of that length is a miss, on Python 3.11 and later as on 3.9. - Glob segments are matched in linear time, so a name with many
*s no longer stalls a lookup; a reversed or escaped character range such as[z-a]matches as in Ruby instead of raisingre.error; deeply nested braces and deep directory trees no longer raiseRecursionError; a segment such as.*matches., so.*/a.yamlfinds./a.yaml. - A location keeps an incoming
..behind a datadir ending in.., a trailing/and an inner//as Ruby'sPathname#+does, soa.yaml/is not the filea.yaml. - A drive-letter prefix is an anchor only on Windows; elsewhere
path: 'c:/a.yaml'is a file inside the datadir. - Windows: a UNC datadir or pattern is walked by
glob, a literal glob segment after**follows the filesystem's case rule like any other literal, and a backslash-absolute interpolated path is absolute. - Type objects follow Puppet 8.10 where they differed.
ScalarDataaccepts only an Integer, Float, String or Boolean (it also accepted an Array or Hash of them). AStructkey is optional when its value type accepts undef, soStruct[{name => String, port => Optional[Integer]}]accepts{'name' => 'x'}(it requiredport), andNotUndef[key]makes a key required. ATuple's trailing size is a minimum with the last type repeating (Tuple[String, 1]accepts['a', 'b']; it was an exact size), and bareTupleis any array. BareOptionalaccepts only undef, barePatternandEnumaccept any String, a trailingtrueinEnummakes it case-insensitive,Sensitive[T]keeps onlyT's generalized type, and NaN is not aFloat.Integer[0x10]is 16 andInteger[010]is 8, size bounds default to 0, andVariantflattens. - Ruby regular expressions in
Pattern,Regexpandlookup_optionskeys are translated in one pass:\zno longer matches before a trailing newline,\h, POSIX bracket classes,(?m)and(?i)(scoped to the rest of its group) work,\w \d \sare ASCII, and a construct with no Python form (\p{..},\R, a nested class, ...) raisesHieraLookupErrornaming it instead ofre.error. new()andconvert_to:Integer,FloatandNumerichonourabsand the{from, radix, abs}hash, read strings with Puppet's anchored patterns ('12\n','inf','1_000'are refused) and check their argument count; aStringformat must be exactly one directive, follows Ruby'sformatper value type (negative%xis..f01) and raises Puppet's "Illegal format" text for a directive the type does not take; a Hash prints as{'a' => 1}. A format that was not a directive, or an illegal one, used to be silently ignored.hyera.typessubscripts keep the parameters of a nested type (Optional[Integer[1, 3]]wasOptional[Integer]), and type objects are immutable and compare equal only to other type objects.- A failed
convert_toconversion of any kind raisesHieraLookupError("The convert_to lookup_option for key ... raised error"); a type expression nested more than 200 levels, and an unusableHashkey, raise it too. - Concurrent type parses no longer return another thread's source text.
- A
Structmismatch against a hash with a non-string key reports a size or type mismatch as Puppet does, andEnum/Structmembers render with Puppet's own quoting. - A bare
Enummatches no string, as in Puppet (--type Enumfails andconvert_to: Enumis refused); it matched every string. Hash.newandconvert_to: Hashtake an Array as a key (it renders as["a", 1]) and thetreeandhash_treebuild options.- A type expression reads its range parameters as Puppet's type parser does:
Integer[1.0, 50],Integer[undef, 2],Hash[0, 0],Hash[1, 2](whose key and value types areDefault),Array[1, 2](element typeDefault) and a size given as an Integer type (String[Integer[1, 5]],Array[String, Integer[2, 2]],Hash[String, Integer, Integer[2]]) parse instead of being refused or, forArray[1, 2], matching any element.hyera.types.Array[1, 3]still bounds the size only. - A type mismatch names
NotUndef[Integer]andOptional[NotUndef[Integer]]as Puppet does (expects an Integer value, got Undef), mergesOptionalmembers of aVariantinto oneOptional, and a bareOptionalrejects a value with an empty message, as Puppet's assertion does. Optional[None],NotUndef[None],Variant[Integer, None],Hash[None, Integer],Tuple[None, Integer]andSensitive[None]raiseValueError:Noneis Puppet'sdefault, which none of those positions accept, and they built a type withNoneinside it.
Security
- A caller that passes scope values or tool arguments it does not control can now opt in to the protections above; the README's "Untrusted input" says what each stops and what it does not. No default changed.
- Text with many unclosed
%{is scanned in linear time, and glob segments with many*s are matched in linear time, so a hostile value or pattern cannot stall a lookup. sopsandfacterrun with their standard input closed instead of the caller's, which underHYERA_MCP=stdiois the protocol stream.facts_from_facterno longer runs afacter.batfound relative to the current directory (Windows, Python 3.9 to 3.11); a relative resolution is refused for both programs, a batch file at an absolute path still runs forfacter.- Errors from facts files,
hiera.yamland a malformed HOCON document no longer keep the file's text (or pyhocon's exception, which holds it) on a chained exception.
Corrections to earlier entries
[0.0.0a0]lists three removals at the end of### Changed: the non-Puppetdata_hashnamesyaml,json,hoconandyaml.enc,hyera.LookupDict/hyera.sym_lookup/hyera.util, and thedata_dirspelling. It also says pre-release tags are not uploaded to PyPI; they are uploaded as PyPI pre-releases, and0.0.0a0is on PyPI.[0.0.0]listshyera.Mergeunder### Removedand under### Added: the removed one is the earlier strategy class, and thehyera.Mergeof that release is the string enum listed under### Added.
0.0.0 - 2026-10-01
Added
eyaml_lookup_key(hyera.EyamlBackend), Puppet's hiera-eyamllookup_keyfunction for PKCS7 values, behind the optionaleyamlextra (cryptography): only the private key is needed, optionspkcs7_private_key,pkcs7_private_key_env_varandpkcs7_b64_private_key_env_varwith hiera-eyaml's own precedence, relative key paths resolved against the working directory; other encryptors (GPG) raise the error Puppet raises without their plugin.lookup_keyanddata_dighierarchy entries call the named backend per key and per location with Puppet's hierarchyoptionsand ahyera.LookupContext(interpolate,not_found,explain,cache,cache_all,cache_has_key,cached_value,cached_entries,cached_file_data,environment_name,module_name); a hierarchy entry with no location key calls its function once, with no location.uri/urishierarchy locations are interpolated, checked with Ruby's URI grammar and handed to the entry's function asoptions["uri"]without any fetch or existence check;yaml_data,json_data,hocon_dataandsops_dataentries using them raise Puppet's missing-pathConfigError(they used to contribute nothing); a malformed URI raisesConfigError("bad URI (is not URI?): ...").- The
defaultmerge strategy (first match, as in Puppet). - The
reverse_deepandunconstrained_deepmerge strategies, which Puppet accepts for Hiera 3 data.unconstrained_deepalso takes deep_merge'skeep_array_duplicates,overwrite_arrays,unpack_arrays,extend_existing_arrays,merge_nil_valuesandpreserve_unmergeables. Hiera.lookup(name, value_type=None, merge=None, default_value=<unset>, *, default_values_hash=None, override=None, block=None)with Puppet's fivelookup()call forms; option names also work as keywords. AHierais callable aslookup,h[...]takes the same arguments, andname in htests for a value.value_typetakes a Puppet type string; a list of names returns the first one found.name(or a name-list entry) may also be a non-empty tuple: an exact key path taken verbatim (no dot splitting, no quote syntax), resolving exactly as the matching quoted dotted string would --h.lookup(("a.b", "c", 0))ish.lookup('"a.b".c.0').Hiera.dig(*keys, ...), Puppet'sdig()over a looked-up value.Hiera.get(dotted, default_value=None, block=None, ...), Puppet'sget()with a dotted navigation string; it returnsdefault_valueinstead of raising on a miss.Hiera.getvar(dotted, default_value=None, block=None), Puppet'sgetvar()over the scope.- Environment and module layers:
Hiera(..., environmentpath=..., basemodulepath=..., modulepath=...)reads<environment>/hiera.yamlfor the scope's environment, and<module>/hiera.yamlfor keys qualifiedmodule::, exactly aspuppet lookupdoes. A lookup merge spans the global, environment and module layers. - Module data keys not qualified with the module's own name are ignored, with a warning naming the module, function and location.
- A version 3 (or missing-
version) hiera.yaml in an environment or module is ignored, with a warning; underScope(strict="error")it raises instead. hiera3_backendis accepted only in the global hiera.yaml; the same key at an environment or module root raisesConfigError.- A named environment directory that does not exist (with
environmentpathset) raisesConfigError. lookup_optionsdeclared in an environment's or a module's data now apply, merged with the global layer's own (global wins over environment, which wins over module, per key). A module'slookup_optionskey or^-prefixed pattern that does not start with that module's own name raisesHieraLookupError.examples/: a runnable Hiera 5 configuration, data tree and facts file, with a lookup script and the equivalent CLI invocation.Hiera(..., cache_size=256)bounds each scope-keyed cache (least recently used entries are dropped;Nonefor no bound,0to disable), andHiera.clear_cache()drops every cache, including parsed data files.Hiera(..., revalidate=True): each lookup re-checks the data files it uses and re-reads one whose inode, modification time or size changed, as Puppet does between compilations; files added or removed atpath/paths/mapped_pathslocations and under globbed directories are seen by the next lookup.revalidate=Falsekeeps every file and glob listing as first read untilclear_cache().Hiera.explain(...), also on scoped views: takes the same arguments aslookup()and returns ahyera.ExplainResultdescribing the lookup the waypuppet lookup --explaindoes..text()is the indented report (each hierarchy entry and path consulted,Path not found,No such key,Found key, merges and their results, interpolations and sub-keys, thelookup_optionssearch,default_hierarchy);.to_hash()is the same tree with Puppet's own keys (branches,type,key,value,event,name,path,original_path, ...). Passingexplain_options=Truereports only howlookup_optionswas assembled for the key (mirroringpuppet lookup --explain-options). An error thatpuppet lookup --explainprints as its own last line (a miss, an invalidlookup_optionsvalue, a failedconvert_to, an interpolation syntax error, a sub-key into a non-hash, or a lookup error raised from environment or module data) becomes the report's last line and.error; any other error (a--typemismatch, an unreadable/unparsable data file, or a lookup error left unhandled by the global layer's own data) raises instead, exactly aslookup()does.- CLI flags from
puppet lookup: several keys (the first one found wins);--type(asserts the found value and--default);--knock-out-prefix,--sort-merged-arraysand--merge-hash-arrays(only with--merge deep);--facts FILE;--node;--environment,--environmentpath,--modulepathand--basemodulepath(global, environment and module layers);--strict off|warning|error(defaultwarning);--explainand--explain-options. hyera.MergeSpec, the type of everymerge=argument.- String enums for every closed set of string values in the public API: a
member is a plain
str(Merge.DEEP == "deep"), so every parameter that already took the string still takes the member, andstr()/format()/an f-string give the value on every supported Python.hyera.Merge(FIRST,UNIQUE,HASH,DEEP:merge=onlookup/dig/get/explain, and aMergeSpecmapping's"strategy"key),hyera.Strict(OFF,WARNING,ERROR:Scope(strict=...),Hiera.scoped(strict=...)),hyera.FunctionKind(DATA_HASH,LOOKUP_KEY,DATA_DIG:HieraLevel.kind,HieraLevel.new(kind=...)),hyera.BackendKind(FUNCTION,V3,FORMAT,RENDER: thekind=namespace argument ofBackend.find/Backend.get/Backend.new/Backend.names) andhyera.RenderAs(S,JSON,YAML:Backend.new(name, kind="render")formats). Every stored/returned field (HieraLevel.kind,Scope.strict,Backend.strict, an explain tree, rendered output) stays a plainstrregardless of whether a member or a string was passed in. ThehyeraCLI's own--merge/--strict/--render-asflags stay plain strings: duho'sEnumCLI support resolves text by member name (FIRST,ERROR), not by value, which would break Puppet's lowercase flag values. hyera.types: one public, isinstance-aware class per Puppet type (Any,Integer,Optional,Struct, ...), not re-exported from top-levelhyeraexceptSensitive(the same object ashyera.Sensitive). The bare class is the unparameterized type (isinstance(5, Integer)); subscripting builds a parameterized type object equal to parsing the same Puppet text, includingstr()/repr()and every error message (Integer[1, 10],Optional[String],Struct[{"a": Integer}]); calling one is Puppet'snew(), returning a plain value (Integer("42") == 42).value_typeonlookup/dig/get/explain/__call__/__getitem__, and a nested type argument in a subscript, now also accept a type object or a barehyera.typesclass, with results identical to the equivalent string.
Changed
- Attribution for an earlier derivative project this started from is
removed (headers,
NOTICEentries, its license file): no code from it remains in the tree. - With the
hyeralogger atDEBUG, each lookup logs one record:Lookup of '<key>'followed by the reportexplain()returns (every path consulted and whether the key was found there). The DEBUG message logged only for a missing dotted key is gone. - The command-line interface logs under
hyera.cli, a child of thehyeralogger, instead ofhyeraitself. default_hierarchyis accepted only in a module's hiera.yaml, as in Puppet. A global or environment hiera.yaml containing it raisesConfigError("'default_hierarchy' is only allowed in the module layer"). Move those entries intohierarchy, or into a module's hiera.yaml.- A module's
default_hierarchyis consulted only for that module's keys, after the global, environment and module hierarchies all miss. Themergepassed tolookup()does not apply there — the merge comes from thelookup_optionsin the default hierarchy's own data, whileconvert_tofrom the mainlookup_optionsstill applies. - Hierarchy
optionsare interpolated (strict mode, no method calls) and passed to the backend withpath/uri. Ayaml_data,json_data,hocon_dataorsops_dataentry that sets any option, or has no path location, raisesConfigErrorwith Puppet's message; such options used to be ignored and a location-less entry contributed nothing. A backend named under a hierarchy key whose hook it does not implement raisesConfigErrorwith Puppet's message instead of "not supported yet". - Interpolation in data values follows Puppet's rules: one left-to-right
pass (
%{literal('%')}{x}yields the literal%{x}, and a substituted value is never scanned again); whitespace inside%{ }is ignored; hash keys are interpolated; a variable whose value contains%{...}is interpolated;%{lookup()},%{hiera()}and%{scope()}always produce a string; a missing key in%{lookup()}/%{hiera()}/%{alias()}gives"";%{scope('x')}treats an undefinedxexactly like%{x}under the scope'sstrict; an unknown method or a malformed method call is an error. Data that used a stand-alone%{lookup('k')}to copy a list or hash must use%{alias('k')}. Hiera.format(text)interpolates exactly as data values are interpolated: all five methods are supported, whitespace inside%{ }is ignored, other braces are left alone, a missing variable follows the scope'sstrictinstead of raisingKeyError, and a non-string argument raisesTypeError. A text that is exactly one%{alias('k')}returnsk's value.- Each YAML-anchored node in a value is interpolated once, and the returned value shares it wherever the file reuses the anchor; mutating one occurrence in place changes every occurrence. A value a merge actually combines with another is always copied per position first, so the merge's in-place semantics never corrupt a position that happens to share a node with it.
- Non-string values interpolated into strings, hierarchy paths and
format()render as Puppet renders them: floats in Ruby's form (1.0e+20), arrays as["a", "b"], hashes as{"k"=>"v"}, andSensitiveasSensitive [value redacted]. - Merges now follow Puppet 8: deep merges put lower-priority array elements
first and drop duplicates on both sides; merged hashes list lower-priority
keys first;
uniqueflattens nested arrays; duplicates are found with Ruby equality, so1,1.0andtruestay distinct;knockout_prefixis a regular expression that removes array elements and blanks strings during each merge step and never removes hash keys;merge_hash_arraysmerges lists of any length;sort_merged_arrayssorts only merged arrays. - Invalid merge input raises
hyera.MergeError: an unknown strategy, a merge hash withoutstrategy, an unknown or mistyped option, ahashmerge of a non-hash, auniquemerge of a hash, and an arraysort_merged_arrayscannot order. - Hierarchy locations use Puppet's
%{...}rules: whitespace, quoted segments, empty%{}, literal braces, and re-interpolated values. - An undefined variable in a
path,paths,glob,globsormapped_pathslocation becomes''and logs a warning. Understrict="off"it logs nothing. The location is probed, not skipped. - An undefined variable in
datadirfollowsstrictand raises under"error". - Method syntax (
%{lookup(...)}etc.) in a location ordatadirraisesConfigError. - The
mapped_pathscollection is a scope reference (dotted,::). A Hash yields["key", "value"]pairs.true,false, numbers raiseConfigError, and the item is a local variable (%{::x}reads the top scope). Configs relying on the old Hash-values iteration must list the values. - A
path/paths/mapped location that is a directory raisesBackendError("Is a directory") instead of loading every file inside it, and a glob drops directory matches. List the files, or use a glob. HieraLevel's fields are nowname, backend, datadir, location_key, locations, and.paths(base_path, scope)returns a list.- Glob hierarchy levels (
glob:/globs:) now match through hyera's own RubyDir.globport instead ofpathlib_next.Path.glob:{a,b}brace alternation (nested, in written order, duplicates kept);**never follows a symlink or a Windows junction, and a trailing**is plain*; a dotfile matches only an explicit leading., and\escapes a metacharacter; results sort in byte order and every wildcard is case-sensitive, on every OS; an unreadable directory is skipped; glob metacharacters indatadirapply to glob levels. lookup_optionspatterns now match as in Puppet: a search from the start of the key (^app::matchesapp::ports), Ruby regex syntax, and lower-priority levels' patterns tried first. An invalid pattern, alookup_optionsvalue that is not a hash, and an entry that is neither a hash nor a string now raiseHieraLookupErrorinstead of being skipped.- Dotted keys follow Puppet:
db.portlooks updb, merges or takes the first level that has it, then readsport, so it is not found when that level'sdblacksport. Quoted segments work, andlookup_optionsmatch the root key. - A key set to
~(null) is found and returnsNone; it no longer falls through to lower levels or the default. lookup_optionsandlookup_options.<x>can no longer be looked up.- An explicit
merge=overrides only the merge fromlookup_options;convert_tois always applied. %{lookup()},%{hiera()}and%{alias()}run full lookups, with the target key's ownlookup_optionsand (for a module key) its module'sdefault_hierarchyfallback;%{alias()}no longer merges with the caller's strategy.- A found value that is not Puppet RichData (a hash key that is a boolean,
a null or a collection; a Ruby symbol) raises
HieraLookupErrornaming the key, the data_hash function and the file. - Pickling or copying a
Hierano longer carries its caches: the copy starts empty and never holds data parsed (or decrypted) by the original. - The CLI prints values the way
puppet lookupdoes, chosen with--render-as s|json|yaml(case-insensitive, defaultyaml):yamlis--- valuewith no...line;jsonis compact, keeps the data's key order and writes non-ASCII characters as UTF-8;sprints strings bare,true/false, an empty line for a null value and Ruby's form for collections ({"a"=>1, "b"=>[nil]}).Sensitivevalues print asSensitive [value redacted]in every format. Output is UTF-8 with LF line ends whatever the console or locale encoding. --config/-cis now--hiera_config. Without it,./hiera.yamlis used when it exists, otherwise Puppet's built-in default configuration (data/common.yaml).--scope NAME=VALUEvalues are YAML (n=0is an Integer,l=[a,b]an Array) and a dotted NAME builds a hash (os.family=RedHat).--mergeaccepts onlyfirst,unique,hashanddeep; anything else exits 2 with Puppet's message.- CLI logging follows
puppet lookup: warnings by default,-vadds info,-vvor-d/--debugadds debug,-qhides warnings (-qqhides errors too). A missing key prints nothing and exits 1, as in Puppet; the message is logged at debug level.-v,-dorDUHO_TRACEBACK=1add the traceback to a2-exit error. Hiera(...)reads only its configuration file. Data files are read by the first lookup that needs them, so a malformed data file raisesBackendErrorfromlookup()instead of from the constructor. To validate data eagerly, look up any key.Hiera.hierarchy,.default_hierarchy,.base,.base_path,.backends,.codedir,.cache_sizeand.revalidateare private; use the documented methods and constructor arguments.SopsBackend's.format(the resolved output format) is private too.- Every public class, function and method has a docstring with
:param:,:returns:and:raises:fields, shown byhelp()and the API reference. - The API reference shipped in the package (
hyera/AGENTS.md) lists every export with its exact signature, every registered backend name, the environment variables read, and the differences from Puppet.
Removed
hyera.Mergeandhyera.make_merge: pass a strategy name or a{"strategy": ...}hash asmerge=.merge=list/set/dict: use"unique"or"hash".- The
merge_deep=argument ofget(): passmerge="deep". sort_merged_arrayswithunique,mergeas an alias ofstrategyin a merge hash, and lenient sorting of arrays that cannot be ordered.Backend.datadir: the entry'sdatadirisHieraLevel.datadir.- The old
Hiera.get(key, default, merge, throw): uselookup(key, merge=..., default_value=...);getnow has Puppet'sget()meaning instead (a dotted-navigation string, not a plain key). Hiera.has(key): usekey in h.ScopedHiera:h.scoped(...)now returns aHierabound to the derived scope.Hiera.cache: callclear_cache()to drop cached data.- The CLI's
--output/-oand therawformat: use--render-as(rawiss). - The CLI's
--deep(use--merge deep),--merge array/set(useunique) and--knockout-prefix(use--knock-out-prefix).
Fixed
- Clearing cached file data no longer makes later lookups report a missing key; a file is re-read when needed.
- The CLI no longer crashes with
UnicodeEncodeErrorprinting a non-ASCII value on a non-UTF-8 stdout (Windows pipes and redirects). JSON output renders hashes with mixed key types instead of failing withTypeError, and a NaN or infinite value exits 2 withNaN not allowed in JSONinstead of printing invalid JSON. When the reader of the output goes away early (… | head -c1), the CLI exits 2 without a traceback. - A self- or mutually-referencing interpolation (
%{lookup('a')}insidea, or a variable whose value refers to itself) raisesInterpolationError"Recursive lookup detected in [a, b]" instead of Python's ownRecursionError. - Glob results no longer depend on the installed
pathlib_nextpatch (dotfile matching, a trailing**), no longer loop or read outside a hierarchy's own tree through a symlink or Windows junction loop, and sort the same way on every OS. --helpand--versionname the programhyera; they used to sayLookup(the command class's own name).-v,-qand--loglevelnow change what the CLI logs; they used to have no effect on it.--helpis plain text and describes--mergecorrectly: omitting it letslookup_optionsdecide.- The CLI no longer performs a reverse-DNS lookup (
socket.getfqdn()) on every run without--node; it now runs only when actually needed to name the node in the "No facts available" message. The lookup can be slow on some hosts (measured: 60+ seconds on a CI runner), which made an ordinary, successful lookup with no facts-related error hang for no visible reason. - Type checkers accept the documented calls: optional arguments accept
None, andlookup()/dig()/get()/getvar()/__call__/__getitem__returnAnyinstead of a wrong inferred union. - A
value_type/convert_totype-expression string containing a character the type grammar does not recognize (e.g."Integer[@]") now raisesHieraLookupError, the same as any other malformed type spec, instead of an internal exception type escaping uncaught. - A hierarchy level naming a function that does not implement the kind it
is used as (e.g. a
data_hash-only function named as alookup_keyentry) no longer refuses the wholeHierainstance when that level's own location does not exist; the mismatch is now reported, with Puppet's own message, only once the function is actually invoked for a location that exists -- matching Puppet, which degrades gracefully and still resolves every key the other, correctly-configured levels can answer. eyaml_lookup_key's decrypt-error message now embeds the whole stored value, not just the oneENC[...]token that failed, matching hiera-eyaml's own text; a malformed private key is now checked (and rejected) before the ciphertext is ever parsed, matching Ruby's own order, so a bad key is reported as a key problem, with Puppet's own text ("Neither PUB key nor PRIV key"), instead of "Could not parse the PKCS7" even when the stored ciphertext is also malformed. The private key is also now parsed at most once per decrypted value instead of once perENC[...]token in it, cutting the cost of a value with many tokens.LookupContext.cached_file_datanow wraps anopen()/read failure (a directory at the configured path, a permission error) inBackendErrorthe same way it already wraps astat()failure, instead of letting it escape raw.
Security
eyaml_lookup_key's check for whether a value needs decrypting no longer risks a denial of service: the previous regex was quadratic to cubic under Python's backtrackingreengine for a value made mostly of unterminatedENC[prefixes. The replacement is a linear, line-at-a-time check with the same acceptance.eyaml_lookup_key's PKCS7 OBJECT IDENTIFIER reader now rejects an identifier longer than a handful of known encodings ever need, instead of spending quadratic time building an unbounded integer per byte of a hostile ciphertext.- A non-string
pkcs7_private_keyoption (anIntegerorBoolean, as YAML can produce) is now rejected before any file operation, instead of being read as an already-open file descriptor by number (and closed). - An
OSErroropening or reading thepkcs7_private_keyfile (a directory, a permission error) is now wrapped as aBackendErrorinstead of escaping raw. eyaml_lookup_key's base64 decoding (pkcs7_b64_private_key_env_var) now tolerates a value containing characters outside the base64 alphabet the way Ruby's own lenient decoder does, instead of letting a rawbinascii.Errorescape.- Neither the decrypted private key PEM nor any decrypted plaintext is
reachable any more from an
eyaml_lookup_keydecrypt failure's__context__exception chain (araise ... from Noneinside anexceptblock still sets__context__, whose own traceback frames kept these values live).
0.0.0a0 - 2026-09-29
Added
- Documentation site at https://jose-pr.github.io/hyera/: a getting-started
page, an API reference generated from the docstrings of
hyera,hyera.backendsandhyera.cli, and this changelog. The package metadata links it asDocumentation; thedocsextra installs its build tools. HYERA_MCP=stdio hyeraserves the command over MCP (stdio): one tool,hyera, taking the command-line fields as arguments and returning what the command prints. Any otherHYERA_MCPvalue exits2.NOTICEandLICENSES/phiera-Apache-2.0.txt: credits phiera, the Apache-2.0 project this library is derived from. The package license is nowMIT AND Apache-2.0.- Hiera 5 spec compliance:
version: 5validation (a non-5 version is rejected); theunique/hash/deepmerge strategies (plusfirst), selectable by name, legacy type, or an options hash (knockout_prefix,sort_merged_arrays,merge_hash_arrays); the reservedlookup_optionsdata key (per-key and regex-pattern merge strategy +convert_to, with an explicitmerge=argument overriding it);mapped_pathssource levels; anddefault_hierarchyfallback. convert_totakes a Puppet type string (Integer,Optional[Integer]) or[Type, *args]([Integer, 16],[String, '%x']) and converts with Puppet'snew(): Integer, Float, Numeric, String, Boolean, Array, Hash, Tuple, Struct, Optional, NotUndef and Sensitive (a redactinghyera.Sensitive). An invalid type or a failed conversion raiseshyera.HieraLookupError.HOCONBackend(hocon_data/hocon) via the optionalpyhocondependency (pip install hyera[hocon]); registered automatically when importable.- CLI merge surface:
--merge first|unique|hash|deep(witharray/setaliases) and--knockout-prefix. src/package layout,pyproject.toml, and PyPI-ready metadata. The distribution, import package and console script are allhyera.- Glob hierarchy levels (
glob:/globs:), expanded viapathlib_nextand resolved in sorted (deterministic) order. - Command-line interface
hyera KEY(built onduho), with--config, repeatable--scope key=value,--merge,--deep,--output raw|json|yaml, and--default. Exit codes:0found (or--defaultprinted),1missing,2any other error. Installed as thehyeraconsole script and runnable viapython -m hyera. - Typed exception hierarchy:
HieraError(base,.pathnames the file concerned) →ConfigError(invalid/missinghiera.yaml),BackendError(a data file could not be read or parsed,.pathnames it), andHieraLookupError(a failure while resolving a key) →InterpolationError,MergeError, andKeyNotFoundError(also aKeyError;.get(..., throw=True)'s miss). All exported fromhyera. - Test suite (pytest) covering lookup, interpolation, merge, glob, backends, and the CLI; green on Python 3.9 and 3.14.
Hiera(None, base_path=...): Puppet's built-in default configuration (data/common.yamlunderbase_path), used when there is no hiera.yaml to point at.ConfigError.pathandConfigError.linename the file (and, where known, the line) a configuration problem was found at.sops_datadecrypts YAML, JSON, INI and dotenv files, choosing the format from the file extension the same way thesopsCLI itself does (.yaml/.yml/.json/.env/.ini, case-sensitive; any other extension is a clear error naming the file instead of a raw or misleading failure).DotenvBackendparses sops's own dotenv output shape; it has no Puppetdata_hashequivalent, so it is reachable only throughsops_data. An INI file is decrypted through sops's own JSON view instead of a dedicated ini parser (see Security).sopsis another name forsops_data;sops_yaml/sops_json/sops_ini/sops_dotenvforce that format regardless of the file's own extension.Scope: Puppet's top scope for lookups.variablesare node parameters; facts become top-scope variables without overriding them, and$facts;server_factsmerge under both;$environmentdefaults toproduction;$trusteddefaults to Puppet's local hash (certname from theclientcertvariable or fact);strictisoff,warning(default) orerror.LICENSES/puppet-Apache-2.0.txtandLICENSES/psych-MIT.txt: credit Puppet and Psych, the Apache-2.0 and MIT projects several modules port translated code from, alongside phiera;NOTICElists each ported file.load_facts(path)reads a facts file withpuppet lookup --factsrules (JSON for.json, YAML for.yaml/.yml, otherwise JSON then YAML; the result must be a mapping; YAML dates, times and:symbolsare rejected;hostname/domain/fqdn/clientcertall or none).facts_from_facter(timeout=30)runsfacter -jand returns its facts. Both raiseBackendError.
Changed
convert_toraiseshyera.HieraLookupErrorwith Puppet's message when its type cannot be parsed or converted to, instead of returning the value unchanged; fix the data or catch the error.hyera.Sensitiveprints asSensitive [value redacted]and equals anotherSensitivethat wraps an equal value.- YAML is parsed with
SafeLoader— hiera data is untrusted config and must not be able to construct arbitrary Python objects. SopsYAMLBackendhardened for unattended use: finite subprocess timeout, captured stderr surfaced inBackendError, and a clear error when thesopsbinary is missing.- Backends register under multiple
data_hashnames (e.g.yaml_data/yaml,json_data/json). - Resolved hierarchy locations are cached per the values of the variables the
hierarchy interpolates (such as
%{trusted.certname},%{facts.os.family}or amapped_pathscollection), as Puppet rebuilds its data providers only when one of those changes. Scopes that differ only in other variables or facts share one entry, so a merge lookup across many keys no longer re-globs/re-stats the tree for each key, and a service serving many scopes no longer re-walks the tree for a scope differing only in a volatile fact. The mergedlookup_optionsare cached per set of locations and the variables their own interpolation reads. - Test and release GitHub Actions workflows (the repo previously had no CI).
test.ymlruns on demand or from aci-*tag across 3.9–3.14;release.ymlgates av*tag on the suite before building and publishing. blackis the formatting standard, pinned to thepy39floor; thedevextra now installs it along withpyhocon, which was missing and left the HOCON backend tests skipping in a dev install.- Unshared
*.local.*files are excluded from the sdist and the wheel, and ignored by git — previously any such file other than*.local.mdwas packaged into both artifacts. - Dependency ranges pinned to a minor series:
pathlib_next>=0.9.0,<0.10, andduho>=0.5.0,<0.6in both theclianddevextras. Both were previously unversioned, so a resolver could pick any release ever published. The upper bounds stop the next pre-1.0 minor, where these projects are free to break their API. duhopin moved to>=0.6.0,<0.7in both theclianddevextras. No source change was required: hiera's own duho surface (Cli,LoggingArgs,Arg/NS/Append/Choice,main()) is untouched by every documented 0.6.0 API change.pyyamlis now required as>=6.0,<7and thehoconextra aspyhocon>=0.3.62,<0.4; both were unversioned. pyhocon 0.3.0-0.3.28 pin pyparsing 2.0.3-2.1.1, which fails to import on Python 3.10 and later; 0.3.29-0.3.61 call a pyparsing API that is deprecated on current pyparsing releases.- Files named
CLAUDE*or.claudeare excluded from the sdist and the wheel, and the repository's contributorAGENTS.mdis no longer in the sdist. The API referencehyera/AGENTS.mdstill ships in both. - Building requires
hatchling>=1.27, so the package metadata declaresLicense-Expression: MIT AND Apache-2.0and listsLICENSE,NOTICEandLICENSES/phiera-Apache-2.0.txtas license files; older hatchling wrote only a free-textLicense:field. - Releases: a
v*tag must name the version being built, or the release stops before anything is published. Pre-release tags (v1.0.0-rc.1) create a GitHub pre-release and are not uploaded to PyPI, and re-running a release skips files already on PyPI. - The engine is split into private modules; import public names from
hyera.hyera.corenow defines onlyHieraandScopedHiera, anddefault_backendslives inhyera.backends. Hiera.get_key,load,load_file,buildcontext,can_resolveand theresolve*methods are private (leading underscore);sources()no longer takes_load; the module globalsfunction,interpolate,rformatandLOGGERare gone.Merge.deepandMerge.typare removed; readMerge.strategy.get(..., throw=True)raisesKeyNotFoundError, still aKeyError; a non-string key raisesTypeError; an unknown merge strategy raisesMergeErrorinstead ofValueError— catchMergeErrororHieraError.- A data file that cannot be read or parsed raises
BackendError(.pathnames the file) instead ofConfigError. A missing, unreadable, a directory, unparsable, non-mapping or malformedhiera.yamlraisesConfigErrorinstead ofFileNotFoundError,BackendError,AttributeError,TypeErrororValueError. CatchConfigErrorforhiera.yamlandBackendErrorfor data files. - Parse errors are one line and name the file, line and column —
Unable to parse (<path>): <problem> at line L column Cfor data files and(<path>): <problem> at line L column Cforhiera.yaml, never a multi-line snippet or the underlying value. - Backends register by subclassing
Backendand declaringNAMES(a mapping of namespace —function/v3/format/render— to the names it answers to in that namespace), and are found by name:Backend.find/.get/.new/.names/.for_path.load(bytes),.read_fileandYAMLBackend.load_orderedare gone — use.loads(text)/.load(path_or_file), which now decode data files as strict UTF-8 (as Puppet does) instead of relying on PyYAML's/json's own detection.SopsYAMLBackendis renamedSopsBackend(sops_data).default_backends()always listsHOCONBackend; withoutpyhoconahocon_datalevel now fails at construction (and at parse time) naming thehyera[hocon]extra, instead ofHOCONBackendsilently vanishing from the default list. A data file whose top-level value is not a Hash now follows Puppet instead of crashing: YAML warns and falls through (raising only under--strict error); JSON/HOCON/a third-party backend's non-Hash result is aBackendErrornaming the backend, the file and the value's Puppet type. - YAML data and
hiera.yamlnow follow Puppet's Psych parser rules instead of PyYAML's own: octal/hex/sexagesimal/comma-separated numbers, Ruby's case-insensitiveyes/no/on/offbooleans andnull,:symbolscalars (RubySymbol, inhyera.backends) and!ruby/symbol/!ruby/symvalues, complex (list/hash) keys as hashable tuples,<<merge keys, multi-document files (only the first is read), and a leading UTF-8 BOM all resolve/parse the way Ruby does. An unrecognized YAML tag is no longer a parse error — it is tokenized/listed/dict-built like an untagged node of the same kind, matching Puppet. A date/timestamp-shaped scalar and!!setare refused (BackendError, no lenient mode), as Puppet's own safe-load refuses them. Symbol keys inhiera.yamlitself (however written) normalize to plain strings for every config version. libyaml (via PyYAML'sCSafeLoader) is used when available; the one known gap in the pure-Python fallback is a tab after:in a plain scalar. - JSON data now follows Ruby's
jsongem, not Python'sjsonmodule directly:/* ... */and// ...comments are accepted;NaN/Infinity/-Infinityand an unescaped lone surrogate code point in a string are rejected (both are accepted by Python's decoder by default). HOCON durations (10s,5 minutes) and size strings (10MB) now stay literal text, matching real Ruby hocon (which has no such type) — they used to becomedatetime.timedelta, which crashed-o yaml. A HOCON file whose top-level value is not an object (e.g.[1, 2]) is now an error instead of returning that value directly. - The
hoconextra'spyhoconfloor comment now also records theget_period_exprreason (present since 0.3.60, already covered by the existing>=0.3.62floor). - A context key containing
.no longer shadows the nested%{a.b}walk: Puppet has no such flat-key fallback (a variable name cannot contain.), so%{a.b}always means "navigate.binto the value ofa" — quote the whole reference (%{'a.b'}) to reach a context entry literally named"a.b"instead. The CLI's--scopefollows the same rule: a dotted--scopename (--scope a.b=v) now exits2rather than storing a variable nothing could ever read. - A hiera.yaml without
version, or withversion: 3, is read as version 3, as Puppet does, and validated against Puppet's own version 3 schema with Puppet's messages -- so a version 5 layout missingversion:fails with them (addversion: 5). - hiera.yaml version 3 lookups (Hiera 1, 2 and 3 files) resolve as Puppet 8
performs them: one data provider per
backendsname, in list order, over the whole hierarchy (not one provider per hierarchy level); per-backenddatadir(default<codedir>/environments/%{::environment}/hieradata, resolved against the process's working directory at construction, not hiera.yaml's directory) andextension(default.<backend>,.conffor hocon, appended to each declared location unless already present);yaml/json/hocon/eyamlmapped to the sameyaml_data/json_data/hocon_data/eyaml_lookup_keyfunctions a v5 config would name.merge_behavior/deep_merge_options/loggerare validated but never applied to a lookup, matching Puppet.Hiera(codedir=...)/hyera --codedirset Puppet's$codedir(its own AIO default per platform otherwise). A Python backend registered under a v3 name (viaNAMES = {"v3": (...)}) serves that name inbackends:and in a v5hiera3_backend:entry (global layer only, extension applied the same way); an unregistered name -- includingsops, which has no v3 name -- raisesConfigError(Puppet, with real Hiera 3 installed, silently contributes nothing for a backend it cannot run). version: 4at the global layer is validated in full first (its own provider list is built, same as Puppet does), then raises "hiera.yaml version 4 cannot be used in the global layer" -- a schema-invalid version 4 file at the global layer raises its schema error instead, never the layer one -- rather than being read as version 5.- hiera.yaml version 4 (
backend: yaml|json|hoconinstead ofdata_hash:,path/pathsdefaulting to the entry's ownname, one provider per entry) is read in the environment and module layers, where Puppet accepts it:backendnames onlyyaml/json/hocon(no third-party fallback, unlike v3); a relativedatadir(config- or entry-level) joins onto the layer's own root literally, with no interpolation at all, unlike every other version. - A version 3 (or missing-
version) hiera.yaml at an environment or module root is still fully schema-validated even though it is only ever ignored (or raised about) afterward -- a schema-invalid one raises regardless of layer, exactly as the global layer already did. - A
%{lookup()}/%{hiera()}/%{alias()}reached while interpolating a version 3 global layer's own data stays confined to the global layer -- it never reaches an environment or module, even for an otherwise-qualified key, and never a module'sdefault_hierarchy-- unless the current environment has a real version 5 hiera.yaml (an absent, ignored-version-3, or version 4 environment all count as none). - Any other unsupported version raises "This runtime does not support hiera.yaml version N".
- A
versionthat is not an Integer ("5",5.0) raises, instead of being accepted or silently truncated. - An empty or non-mapping hiera.yaml logs Puppet's own warning and falls
back to Puppet's version 3 default configuration, then reads that as
version 3, instead of raising a
ConfigErrornaming the fallback as unsupported. - Every version 3 (or missing-
version) hiera.yaml logs Puppet's deprecation warning ("Use of 'hiera.yaml' version 3 is deprecated. It should be converted to version 5") unlessScope(strict="off"). - A relative config path, and a relative
base_path, are made absolute at construction instead of resolving against the current working directory on every read. - A hierarchy entry without
datadirreads fromdata/next to hiera.yaml (or underbase_path); it used to read from/etc/puppetlabs/code/environments/%{environment}/hieradata. - A missing
defaultsbecomes{datadir: data, data_hash: yaml_data}, and a missinghierarchybecomes[{name: Common, path: common.yaml}](also when either is present but empty/null), matching Puppet's own built-in default configuration; both used to raiseConfigError. - hiera.yaml is validated against Puppet's version 5 schema: a closed key
set at the top level, in
defaults, and in each hierarchy/plan_hierarchy/default_hierarchyentry; a required, non-empty, uniquename; exactly one function key (data_hash/lookup_key/data_dig/hiera3_backend) per entry or indefaults; at most one location key (path/paths/glob/globs/uri/uris/mapped_paths); non-emptypaths/globs/uris, and exactly threemapped_paths; a present key must be a non-empty string where one is expected (~is an error, not treated as absent); Puppet's option-name pattern foroptionskeys, withpath/urireserved; andhiera3_backend: json/yaml(orhocon, when available) points at thedata_hashname to use instead. Every violation raisesConfigErrorwith Puppet's own message, and.path/.linewhen known. A hiera.yaml with a misspelled key, a missing or duplicatename, or two location keys on one entry used to load (silently misreading the config) and now raises; fix the config. context=/**kwargsare gone fromHiera(),.get(),.has(),.sources(),.format()and.scoped(); every one of them now binds ahyera.Scopeinstead (Hiera(..., scope=Scope(...))), and an unknown keyword raisesTypeError. Migration:Hiera(cfg, context={"role": "web"})→Hiera(cfg, scope=Scope(variables={"role": "web"}));h.get(k, role="web")→h.scoped(variables={"role": "web"}).get(k).ScopedHiera(hiera, scope)— its.get/.has/.sources/.formatuse the bound scope, and.scoped(...)derives from it (nesting composes instead of each call restarting from the instance's own scope).-
A
False/0/""/[]/{}variable or fact is kept, no longer dropped:%{flag}rendersfalse(not empty) whenflagis the booleanfalse, and avirtual/%{is_virtual}.yamlhierarchy level loadsvirtual/false.yamlinstead of being skipped.None/nullstill renders as the empty string.%{environment}is always defined ("production"unless set) and%{trusted}defaults to Puppet's local hash, in both values and hierarchy paths. -
The non-Puppet
data_hashnamesyaml,json,hoconandyaml.enc(strict Puppet only). Useyaml_data,json_dataandhocon_data;sops_data(see Changed) is the one intentionally kept non-Puppet name. hyera.LookupDict,hyera.sym_lookupand thehyera.utilmodule. Parsed data and merge results are now plaindict/listthroughout; dotted-key navigation is a function over that data ("a.b.0.c"-style lookups still work exactly the same), not a container method. Migration:LookupDict(...).lookup("a.b.0")is nowHiera.get("a.b.0"). Ruby-symbol keys (:key) are normalized tokeywhen data loads, sosym_lookuphas no replacement — nothing needs one.- The
data_dirspelling, which Puppet does not accept — onlydatadiris a real Puppet key. A hierarchy ordefaultsentry usingdata_dirnow raisesConfigError("unrecognized key 'data_dir'"); rename it todatadir.Backendno longer falls back to readingconf["data_dir"].
Fixed
hiera.yamlis read as UTF-8 bytes (a BOM or UTF-16 is detected) instead of the locale encoding, and closed right after reading. On Windows a non-ASCII config was silently misread (adatadirwith an accent found nothing), a UTF-8 BOM config failed to parse, and the previously-open handle stopped the file from being replaced and the instance from being pickled or deep-copied.Hiera.base_confignow keeps the exact path it was given.- A
glob/globshierarchy level whose directory does not exist now contributes no files instead of raisingFileNotFoundErrorfromHiera()or.get(). The documented example config crashed at construction whendata/moduleswas absent, and a per-node glob directory crashed lookups for any node without one. - A HOCON file that is not valid UTF-8 now raises
BackendErrorinstead of a rawUnicodeDecodeError. - An installed but broken
pyhocon(an import-time exception other thanImportError, e.g. against a too-new stdlib) no longer breaks everyHiera();HOCONBackendis simply left unregistered, and constructing one directly raisesBackendError. - CLI
--output yamlnow renders hashes and redactedSensitivevalues instead of crashing withRepresenterErrorand exit 1. - An explicit
--merge firston the CLI now overrides alookup_optionsmerge, aspuppet lookup --merge firstdoes (omitting--mergestill letslookup_optionsdecide). - The
hyeraconsole script andpython -m hyeranow printpip install "hyera[cli]"and exit 2 when thecliextra is missing, instead of crashing with aModuleNotFoundErrortraceback. ScopedHieracan be copied, deep-copied and pickled; each previously raisedRecursionError.- The
HYERA_MCPtrigger env var name (and the served tool's name) no longer depends onsys.argv[0]. Running the CLI aspython -m hyera.cli(trigger varCLI_MCP) or embeddingLookupin a differently-named script previously changed which environment variable launched the MCP server, silently breaking the documentedHYERA_MCPcontract. - The missing-extra install hints (
pip install "hyera[cli]"andpip install "hyera[hocon]") are now double-quoted throughout; the old single-quoted form fails when pasted intocmd.exe, where single quotes are literal. - A real
include file(...)/include url(...)resolution (now the default -- see the HOCONincludeentry under Security) no longer crashes on Python 3.14+: pyhocon 0.3.63 itself still calls the deprecatedcodecs.open()andLogger.warn(), which raiseDeprecationWarningthere, turned into a fatal error by this project's ownfilterwarnings = ["error"]. Both calls are shimmed in hyera's already-privatepyhocon.config_parsermodule copy; the sharedpyhoconmodule, and every other caller of it, are unaffected. Hiera(dict_config)no longer modifies the caller's dict; the config is deep-copied at construction.- A malformed hiera.yaml shape (a 2-element
mapped_paths, ahierarchythat is a Hash or a list of strings, a non-Hashdefaults, a non-Arraydata_hash, anullentry, and the like) now raisesConfigErrorwith Puppet's own message, instead of a rawValueError,TypeErrororAttributeError. A stringpaths/globs/urisvalue is rejected outright instead of being silently split into one source per character. - A hierarchy entry with
lookup_key,data_dig,hiera3_backendorv4_data_hashno longer falls back todefaults.data_hashand reads its file as plain YAML; eyaml ciphertext used to be returned as the value. An unknown function name now raises Puppet's own "Unable to find '' function named ' '"; a known lookup_key/data_digfunction raisesConfigError("not supported yet") instead. - Per-call context now reaches hierarchy path resolution.
Hiera.get()built its context fromcontext=plus**kwargsbut resolved sources from the rawcontextargument, soget(key, environment="production")silently skipped theenvironments/%{environment}.yamllevel and fell through tocommon.yaml.has()funnels all context through**kwargsand so was affected wholesale; it now takes an explicitcontext=. ScopedHiera.has()no longer lets its bound context override per-call arguments. It layered the bound context over**kwargs, the inverse ofScopedHiera.get()and of the documented contract, so.has()and.get()could disagree about the same lookup.- Dotted context references (
%{trusted.certname}) resolve as nested lookups instead of raising. They becamestr.formatattribute access, which raisesAttributeErroron the dict contexts hiera actually uses — andHieraLevel.paths()caught onlyKeyError, so the error escaped and crashedget(). The documented example config, which leads withnodes/%{trusted.certname}.yaml, failed at construction time. Paths,data_dir,mapped_pathstemplates, values,format()and%{scope('a.b')}now share one nested-lookup rule; numeric segments index lists, a flat context key containing dots still wins, and an unresolvable reference skips the level or yields""rather than raising. - Dotted lookup keys and
%{...}context references now follow Puppet's ownsplit_key/sub_lookupsub-key grammar exactly, instead of a naivestr.split("."): a segment may be single- or double-quoted (soget("'a.b'.c")-style keys reach a key that literally contains a dot), a negative or out-of-range list index (lst.-1) is not found rather than wrapping to the last item, an integer segment matches only an integer hash key (h.0finds{0: x}, never{"0": x}), and walking further into anullvalue (n.xwherenis~) is not found rather than raising a rawTypeError. A genuine type mismatch (s.x/lst.x/f.xwalking into a scalar, array or float) now raisesHieraLookupErrorwith Puppet's "Data Provider type mismatch" message instead of a rawTypeError/ValueError, and a malformed key (a..b,a.,.a, an unbalanced or empty quoted segment) raisesHieraLookupErrorwith Puppet's "Syntax error in key/string" text instead of silently returning the default. Both kinds of error are raised even with adefault=given, and through.has()— only a genuine miss is silent, matching Puppet's ownlookup(), which raises both even withdefault_valueset. sort_merged_arraysnow applies todeepmerges, where Puppet defines it. It was honoured only on theuniquebranch and silently swallowed ondeep, which both the README and the API header advertised as supported. Sorting runs after knockout and reaches lists nested anywhere in the result; a list with no total order is left in merge order.- A
lookup_optionskey is treated as a regular expression only when it starts with^, per Hiera 5. Any key containing a regex metacharacter was compiled as a pattern, so an entry fordb.portalso matcheddbxport. - Interpolation no longer treats resolved values as
re.subreplacement templates — backslashes and\g<...>sequences in data now pass through literally instead of raising or being mangled. Hiera.format()now formats with the context mapping (format_map) instead of passing the dict as a single positional argument.- Mutable default arguments (
context={}) replaced withNonesentinels, fixing cross-call context contamination inscoped()and others. - Unknown/missing
data_hashbackends now raise a clearConfigErrornaming the known backends, rather than an opaqueKeyError. LookupDictis no longer (unsafely) hashable.- Function calls resolving to a falsy value (
0,"",False) no longer raiseInterpolationError— only a genuinely absent value is rejected. A%{hiera(...)}whose key is missing now degrades to that rejection instead of propagating aKeyError. - The bare-
%{var}interpolation regex no longer also matches function-style%{hiera(...)}tokens, so an unresolved function leftover is not blanked. - Invalid
--scopevalues are reported through the logger and exit2, in line with the CLI's exit-code contract (previously a rawSystemExit). - Deep hash merge now respects hiera precedence: a scalar provided by an earlier (higher-priority) hierarchy level is no longer clobbered by a later level. Previously the last level won for scalars, inverting precedence.
- Non-string scalar values (ints, floats, booleans) resolved by a
%{hiera(...)}/%{lookup(...)}call embedded in a larger string are now stringified instead of raising; a single stand-alone call still preserves the resolved value's native type. - The CLI exits
2with a one-lineLookup of key 'K' failed: …message for every failure other than a missing key; several failures used to print a traceback and exit1, the missing-key code (a plainKeyErrorfrom a custom.get()override, for example, could be mistaken for a miss).-vorDUHO_TRACEBACK=1adds the traceback.
Security
- An INI file decrypted by
sops_datais now parsed from sops's own--output-type=jsonview, never ini text: sops's own INI writer emits a value containing"""plus a newline ambiguously, so a decrypted value could be read back as a different key or as an injected section (reproduced against real sops 3.13.3: adb.passwordvalue replaced by a later, attacker-supplied one).sops_ini/extension-inferrediniboth still tell sops to read the file as ini; only the output/parse side changed. - Three more
_yaml_loadermessages a decrypted YAML file can shape itself into (invalid value for Float()/Integer(), andTried to load unspecified class:for a!ruby/object/!ruby/hashtag) no longer quote the offending scalar or class name on thesops_datadecrypt path;yaml_data(no sops involved) is unchanged. A handful of fixed names hyera itself raises for a known YAML shape (Time,Date, an unnamed!ruby/object,!!set) still show, since none of them ever echo text from the document. - A
sopsdecrypt timeout no longer leaves the underlyingsubprocess.TimeoutExpired(and its captured partial stdout) reachable via the raisedBackendError's__context__; only__cause__was addressed by the previousfrom Nonefix. - A
UnicodeDecodeErrorfrom a non-UTF-8 decrypted file now reports only the byte offset, not the offending byte value or the stock codec message's surrounding text. - Decrypted
sopsplaintext no longer appears in error messages or logs when a decrypted file fails to parse. - The data file passed to
sopsis always an absolute path after a literal--, so a name starting with-can never become asopsoption; thesopsfound onPATHis executed by its full resolved path, and asops.bat/sops.cmdshim is refused. - HOCON
includedirectives resolve exactly as Puppet's ownhocon_datadoes by default (hyera never does less than Puppet by default, only as an explicit opt-in): a plaininclude "file"contributes nothing, as in Puppet;include file(...)really reads the file (relative to the process working directory, or absolute), as Puppet'shocon_datadoes; a directive in value position (including inside a[...]array) is kept as literal text, as Puppet keeps it; andurl(...),classpath(...),required(...),package(...), a case-mismatched keyword, or a bareincludewith nothing valid after it all raiseBackendError, matching Puppet's own parse/method errors for those forms (Ruby hocon implements none of them). The pre-fidelity refusal -- every form other than a plain quoted include raises,include file(...)included -- is kept as an opt-in:HOCONBackend(hocon_includes=False), or ahocon_includes: falsekey on the hierarchy entry/defaults(hyera's own extension, not Puppet vocabulary). One accepted divergence: Puppet'sinclude file("*.conf")never globs; pyhocon's own resolution does and includes every match. sopsis refused when it resolves to a relative path (e.g. from the current directory or a relativePATHentry), closing a gap where Python 3.9'sshutil.whichcould still return such a path even with the Windows implicit-current-directory opt-out set.- Three sops-decrypted YAML parse errors (an undefined alias, an unknown
tag, a duplicate anchor) no longer quote the offending value verbatim in
the raised error, the log, or the CLI's output. A sops timeout also no
longer chains the underlying
TimeoutExpired(which carries any partial decrypted stdout). - Closed several remaining gaps in the HOCON
includetext scanner: a caselessly-matched keyword using a non-ASCII look-alike character (e.g. a dotless "ı"), a triple-quoted string ending in extra quote characters, a backslash-escaped"/#/${in unquoted text, and a//that is part of ordinary unquoted text (as in a URL-shaped value) rather than a comment could each let a realinclude file(...)/url(...)reach pyhocon's own include machinery.includeinside a[...]array is now also treated as value position (kept as literal text by default, raised under thehocon_includes=Falseopt-in) rather than being blanked into a shorter array. As a fail-closed backstop, pyhocon's own include-resolving methods raise for the duration of a HOCON parse for every form the active mode does not intend to resolve for real, so even an undiscovered scanner gap cannot read a file or reach the network; they behave normally for any other use of pyhocon in the same process. This backstop now also wraps hyera's own privatepyhocon.config_parsermodule copy (added for Ruby-hocon-compatible duration parsing) -- previously it wrapped only the sharedpyhoconmodule, whichHOCONBackendnever actually parses through, leaving the backstop installed but inert for every realHOCONBackendcall; found and fixed while widening the default's own capability, which makes the backstop's guarantee matter more, not less.