#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-pyproject-metadata. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE IS. A PEP 621 metadata parser, and NOTHING ELSE -- the recipe's own DESCRIPTION says it "does not implement the parsing of pyproject.toml". `StandardMetadata.from_pyproject(data, project_dir)' takes an ALREADY-PARSED mapping, validates it, and `as_rfc822()' renders the result as the core metadata a wheel or sdist carries. So the TOML reading in this probe is `tomllib' (stdlib, python3-tomllib on this image), and the fixtures are `pyproject.toml' files this probe writes into a temporary directory -- no file on the rootfs is read, no process is spawned, no device, no peer, no terminal. That is what lets the port close on its suite. THE TWO FILE-VALUED FIELDS ARE THE ONLY I/O. `project.license.file' and `project.readme' (or `project.readme.file') are resolved against `project_dir' and read, which is why every fixture below gets its own directory under $TMPDIR and why `readme-missing-file' and `license-missing-file' are cases. FOUR THINGS A READER OF THE TICKET WOULD GET WRONG, all of them measured here: AN UNKNOWN KEY IN `[project]' DOES NOT RAISE. `from_pyproject' reads the fields it knows and never enumerates the table, so `project.frobnicate = 1' parses clean and is silently dropped. Only the `license' and `readme' SUB-TABLES reject an unexpected field. `unknown-key-ignored' against `license-unexpected-field' is that pair. A MALFORMED VERSION RAISES `InvalidVersion', NOT `ConfigurationError'. `packaging.version.Version(version_string)' is called unguarded, so packaging's own exception comes straight out. Same for `requires-python', which raises `InvalidSpecifier'. A caller catching only ConfigurationError -- which the package's own README suggests -- does not catch either. A MISSING VERSION IS NOT A PARSE ERROR AT ALL. `__post_init__' answers it by APPENDING `version' to `dynamic', so the object is built; the failure is deferred to `as_rfc822()', which raises ConfigurationError('Missing version field'). `version-absent-*' is the pair. `as_rfc822()' IS NOT IDEMPOTENT WHEN THERE ARE EXTRAS. `_build_extra_req' MUTATES the stored Requirement's marker in place, so the second call appends the extra marker to a requirement that already carries it and renders `extra == "test" and extra == "test"'. `rfc-extra-req-first' and `rfc-extra-req-second' are the two renderings of one unchanged object. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- multi-line renderings are folded onto one line with `|' and said to be folded. And no value may depend on the encoding stdout happens to be in: every one is ASCII. """ import os import sys import tempfile emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def flat(s): """One line, ASCII, newlines shown as `|'.""" return str(s).replace('\r', '').replace('\n', '|') def call(fn, *a, **kw): """fn(*a, **kw) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message where the message is not the contract; where it IS the contract the case uses `msg()' instead.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ def msg(fn, *a, **kw): """`!ExceptionName: message' -- for the messages the package documents.""" try: fn(*a, **kw) return 'no-raise' except Exception as exc: return '!%s: %s' % (type(exc).__name__, flat(exc)) try: import tomllib except Exception as exc: # pragma: no cover print('tomllib_import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) try: import packaging import packaging.requirements import packaging.specifiers import packaging.utils import packaging.version except Exception as exc: # pragma: no cover print('packaging_import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) try: import pyproject_metadata from pyproject_metadata import ( ConfigurationError, License, Readme, RFC822Message, StandardMetadata, ) except Exception as exc: print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # Under $TMPDIR, which is a tmpfs on this image -- nothing here touches the # virtiofs rootfs. The suite removes it; the probe does not, so that a failed # case leaves its fixture behind to be looked at. ROOT = tempfile.mkdtemp(prefix='bst-pyproject-metadata.') _seq = [0] def project(toml_text, files=None): """Write a pyproject.toml (plus any sibling files) into a fresh directory. Returns (data, project_dir). The directory is what `project_dir' resolves `license.file' and `readme' against, so each fixture gets its own.""" _seq[0] += 1 d = os.path.join(ROOT, 'p%d' % _seq[0]) os.makedirs(d) with open(os.path.join(d, 'pyproject.toml'), 'w') as fh: fh.write(toml_text) for name, body in (files or {}).items(): with open(os.path.join(d, name), 'w') as fh: fh.write(body) with open(os.path.join(d, 'pyproject.toml'), 'rb') as fh: return tomllib.load(fh), d def parse(toml_text, files=None): data, d = project(toml_text, files) return StandardMetadata.from_pyproject(data, d) def parse_msg(toml_text, files=None): data, d = project(toml_text, files) return msg(StandardMetadata.from_pyproject, data, d) emit('probe_workdir', ROOT) # ============================================================== identity ===== emit('module_file', getattr(pyproject_metadata, '__file__', '')) emit('is_package', 'yes' if hasattr(pyproject_metadata, '__path__') else 'no') emit('version_attr', getattr(pyproject_metadata, '__version__', '')) emit('has_dunder_all', 'yes' if hasattr(pyproject_metadata, '__all__') else 'no') # No `__all__', so a star-import exports every non-underscore module-level name # -- which includes the four stdlib modules the module imported and the # `packaging' package itself. Ten names where a caller expected three. emit('star_names', ','.join(sorted( n for n in vars(pyproject_metadata) if not n.startswith('_')))) emit('metadata_is_dataclass', 'yes' if hasattr(StandardMetadata, '__dataclass_fields__') else 'no') emit('metadata_fields', ','.join(StandardMetadata.__dataclass_fields__)) emit('license_is_namedtuple', ','.join(getattr(License, '_fields', ()))) emit('readme_is_namedtuple', ','.join(getattr(Readme, '_fields', ()))) emit('configuration_error_base', ConfigurationError.__mro__[1].__name__) # The packaging the parse results are built out of. Asserted so a version bump # under this port is visible here rather than as a puzzle in `rfc_requires_dist'. emit('packaging_version', getattr(packaging, '__version__', '')) emit('tomllib_file', getattr(tomllib, '__file__', '')) # =========================================================== the full parse == FULL = ''' [project] name = "Sample_Project.Name" version = "1.2.3.dev4" description = "A sample project used by the brintOS self-test." readme = "README.md" requires-python = ">=3.8,<4" license = { file = "LICENSE.txt" } keywords = ["alpha", "beta", "gamma"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", ] dependencies = ["packaging>=19.0", "tomli; python_version < '3.11'"] [[project.authors]] name = "Ann Author" email = "ann@example.com" [[project.authors]] name = "Nameless Only" [[project.maintainers]] email = "solo@example.com" [project.urls] homepage = "https://example.com/home" "bug tracker" = "https://example.com/bugs" [project.optional-dependencies] test = ["pytest>=7", "coverage"] [project.scripts] sample-cli = "sample.cli:main" [project.gui-scripts] sample-gui = "sample.gui:main" [project.entry-points."sample.plugins"] alpha = "sample.plugins.alpha:Plugin" ''' FULL_FILES = { 'README.md': 'The readme body.\nSecond line.\n', 'LICENSE.txt': 'MIT, for the purposes of this test.\n', } full = parse(FULL, FULL_FILES) # THE NAME IS NORMALISED IN `__post_init__', not left as written. emit('full_name', full.name) emit('full_name_matches_canonicalize', 'yes' if full.name == packaging.utils.canonicalize_name('Sample_Project.Name') else 'no') emit('full_version', str(full.version)) emit('full_version_type', type(full.version).__name__) emit('full_version_is_prerelease', str(full.version.is_prerelease)) emit('full_version_release', str(full.version.release)) emit('full_version_matches_packaging', 'yes' if full.version == packaging.version.Version('1.2.3.dev4') else 'no') emit('full_description', full.description) emit('full_requires_python', str(full.requires_python)) emit('full_requires_python_type', type(full.requires_python).__name__) emit('full_requires_python_accepts_39', 'yes' if full.version is not None and full.requires_python.contains('3.9') else 'no') emit('full_requires_python_rejects_37', 'no' if full.requires_python.contains('3.7') else 'yes') emit('full_keywords', ','.join(full.keywords)) emit('full_classifiers_count', str(len(full.classifiers))) emit('full_classifier_first', full.classifiers[0]) # `authors' is a list of (name, email) PAIRS, and an entry with no `name' gets # the literal 'Unknown' rather than being dropped or left empty. emit('full_authors', ';'.join('%s|%s' % (n, e) for n, e in full.authors)) emit('full_maintainers', ';'.join('%s|%s' % (n, e) for n, e in full.maintainers)) emit('full_urls', ','.join('%s=%s' % kv for kv in full.urls.items())) emit('full_dependencies', ' / '.join(str(d) for d in full.dependencies)) emit('full_dependency_type', type(full.dependencies[0]).__name__) emit('full_dependency_name', full.dependencies[0].name) emit('full_optional_extras', ','.join(sorted(full.optional_dependencies))) emit('full_optional_test', ' / '.join(str(d) for d in full.optional_dependencies['test'])) # `scripts' and `gui-scripts' are their OWN fields, NOT folded into # `entrypoints' -- and none of the three reaches the RFC 822 rendering at all. emit('full_scripts', ','.join('%s=%s' % kv for kv in full.scripts.items())) emit('full_gui_scripts', ','.join('%s=%s' % kv for kv in full.gui_scripts.items())) emit('full_entrypoint_sections', ','.join(sorted(full.entrypoints))) emit('full_entrypoint_alpha', full.entrypoints['sample.plugins']['alpha']) emit('full_entrypoints_exclude_scripts', 'yes' if 'console_scripts' not in full.entrypoints else 'no') # The readme and the license are read off disk, relative to `project_dir'. emit('full_readme_text', flat(full.readme.text)) emit('full_readme_content_type', full.readme.content_type) emit('full_readme_file_name', os.path.basename(str(full.readme.file))) emit('full_license_text', flat(full.license.text)) emit('full_license_file_name', os.path.basename(str(full.license.file))) # `dynamic' is EMPTY here because the version is given. That is the whole of # the interaction, and the `version-absent' cases below are the other half. emit('full_dynamic', ','.join(full.dynamic) or '(empty)') # ============================================ name normalisation, on its own = for key, raw in (('dotted', 'Sample_Project.Name'), ('double_dash', 'A--B'), ('mixed', 'a_b.c'), ('upper', 'UPPER'), ('single', 'x')): emit('norm_' + key, StandardMetadata(raw).name) emit('norm_agrees_with_packaging', 'yes' if all( StandardMetadata(r).name == packaging.utils.canonicalize_name(r) for r in ('Sample_Project.Name', 'A--B', 'a_b.c', 'UPPER', 'x')) else 'no') # ================================================================ dynamic ==== DYN = ''' [project] name = "dyn" version = "1.0" dynamic = ["description", "keywords"] ''' dyn = parse(DYN) emit('dynamic_list', ','.join(dyn.dynamic)) # A field DECLARED dynamic and not given is simply absent from the object. emit('dynamic_description_absent', 'yes' if dyn.description is None else 'no') emit('dynamic_keywords_empty', 'yes' if dyn.keywords == [] else 'no') emit('dynamic_and_defined', parse_msg(''' [project] name = "dyn" version = "1.0" description = "given anyway" dynamic = ["description"] ''')) emit('dynamic_name_rejected', parse_msg(''' [project] name = "dyn" version = "1.0" dynamic = ["name"] ''')) # A MISSING VERSION IS NOT A PARSE ERROR: `__post_init__' appends `version' to # `dynamic' and the object is built. `as_rfc822()' is where it fails. noversion = parse('[project]\nname = "nov"\n') emit('version_absent_parses', 'yes') emit('version_absent_dynamic', ','.join(noversion.dynamic)) emit('version_absent_version_is_none', 'yes' if noversion.version is None else 'no') emit('version_absent_rfc822', msg(noversion.as_rfc822)) # Setting the version afterwards RETRACTS the dynamic entry -- `__setattr__' # watches the field. noversion.version = packaging.version.Version('2.0') emit('version_set_clears_dynamic', ','.join(noversion.dynamic) or '(empty)') emit('version_set_rfc822_version', str(noversion.as_rfc822().headers.get('Version', [''])[0])) # Explicitly declaring `version' dynamic, then giving one, is the same path. dynver = parse('[project]\nname = "dv"\ndynamic = ["version"]\n') emit('dynamic_version_declared', ','.join(dynver.dynamic)) # ============================================================== the readme === emit('readme_md_content_type', parse('[project]\nname = "r"\nversion = "1"\nreadme = "README.md"\n', {'README.md': 'md body\n'}).readme.content_type) emit('readme_rst_content_type', parse('[project]\nname = "r"\nversion = "1"\nreadme = "README.rst"\n', {'README.rst': 'rst body\n'}).readme.content_type) # A `.txt' readme raises even though the file is right there: the content type # is inferred from the SUFFIX and there is no third branch. emit('readme_txt_no_content_type', parse_msg('[project]\nname = "r"\nversion = "1"\nreadme = "README.txt"\n', {'README.txt': 'txt body\n'})) # THE MESSAGE SAYS `readme', THE `key' SAYS `project.license.file' -- an # upstream copy-paste this suite records rather than corrects. emit('readme_missing_file', parse_msg('[project]\nname = "r"\nversion = "1"\nreadme = "NOPE.md"\n')) try: parse('[project]\nname = "r"\nversion = "1"\nreadme = "NOPE.md"\n') emit('readme_missing_file_key', 'no-raise') except ConfigurationError as exc: emit('readme_missing_file_key', str(exc.key)) table = parse(''' [project] name = "r" version = "1" readme = { text = "inline body", content-type = "text/x-rst" } ''') emit('readme_table_text', table.readme.text) emit('readme_table_content_type', table.readme.content_type) emit('readme_table_file_is_none', 'yes' if table.readme.file is None else 'no') # The table form does NOT infer a content type -- it insists on one, even for a # `.md' file whose string spelling would have inferred `text/markdown'. emit('readme_table_file_needs_content_type', parse_msg(''' [project] name = "r" version = "1" readme = { file = "README.md" } ''', {'README.md': 'md body\n'})) emit('readme_table_both', parse_msg(''' [project] name = "r" version = "1" readme = { file = "README.md", text = "x", content-type = "text/markdown" } ''', {'README.md': 'md body\n'})) emit('readme_table_neither', parse_msg(''' [project] name = "r" version = "1" readme = { content-type = "text/markdown" } ''')) emit('readme_table_unexpected_field', parse_msg(''' [project] name = "r" version = "1" readme = { text = "x", content-type = "text/markdown", charset = "utf-8" } ''')) emit('readme_wrong_type', parse_msg(''' [project] name = "r" version = "1" readme = 42 ''')) emit('readme_absent_is_none', 'yes' if parse( '[project]\nname = "r"\nversion = "1"\n').readme is None else 'no') # ============================================================= the license === lic = parse('[project]\nname = "l"\nversion = "1"\nlicense = { text = "MIT" }\n') emit('license_text_form', lic.license.text) emit('license_text_file_is_none', 'yes' if lic.license.file is None else 'no') emit('license_file_form', flat(parse( '[project]\nname = "l"\nversion = "1"\nlicense = { file = "LIC" }\n', {'LIC': 'the license body\n'}).license.text)) emit('license_both', parse_msg( '[project]\nname = "l"\nversion = "1"\n' 'license = { file = "LIC", text = "MIT" }\n', {'LIC': 'x\n'})) emit('license_neither', parse_msg( '[project]\nname = "l"\nversion = "1"\nlicense = {}\n')) emit('license_unexpected_field', parse_msg( '[project]\nname = "l"\nversion = "1"\n' 'license = { text = "MIT", spdx = "MIT" }\n')) emit('license_missing_file', parse_msg( '[project]\nname = "l"\nversion = "1"\nlicense = { file = "NOPE" }\n')) emit('license_absent_is_none', 'yes' if parse( '[project]\nname = "l"\nversion = "1"\n').license is None else 'no') # ========================================================== the error paths == emit('missing_project_section', parse_msg('[build-system]\nrequires = ["x"]\n')) emit('missing_name', parse_msg('[project]\nversion = "1"\n')) emit('empty_name', parse_msg('[project]\nname = ""\nversion = "1"\n')) # NOT ConfigurationError. packaging's own exception comes straight out of an # unguarded `Version(...)' / `SpecifierSet(...)'. emit('malformed_version', parse_msg('[project]\nname = "e"\nversion = "not.a.version"\n')) emit('malformed_requires_python', parse_msg( '[project]\nname = "e"\nversion = "1"\nrequires-python = "totally bogus"\n')) # AN UNKNOWN KEY IS SILENTLY IGNORED. There is no enumeration of `[project]'. unknown = parse('[project]\nname = "e"\nversion = "1"\nfrobnicate = 1\n') emit('unknown_key_ignored', 'yes') emit('unknown_key_not_an_attribute', 'yes' if not hasattr(unknown, 'frobnicate') else 'no') emit('name_wrong_type', parse_msg('[project]\nname = 42\nversion = "1"\n')) emit('keywords_wrong_type', parse_msg( '[project]\nname = "e"\nversion = "1"\nkeywords = "alpha"\n')) emit('keywords_wrong_item_type', parse_msg( '[project]\nname = "e"\nversion = "1"\nkeywords = ["a", 2]\n')) emit('urls_wrong_value_type', parse_msg( '[project]\nname = "e"\nversion = "1"\n[project.urls]\nhomepage = 1\n')) emit('authors_wrong_type', parse_msg( '[project]\nname = "e"\nversion = "1"\nauthors = ["Ann"]\n')) emit('bad_dependency', parse_msg( '[project]\nname = "e"\nversion = "1"\ndependencies = ["not a requirement!!"]\n')) emit('bad_optional_dependency', parse_msg( '[project]\nname = "e"\nversion = "1"\n' '[project.optional-dependencies]\ntest = ["not a requirement!!"]\n')) emit('optional_dependency_wrong_type', parse_msg( '[project]\nname = "e"\nversion = "1"\n' '[project.optional-dependencies]\ntest = "pytest"\n')) emit('entrypoint_wrong_type', parse_msg( '[project]\nname = "e"\nversion = "1"\n' '[project.entry-points]\nalpha = "not-a-table"\n')) # ========================================================== the RFC 822 form = rfc = full.as_rfc822() emit('rfc_type', type(rfc).__name__) emit('rfc_headers_order', ','.join(rfc.headers)) def hdr(name): v = rfc.headers.get(name) return '(absent)' if v is None else '|'.join(v) emit('rfc_metadata_version', hdr('Metadata-Version')) emit('rfc_name', hdr('Name')) emit('rfc_version', hdr('Version')) emit('rfc_summary', hdr('Summary')) # SPACE-SEPARATED, not comma-separated: `' '.join(self.keywords)'. emit('rfc_keywords', hdr('Keywords')) emit('rfc_home_page', hdr('Home-page')) # Only people WITHOUT an email reach `Author'; everyone WITH one reaches # `Author-Email', rendered `Name ' -- and a person given as email alone # is rendered with the literal name `Unknown'. emit('rfc_author', hdr('Author')) emit('rfc_author_email', hdr('Author-Email')) emit('rfc_maintainer', hdr('Maintainer')) emit('rfc_maintainer_email', hdr('Maintainer-Email')) emit('rfc_license', flat(hdr('License'))) emit('rfc_classifier', hdr('Classifier')) # `name.capitalize()' -- so a multi-word URL label comes back with only its # FIRST letter capitalised, and `homepage' is rendered twice, once as # `Home-page' and once as a `Project-URL'. emit('rfc_project_url', hdr('Project-URL')) emit('rfc_requires_python', hdr('Requires-Python')) emit('rfc_requires_dist', hdr('Requires-Dist')) emit('rfc_provides_extra', hdr('Provides-Extra')) emit('rfc_description_content_type', hdr('Description-Content-Type')) emit('rfc_body', flat(rfc.body)) # Nothing of `scripts', `gui-scripts' or `entry-points' is core metadata. emit('rfc_no_scripts_header', 'yes' if not [h for h in rfc.headers if 'scripts' in h.lower()] else 'no') emit('rfc_no_dynamic_header', hdr('Dynamic')) # THE IDEMPOTENCY BREAK. `_build_extra_req' mutates the stored Requirement, so # the SAME unchanged object renders a doubled marker the second time. emit('rfc_extra_req_first', ' / '.join( h for h in rfc.headers['Requires-Dist'] if 'extra' in h)) rfc2 = full.as_rfc822() emit('rfc_extra_req_second', ' / '.join( h for h in rfc2.headers['Requires-Dist'] if 'extra' in h)) # ---- a second, minimal object, so the empty-value rules are visible alone --- mini = parse('[project]\nname = "Mini"\nversion = "0.1"\n') mrfc = mini.as_rfc822() emit('mini_headers', ','.join(mrfc.headers)) emit('mini_metadata_version', '|'.join(mrfc.headers['Metadata-Version'])) # `__setitem__' DROPS a falsy value, so an empty Keywords/Author never appears. emit('mini_no_keywords', 'yes' if 'Keywords' not in mrfc.headers else 'no') emit('mini_no_author', 'yes' if 'Author' not in mrfc.headers else 'no') emit('mini_body_is_none', 'yes' if mrfc.body is None else 'no') emit('mini_str', flat(mrfc)) emit('mini_bytes_match', 'yes' if bytes(mrfc) == str(mrfc).encode() else 'no') # A `dynamic' field lifts Metadata-Version to 2.2 and adds one header per entry. dyn_rfc = dyn.as_rfc822() emit('dyn_metadata_version', '|'.join(dyn_rfc.headers['Metadata-Version'])) emit('dyn_dynamic_headers', '|'.join(dyn_rfc.headers['Dynamic'])) # ------------------------------------------ the message object on its own ---- m = RFC822Message() m['Empty'] = '' m['None'] = None m['Rep'] = 'one' m['Rep'] = 'two' m['Fold'] = 'first\nsecond' emit('msg_drops_empty', 'yes' if 'Empty' not in m.headers else 'no') emit('msg_drops_none', 'yes' if 'None' not in m.headers else 'no') emit('msg_repeats', '|'.join(m.headers['Rep'])) # A multi-line value is folded with EIGHT spaces of continuation indent. emit('msg_fold', flat(m)) emit('msg_body_absent', 'yes' if m.body is None else 'no') # ================================================================= trailer === emit('emitted_count', str(len(emitted) + 2)) emit('probe_done', 'yes')