"""Parse a fixed INI fixture with iniconfig and report what it read, in one interpreter start. A `python3` start on this arm costs tens of seconds, so every question the suite asks about the module is answered from THIS run's output rather than from a process per question. Output is one `key=value` per line. The suite greps keys; it never parses this file. A key is emitted at most once, and a key that could not be measured is simply absent -- the suite's `probe-output` and `probe-complete` cases are what say an absent key means a dead probe rather than a false answer. THE FIXTURE IS EMBEDDED HERE RATHER THAN SHIPPED BESIDE THIS FILE, so the text parsed by the x86-64 control and the text parsed on target are the same bytes by construction. It is written into a scratch directory given as argv[1] -- iniconfig reads a PATH, and a fixture opened off the virtiofs root would also put a rootfs read in the probe's way for no gain. Every value emitted is content-determined: line numbers, parsed strings and exception fields over ASCII text. Nothing here depends on pointer width, byte order or a locale, which is what makes the x86-64 control transferable to wasm32. """ import os import sys # ---------------------------------------------------------------- fixtures --- # THE GOOD FIXTURE, which exercises every shape iniconfig's parser has: # # - both comment characters (`#` and `;`), as full lines and after a section # header, and a blank line; # - `key = value` and `key: value`, which take DIFFERENT arms of _parseline -- # the `:` arm is only reached when the `=` split raises; # - an empty value, which is a value and not a missing key; # - surrounding whitespace, which is stripped from both name and value; # - a continuation, whose leading whitespace is what marks it, joined with # newlines onto the value above it; # - a section that declares no keys, which is a section and not an absence. # # The trailing spaces on the `spaced` line are deliberate and load-bearing: they # are what `line.rstrip()` removes, and an editor that strips them would silently # weaken the case. `spaced-value` below asserts the result. GOOD = ( "# a full-line comment, hash style\n" "; a full-line comment, semicolon style\n" "\n" "[beta] # a comment after the section header\n" "one = 1\n" "two = 2\n" "\n" "[alpha]\n" "key1 = value1\n" "key2: value2\n" "empty =\n" "spaced = trimmed \n" "multi = first\n" " second\n" " third\n" "\n" "[gamma]\n" ) # BETA IS DECLARED BEFORE ALPHA ON PURPOSE. `__iter__` yields sections in the # order they were DECLARED and `sorted(cfg.sections)` yields them alphabetically; # with the file in alphabetical order the two agree and `iter_sections` could not # tell an ordering failure from a sorting one. Measured: they agreed before this # was reordered. # THE MALFORMED FIXTURES, one per documented ParseError. Each is the SMALLEST # input that reaches its own `raise`, so a red names one arm of the parser rather # than a fixture that could have failed several ways. # # The name is the key prefix; the value is (text, expected-0-based-lineno). The # lineno is emitted, never assumed -- the suite compares it against the control. BAD = { # A line that is neither a section, nor `name=value`, nor `name:value`, nor a # continuation. Reaches _parseline's own raise. "unexpected_line": "[s]\njustaword\n", # A value before any section header. The parse succeeds; IniConfig's own loop # rejects it, which is a different raise site from the four above. "no_section": "key = value\n", # `[]` -- the header parses, and the empty name is rejected by parse_lines. "empty_section_name": "[]\n", # A leading-whitespace line with nothing to continue. "continuation_first": " orphan\n", # The same section header twice. "duplicate_section": "[s]\n[s]\n", # The same key twice inside one section. "duplicate_name": "[s]\na = 1\na = 2\n", } def emit(key, value): print("%s=%s" % (key, value)) def yn(b): return "yes" if b else "no" def main(): scratch = sys.argv[1] if len(sys.argv) > 1 else "." import iniconfig from iniconfig import IniConfig, ParseError emit("module_file", getattr(iniconfig, "__file__", "")) # No compiled extension answered. iniconfig is pure Python, and this image # could not import a .so at all (BRINTOS-1476), so this states the negative # from inside the interpreter. emit("module_is_py", yn(str(getattr(iniconfig, "__file__", "")).endswith(".py"))) # THE PACKAGE CARRIES NO `__version__` -- measured. `_version.py` exists in # the sdist and holds the string, but `__init__.py` never imports it, so the # attribute is NOT on the package and the version can only come from the # installed distribution's metadata. The suite pins this negative so a later # reader does not "fix" it into a red. emit("has_version_attr", yn(hasattr(iniconfig, "__version__"))) try: import importlib.metadata as md emit("version", md.version("iniconfig")) except Exception as e: emit("version_error", "%s: %s" % (type(e).__name__, e)) # THE DOCUMENTED SURFACE. `__all__` is the package's own statement of what it # exports, emitted whole so a name added or dropped is named rather than # merely counted. emit("all_names", "|".join(getattr(iniconfig, "__all__", []))) emit("api_iniconfig", yn(hasattr(iniconfig, "IniConfig"))) emit("api_parseerror", yn(hasattr(iniconfig, "ParseError"))) emit("api_commentchars", yn(hasattr(iniconfig, "COMMENTCHARS"))) emit("api_iscommentline", yn(hasattr(iniconfig, "iscommentline"))) # SectionWrapper is NOT in __all__ -- it is reached only through indexing a # config -- but it is a public name on the module and the type `__getitem__` # returns, so its absence would break every consumer that annotates one. emit("api_sectionwrapper", yn(hasattr(iniconfig, "SectionWrapper"))) # The two comment characters, and the predicate that uses them. `iscommentline` # tests the FIRST NON-BLANK character, so an indented comment is a comment and # a line whose `#` comes later is not. emit("commentchars", iniconfig.COMMENTCHARS) emit("iscomment_hash", yn(iniconfig.iscommentline("# x"))) emit("iscomment_semi", yn(iniconfig.iscommentline("; x"))) emit("iscomment_indented", yn(iniconfig.iscommentline(" # x"))) emit("iscomment_value", yn(iniconfig.iscommentline("a = 1 # x"))) emit("iscomment_blank", yn(iniconfig.iscommentline(""))) # ------------------------------------------------------- the good parse --- path = os.path.join(scratch, "good.ini") with open(path, "w") as fp: fp.write(GOOD) cfg = IniConfig(path) # THE PATH IS KEPT AS GIVEN. `os.fspath` on a str is the identity, so this is # the assertion that the config knows which file it read -- which is what # makes every ParseError below traceable to a fixture. emit("path_basename", os.path.basename(cfg.path)) # THE SECTIONS, whole and in file order. `__iter__` sorts by the line each # section was declared on, so this is an ordering assertion as well as a # membership one. emit("sections", "|".join(sorted(cfg.sections))) emit("iter_sections", "|".join(s.name for s in cfg)) emit("count_sections", len(cfg.sections)) # `__contains__` -- the cheap membership test, both ways round. emit("contains_alpha", yn("alpha" in cfg)) emit("contains_missing", yn("nope" in cfg)) # THE KEY/VALUE PAIRS, compared against literals. Emitted one key at a time # so a red names WHICH pair the parser got wrong, and as a joined whole so a # key that vanished entirely is caught too. emit("alpha_keys", "|".join(sorted(cfg.sections["alpha"]))) emit("alpha_iter_keys", "|".join(iter(cfg["alpha"]))) emit("key1", cfg["alpha"]["key1"]) # The `:` separator, which reaches a different arm of _parseline than `=`. emit("key2", cfg["alpha"]["key2"]) # An empty value is a VALUE. `empty_is_str` separates "the key holds ''" from # "the key is missing and get() returned its default", which read alike. emit("empty_value", repr(cfg["alpha"]["empty"])) emit("empty_is_str", yn(cfg["alpha"]["empty"] == "")) # Whitespace is stripped from both sides of both name and value. emit("spaced_value", cfg["alpha"]["spaced"]) emit("spaced_key_present", yn("spaced" in cfg.sections["alpha"])) # THE CONTINUATION, joined with newlines. Emitted with the newlines made # visible, because a `key=value` output line cannot carry a real one. emit("multi_value", cfg["alpha"]["multi"].replace("\n", "\\n")) emit("multi_lines", len(cfg["alpha"]["multi"].splitlines())) # The header's trailing comment was stripped from the SECTION NAME, not kept. emit("beta_keys", "|".join(sorted(cfg.sections["beta"]))) emit("beta_one", cfg["beta"]["one"]) # A section that declares no keys is a section, and its mapping is empty. emit("gamma_empty", yn(cfg.sections["gamma"] == {})) emit("gamma_present", yn("gamma" in cfg)) # `.get()` -- the arm that does NOT raise. A missing key returns the default, # which is the documented difference from `[...]` below. emit("get_present", cfg.get("alpha", "key1")) emit("get_missing_default", repr(cfg.get("alpha", "nokey"))) emit("get_missing_explicit", cfg.get("alpha", "nokey", "fallback")) emit("get_missing_section", repr(cfg.get("nosuch", "key1"))) # ...and the `convert` callable, applied only when the key is present. emit("get_convert", cfg.get("beta", "one", convert=int) + 41) emit("get_convert_missing", repr(cfg.get("beta", "none", convert=int))) # The same two through the section wrapper, which forwards to the above. emit("wrapper_get", cfg["beta"].get("two")) emit("wrapper_get_default", cfg["beta"].get("none", "dflt")) emit("wrapper_name", cfg["alpha"].name) # LINE NUMBERS, 1-BASED. `lineof` adds one to the parser's own 0-based count, # so these are the numbers a human reads in an editor -- which is the whole # point of the package. emit("lineof_alpha", cfg.lineof("alpha")) emit("lineof_key1", cfg.lineof("alpha", "key1")) emit("lineof_multi", cfg.lineof("alpha", "multi")) emit("lineof_beta", cfg.lineof("beta")) emit("lineof_gamma", cfg.lineof("gamma")) # A name it never saw reports None rather than raising or guessing a 0. emit("lineof_missing", repr(cfg.lineof("alpha", "nokey"))) emit("wrapper_lineof", cfg["alpha"].lineof("key2")) # `.items()` on the wrapper, in line order -- the iteration a consumer walks. emit("beta_items", "|".join("%s=%s" % kv for kv in cfg["beta"].items())) # PARSING FROM A STRING, with the path used only for error reports. This is # the constructor arm pytest uses, and it must agree with the file arm above. cfg2 = IniConfig("in-memory.ini", data=GOOD) emit("data_arm_sections", "|".join(sorted(cfg2.sections))) emit("data_arm_agrees", yn(cfg2.sections == cfg.sections)) # ------------------------------------------------------ the KeyError arm --- # THE DOCUMENTED RAISE, and the one the ticket names. `[...]` raises where # `.get()` returns a default, and the two are asserted together above and here # so a release that made indexing forgiving is caught. # # A missing SECTION and a missing KEY raise from different `__getitem__`s -- # IniConfig's own, and SectionWrapper's forward into the mapping -- so both # are driven. try: cfg["nosuch"] emit("keyerror_section", "NO-RAISE") except KeyError as e: emit("keyerror_section", type(e).__name__) emit("keyerror_section_arg", e.args[0]) except Exception as e: emit("keyerror_section", "WRONG:%s" % type(e).__name__) try: cfg["alpha"]["nokey"] emit("keyerror_key", "NO-RAISE") except KeyError as e: emit("keyerror_key", type(e).__name__) emit("keyerror_key_arg", e.args[0]) except Exception as e: emit("keyerror_key", "WRONG:%s" % type(e).__name__) # ---------------------------------------------------- the ParseError arm --- # THE LINE-PRECISE ERROR REPORT, which is what this package is for. For each # malformed fixture: the exception type, its 0-BASED `.lineno` attribute, the # 1-BASED number its `__str__` renders, and the message. The two line numbers # differ BY DESIGN and are emitted separately, because a consumer that read # `.lineno` as 1-based would be off by one on every report and no single # rendering would show it. for name, text in sorted(BAD.items()): p = os.path.join(scratch, name + ".ini") with open(p, "w") as fp: fp.write(text) try: IniConfig(p) emit("bad_%s" % name, "NO-RAISE") except ParseError as e: emit("bad_%s" % name, "ParseError") emit("bad_%s_lineno" % name, e.lineno) # `__str__` renders `{path}:{lineno+1}: {msg}`. The number is cut # against the KNOWN path rather than by splitting on ":" -- the # message carries its own colon ("unexpected line: 'justaword'"), # and a positional split reports part of the message as the line # number. Measured: it did exactly that before this was fixed. _rendered = str(e) _prefix = "%s:" % e.path _tail = _rendered[len(_prefix):] if _rendered.startswith(_prefix) else "" emit("bad_%s_str_lineno" % name, _tail.split(":", 1)[0]) # ...and the whole report as a consumer sees it, path aside: the # number and the message in one assertion, in the rendered order. emit("bad_%s_render" % name, _tail) emit("bad_%s_msg" % name, e.msg) # The report names the file it came from, which is what makes it # actionable when a tool parses several. emit("bad_%s_path" % name, os.path.basename(e.path)) except Exception as e: emit("bad_%s" % name, "WRONG:%s" % type(e).__name__) # ParseError is an ordinary Exception, so a consumer's bare `except` catches # it -- stated because the class is exported and consumers subclass on it. emit("parseerror_is_exception", yn(issubclass(ParseError, Exception))) emit("done", "yes") if __name__ == "__main__": try: main() except Exception as e: emit("fatal", "%s: %s" % (type(e).__name__, e)) sys.exit(1)