#!/usr/bin/env python3 # Payload for tests/99-pyxdg.test. Prints one tagged line per measurement # (R_ ) plus ALL-PYXDG-OK when every internal check held, so the # .test can both gate on the whole and compare each value by name. # # THE TAGS ARE UNDERSCORED, NOT HYPHENATED, AND THAT IS THE POINT OF THEM. The # .test reads this output into shell variables with one `eval` per line and no # subprocess at all -- a tag that is already a valid sh identifier is what makes # that possible. The obvious alternative, one `awk` per value the way # 36-libjson-perl.test does it, costs an exec per lookup, and an exec on this arm # is a fresh wasm module instantiation at 2-3 s. Seventy-nine of them would be # the whole of this suite's runtime, spent entirely on parsing. # # WHY ONE SPAWN. exec on wasm32-hwjs is a fresh module instantiation and python3 # is one static ~N-MB interpreter, so a spawn is the expensive unit and a suite # that ran one `python3 -c` per assertion would spend its whole budget in # start-up. Everything this file measures is therefore measured in ONE # interpreter, and the .test compares the results by name afterwards in the shell. # # WHY A FIXTURE TREE RATHER THAN THE IMAGE'S OWN XDG DIRECTORIES. Every module # here answers questions about files it finds by searching XDG_DATA_DIRS / # XDG_CONFIG_DIRS, so an assertion made against whatever the rootfs happens to # carry is an assertion about the image's package list and not about pyxdg. This # file stages a corpus in tmpfs, points the XDG variables at it, and enumerates # the answers it expects -- which is also the only way to exercise Mime's glob # and magic paths at all on an image that ships no shared-mime-info. # # THE ENVIRONMENT ARRIVES FROM THE CALLER AND THAT IS LOAD-BEARING, NOT STYLE. # xdg.BaseDirectory computes xdg_data_dirs, xdg_config_dirs and their _home # siblings at MODULE IMPORT time, from os.environ, and every other module reads # those module-level names -- so the environment has to be complete before the # first `import xdg.*` anywhere in the process, and nothing above that import may # touch the package. A version of this file that imported at the top measured # the runner's own environment and reported it as the fixture's. # # This file used to satisfy that by setting the variables itself, and on # wasm32-hwjs it cannot: setenv(3) returns EINVAL for every name. The caller # exports them instead and passes the fixture root as argv[1]; see the fixture # section below for the measurement. import os import shutil import sys FAIL = 0 def emit(tag, val): print("%s %s" % (tag, val)) def ck(cond, why): global FAIL if not cond: FAIL += 1 print("CHECK-FAILED %s" % why) return cond def show(v): """A shell-comparable rendering of a value that may be empty or absent. repr() would do, and does not: the .test compares these in sh, where a repr's own quotes have to be quoted again and an empty string renders as two apostrophes that the shell then eats. Naming the two cases is both unambiguous and readable in a red's detail field. """ if v is None: return "" if v == "": return "" return v def write(path, text, mode=None): d = os.path.dirname(path) if d and not os.path.isdir(d): os.makedirs(d) with open(path, "wb" if isinstance(text, bytes) else "w") as f: f.write(text) if mode is not None: os.chmod(path, mode) # --------------------------------------------------------- the fixture tree -- # ROOT COMES FROM THE CALLER, and that is a platform constraint rather than a # preference: setenv(3) fails EINVAL on this arm for every name (measured # 2026-08-28; brintos/glibc#11), so the XDG_* variables this file needs cannot # be set from inside the interpreter -- they have to arrive through execve's # envp, which means the shell has to know the fixture root before python3 # starts. 99-pyxdg.test makes the directory and passes it here, and the x86-64 # control is taken the same way -- one `mkdir`, the same six variables exported, # the same argument -- so the two arms run the identical file over the identical # fixture and neither of them relies on setenv. if len(sys.argv) < 2: sys.stderr.write("usage: pyxdg-selftest.py " "(with HOME and the XDG_* variables already exported " "to point into it; see the header)\n") sys.exit(2) ROOT = sys.argv[1] HOME = os.path.join(ROOT, "home") DH = os.path.join(ROOT, "data-home") CH = os.path.join(ROOT, "config-home") D1 = os.path.join(ROOT, "data1") D2 = os.path.join(ROOT, "data2") C1 = os.path.join(ROOT, "config1") C2 = os.path.join(ROOT, "config2") for d in (HOME, DH, CH, D1, D2, C1, C2): if not os.path.isdir(d): os.makedirs(d) # --- desktop entries. # gedit.desktop carries a localised Name and GenericName, a list-valued # Categories and Keywords, and the X- extension prefix -- the four shapes # DesktopEntry's getters differ over. write(os.path.join(D1, "applications", "gedit.desktop"), """[Desktop Entry] Type=Application Name=Text Editor Name[de]=Texteditor Name[de_DE]=Texteditor DE Name[fr]=Editeur de texte GenericName=Editor Comment=Edit text files Exec=gedit %U Icon=accessories-text-editor Terminal=false Categories=GNOME;Utility;TextEditor; Keywords=Plaintext;Write; MimeType=text/plain; X-Brintos-Fixture=yes """) # A SECOND entry with the SAME basename, one directory later on XDG_DATA_DIRS. # The basedir spec says the first match wins and nothing merges, so this file # must never be the one read -- it is the control for that rule and its Name # differs from gedit.desktop's on purpose. write(os.path.join(D2, "applications", "gedit.desktop"), """[Desktop Entry] Type=Application Name=Shadowed Editor Exec=shadow %U """) write(os.path.join(D2, "applications", "calc.desktop"), """[Desktop Entry] Type=Application Name=Calculator Exec=calc Categories=Utility;Calculator; """) # An entry that is INVALID by the spec and valid as an ini file: Type=Application # with no Exec, plus a key outside the registered set that does not carry the X- # prefix. DesktopEntry.parse() accepts it; validate() is what must object. write(os.path.join(D1, "applications", "broken.desktop"), """[Desktop Entry] Type=Application Name=Broken NotARegisteredKey=1 """) # --- desktop-directories, for the menu. for name, title in (("Utility.directory", "Accessories"), ("Editors.directory", "Editors")): write(os.path.join(D1, "desktop-directories", name), "[Desktop Entry]\nType=Directory\nName=%s\n" % title) # --- the MIME database. globs2 in D1 and a second globs2 in D2: the merge, the # weights and the case-sensitivity flag are all exercised by the pair. write(os.path.join(D1, "mime", "globs2"), """#globs2 MIME data file 50:text/x-python:*.py 50:text/x-csrc:*.c:cs 50:text/x-c++src:*.C:cs 50:text/x-makefile:makefile 10:text/x-readme:readme* 50:image/png:*.png """) write(os.path.join(D2, "mime", "globs2"), """#globs2 MIME data file 20:text/x-readme2:readme2* 50:image/jpeg:*.jpg 50:image/jpeg:*.jpeg """) # The magic database, in the binary format shared-mime-info generates. # ">0=\x00\x04\x89PNG" is: at offset 0, a 4-byte value, the PNG signature's # first four bytes. The 0x00 0x04 pair is the big-endian value length, which is # why this is written as bytes and not as text. write(os.path.join(D1, "mime", "magic"), b"MIME-Magic\x00\n" b"[50:image/png]\n>0=\x00\x04\x89PNG\n" b"[50:image/jpeg]\n>0=\x00\x03\xff\xd8\xff\n" b"[40:application/x-brintos-fixture]\n>0=\x00\x08BRINTOSF\n") # aliases and subclasses -- what canonical() and inherits_from() read. write(os.path.join(D1, "mime", "aliases"), "text/xml application/xml\n") write(os.path.join(D1, "mime", "subclasses"), "text/x-python text/plain\ntext/x-csrc text/plain\n") write(os.path.join(D1, "mime", "text", "x-python.xml"), '\n' '\n' ' Python script\n' ' Python-Skript\n' '\n') # --- icon themes. `brintos-child` inherits `brintos-parent`, and only the # parent carries the 48x48 apps icon, so a 48 lookup that succeeds proves the # Inherits chain was walked rather than the child searched twice. write(os.path.join(D1, "icons", "brintos-child", "index.theme"), "[Icon Theme]\nName=Child\nComment=fixture\n" "Inherits=brintos-parent\n" "Directories=16x16/apps,32x32/apps,scalable/apps\n\n" "[16x16/apps]\nSize=16\nContext=Applications\nType=Fixed\n\n" "[32x32/apps]\nSize=32\nContext=Applications\nType=Threshold\nThreshold=4\n\n" "[scalable/apps]\nSize=48\nMinSize=32\nMaxSize=256\n" "Context=Applications\nType=Scalable\n") write(os.path.join(D1, "icons", "brintos-parent", "index.theme"), "[Icon Theme]\nName=Parent\nComment=fixture\n" "Directories=48x48/apps\n\n" "[48x48/apps]\nSize=48\nContext=Applications\nType=Fixed\n") for rel in ("brintos-child/16x16/apps/bst-icon.png", "brintos-child/32x32/apps/bst-icon.png", "brintos-child/scalable/apps/bst-scalable.svg", "brintos-parent/48x48/apps/bst-parent-only.png"): write(os.path.join(D1, "icons", rel), "not really an image\n") # --- the menu. applications.menu s fragment.menu, and the merged # result is what section R6 counts: merging is the one Menu behaviour that # cannot be observed from a single file. write(os.path.join(C1, "menus", "applications.menu"), '\n' "\n" " Applications\n" # Without these two the builder has no application or directory pool to # draw from and every matches nothing -- a menu that parses, # reports its own name, and is empty. Measured: the first version of this # fixture omitted them and R6 read `` for both submenus while # nothing had gone wrong with the merge at all. " \n" " \n" " fragment.menu\n" " \n" " Accessories\n" " Utility.directory\n" " Utility\n" " \n" "\n") write(os.path.join(C1, "menus", "fragment.menu"), "\n" " Applications\n" " \n" " Editors\n" " Editors.directory\n" " TextEditor\n" " \n" "\n") # ---------------------------------------------- the controlled environment --- # THE ENVIRONMENT IS THE CALLER'S TO SET, and this file only checks that it was. # It used to set it here, and could not: os.environ.__setitem__ goes through # putenv(3) -> setenv(3), and setenv on wasm32-hwjs returns EINVAL for every # name -- a brand-new one as much as an existing one. Measured on the batch-1649 # image: `os.environ["BSTZZ"] = "1"` raises OSError [Errno 22] Invalid argument, # as do os.putenv and os.unsetenv, while the same interpreter imports xdg and # reads its environment normally. So the first statement of the old section 2 # ended the whole payload in a traceback and every one of the seventy-eight # measurements below reported empty. # # What replaces it costs nothing: the variables come in through execve's envp, # which this image serves correctly, and the only thing lost is that the caller # and this file now have to agree on the fixture layout. They are checked # against each other rather than trusted -- a mismatch is a named failure here # instead of seventy-eight unexplained empties downstream. _want = { "HOME": HOME, "XDG_DATA_HOME": DH, "XDG_DATA_DIRS": "%s:%s" % (D1, D2), "XDG_CONFIG_HOME": CH, "XDG_CONFIG_DIRS": "%s:%s" % (C1, C2), "XDG_CACHE_HOME": os.path.join(ROOT, "cache"), } _bad = ["%s=%s" % (k, show(os.environ.get(k))) for k, v in _want.items() if os.environ.get(k) != v] # XDG_RUNTIME_DIR must be ABSENT, not empty: R1_runtime_fallback measures # get_runtime_dir(strict=False), whose fallback path is taken only when the # variable is unset. The caller unsets it; an image that started leaking one # would otherwise turn that case red with nothing to say why. if "XDG_RUNTIME_DIR" in os.environ: _bad.append("XDG_RUNTIME_DIR=%s (must be unset)" % os.environ["XDG_RUNTIME_DIR"]) if _bad: print("ENV-NOT-STAGED %s" % " ".join(_bad)) sys.exit(2) try: # ------------------------------------------------------ R0: it imports --- from xdg import BaseDirectory from xdg import IniFile from xdg import Mime from xdg import IconTheme from xdg import Locale from xdg.DesktopEntry import DesktopEntry from xdg.Exceptions import ValidationError, NoKeyError, ParsingError from xdg.Menu import XMLMenuBuilder import xdg.RecentFiles # noqa: F401 -- top-level `import fcntl` emit("R0_import", "ok") # ------------------------------------------- R1: BaseDirectory resolution emit("R1_data_home", BaseDirectory.xdg_data_home == DH and "ok" or BaseDirectory.xdg_data_home) emit("R1_config_home", BaseDirectory.xdg_config_home == CH and "ok" or BaseDirectory.xdg_config_home) # xdg_data_dirs is XDG_DATA_HOME followed by XDG_DATA_DIRS, in that order -- # the spec's precedence, and the reason the shadowed gedit.desktop below is # never the one read. emit("R1_data_dirs_len", len(BaseDirectory.xdg_data_dirs)) emit("R1_data_dirs_order", ":".join("dh" if p == DH else "d1" if p == D1 else "d2" if p == D2 else p for p in BaseDirectory.xdg_data_dirs)) emit("R1_config_dirs_order", ":".join("ch" if p == CH else "c1" if p == C1 else "c2" if p == C2 else p for p in BaseDirectory.xdg_config_dirs)) # load_data_paths yields only the paths that EXIST, nearest first. paths = list(BaseDirectory.load_data_paths("applications", "gedit.desktop")) emit("R1_load_data_paths_count", len(paths)) emit("R1_load_data_paths_first", "d1" if paths and paths[0].startswith(D1) else "?") # save_config_path CREATES its directory and returns it -- the one call in # this module with a side effect, and the mode is the spec's 0700. scp = BaseDirectory.save_config_path("bst-fixture") emit("R1_save_config_path", "ok" if scp == os.path.join(CH, "bst-fixture") else scp) emit("R1_save_config_exists", "ok" if os.path.isdir(scp) else "missing") emit("R1_save_config_mode", oct(os.stat(scp).st_mode & 0o777)) # A resource with nothing to find yields nothing rather than raising. emit("R1_load_nothing", len(list(BaseDirectory.load_data_paths("no-such-resource-here")))) # get_runtime_dir(strict=False) falls back when XDG_RUNTIME_DIR is unset, # which is this image's condition: the runner exports no such variable. rd = BaseDirectory.get_runtime_dir(strict=False) emit("R1_runtime_fallback", "ok" if rd.startswith("/tmp/pyxdg-runtime-dir-fallback-") else rd) # -------------------------------------------------- R2: IniFile round-trip ini_src = os.path.join(ROOT, "round.ini") write(ini_src, "[Group A]\nKey=value\nNum=42\n" "Loc=plain\nLoc[de]=deutsch\n" "List=one;two;three;\n\n" "[Group B]\nKey=other\n") f = IniFile.IniFile() f.parse(ini_src, ["Group A", "Group B"]) emit("R2_groups", ",".join(sorted(f.groups()))) emit("R2_get", f.get("Key", "Group A")) emit("R2_get_other_group", f.get("Key", "Group B")) emit("R2_get_list", "|".join(f.get("List", "Group A", list=True))) # THE LOCALE LOOKUP. xdg.Locale.langs is process-global and starts from the # runner's own environment, which on this image names no language at all -- # so a locale=True get is asserted TWICE, once with the list empty (the # untranslated value) and once after Locale.update names one. emit("R2_get_locale_none", f.get("Loc", "Group A", locale=True)) Locale.update("de") emit("R2_get_locale_de", f.get("Loc", "Group A", locale=True)) Locale.update("C") emit("R2_get_locale_c", f.get("Loc", "Group A", locale=True)) emit("R2_get_missing", show(f.get("NoSuchKey", "Group A"))) # A strict get on a missing key is the documented error, not a None. try: f.get("NoSuchKey", "Group A", strict=True) emit("R2_strict_missing", "no-error") except NoKeyError: emit("R2_strict_missing", "NoKeyError") # THE ROUND TRIP. write() then re-parse, and compare every key of every # group -- an oracle a tool that merely runs cannot satisfy, because a # dropped key or a mangled locale suffix shows up on the reading side. ini_dst = os.path.join(ROOT, "round-out.ini") f.write(ini_dst) g = IniFile.IniFile() g.parse(ini_dst, ["Group A", "Group B"]) same = all(f.content[grp] == g.content[grp] for grp in f.groups()) and \ sorted(f.groups()) == sorted(g.groups()) emit("R2_roundtrip", "identical" if same else "differs") Locale.update("de") emit("R2_roundtrip_locale", g.get("Loc", "Group A", locale=True)) Locale.update("C") # A file that is not an ini file at all is a ParsingError, not a traceback. bad = os.path.join(ROOT, "bad.ini") write(bad, "this line belongs to no group\n") try: IniFile.IniFile().parse(bad) emit("R2_parse_garbage", "no-error") except ParsingError: emit("R2_parse_garbage", "ParsingError") # ------------------------------------------------------- R3: DesktopEntry de = DesktopEntry(paths[0]) emit("R3_name", de.getName()) emit("R3_type", de.getType()) emit("R3_exec", de.getExec()) emit("R3_generic", de.getGenericName()) emit("R3_categories", "|".join(de.getCategories())) emit("R3_keywords", "|".join(de.getKeywords())) emit("R3_mimetypes", "|".join(de.getMimeTypes())) emit("R3_terminal", de.getTerminal()) emit("R3_x_extension", de.get("X-Brintos-Fixture")) # LOCALISATION. Locale.setlocale drives the lookup list the ini getters use; # de_DE must prefer the exact key and fall back to the bare language, which # is the whole of the spec's rule and the part a naive lookup gets wrong. Locale.update("de_DE.UTF-8") emit("R3_name_de_DE", de.getName()) # `de` alone does NOT give the untranslated-territory form: Locale.update # runs the string through locale.normalize(), which turns "de" into # "de_DE.ISO8859-1", so the expansion is ["de_DE", "de"] and Name[de_DE] # wins. Pinned deliberately -- it is surprising, it is upstream's documented # path through the stdlib, and a reader who expected "Texteditor" here # should find the answer beside the case rather than in a bug report. Locale.update("de") emit("R3_name_de", de.getName()) Locale.update("fr") emit("R3_name_fr", de.getName()) # A locale with no translation falls back to the untranslated value. Locale.update("ja_JP.UTF-8") emit("R3_name_ja", de.getName()) Locale.update("C") emit("R3_name_c", de.getName()) # The expansion list itself: de_DE must expand to the territory form FIRST # and the bare language second, which is the order the getters walk. emit("R3_expand_de_DE", "|".join(Locale.expand_languages(["de_DE.UTF-8"]))) # THE SHADOWING CONTROL: the D2 copy must never be reached. emit("R3_not_shadowed", "ok" if de.getName() == "Text Editor" else de.getName()) # VALIDATION. broken.desktop parses and must NOT validate. bd = DesktopEntry(os.path.join(D1, "applications", "broken.desktop")) emit("R3_broken_parses", bd.getName()) try: bd.validate() emit("R3_validate_broken", "no-error") except ValidationError: emit("R3_validate_broken", "ValidationError") # ... and the good one must validate. validate() returns None and raises on # failure, so the value here is the absence of an exception. try: de.validate() emit("R3_validate_good", "ok") except ValidationError as e: emit("R3_validate_good", "ValidationError: %s" % e) # A missing key is NoKeyError under strict, "" otherwise -- both documented. emit("R3_missing_key", show(de.get("NoSuchKey"))) # ------------------------------------------------------------- R4: Mime Mime.update_cache() # BY GLOB. The extension table, the case-sensitive flag, a literal name and # a weighted prefix pattern -- one enumerated answer each. emit("R4_by_name_py", str(Mime.get_type_by_name("foo.py"))) emit("R4_by_name_c", str(Mime.get_type_by_name("foo.c"))) emit("R4_by_name_C", str(Mime.get_type_by_name("foo.C"))) emit("R4_by_name_literal", str(Mime.get_type_by_name("/x/makefile"))) emit("R4_by_name_merged", str(Mime.get_type_by_name("bar.jpeg"))) emit("R4_by_name_weights", str(Mime.get_type_by_name("readme2.txt"))) emit("R4_by_name_none", show(Mime.get_type_by_name("qrte"))) # BY MAGIC. The PNG signature, our own fixture signature, and a buffer that # matches nothing. emit("R4_by_data_png", str(Mime.get_type_by_data(b"\x89PNG\r\n\x1a\n"))) emit("R4_by_data_fixture", str(Mime.get_type_by_data(b"BRINTOSF payload"))) emit("R4_by_data_none", show(Mime.get_type_by_data(b"\x01\x02\x03nothing"))) # NAME BEATS CONTENTS when both answer -- get_type2's documented order. png_on_disk = os.path.join(ROOT, "thing.png") write(png_on_disk, b"\x89PNG\r\n\x1a\n") emit("R4_get_type2_name_wins", str(Mime.get_type2(png_on_disk))) mystery = os.path.join(ROOT, "mystery") write(mystery, b"\x89PNG\r\n\x1a\n") emit("R4_get_type2_contents", str(Mime.get_type2(mystery))) emit("R4_get_type2_dir", str(Mime.get_type2(ROOT))) emit("R4_get_type2_missing", str(Mime.get_type2(os.path.join(ROOT, "no-such-file")))) # ALIASES and SUBCLASSES, read from our own two files. emit("R4_canonical", str(Mime.lookup("text/xml").canonical())) emit("R4_inherits", "|".join(sorted( str(t) for t in Mime.lookup("text/x-python").inherits_from()))) emit("R4_comment", Mime.lookup("text/x-python").get_comment()) # The text/binary predicate, which has no database behind it at all. emit("R4_is_text", Mime._is_text(b"plain ascii\n")) emit("R4_is_not_text", Mime._is_text(b"has a \x08 in it")) # --------------------------------------------------------- R5: IconTheme IconTheme.icondirs = [os.path.join(D1, "icons")] IconTheme.themes = [] IconTheme.theme_cache = {} IconTheme.dir_cache = {} IconTheme.icon_cache = {} t = IconTheme.IconTheme() t.parse(os.path.join(D1, "icons", "brintos-child", "index.theme")) emit("R5_theme_name", t.getName()) emit("R5_theme_inherits", "|".join(t.getInherits())) emit("R5_theme_dirs", "|".join(t.getDirectories())) def icon(name, size): p = IconTheme.getIconPath(name, size, theme="brintos-child", extensions=["png", "svg"]) if not p: return "" return os.path.relpath(p, os.path.join(D1, "icons")) # SIZE MATCHING: an exact Fixed hit at each of the child's two fixed sizes. emit("R5_icon_16", icon("bst-icon", 16)) emit("R5_icon_32", icon("bst-icon", 32)) # 34 is inside 32x32/apps's Threshold=4 band and there is no 34x34 dir, so # the threshold rule is the only thing that can answer this. emit("R5_icon_34_threshold", icon("bst-icon", 34)) # The scalable directory's MinSize..MaxSize band answers a size no fixed # directory carries. emit("R5_icon_scalable_64", icon("bst-scalable", 64)) # INHERITANCE: bst-parent-only exists ONLY in brintos-parent, so a hit here # means the Inherits chain was walked. emit("R5_icon_inherited", icon("bst-parent-only", 48)) emit("R5_icon_absent", icon("no-such-icon-anywhere", 32)) # ------------------------------------------------------------- R6: Menu # The merge is the point: Editors comes from fragment.menu and Accessories # from applications.menu, and only a builder that performed the MergeFile # sees both under one root. menu = XMLMenuBuilder().parse(os.path.join(C1, "menus", "applications.menu")) emit("R6_root", menu.getName()) subs = sorted(m.getName() for m in menu.getEntries() if hasattr(m, "getEntries")) emit("R6_submenus", "|".join(subs)) by_name = dict((m.getName(), m) for m in menu.getEntries() if hasattr(m, "getEntries")) for label, key in (("accessories", "Accessories"), ("editors", "Editors")): sub = by_name.get(key) if sub is None: emit("R6_%s" % label, "") continue names = sorted(e.DesktopEntry.getName() for e in sub.getEntries() if hasattr(e, "DesktopEntry")) emit("R6_%s" % label, "|".join(names)) # ---------------------------------------------------------- R7: version import xdg emit("R7_version", getattr(xdg, "__version__", "")) ck(FAIL == 0, "internal") if FAIL == 0: print("ALL-PYXDG-OK") except Exception: import traceback print("PYXDG-PAYLOAD-DIED %s" % " ".join(traceback.format_exc().split())[-400:]) sys.exit(1) finally: shutil.rmtree(ROOT, ignore_errors=True)