summaryrefslogtreecommitdiff
path: root/pyproject_save_files.py
diff options
context:
space:
mode:
Diffstat (limited to 'pyproject_save_files.py')
-rw-r--r--pyproject_save_files.py775
1 files changed, 775 insertions, 0 deletions
diff --git a/pyproject_save_files.py b/pyproject_save_files.py
new file mode 100644
index 0000000..00d706d
--- /dev/null
+++ b/pyproject_save_files.py
@@ -0,0 +1,775 @@
+import argparse
+import fnmatch
+import json
+import os
+
+from collections import defaultdict
+from keyword import iskeyword
+from pathlib import PosixPath, PurePosixPath
+from importlib.metadata import Distribution
+
+
+# From RPM's build/files.c strtokWithQuotes delim argument
+RPM_FILES_DELIMETERS = ' \n\t'
+
+# RPM hardcodes the lists of manpage extensions and directories,
+# so we have to maintain separate ones :(
+# There is an issue for RPM to provide the lists as macros:
+# https://github.com/rpm-software-management/rpm/issues/1865
+# The original lists can be found here:
+# https://github.com/rpm-software-management/rpm/blob/master/scripts/brp-compress
+MANPAGE_EXTENSIONS = ['gz', 'Z', 'bz2', 'xz', 'lzma', 'zst', 'zstd']
+MANDIRS = [
+ '/man/man*',
+ '/man/*/man*',
+ '/info',
+ '/share/man/man*',
+ '/share/man/*/man*',
+ '/share/info',
+ '/kerberos/man',
+ '/X11R6/man/man*',
+ '/lib/perl5/man/man*',
+ '/share/doc/*/man/man*',
+ '/lib/*/man/man*',
+ '/share/fish/man/man*',
+]
+
+
+class BuildrootPath(PurePosixPath):
+ """
+ This path represents a path in a buildroot.
+ When absolute, it is "relative" to a buildroot.
+
+ E.g. /usr/lib means %{buildroot}/usr/lib
+ The object carries no buildroot information.
+ """
+
+ @staticmethod
+ def from_real(realpath, *, root):
+ """
+ For a given real disk path, return a BuildrootPath in the given root.
+
+ For example::
+
+ >>> BuildrootPath.from_real(PosixPath('/tmp/buildroot/foo'), root=PosixPath('/tmp/buildroot'))
+ BuildrootPath('/foo')
+ """
+ return BuildrootPath("/") / realpath.relative_to(root)
+
+ def to_real(self, root):
+ """
+ Return a real PosixPath in the given root
+
+ For example::
+
+ >>> BuildrootPath('/foo').to_real(PosixPath('/tmp/buildroot'))
+ PosixPath('/tmp/buildroot/foo')
+ """
+ return root / self.relative_to("/")
+
+ def normpath(self):
+ """
+ Normalize all the potential /../ parts of the path without touching real files.
+
+ PurePaths don't have .resolve().
+ Paths have .resolve() but it touches real files.
+ This is an alternative. It assumes there are no symbolic links.
+
+ Example:
+
+ >>> BuildrootPath('/usr/lib/python/../pypy').normpath()
+ BuildrootPath('/usr/lib/pypy')
+ """
+ return type(self)(os.path.normpath(self))
+
+
+def pycache_dir(script):
+ """
+ For a script BuildrootPath, return a BuildrootPath of its __pycache__ directory.
+
+ Example:
+
+ >>> pycache_dir(BuildrootPath('/whatever/bar.py'))
+ BuildrootPath('/whatever/__pycache__')
+
+ >>> pycache_dir(BuildrootPath('/opt/python3.10/foo.py'))
+ BuildrootPath('/opt/python3.10/__pycache__')
+ """
+ return script.parent / "__pycache__"
+
+
+def pycached(script, python_version):
+ """
+ For a script BuildrootPath, return a list with that path and its bytecode glob.
+ Like the %pycached macro.
+
+ The glob is represented as a BuildrootPath.
+
+ Examples:
+
+ >>> pycached(BuildrootPath('/whatever/bar.py'), '3.8')
+ [BuildrootPath('/whatever/bar.py'), BuildrootPath('/whatever/__pycache__/bar.cpython-38{,.opt-?}.pyc')]
+
+ >>> pycached(BuildrootPath('/opt/python3.10/foo.py'), '3.10')
+ [BuildrootPath('/opt/python3.10/foo.py'), BuildrootPath('/opt/python3.10/__pycache__/foo.cpython-310{,.opt-?}.pyc')]
+ """
+ assert script.suffix == ".py"
+ pyver = "".join(python_version.split(".")[:2])
+ pycname = f"{script.stem}.cpython-{pyver}{{,.opt-?}}.pyc"
+ pyc = pycache_dir(script) / pycname
+ return [script, pyc]
+
+
+def add_file_to_module(paths, module_name, module_type, files_dirs, *files):
+ """
+ Helper procedure, adds given files to the module_name of a given module_type
+ """
+ for module in paths["modules"][module_name]:
+ if module["type"] == module_type:
+ if files[0] not in module[files_dirs]:
+ module[files_dirs].extend(files)
+ break
+ else:
+ paths["modules"][module_name].append(
+ {"type": module_type, "files": [], "dirs": [], files_dirs: list(files)}
+ )
+
+
+def add_py_file_to_module(paths, module_name, module_type, path, python_version,
+ *, include_pycache_dir):
+ """
+ Helper procedure, adds given .py file to the module_name of a given module_type
+ Always also adds the bytecode cache.
+ If include_pycache_dir is set, also include the __pycache__ directory.
+ """
+ add_file_to_module(paths, module_name, module_type, "files", *pycached(path, python_version))
+ if include_pycache_dir:
+ add_file_to_module(paths, module_name, module_type, "dirs", pycache_dir(path))
+
+
+def add_lang_to_module(paths, module_name, path):
+ """
+ Helper procedure, divides lang files by language and adds them to the module_name
+
+ Returns True if the language code detection was successful
+ """
+ for i, parent in enumerate(path.parents):
+ if parent.name == 'LC_MESSAGES':
+ lang_country_code = path.parents[i+1].name
+ break
+ else:
+ return False
+ # convert potential en_US to plain en
+ lang_code = lang_country_code.partition('_')[0]
+ if module_name not in paths["lang"]:
+ paths["lang"].update({module_name: defaultdict(list)})
+ paths["lang"][module_name][lang_code].append(path)
+ return True
+
+
+def prepend_mandirs(prefix):
+ """
+ Return the list of man page directories prepended with the given prefix.
+ """
+ return [str(prefix) + mandir for mandir in MANDIRS]
+
+
+def normalize_manpage_filename(prefix, path):
+ """
+ If a path is processed by RPM's brp-compress script, strip it of the extension
+ (if the extension matches one of the listed by brp-compress),
+ append '*' to the filename and return it. If not, return the unchanged path.
+ Rationale: https://docs.fedoraproject.org/en-US/packaging-guidelines/#_manpages
+
+ Examples:
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/share/man/de/man1/linkchecker.1'))
+ BuildrootPath('/usr/share/man/de/man1/linkchecker.1*')
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/share/doc/en/man/man1/getmac.1'))
+ BuildrootPath('/usr/share/doc/en/man/man1/getmac.1*')
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/share/man/man8/abc.8.zstd'))
+ BuildrootPath('/usr/share/man/man8/abc.8*')
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/kerberos/man/dir'))
+ BuildrootPath('/usr/kerberos/man/dir')
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/kerberos/man/dir.1'))
+ BuildrootPath('/usr/kerberos/man/dir.1*')
+
+ >>> normalize_manpage_filename(PosixPath('/usr'), BuildrootPath('/usr/bin/getmac'))
+ BuildrootPath('/usr/bin/getmac')
+ """
+
+ prefixed_mandirs = prepend_mandirs(prefix)
+ for mandir in prefixed_mandirs:
+ # "dir" is explicitly excluded by RPM
+ # https://github.com/rpm-software-management/rpm/blob/rpm-4.17.0-release/scripts/brp-compress#L24
+ if fnmatch.fnmatch(str(path.parent), mandir) and path.name != "dir":
+ # "abc.1.gz2" -> "abc.1*"
+ if path.suffix[1:] in MANPAGE_EXTENSIONS:
+ return BuildrootPath(path.parent / (path.stem + "*"))
+ # "abc.1 -> abc.1*"
+ else:
+ return BuildrootPath(path.parent / (path.name + "*"))
+ else:
+ return path
+
+
+def is_valid_module_name(s):
+ """Return True if a string is considered a valid module name and False otherwise.
+
+ String must be a valid Python name, not a Python keyword and must not
+ start with underscore - we treat those as private.
+ Examples:
+
+ >>> is_valid_module_name('module_name')
+ True
+
+ >>> is_valid_module_name('12module_name')
+ False
+
+ >>> is_valid_module_name('module-name')
+ False
+
+ >>> is_valid_module_name('return')
+ False
+
+ >>> is_valid_module_name('_module_name')
+ False
+ """
+ if (s.isidentifier() and not iskeyword(s) and not s.startswith("_")):
+ return True
+ return False
+
+
+def module_names_from_path(path):
+ """Get all importable module names from given path.
+
+ Paths containing ".py" and ".so" files are considered importable modules,
+ and so their respective directories (ie. "foo/bar/baz.py": "foo", "foo.bar",
+ "foo.bar.baz").
+ Paths containing invalid Python strings are discarded.
+
+ Return set of all valid possibilities.
+ """
+ # Discard all files that are not valid modules
+ if path.suffix not in (".py", ".so"):
+ return set()
+
+ parts = list(path.parts)
+
+ # Modify the file names according to their suffixes
+ if path.suffix == ".py":
+ parts[-1] = path.stem
+ elif path.suffix == ".so":
+ # .so files can have two suffixes - cut both of them
+ parts[-1] = PosixPath(path.stem).stem
+
+ # '__init__' indicates a module but we don't want to import the actual file
+ # It's unclear whether there can be __init__.so files in the Python packages.
+ # The idea to implement this file was raised in 2008 on Python-ideas mailing list
+ # (https://mail.python.org/pipermail/python-ideas/2008-October/002292.html)
+ # and there are a few reports of people compiling their __init__.py to __init__.so.
+ # However it's not officially documented nor forbidden,
+ # so we're checking for the stem after stripping the suffix from the file.
+ if parts[-1] == "__init__":
+ del parts[-1]
+
+ # For each part of the path check whether it's valid
+ # If not, discard the whole path - return an empty set
+ for path_part in parts:
+ if not is_valid_module_name(path_part):
+ return set()
+ else:
+ return {'.'.join(parts[:x+1]) for x in range(len(parts))}
+
+
+def is_license_file(path, license_files, license_directories):
+ """
+ Check if the given BuildrootPath path matches any of the "License-File" entries.
+ The path is considered matched when resolved from any of the license_directories
+ matches string-wise what is stored in any "License-File" entry (license_files).
+
+ Examples:
+ >>> site_packages = BuildrootPath('/usr/lib/python3.12/site-packages')
+ >>> distinfo = site_packages / 'foo-1.0.dist-info'
+ >>> license_directories = [distinfo / 'licenses', distinfo]
+ >>> license_files = ['LICENSE.txt', 'AUTHORS.md']
+ >>> is_license_file(distinfo / 'AUTHORS.md', license_files, license_directories)
+ True
+ >>> is_license_file(distinfo / 'licenses/LICENSE.txt', license_files, license_directories)
+ True
+ >>> # we don't match based on directory only
+ >>> is_license_file(distinfo / 'licenses/COPYING', license_files, license_directories)
+ False
+ >>> is_license_file(site_packages / 'foo/LICENSE.txt', license_files, license_directories)
+ False
+ """
+ if not license_files or not license_directories:
+ return False
+ for license_dir in license_directories:
+ if (path.is_relative_to(license_dir) and
+ str(path.relative_to(license_dir)) in license_files):
+ return True
+ return False
+
+
+def classify_paths(
+ record_path, parsed_record_content, metadata, sitedirs, python_version, prefix
+):
+ """
+ For each BuildrootPath in parsed_record_content classify it to a dict structure
+ that allows to filter the files for the %files and %check section easier.
+
+ For the dict structure, look at the beginning of this function's code.
+
+ Each "module" is a dict with "type" ("package", "script", "extension"), and "files" and "dirs".
+ """
+ distinfo = record_path.parent
+ paths = {
+ "metadata": {
+ "files": [], # regular %file entries with dist-info content
+ "dirs": [distinfo], # %dir %file entries with dist-info directory
+ "docs": [], # to be used once there is upstream way to recognize READMEs
+ "licenses": [], # %license entries parsed from dist-info METADATA file
+ },
+ "lang": {}, # %lang entries: [module_name or None][language_code] lists of .mo files
+ "modules": defaultdict(list), # each importable module (directory, .py, .so)
+ "module_names": set(), # qualified names of each importable module ("foo.bar.baz")
+ "other": {"files": []}, # regular %file entries we could not parse :(
+ }
+
+ license_files = metadata.get_all('License-File')
+ license_directory = distinfo / 'licenses' # See PEP 369 "Root License Directory"
+ # setuptools was the first known build backend to implement License-File.
+ # Unfortunately they don't put licenses to the license directory (yet):
+ # https://github.com/pypa/setuptools/issues/3596
+ # Hence, we check licenses in both licenses and dist-info
+ license_directories = (license_directory, distinfo)
+
+ # In RECORDs generated by pip, there are no directories, only files.
+ # The example RECORD from PEP 376 does not contain directories either.
+ # Hence, we'll only assume files, but TODO get it officially documented.
+ for path in parsed_record_content:
+ if path.suffix == ".pyc":
+ # we handle bytecode separately
+ continue
+
+ if distinfo in path.parents:
+ if path.parent == distinfo and path.name in ("RECORD", "REQUESTED"):
+ # RECORD and REQUESTED files are removed in %pyproject_install
+ # See PEP 627
+ continue
+ if is_license_file(path, license_files, license_directories):
+ paths["metadata"]["licenses"].append(path)
+ else:
+ paths["metadata"]["files"].append(path)
+ # nested directories within distinfo
+ index = path.parents.index(distinfo)
+ for parent in list(path.parents)[:index]: # no direct slice until Python 3.10
+ if parent not in paths["metadata"]["dirs"]:
+ paths["metadata"]["dirs"].append(parent)
+ continue
+
+ for sitedir in sitedirs:
+ if sitedir in path.parents:
+ # Get only the part without sitedir prefix to classify module names
+ relative_path = path.relative_to(sitedir)
+ paths["module_names"].update(module_names_from_path(relative_path))
+ if path.parent == sitedir:
+ if path.suffix == ".so":
+ # extension modules can have 2 suffixes
+ name = BuildrootPath(path.stem).stem
+ add_file_to_module(paths, name, "extension", "files", path)
+ elif path.suffix == ".py":
+ name = path.stem
+ # we add the .pyc files, but not top-level __pycache__
+ add_py_file_to_module(
+ paths, name, "script", path, python_version,
+ include_pycache_dir=False
+ )
+ else:
+ paths["other"]["files"].append(path)
+ else:
+ # this file is inside a dir, we add all dirs upwards until sitedir
+ index = path.parents.index(sitedir)
+ module_dir = path.parents[index - 1]
+ for parent in list(path.parents)[:index]: # no direct slice until Python 3.10
+ add_file_to_module(paths, module_dir.name, "package", "dirs", parent)
+ is_lang = False
+ if path.suffix == ".mo":
+ is_lang = add_lang_to_module(paths, module_dir.name, path)
+ if not is_lang:
+ if path.suffix == ".py":
+ # we add the .pyc files, and their __pycache__
+ add_py_file_to_module(
+ paths, module_dir.name, "package", path, python_version,
+ include_pycache_dir=True
+ )
+ else:
+ add_file_to_module(paths, module_dir.name, "package", "files", path)
+ break
+ else:
+ if path.suffix == ".mo":
+ add_lang_to_module(paths, None, path) or paths["other"]["files"].append(path)
+ else:
+ path = normalize_manpage_filename(prefix, path)
+ paths["other"]["files"].append(path)
+
+ return paths
+
+
+def escape_rpm_path(path):
+ """
+ Escape special characters in string-paths or BuildrootPaths
+
+ E.g. a space in path otherwise makes RPM think it's multiple paths,
+ unless we put it in "quotes".
+ Or a literal % symbol in path might be expanded as a macro if not escaped.
+
+ Due to limitations in RPM,
+ some paths with spaces and other special characters are not supported.
+
+ Examples:
+
+ >>> escape_rpm_path(BuildrootPath('/usr/lib/python3.9/site-packages/setuptools'))
+ '/usr/lib/python3.9/site-packages/setuptools'
+
+ >>> escape_rpm_path('/usr/lib/python3.9/site-packages/setuptools/script (dev).tmpl')
+ '"/usr/lib/python3.9/site-packages/setuptools/script (dev).tmpl"'
+
+ >>> escape_rpm_path('/usr/share/data/100%valid.path')
+ '/usr/share/data/100%%%%%%%%valid.path'
+
+ >>> escape_rpm_path('/usr/share/data/100 % valid.path')
+ '"/usr/share/data/100 %%%%%%%% valid.path"'
+
+ >>> escape_rpm_path('/usr/share/data/1000 %% valid.path')
+ '"/usr/share/data/1000 %%%%%%%%%%%%%%%% valid.path"'
+
+ >>> escape_rpm_path('/usr/share/data/spaces and "quotes"')
+ Traceback (most recent call last):
+ ...
+ NotImplementedError: ...
+
+ >>> escape_rpm_path('/usr/share/data/spaces and [square brackets]')
+ Traceback (most recent call last):
+ ...
+ NotImplementedError: ...
+ """
+ orig_path = path = str(path)
+ if "%" in path:
+ # Escaping by 8 %s has been verified in RPM 4.16 and 4.17, but probably not stable
+ # See this thread http://lists.rpm.org/pipermail/rpm-list/2021-June/002048.html
+ # On the CI, we build tests/escape_percentages.spec to verify this assumption
+ path = path.replace("%", "%" * 8)
+ if any(symbol in path for symbol in RPM_FILES_DELIMETERS):
+ if '"' in path:
+ # As far as we know, RPM cannot list such file individually
+ # See this thread http://lists.rpm.org/pipermail/rpm-list/2021-June/002048.html
+ raise NotImplementedError(f'" symbol in path with spaces is not supported by %pyproject_save_files: {orig_path!r}')
+ if "[" in path or "]" in path:
+ # See https://bugzilla.redhat.com/show_bug.cgi?id=1990879
+ # and https://github.com/rpm-software-management/rpm/issues/1749
+ raise NotImplementedError(f'[ or ] symbol in path with spaces is not supported by %pyproject_save_files: {orig_path!r}')
+ return f'"{path}"'
+ return path
+
+
+def generate_file_list(paths_dict, module_globs, include_others=False):
+ """
+ This function takes the classified paths_dict and turns it into lines
+ for the %files section. Returns list with text lines, no Path objects.
+
+ Only includes files from modules that match module_globs, metadata and
+ optionaly all other files.
+
+ It asserts that all globs match at least one module, raises ValueError otherwise.
+ Multiple globs matching identical module(s) are OK.
+ """
+ files = set()
+
+ if include_others:
+ files.update(f"{escape_rpm_path(p)}" for p in paths_dict["other"]["files"])
+ try:
+ for lang_code in paths_dict["lang"][None]:
+ files.update(f"%lang({lang_code}) {escape_rpm_path(p)}" for p in paths_dict["lang"][None][lang_code])
+ except KeyError:
+ pass
+
+ files.update(f"{escape_rpm_path(p)}" for p in paths_dict["metadata"]["files"])
+ for macro in "dir", "doc", "license":
+ files.update(f"%{macro} {escape_rpm_path(p)}" for p in paths_dict["metadata"][f"{macro}s"])
+
+ modules = paths_dict["modules"]
+ done_modules = set()
+ done_globs = set()
+
+ for glob in module_globs:
+ for name in modules:
+ if fnmatch.fnmatchcase(name, glob):
+ if name not in done_modules:
+ try:
+ for lang_code in paths_dict["lang"][name]:
+ files.update(f"%lang({lang_code}) {escape_rpm_path(p)}" for p in paths_dict["lang"][name][lang_code])
+ except KeyError:
+ pass
+ for module in modules[name]:
+ files.update(f"%dir {escape_rpm_path(p)}" for p in module["dirs"])
+ files.update(f"{escape_rpm_path(p)}" for p in module["files"])
+ done_modules.add(name)
+ done_globs.add(glob)
+
+ # Users using '*' don't care about the files in the package, so it's ok
+ # not to fail the build when no modules are detected
+ # There can be legitimate reasons to create a package without Python modules
+ if not modules and fnmatch.fnmatchcase("", glob):
+ done_globs.add(glob)
+
+ missed = module_globs - done_globs
+ if missed:
+ missed_text = ", ".join(sorted(missed))
+ raise ValueError(f"Globs did not match any module: {missed_text}")
+
+ return sorted(files)
+
+
+def generate_module_list(paths_dict, module_globs):
+ """
+ This function takes the paths_dict created by the classify_paths() function and
+ reads the modules names from it.
+ It filters those whose top-level module names match any of the provided module_globs.
+
+ Returns list with matching qualified module names.
+
+ Examples:
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'foo'})
+ ['foo', 'foo.bar']
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'*foo'})
+ ['foo', 'foo.bar']
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'foo', 'baz'})
+ ['baz', 'foo', 'foo.bar']
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'*'})
+ ['baz', 'foo', 'foo.bar']
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'bar'})
+ []
+
+ Submodules aren't discovered:
+
+ >>> generate_module_list({'module_names': {'foo', 'foo.bar', 'baz'}}, {'*bar'})
+ []
+ """
+
+ module_names = paths_dict['module_names']
+ filtered_module_names = set()
+
+ for glob in module_globs:
+ for name in module_names:
+ # Match the top-level part of the qualified name, eg. 'foo.bar.baz' -> 'foo'
+ top_level_name = name.split('.')[0]
+ if fnmatch.fnmatchcase(top_level_name, glob):
+ filtered_module_names.add(name)
+
+ return sorted(filtered_module_names)
+
+
+def parse_varargs(varargs):
+ """
+ Parse varargs from the %pyproject_save_files macro
+
+ Arguments starting with + are treated as a flags, everything else is a glob
+
+ Returns as set of globs, boolean flag whether to include all the other files
+
+ Raises ValueError for unknown flags and globs with dots (namespace packages).
+
+ Good examples:
+
+ >>> parse_varargs(['*'])
+ ({'*'}, False)
+
+ >>> mods, auto = parse_varargs(['requests*', 'kerberos', '+auto'])
+ >>> auto
+ True
+ >>> sorted(mods)
+ ['kerberos', 'requests*']
+
+ >>> mods, auto = parse_varargs(['tldr', 'tensorf*'])
+ >>> auto
+ False
+ >>> sorted(mods)
+ ['tensorf*', 'tldr']
+
+ >>> parse_varargs(['+auto'])
+ (set(), True)
+
+ Bad examples:
+
+ >>> parse_varargs(['+kinkdir'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Invalid argument: +kinkdir
+
+ >>> parse_varargs(['good', '+bad', '*ugly*'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Invalid argument: +bad
+
+ >>> parse_varargs(['+bad', 'my.bad'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Invalid argument: +bad
+
+ >>> parse_varargs(['mod', 'mod.*'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Attempted to use a namespaced package with . in the glob: mod.*. ...
+
+ >>> parse_varargs(['my.bad', '+bad'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Attempted to use a namespaced package with . in the glob: my.bad. ...
+
+ >>> parse_varargs(['mod/submod'])
+ Traceback (most recent call last):
+ ...
+ ValueError: Attempted to use a namespaced package with / in the glob: mod/submod. ...
+ """
+ include_auto = False
+ globs = set()
+ namespace_error_template = (
+ "Attempted to use a namespaced package with {symbol} in the glob: {arg}. "
+ "That is not (yet) supported. Use {top} instead and see "
+ "https://bugzilla.redhat.com/1935266 for details."
+ )
+ for arg in varargs:
+ if arg.startswith("+"):
+ if arg == "+auto":
+ include_auto = True
+ else:
+ raise ValueError(f"Invalid argument: {arg}")
+ elif "." in arg:
+ top, *_ = arg.partition(".")
+ raise ValueError(namespace_error_template.format(symbol=".", arg=arg, top=top))
+ elif "/" in arg:
+ top, *_ = arg.partition("/")
+ raise ValueError(namespace_error_template.format(symbol="/", arg=arg, top=top))
+ else:
+ globs.add(arg)
+
+ return globs, include_auto
+
+
+def load_parsed_record(pyproject_record):
+ parsed_record = {}
+ with open(pyproject_record) as pyproject_record_file:
+ content = json.load(pyproject_record_file)
+
+ if len(content) > 1:
+ raise FileExistsError("%pyproject install has found more than one *.dist-info/RECORD file. "
+ "Currently, %pyproject_save_files supports only one wheel → one file list mapping. "
+ "Feel free to open a bugzilla for pyproject-rpm-macros and describe your usecase.")
+
+ # Redefine strings stored in JSON to BuildRootPaths
+ for record_path, files in content.items():
+ parsed_record[BuildrootPath(record_path)] = [BuildrootPath(f) for f in files]
+
+ return parsed_record
+
+
+def dist_metadata(buildroot, record_path):
+ """
+ Returns distribution metadata (email.message.EmailMessage), possibly empty
+ """
+ real_dist_path = record_path.parent.to_real(buildroot)
+ dist = Distribution.at(real_dist_path)
+ return dist.metadata
+
+
+def pyproject_save_files_and_modules(buildroot, sitelib, sitearch, python_version, pyproject_record, prefix, varargs):
+ """
+ Takes arguments from the %{pyproject_save_files} macro
+
+ Returns tuple: list of paths for the %files section and list of module names
+ for the %check section
+ """
+ # On 32 bit architectures, sitelib equals to sitearch
+ # This saves us browsing one directory twice
+ sitedirs = sorted({sitelib, sitearch})
+
+ globs, include_auto = parse_varargs(varargs)
+ parsed_records = load_parsed_record(pyproject_record)
+
+ final_file_list = []
+ final_module_list = []
+
+ for record_path, files in parsed_records.items():
+ metadata = dist_metadata(buildroot, record_path)
+ paths_dict = classify_paths(
+ record_path, files, metadata, sitedirs, python_version, prefix
+ )
+
+ final_file_list.extend(
+ generate_file_list(paths_dict, globs, include_auto)
+ )
+ final_module_list.extend(
+ generate_module_list(paths_dict, globs)
+ )
+
+ return final_file_list, final_module_list
+
+
+def main(cli_args):
+ file_section, module_names = pyproject_save_files_and_modules(
+ cli_args.buildroot,
+ cli_args.sitelib,
+ cli_args.sitearch,
+ cli_args.python_version,
+ cli_args.pyproject_record,
+ cli_args.prefix,
+ cli_args.varargs,
+ )
+
+ cli_args.output_files.write_text("\n".join(file_section) + "\n", encoding="utf-8")
+ cli_args.output_modules.write_text("\n".join(module_names) + "\n", encoding="utf-8")
+
+
+def argparser():
+ parser = argparse.ArgumentParser(
+ description="Create %{pyproject_files} for a Python project.",
+ prog="%pyproject_save_files",
+ add_help=False,
+ # custom usage to add +auto
+ usage="%(prog)s MODULE_GLOB [MODULE_GLOB ...] [+auto]",
+ )
+ parser.add_argument(
+ '--help', action='help',
+ default=argparse.SUPPRESS,
+ help=argparse.SUPPRESS,
+ )
+ r = parser.add_argument_group("required arguments")
+ r.add_argument("--output-files", type=PosixPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--output-modules", type=PosixPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--buildroot", type=PosixPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--sitelib", type=BuildrootPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--sitearch", type=BuildrootPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--python-version", type=str, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--pyproject-record", type=PosixPath, required=True, help=argparse.SUPPRESS)
+ r.add_argument("--prefix", type=PosixPath, required=True, help=argparse.SUPPRESS)
+ parser.add_argument(
+ "varargs", nargs="+", metavar="MODULE_GLOB",
+ help="Shell-like glob matching top-level module names to save into %%{pyproject_files}",
+ )
+ return parser
+
+
+if __name__ == "__main__":
+ cli_args = argparser().parse_args()
+ main(cli_args)