# python3-types-python-dateutil probe. ONE interpreter start, every measurement # below. # # Emits `key=value' lines on stdout. The suite reads them by key, so a case added # here without a reader in the suite is inert rather than fatal, and a key the # suite reads but the probe never emits reads as the empty string -- which # `probe_done' and `emitted_count' are what catch. # # EXITS 0 WHATEVER HAPPENS, for the reason every probe in this directory does: a # non-zero exit turns one red into a probe overrun and costs the transcript. Every # result is reported as a VALUE and never as a status. # # THERE ARE NO FIXTURES HERE. The other python probes beside this one write files # under $TMPDIR because their package DOES something. This distribution is # stub-only -- thirteen `.pyi' files, a `METADATA.toml' and a `py.typed', with no # executable line anywhere in it -- so there is no behaviour to drive and nothing # to set up. What this probe does instead is read the installed payload and PARSE # it, which is the one genuine demand a stubs package makes of a ported # interpreter. # # TWO THINGS MAKE THIS PACKAGE DIFFERENT FROM ITS SIBLING types-psutil, and both # are measured rather than carried over: # # - IT IS NESTED. `parser/', `tz/' and `zoneinfo/' are real subdirectories of # the stubs package, so every walk here is recursive and every path is a # RELATIVE path with `/' in it. A flat `os.listdir' would have measured 10 of # the 15 files and reported a green. # - IT IS A PARTIAL STUB PACKAGE. It ships `py.typed' carrying the single word # `partial' -- the PEP 561 marker for a stub distribution that does not claim # to be complete -- where types-psutil ships none at all. So `py.typed' is # asserted PRESENT here, with its content, and its absence would be the # finding. import ast import base64 import hashlib import importlib import importlib.util import os import sys import traceback _emitted = [] def emit(key, value): _emitted.append(key) sys.stdout.write("%s=%s\n" % (key, value)) def yn(b): return "yes" if b else "no" def flat(s): """One line, so a value carrying newlines stays one counted result.""" return s.replace("\r", "").replace("\n", "|") def guard(key, fn): """Emit fn()'s value, or `RAISED:' if it raised. One probe case that throws must not take the remaining cases down with it.""" try: emit(key, fn()) except Exception as e: emit(key, "RAISED:%s" % type(e).__name__) DIST = "types_python_dateutil-2.9.0.20240316.dist-info" STUBS = "dateutil-stubs" # ------------------------------------------------------------------ layout --- # WHERE THE PAYLOAD IS, FOUND RATHER THAN ASSUMED. The suite pins the answer to # the target's site-packages, so a payload satisfied from somewhere else on # sys.path -- a leftover in the cwd, a second install -- is a red there rather # than a green run measuring the wrong tree. Deriving it here also lets this # probe run unchanged against an x86-64 control, which is where every expected # value below was measured. _site = "" for _d in sys.path: if _d and os.path.isdir(os.path.join(_d, STUBS)): _site = _d break emit("site_dir", _site) emit("stubs_found", yn(bool(_site))) if not _site: emit("probe_done", "yes") emit("emitted_count", len(_emitted) + 1) sys.exit(0) SD = os.path.join(_site, STUBS) DD = os.path.join(_site, DIST) def _walk_rel(root): """Every file under `root', as a sorted list of `/'-joined relative paths. RECURSIVE, because this distribution is nested three subpackages deep.""" out = [] for dirpath, _dirnames, filenames in os.walk(root): for fn in filenames: out.append(os.path.relpath(os.path.join(dirpath, fn), root).replace(os.sep, "/")) return sorted(out) def _walk_dirs(root): out = [] for dirpath, dirnames, _filenames in os.walk(root): for dn in dirnames: out.append(os.path.relpath(os.path.join(dirpath, dn), root).replace(os.sep, "/")) return sorted(out) _names = _walk_rel(SD) _dirs = _walk_dirs(SD) emit("stub_file_set", ",".join(_names)) emit("stub_file_count", len(_names)) emit("pyi_count", sum(1 for n in _names if n.endswith(".pyi"))) # THE SUBPACKAGE DIRECTORIES, as their own result. `packages=' upstream names # only `dateutil-stubs'; `parser/', `tz/' and `zoneinfo/' arrive as # `package_data' entries carrying a `/' in their name, which is the part of this # distribution's packaging that could plausibly go wrong on a setuptools that # handled nested data differently. Asserted as a set so a subdirectory that # failed to install is a red naming itself. emit("stub_dir_set", ",".join(_dirs)) emit("stub_dir_count", len(_dirs)) # NO EXECUTABLE CODE SHIPS, asserted over the payload rather than over the # recipe. A `.py' here would be a behaviour this package promises it does not # have; a `.so' would be an ImportError besides, because python3 on this image is # one static binary with an empty lib-dynload (BRINTOS-1476). emit("py_files", ",".join(n for n in _names if n.endswith(".py"))) emit("so_files", ",".join(n for n in _names if n.endswith(".so"))) emit("has_pycache", yn(any(n.startswith("__pycache__/") or "/__pycache__/" in n for n in _names))) emit("has_init_pyi", yn("__init__.pyi" in _names)) emit("has_init_py", yn("__init__.py" in _names)) emit("has_metadata_toml", yn("METADATA.toml" in _names)) def _read(p): with open(p, "rb") as fh: return fh.read() # THE PEP 561 PARTIAL MARKER. types-psutil ships no `py.typed' and this one does, # because typeshed marks the python-dateutil stubs `partial': the annotations do # not claim to cover the whole library. The marker's CONTENT is what says so -- # an empty `py.typed' would mean "complete" -- so the word is asserted and not # merely the file's existence. emit("has_py_typed", yn("py.typed" in _names)) guard("py_typed_bytes", lambda: len(_read(os.path.join(SD, "py.typed")))) guard("py_typed_text", lambda: flat(_read(os.path.join(SD, "py.typed")).decode("utf-8").strip())) # `__init__.pyi' IS EMPTY, AND THAT IS THE PACKAGING, NOT A TRUNCATION. Upstream # ships a zero-byte `dateutil-stubs/__init__.pyi': the top-level `dateutil' # package declares nothing itself and every name lives in a submodule. It is # emitted as a measured size so a suite reading it is asserting the shipped # layout rather than being unable to tell an empty file from a lost one -- and it # is why the suite tests that file with `-f' and not `-s'. guard("init_pyi_bytes", lambda: len(_read(os.path.join(SD, "__init__.pyi")))) # --------------------------------------------------------------- METADATA --- # The stub distribution's own marker file. It declares which python-dateutil # release the annotations aim at and that they are partial -- the two facts about # this package not derivable from the file names. guard("metadata_toml_bytes", lambda: len(_read(os.path.join(SD, "METADATA.toml")))) guard("metadata_toml_text", lambda: flat(_read(os.path.join(SD, "METADATA.toml")).decode("utf-8").strip())) # ----------------------------------------------------------------- RECORD --- # THE INSTALLED SET AGAINST THE WHEEL'S OWN FILE LIST. RECORD is `path,sha256=< # urlsafe-b64, unpadded>,size' per line -- the installer's own manifest -- so # re-deriving both halves from disk asserts the payload against the packaging # instead of against an expectation typed here. # # RECORD's own line carries no digest and no size (it cannot checksum itself), so # it is counted and then skipped rather than scored. _rec_entries = 0 _rec_missing = [] _rec_size_bad = [] _rec_hash_bad = [] _rec_paths = [] try: for _line in _read(os.path.join(DD, "RECORD")).decode("utf-8").splitlines(): if not _line.strip(): continue _p, _h, _s = _line.rsplit(",", 2) _rec_entries += 1 _rec_paths.append(_p) if not _h or not _s: continue _full = os.path.join(_site, _p) if not os.path.isfile(_full): _rec_missing.append(_p) continue _blob = _read(_full) if len(_blob) != int(_s): _rec_size_bad.append(_p) _alg, _b64 = _h.split("=", 1) _want = base64.urlsafe_b64decode(_b64 + "=" * (-len(_b64) % 4)) if hashlib.new(_alg, _blob).digest() != _want: _rec_hash_bad.append(_p) emit("record_read", "yes") except Exception: emit("record_read", "no") emit("record_error", flat(traceback.format_exc().strip())) emit("record_entries", _rec_entries) emit("record_missing", ",".join(sorted(_rec_missing))) emit("record_size_mismatch", ",".join(sorted(_rec_size_bad))) emit("record_hash_mismatch", ",".join(sorted(_rec_hash_bad))) # THE ZERO-BYTE FILE IS SCORED LIKE ANY OTHER, and this is the case that says the # scoring above did not quietly skip it. `dateutil-stubs/__init__.pyi' has # RECORD's `47DEQpj8...' -- the sha256 of the empty string -- so a size-0 entry # that had been dropped from the loop would leave this "no" while every other # result stayed green. emit("record_scored_empty_init", yn(any(p == STUBS + "/__init__.pyi" for p in _rec_paths))) # THE OTHER DIRECTION. RECORD naming every installed file does not say that every # installed file is named -- a payload carrying an EXTRA `.pyi' would satisfy # every check above. Scored over the stubs directory, which is the half a release # would change. _recorded = set(p.split("/", 1)[1] for p in _rec_paths if p.startswith(STUBS + "/")) emit("record_unlisted", ",".join(sorted(set(_names) - _recorded))) # ------------------------------------------------------------------- parse --- # EVERY `.pyi' THROUGH THE INTERPRETER'S OWN PARSER, in `type_comments=True' mode # -- the mode a type checker uses, and a genuinely different code path in # CPython's parser rather than a flag it ignores. # # The stub grammar this distribution exercises is a corner that appears almost # nowhere else in this image's python payload: `@overload' stacks, `TypeAlias' # assignments, PEP 604 unions in annotation position, `Literal[...]' in a return # type, positional-only `/' markers, a `Protocol' body, `...' as an entire # function body, and `__reduce__ = object.__reduce__' as a class-scope alias. _parse_fail = [] _parse_ok = 0 _nodes = 0 _trees = {} for _n in sorted(n for n in _names if n.endswith(".pyi")): try: _src = _read(os.path.join(SD, _n)).decode("utf-8") _t = ast.parse(_src, filename=_n, type_comments=True) _trees[_n] = _t _parse_ok += 1 _nodes += sum(1 for _ in ast.walk(_t)) except Exception as e: _parse_fail.append("%s:%s" % (_n, type(e).__name__)) emit("parse_ok_count", _parse_ok) emit("parse_failures", ",".join(_parse_fail)) emit("parse_node_count", _nodes) # THE PARSE HAS TEETH, and this is what says so. A harness that silently caught # everything would report `parse_ok_count' correctly for thirteen files and would # also report it for thirteen empty ones. Feeding the same parser a file that # MUST fail proves the mode is really on and the exception really propagates. def _neg(): try: ast.parse("def f(:\n", filename="", type_comments=True) return "no" except SyntaxError: return "yes" guard("parse_negative_control", _neg) # `...' AS A WHOLE FUNCTION BODY is the stub form, and it is worth asserting # structurally rather than trusting the parse alone: a body that came back as # something other than a bare `Ellipsis' expression would mean the parser had # reinterpreted the construct this whole distribution is written in. def _ellipsis_bodies(): n = 0 for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): b = node.body if (len(b) == 1 and isinstance(b[0], ast.Expr) and isinstance(b[0].value, ast.Constant) and b[0].value.value is Ellipsis): n += 1 return n guard("ellipsis_body_count", _ellipsis_bodies) # `...' AS A DEFAULT ARGUMENT VALUE, which is the OTHER thing the token means in a # stub and is a different production. `parser.parse' alone carries seven of them. # Counted apart from the bodies so a parser that conflated the two is a red in one # of the two counts rather than a wash across both. def _ellipsis_defaults(): n = 0 for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, ast.arguments): for d in list(node.defaults) + [d for d in node.kw_defaults if d is not None]: if isinstance(d, ast.Constant) and d.value is Ellipsis: n += 1 return n guard("ellipsis_default_count", _ellipsis_defaults) # `@overload' STACKS. The construct a stub file leans on hardest and the one a # parser is most likely to get wrong, so it is counted rather than assumed. All of # them are in `relativedelta.pyi', on the three arithmetic dunders. def _overloads(): n = 0 for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): for d in node.decorator_list: if (isinstance(d, ast.Name) and d.id == "overload") or \ (isinstance(d, ast.Attribute) and d.attr == "overload"): n += 1 return n guard("overload_count", _overloads) # `TypeAlias' ASSIGNMENTS, counted off the annotation. This is the PEP 613 form # -- `_X: TypeAlias = ' -- and it is a stub-only construct: the # right-hand side is a TYPE in expression position, which is what makes it worth # parsing rather than assuming. def _type_aliases(): out = [] for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): ann = node.annotation if (isinstance(ann, ast.Name) and ann.id == "TypeAlias") or \ (isinstance(ann, ast.Attribute) and ann.attr == "TypeAlias"): out.append(node.target.id) return ",".join(sorted(set(out))) guard("type_alias_names", _type_aliases) # PEP 604 UNIONS IN ANNOTATION POSITION (`int | None'), which this distribution # is written in throughout and which parse as `BinOp(BitOr)'. A parser that # folded them into something else would still parse every file. def _bitor_annotations(): n = 0 for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): n += 1 return n guard("pep604_union_count", _bitor_annotations) # POSITIONAL-ONLY PARAMETERS (`/'), which appear only in the two `Protocol' # bodies in `tz/tz.pyi'. A narrow production and one this image's payload # otherwise barely uses. def _posonly(): n = 0 for _t in _trees.values(): for node in ast.walk(_t): if isinstance(node, ast.arguments): n += len(node.posonlyargs) return n guard("posonly_arg_count", _posonly) # --------------------------------------------------------------- structure --- # A SAMPLE OF THE DECLARATIONS, so a truncated install is caught rather than # passing as "it parsed". An empty file parses perfectly -- and here one of the # files IS empty, which is exactly why the sample is taken from the submodules. def _classes(tree): return [n.name for n in tree.body if isinstance(n, ast.ClassDef)] def _functions(tree): return [n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))] def _class(tree, name): for n in tree.body: if isinstance(n, ast.ClassDef) and n.name == name: return n return None def _methods(cls): return sorted(n.name for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))) def _annassign_names(tree): return [n.target.id for n in tree.body if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name)] # `__init__.pyi' DECLARES NOTHING, which is the shipped layout and not damage. _init = _trees.get("__init__.pyi") emit("init_parsed", yn(_init is not None)) guard("init_body_statements", lambda: len(_init.body)) # --- dateutil.parser -- the module the ticket names first -------------------- _parser = _trees.get("parser/__init__.pyi") emit("parser_parsed", yn(_parser is not None)) if _parser is not None: emit("parser_classes", ",".join(sorted(_classes(_parser)))) emit("parser_functions", ",".join(sorted(_functions(_parser)))) # `parse' -- THE FUNCTION THE TICKET NAMES -- described by its signature # rather than by its presence. The interesting half is the KEYWORD-ONLY # block after the `*': seven names that a caller may only pass by keyword, # each defaulting to `...'. A release that moved one across the `*' would be # a red here and invisible to a name check. def _parse_sig(): for n in _parser.body: if isinstance(n, ast.FunctionDef) and n.name == "parse": return n return None _pf = _parse_sig() emit("parse_fn_found", yn(_pf is not None)) if _pf is not None: emit("parse_fn_args", ",".join(a.arg for a in _pf.args.args)) emit("parse_fn_kwonly", ",".join(a.arg for a in _pf.args.kwonlyargs)) guard("parse_fn_returns", lambda: ast.unparse(_pf.returns)) emit("parse_fn_has_vararg", yn(_pf.args.vararg is not None)) # `parserinfo' IS THE CONFIGURATION OBJECT `parse' takes, and its method set # is the part of this stub a caller actually reads. _pi = _class(_parser, "parserinfo") emit("parserinfo_found", yn(_pi is not None)) if _pi is not None: emit("parserinfo_methods", ",".join(_methods(_pi))) emit("parserinfo_attrs", ",".join(sorted(n.target.id for n in _pi.body if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name)))) # `ParserError' INHERITS FROM `ValueError'. The one inheritance in this file # a caller depends on -- it is what lets `except ValueError' catch a bad # parse -- so the base is read off the tree. guard("parser_error_bases", lambda: ",".join(ast.unparse(b) for b in _class(_parser, "ParserError").bases)) # --- dateutil.relativedelta -------------------------------------------------- _rd = _trees.get("relativedelta.pyi") emit("relativedelta_parsed", yn(_rd is not None)) if _rd is not None: emit("relativedelta_classes", ",".join(sorted(_classes(_rd)))) # THE SEVEN WEEKDAY CONSTANTS, as module-level annotated names. They are the # public surface of this module beside the class itself. emit("relativedelta_constants", ",".join(_annassign_names(_rd))) _rdc = _class(_rd, "relativedelta") emit("relativedelta_class_found", yn(_rdc is not None)) if _rdc is not None: emit("relativedelta_bases", ",".join(ast.unparse(b) for b in _rdc.bases)) emit("relativedelta_methods", ",".join(_methods(_rdc))) emit("relativedelta_method_count", len([n for n in _rdc.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))])) emit("relativedelta_fields", ",".join(sorted( n.target.id for n in _rdc.body if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name)))) # `weeks' IS A PROPERTY WITH A SETTER, which is the one member of this # class declared differently from the rest -- everything else is a plain # annotated field. Both decorators are read, because a stub that lost the # setter would still declare `weeks'. def _weeks_decorators(): out = [] for n in _rdc.body: if isinstance(n, ast.FunctionDef) and n.name == "weeks": for d in n.decorator_list: out.append(ast.unparse(d)) return ",".join(out) guard("relativedelta_weeks_decorators", _weeks_decorators) # THE `__init__' KEYWORD SURFACE. Twenty-one parameters after `self', # which is the whole of what a caller constructs a relativedelta with. def _init_args(): for n in _rdc.body: if isinstance(n, ast.FunctionDef) and n.name == "__init__": return ",".join(a.arg for a in n.args.args[1:]) return "absent" guard("relativedelta_init_args", _init_args) # --- dateutil.rrule ---------------------------------------------------------- _rr = _trees.get("rrule.pyi") emit("rrule_parsed", yn(_rr is not None)) if _rr is not None: emit("rrule_classes", ",".join(sorted(_classes(_rr)))) # THE FREQUENCY CONSTANTS THE TICKET NAMES, separated from the rest of the # module-level names. They are declared `int' and not `Literal[...]', which # is itself the fact: upstream's rrule takes any of them as a plain integer. _consts = _annassign_names(_rr) emit("rrule_freq_constants", ",".join(c for c in _consts if c in ("YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY"))) emit("rrule_module_names", ",".join(sorted(_consts))) def _freq_annotations(): out = set() for n in _rr.body: if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) \ and n.target.id in ("YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY"): out.add(ast.unparse(n.annotation)) return ",".join(sorted(out)) guard("rrule_freq_annotation", _freq_annotations) guard("rrule_class_bases", lambda: ",".join(ast.unparse(b) for b in _class(_rr, "rrule").bases)) guard("rruleset_bases", lambda: ",".join(ast.unparse(b) for b in _class(_rr, "rruleset").bases)) guard("rrulebase_methods", lambda: ",".join(_methods(_class(_rr, "rrulebase")))) # A CLASS DECLARED INSIDE A CLASS. `rruleset._genitem' is the only nested # class in the distribution, and it also carries the only class-scope # assignment alias (`next = __next__') -- two productions in one place. def _nested(): rs = _class(_rr, "rruleset") return ",".join(sorted(n.name for n in rs.body if isinstance(n, ast.ClassDef))) guard("rruleset_nested_classes", _nested) # --- dateutil.tz ------------------------------------------------------------- _tz = _trees.get("tz/tz.pyi") emit("tz_parsed", yn(_tz is not None)) if _tz is not None: emit("tz_classes", ",".join(sorted(_classes(_tz)))) emit("tz_functions", ",".join(sorted(_functions(_tz)))) guard("tzstr_bases", lambda: ",".join(ast.unparse(b) for b in _class(_tz, "tzstr").bases)) # THE `Protocol' BODIES, which are where the positional-only `/' markers # counted above live. A `Protocol' base in a stub is a structural type # declaration and parses through the ordinary class production, so the # assertion is that the two are declared and named as upstream names them. emit("tz_protocol_classes", ",".join(sorted( n.name for n in _tz.body if isinstance(n, ast.ClassDef) and any(isinstance(b, ast.Name) and b.id == "Protocol" for b in n.bases)))) # --- dateutil.easter and dateutil.zoneinfo ----------------------------------- _ea = _trees.get("easter.pyi") emit("easter_parsed", yn(_ea is not None)) if _ea is not None: emit("easter_functions", ",".join(sorted(_functions(_ea)))) emit("easter_constants", ",".join(sorted(_annassign_names(_ea)))) # THE METHOD CONSTANTS ARE `Literal' TYPES CARRYING THEIR VALUE, which is the # narrowest annotation form in the distribution: the type IS the value. Read # back as text so a `Literal' that lost its argument is a red. guard("easter_constant_annotations", lambda: ",".join("%s=%s" % (n.target.id, ast.unparse(n.annotation)) for n in _ea.body if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name))) _zi = _trees.get("zoneinfo/__init__.pyi") emit("zoneinfo_parsed", yn(_zi is not None)) if _zi is not None: emit("zoneinfo_classes", ",".join(sorted(_classes(_zi)))) emit("zoneinfo_functions", ",".join(sorted(_functions(_zi)))) # THE ONLY `__all__' IN THE DISTRIBUTION, read as its literal elements. def _all(): for n in _zi.body: if isinstance(n, ast.Assign) and any( isinstance(t, ast.Name) and t.id == "__all__" for t in n.targets): return ",".join(e.value for e in n.value.elts) return "absent" guard("zoneinfo_all", _all) # ---------------------------------------------------------------- import --- # WHAT `import' ACTUALLY DOES WITH A STUBS-ONLY DIRECTORY, measured rather than # assumed -- and the answer corrects the obvious expectation. # # The ticket asks the suite to assert that "the stubs are not importable as a # runtime module". As an IMPORT STATEMENT that is exactly right, and for a reason # that has nothing to do with this image: `dateutil-stubs' is not an identifier, # so `import dateutil-stubs' is a SyntaxError in any CPython. # # But `importlib.import_module("dateutil-stubs")' SUCCEEDS, on this arm and on the # control both, and calling that "not importable" in the suite would have written # a false statement into the record. PEP 420 makes any directory on sys.path a # NAMESPACE PACKAGE portion whatever it is called, so the name resolves -- to a # module with no `__file__', no loader-backed source, and an entirely empty public # surface. Nothing in the payload is reachable through it: `.pyi' is not a source # suffix, so every submodule import fails, INCLUDING the three that are real # directories (`parser', `tz', `zoneinfo'). Those three are the interesting case # here and not in types-psutil, which is flat: a subdirectory of a namespace # package is itself a namespace portion, so it RESOLVES while still delivering # nothing -- and `importable_submodules' below reports that measured, split from # the `.pyi' names which do not resolve at all. def _stmt_syntaxerror(): try: compile("import dateutil-stubs", "", "exec") return "no" except SyntaxError: return "yes" except Exception as e: return "RAISED:%s" % type(e).__name__ guard("import_stmt_syntaxerror", _stmt_syntaxerror) try: _m = importlib.import_module(STUBS) emit("import_module_ok", "yes") emit("import_module_file", str(getattr(_m, "__file__", None))) emit("import_module_origin", str(_m.__spec__.origin)) emit("import_module_loader", type(_m.__loader__).__name__) emit("import_module_is_namespace", yn(_m.__spec__.origin is None and _m.__spec__.submodule_search_locations is not None)) emit("import_module_surface", ",".join(sorted(n for n in dir(_m) if not n.startswith("_")))) except Exception as e: emit("import_module_ok", "no") emit("import_module_file", "absent") emit("import_module_origin", "absent") emit("import_module_loader", "absent") emit("import_module_is_namespace", "no") emit("import_module_surface", "RAISED:%s" % type(e).__name__) # NO `.pyi' MODULE IS REACHABLE. Split from the directory names below, because # the two fail for different reasons and one combined result would name neither. _sub_ok = [] for _s in ("relativedelta", "rrule", "easter", "utils", "_common", "__init__"): try: importlib.import_module("%s.%s" % (STUBS, _s)) _sub_ok.append(_s) except Exception: pass emit("importable_pyi_submodules", ",".join(_sub_ok)) # THE THREE SUBDIRECTORIES DO RESOLVE, as further namespace portions, and DELIVER # NOTHING -- which is the accurate statement and the one this suite makes. Their # surfaces are asserted empty beside the names, because "it imported" alone would # be the misleading half. _dir_ok = [] _dir_surface = [] for _s in ("parser", "tz", "zoneinfo"): try: _sm = importlib.import_module("%s.%s" % (STUBS, _s)) _dir_ok.append(_s) _dir_surface.extend(n for n in dir(_sm) if not n.startswith("_")) except Exception: pass emit("importable_dir_submodules", ",".join(_dir_ok)) emit("importable_dir_surface", ",".join(sorted(set(_dir_surface)))) # `find_spec' OVER A `.pyi' NAME, which is the mechanism underneath the lines # above: `.pyi' is not in `importlib.machinery.SOURCE_SUFFIXES', so the finder # never offers the file however the name is spelled. emit("pyi_in_source_suffixes", yn(".pyi" in importlib.machinery.SOURCE_SUFFIXES)) # ------------------------------------------------------- no interference --- # NOTHING IN THE DISTRIBUTION SHADOWS A REAL MODULE NAME, which is the failure # that would break an unrelated import if the packaging went wrong. It is a # sharper question here than for types-psutil: this payload contains directories # called `parser', `tz' and `zoneinfo', and `parser' and `zoneinfo' are both # names CPython has used -- `zoneinfo' is a stdlib module in 3.9+ and `parser' # was one until 3.10. If any of them had been installed at the TOP of # site-packages rather than inside `dateutil-stubs/', importing `zoneinfo' # anywhere on this image would resolve to an empty stubs directory. # # Measured, name by name, through `find_spec' rather than through a successful # import: the question is WHICH file the finder offers, not whether the import # worked. def _spec_origin(name): try: s = importlib.util.find_spec(name) except Exception as e: return "RAISED:%s" % type(e).__name__ if s is None: return "none" return str(s.origin) _TOPLEVEL = ("parser", "tz", "zoneinfo", "utils", "easter", "rrule", "relativedelta") for _name in _TOPLEVEL: emit("toplevel_spec_" + _name, _spec_origin(_name)) # THE DERIVED ANSWER, and the one the suite leans on. The seven origins above are # reported for the record, but six of them are "did some OTHER package on this # image happen to install a module by that name" -- a question about the image and # not about this port. What this port is answerable for is that NONE of those # names resolves INTO the stubs directory, and that holds whatever else the image # carries. emit("toplevel_from_stubs", ",".join(n for n in _TOPLEVEL if _spec_origin(n).startswith(SD))) # `zoneinfo' IS THE STDLIB'S, not this payload's -- asserted as the property that # matters rather than as a path, so it holds whatever prefix the image uses. def _zoneinfo_not_ours(): o = _spec_origin("zoneinfo") return yn(o == "none" or not o.startswith(SD)) guard("zoneinfo_not_from_stubs", _zoneinfo_not_ours) # THE LIBRARY THESE STUBS DESCRIBE IS NOT ON THIS IMAGE, and that is deliberate # rather than an oversight: a PEP 561 stub distribution declares no dependency on # the package it annotates, which is the whole reason it can be shipped alone. # `python3-dateutil' is not in IMAGE_INSTALL and nothing in the layer RDEPENDS on # it, so `import dateutil' must fail -- and it must fail as a MISSING MODULE and # not as something the stubs half-satisfied. # The SAME FOUR KEYS are emitted whichever way this goes, so `emitted_count' is a # fixed number rather than one that moves with the result. A probe whose key count # depends on what it found cannot be used to detect a probe that stopped early. try: import dateutil as _dateutil _du_file = str(getattr(_dateutil, "__file__", None)) emit("dateutil_import_ok", "yes") emit("dateutil_import_error", "none") emit("dateutil_file", _du_file) emit("dateutil_from_stubs_dir", yn((_du_file or "").startswith(SD))) except Exception as e: emit("dateutil_import_ok", "no") emit("dateutil_import_error", type(e).__name__) emit("dateutil_file", "absent") emit("dateutil_from_stubs_dir", "no") emit("dateutil_spec_origin", _spec_origin("dateutil")) # ------------------------------------------------------------- dist-info --- _dnames = sorted(os.listdir(DD)) if os.path.isdir(DD) else [] emit("dist_info_file_set", ",".join(_dnames)) def _meta_line(prefix): for line in _read(os.path.join(DD, "METADATA")).decode("utf-8").splitlines(): if line.startswith(prefix): return line[len(prefix):].strip() return "absent" guard("meta_name", lambda: _meta_line("Name:")) guard("meta_version", lambda: _meta_line("Version:")) guard("meta_license", lambda: _meta_line("License:")) guard("meta_requires_python", lambda: _meta_line("Requires-Python:")) # NO RUNTIME DEPENDENCY IS DECLARED, AND SPECIFICALLY NOT ON `python-dateutil'. # This is the fact that lets a stubs package be installed on an image that does # not carry the library it describes -- `install_requires=[]' upstream -- and it # is what makes the recipe's `Depends: python3-core' correct rather than thin. A # `Requires-Dist:' line appearing here is a release changing its mind. def _requires_dist(): out = [l for l in _read(os.path.join(DD, "METADATA")).decode("utf-8").splitlines() if l.startswith("Requires-Dist:")] return ",".join(out) if out else "none" guard("meta_requires_dist", _requires_dist) # `Typing :: Stubs Only' IS DECLARED IN THE CLASSIFIERS, which is the packaging's # own statement of what `no_py_files' measures on disk. def _classifiers(): return ";".join(l[len("Classifier:"):].strip() for l in _read(os.path.join(DD, "METADATA")).decode("utf-8").splitlines() if l.startswith("Classifier:")) guard("meta_classifiers", _classifiers) guard("dist_top_level", lambda: _read(os.path.join(DD, "top_level.txt")).decode("utf-8").strip()) # A PURE-PYTHON WHEEL, from the wheel's own metadata. It is the packaging-side # statement of `no_so_files' above: `Root-Is-Purelib: true' means nothing in it is # platform-specific, which for a wasm32 image is the difference between a package # that ports and one that cannot. The tag says the same thing a second way. def _wheel_field(prefix): for l in _read(os.path.join(DD, "WHEEL")).decode("utf-8").splitlines(): if l.startswith(prefix): return l.split(":", 1)[1].strip() return "absent" guard("wheel_root_is_purelib", lambda: _wheel_field("Root-Is-Purelib:")) guard("wheel_tag", lambda: _wheel_field("Tag:")) # `importlib.metadata' MUST ANSWER FOR IT. The dist-info being on disk and the # interpreter being able to FIND it are two different facts, and only the second # is what a caller asking "is types-python-dateutil installed" actually uses. def _im_version(): import importlib.metadata as md return md.version("types-python-dateutil") guard("importlib_metadata_version", _im_version) def _im_files(): import importlib.metadata as md fs = md.files("types-python-dateutil") or [] return str(len(fs)) guard("importlib_metadata_file_count", _im_files) emit("probe_done", "yes") emit("emitted_count", len(_emitted) + 1) sys.exit(0)