diff --git a/build/bin/sage-get-system-packages b/build/bin/sage-get-system-packages index 0172155023d..0a90232ed6e 100755 --- a/build/bin/sage-get-system-packages +++ b/build/bin/sage-get-system-packages @@ -50,6 +50,12 @@ case "$SYSTEM" in ;; esac +case "$SPKGS" in + *pkg:*|pypi/*|generic/*) + PATH="${SAGE_ROOT}/build/bin:$PATH" SPKGS=$(sage-package list $SPKGS) + ;; +esac + for PKG_BASE in $SPKGS; do if [ $FROM_PYPROJECT_TOML -eq 1 ]; then if [ -f "$SAGE_ROOT/src/pyproject.toml" ]; then diff --git a/build/sage_bootstrap/app.py b/build/sage_bootstrap/app.py index 96bca3a6d8c..01c27b4ef46 100644 --- a/build/sage_bootstrap/app.py +++ b/build/sage_bootstrap/app.py @@ -10,7 +10,10 @@ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2016 Volker Braun +# 2020-2024 Matthias Koeppe +# 2022 Thierry Monteil +# 2024 Marc Culler # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,6 +24,7 @@ import os +import re import logging log = logging.getLogger() @@ -36,6 +40,10 @@ from sage_bootstrap.env import SAGE_DISTFILES +# Approximation of https://peps.python.org/pep-0508/#names dependency specification +dep_re = re.compile('^ *([-A-Z0-9._]+)', re.IGNORECASE) + + class Application(object): def config(self): @@ -88,7 +96,7 @@ def properties(self, *package_classes, **kwds): source_maxima='normal' trees_maxima='SAGE_LOCAL' """ - props = kwds.pop('props', ['path', 'version_with_patchlevel', 'type', 'source', 'trees']) + props = kwds.pop('props', ['path', 'version_with_patchlevel', 'type', 'source', 'trees', 'purl']) format = kwds.pop('format', 'plain') log.debug('Looking up properties') pc = PackageClass(*package_classes) @@ -256,6 +264,9 @@ def update_latest(self, package_name, commit=False): Update a package to the latest version. This modifies the Sage sources. """ pkg = Package(package_name) + if pkg.source not in ['normal', 'wheel']: + log.debug('update_latest can only update normal and wheel packages; %s is a %s package' % (pkg, pkg.source)) + return dist_name = pkg.distribution_name if dist_name is None: log.debug('%s does not have Python distribution info in version_requirements.txt' % pkg) @@ -380,7 +391,8 @@ def fix_checksum(self, package_name): update.fix_checksum() def create(self, package_name, version=None, tarball=None, pkg_type=None, upstream_url=None, - description=None, license=None, upstream_contact=None, pypi=False, source=None): + description=None, license=None, upstream_contact=None, pypi=False, source=None, + dependencies=None): """ Create a package @@ -392,7 +404,12 @@ def create(self, package_name, version=None, tarball=None, pkg_type=None, upstre $ sage --package create jupyterlab_markup --pypi --source wheel --type optional """ - if '-' in package_name: + if package_name.startswith('pypi/'): + package_name = 'pkg:' + package_name + if package_name.startswith('pkg:pypi/'): + pypi = True + package_name = package_name[len('pkg:pypi/'):].lower().replace('-', '_').replace('.', '_') + elif '-' in package_name: raise ValueError('package names must not contain dashes, use underscore instead') if pypi: if source is None: @@ -420,6 +437,24 @@ def create(self, package_name, version=None, tarball=None, pkg_type=None, upstre raise ValueError('Only platform-independent wheels can be used for wheel packages, got {0}'.format(tarball)) if not version: version = pypi_version.version + if dependencies is None: + requires_dist = pypi_version.requires_dist + if requires_dist: + dependencies = [] + for item in requires_dist: + if "extra ==" in item: + continue + try: + dep = dep_re.match(item).groups()[0].strip() + except Exception: + continue + dep = 'pkg:pypi/' + dep + try: + dep = Package(dep).name + except ValueError: + self.create(dep, pkg_type=pkg_type) + dep = Package(dep).name + dependencies.append(dep) upstream_url = 'https://pypi.io/packages/{2}/{0:1.1}/{0}/{1}'.format(package_name, tarball, pypi_version.python_version) if not description: description = pypi_version.summary @@ -444,7 +479,8 @@ def create(self, package_name, version=None, tarball=None, pkg_type=None, upstre if description or license or upstream_contact: creator.set_description(description, license, upstream_contact) if pypi or source == 'pip': - creator.set_python_data_and_scripts(pypi_package_name=pypi_version.name, source=source) + creator.set_python_data_and_scripts(pypi_package_name=pypi_version.name, source=source, + dependencies=dependencies) if tarball: creator.set_tarball(tarball, upstream_url) if upstream_url and version: diff --git a/build/sage_bootstrap/cmdline.py b/build/sage_bootstrap/cmdline.py index 3ed185a9185..a9ea516b94d 100644 --- a/build/sage_bootstrap/cmdline.py +++ b/build/sage_bootstrap/cmdline.py @@ -12,7 +12,9 @@ """ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2015-2016 Volker Braun +# 2020-2024 Matthias Koeppe +# 2022 Thierry Monteil # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -277,9 +279,10 @@ def make_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, help='Print a list of packages known to Sage') parser_list.add_argument( - 'package_class', metavar='[package_name|:package_type:]', + 'package_class', metavar='[PACKAGE_NAME|pkg:pypi/DISTRIBUTION-NAME|:PACKAGE_TYPE:]', type=str, default=[':all-or-nothing:'], nargs='*', - help=('package name or designator for all packages of a given type ' + help=('package name, pkg:pypi/ followed by a distribution name, ' + 'or designator for all packages of a given type ' '(one of :all:, :standard:, :optional:, and :experimental:); ' 'default: :all: (or nothing when --include-dependencies or --exclude-dependencies is given')) parser_list.add_argument( @@ -305,9 +308,10 @@ def make_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, help='Print properties of given packages') parser_properties.add_argument( - 'package_class', metavar='[package_name|:package_type:]', + 'package_class', metavar='[PACKAGE_NAME|pkg:pypi/DISTRIBUTION-NAME|:PACKAGE_TYPE:]', type=str, nargs='+', - help=('package name or designator for all packages of a given type ' + help=('package name, pkg:pypi/ followed by a distribution name, ' + 'or designator for all packages of a given type ' '(one of :all:, :standard:, :optional:, and :experimental:)')) parser_properties.add_argument( '--format', type=str, default='plain', @@ -410,11 +414,11 @@ def make_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, help='Fix the checksum of normal packages.') parser_fix_checksum.add_argument( - 'package_class', metavar='[package_name|:package_type:]', + 'package_class', metavar='[PACKAGE_NAME|pkg:pypi/DISTRIBUTION-NAME|:PACKAGE_TYPE:]', type=str, default=[':all:'], nargs='*', - help=('package name or designator for all packages of a given type ' - '(one of :all:, :standard:, :optional:, and :experimental:); ' - 'default: :all:')) + help=('package name, pkg:pypi/ followed by a distribution name, ' + 'or designator for all packages of a given type ' + '(one of :all:, :standard:, :optional:, and :experimental:; default: :all:)')) parser_create = subparsers.add_parser( 'create', epilog=epilog_create, @@ -453,9 +457,10 @@ def make_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, help='Print metrics of given packages') parser_metrics.add_argument( - 'package_class', metavar='[package_name|:package_type:]', + 'package_class', metavar='[PACKAGE_NAME|pkg:pypi/DISTRIBUTION-NAME|:PACKAGE_TYPE:]', type=str, nargs='*', default=[':all:'], - help=('package name or designator for all packages of a given type ' + help=('package name, pkg:pypi/ followed by a distribution name, ' + 'or designator for all packages of a given type ' '(one of :all:, :standard:, :optional:, and :experimental:; default: :all:)')) return parser diff --git a/build/sage_bootstrap/creator.py b/build/sage_bootstrap/creator.py index a738d772215..e16002f12f9 100644 --- a/build/sage_bootstrap/creator.py +++ b/build/sage_bootstrap/creator.py @@ -4,7 +4,8 @@ """ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2015-2016 Volker Braun +# 2020-2024 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -105,7 +106,7 @@ def _remove_files(self, files): except OSError: pass - def set_python_data_and_scripts(self, pypi_package_name=None, source='normal'): + def set_python_data_and_scripts(self, pypi_package_name=None, source='normal', dependencies=None): """ Write the file ``dependencies`` and other files for Python packages. @@ -121,7 +122,15 @@ def set_python_data_and_scripts(self, pypi_package_name=None, source='normal'): if pypi_package_name is None: pypi_package_name = self.package_name with open(os.path.join(self.path, 'dependencies'), 'w+') as f: - f.write(' | $(PYTHON_TOOLCHAIN) $(PYTHON)\n\n') + if dependencies: + dependencies = ' '.join(dependencies) + else: + dependencies = '' + if source == 'wheel': + dependencies_order_only = 'pip $(PYTHON)' + else: + dependencies_order_only = '$(PYTHON_TOOLCHAIN) $(PYTHON)' + f.write(dependencies + ' | ' + dependencies_order_only + '\n\n') f.write('----------\nAll lines of this file are ignored except the first.\n') if source == 'normal': with open(os.path.join(self.path, 'spkg-install.in'), 'w+') as f: diff --git a/build/sage_bootstrap/download/cmdline.py b/build/sage_bootstrap/download/cmdline.py index a6afb4f92be..594cc773b38 100644 --- a/build/sage_bootstrap/download/cmdline.py +++ b/build/sage_bootstrap/download/cmdline.py @@ -6,7 +6,9 @@ """ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2015-2016 Volker Braun +# 2015 Jeroen Demeyer +# 2020 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/build/sage_bootstrap/download/mirror_list.py b/build/sage_bootstrap/download/mirror_list.py index 4cab19f5d64..f464d87ffbd 100644 --- a/build/sage_bootstrap/download/mirror_list.py +++ b/build/sage_bootstrap/download/mirror_list.py @@ -4,7 +4,9 @@ """ #***************************************************************************** -# Copyright (C) 2015 Volker Braun +# Copyright (C) 2014-2016 Volker Braun +# 2015 Jeroen Demeyer +# 2023 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/build/sage_bootstrap/expand_class.py b/build/sage_bootstrap/expand_class.py index c7fdb308bd3..c5bab8a313e 100644 --- a/build/sage_bootstrap/expand_class.py +++ b/build/sage_bootstrap/expand_class.py @@ -4,7 +4,8 @@ """ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2016 Volker Braun +# 2020-2024 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -52,12 +53,21 @@ def included_in_filter(pkg): self._init_optional(predicate=included_in_filter) elif package_name_or_class == ':experimental:': self._init_experimental(predicate=included_in_filter) + elif any(package_name_or_class.startswith(prefix) + for prefix in ["pkg:", "pypi/", "generic"]): + self.__names.add(Package(package_name_or_class).name) else: if ':' in package_name_or_class: - raise ValueError('a colon may only appear in designators of package types, ' + raise ValueError('a colon may only appear in a PURL such as ' + 'pkg:pypi/DISTRIBUTION-NAME ' + 'and in designators of package types, ' 'which must be one of ' ':all:, :standard:, :optional:, or :experimental:' 'got {}'.format(package_name_or_class)) + if '-' in package_name_or_class: + raise ValueError('dashes may only appear in a PURL such as ' + 'pkg:pypi/DISTRIBUTION-NAME; ' + 'SPKG names use underscores') self.__names.add(package_name_or_class) def include_recursive_dependencies(names, package_name): diff --git a/build/sage_bootstrap/package.py b/build/sage_bootstrap/package.py index aea31e4b6a4..176e842e43b 100644 --- a/build/sage_bootstrap/package.py +++ b/build/sage_bootstrap/package.py @@ -4,7 +4,9 @@ """ # **************************************************************************** -# Copyright (C) 2015 Volker Braun +# Copyright (C) 2015-2016 Volker Braun +# 2018 Jeroen Demeyer +# 2020-2024 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -25,6 +27,33 @@ class Package(object): + def __new__(cls, package_name): + if package_name.startswith("pypi/") or package_name.startswith("generic/"): + package_name = "pkg:" + package_name + if package_name.startswith("pkg:"): + package_name = package_name.replace('_', '-') + if package_name.startswith("pkg:generic/"): # fast path + try: + pkg = cls(package_name[len("pkg:generic/"):].replace('-', '_')) + if pkg.purl == package_name: + return pkg # assume unique + except Exception: + pass + elif package_name.startswith("pkg:pypi/"): # fast path + try: + pkg = cls(package_name[len("pkg:pypi/"):].replace('-', '_')) + if pkg.purl == package_name: + return pkg # assume unique + except Exception: + pass + for pkg in cls.all(): + if pkg.purl == package_name: + return pkg # assume unique + raise ValueError('no package for PURL {0}'.format(package_name)) + self = object.__new__(cls) + self.__init__(package_name) + return self + def __init__(self, package_name): """ Sage Package @@ -41,12 +70,22 @@ def __init__(self, package_name): -- ``package_name`` -- string. Name of the package. The Sage convention is that all package names are lower case. """ + if any(package_name.startswith(prefix) + for prefix in ["pkg:", "pypi/", "generic"]): + # Already initialized + return + if package_name != package_name.lower(): + raise ValueError('package names should be lowercase, got {0}'.format(package_name)) + if '-' in package_name: + raise ValueError('package names use underscores, not dashes, got {0}'.format(package_name)) + self.__name = package_name self.__tarball = None self._init_checksum() self._init_version() self._init_type() self._init_version_requirements() + self._init_requirements() self._init_dependencies() self._init_trees() @@ -323,7 +362,7 @@ def source(self): """ Return the package source type """ - if self.has_file('requirements.txt'): + if self.__requirements is not None: return 'pip' if self.tarball_filename: if self.tarball_filename.endswith('.whl'): @@ -346,15 +385,39 @@ def trees(self): return self.__trees if self.__version_requirements is not None: return 'SAGE_VENV' - if self.has_file('requirements.txt'): + if self.__requirements is not None: return 'SAGE_VENV' return 'SAGE_LOCAL' + @property + def purl(self): + """ + Return a PURL (Package URL) for the package + + OUTPUT: + + A string in the format ``SCHEME:TYPE/NAMESPACE/NAME``, + i.e., without components for version, qualifiers, and subpath. + See https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#package-url-specification-v10x + for details + """ + dist = self.distribution_name + if dist: + return 'pkg:pypi/' + dist.lower().replace('_', '-') + return 'pkg:generic/' + self.name.replace('_', '-') + @property def distribution_name(self): """ Return the Python distribution name or ``None`` for non-Python packages """ + if self.__requirements is not None: + for line in self.__requirements.split('\n'): + line = line.strip() + if line.startswith('#'): + continue + for part in line.split(): + return part if self.__version_requirements is None: return None for line in self.__version_requirements.split('\n'): @@ -420,7 +483,7 @@ def all(cls): continue try: yield cls(subdir) - except BaseException: + except Exception: log.error('Failed to open %s', subdir) raise @@ -517,6 +580,13 @@ def _init_version_requirements(self): except IOError: self.__version_requirements = None + def _init_requirements(self): + try: + with open(os.path.join(self.path, 'requirements.txt')) as f: + self.__requirements = f.read().strip() + except IOError: + self.__requirements = None + def _init_dependencies(self): try: with open(os.path.join(self.path, 'dependencies')) as f: diff --git a/build/sage_bootstrap/pypi.py b/build/sage_bootstrap/pypi.py index e3ca4e560c7..9427e9c8808 100644 --- a/build/sage_bootstrap/pypi.py +++ b/build/sage_bootstrap/pypi.py @@ -5,7 +5,8 @@ # **************************************************************************** -# Copyright (C) 2016 Volker Braun +# Copyright (C) 2016 Volker Braun +# 2020-2023 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -106,6 +107,13 @@ def summary(self): """ return self.json['info']['summary'] + @property + def requires_dist(self): + """ + Return the dependencies + """ + return self.json['info']['requires_dist'] + def update(self, package=None): if package is None: package = Package(self.name) diff --git a/build/sage_bootstrap/tarball.py b/build/sage_bootstrap/tarball.py index 441d97cb3c6..c974d8ac310 100644 --- a/build/sage_bootstrap/tarball.py +++ b/build/sage_bootstrap/tarball.py @@ -4,7 +4,9 @@ """ # **************************************************************************** -# Copyright (C) 2015 Volker Braun +# Copyright (C) 2014-2015 Volker Braun +# 2017 Jeroen Demeyer +# 2020 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/build/sage_bootstrap/uninstall.py b/build/sage_bootstrap/uninstall.py index 08ce337386d..25496246ac7 100644 --- a/build/sage_bootstrap/uninstall.py +++ b/build/sage_bootstrap/uninstall.py @@ -24,7 +24,9 @@ are also removed. """ # **************************************************************************** -# Copyright (C) 2017 Erik M. Bray +# Copyright (C) 2017-2018 Erik M. Bray +# 2019 Jeroen Demeyer +# 2021-2022 Matthias Koeppe # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/src/doc/en/developer/packaging.rst b/src/doc/en/developer/packaging.rst index 83720282c35..6a778c3a71a 100644 --- a/src/doc/en/developer/packaging.rst +++ b/src/doc/en/developer/packaging.rst @@ -14,7 +14,7 @@ The installation of packages is done through a bash script located in :sage_root:`build/bin/sage-spkg`. This script is typically invoked by giving the command:: - [alice@localhost sage]$ sage -i ... + [alice@localhost sage]$ ./sage -i ... options can be: @@ -1049,10 +1049,10 @@ Creating packages Assuming that you have downloaded ``$SAGE_ROOT/upstream/FoO-1.3.tar.gz``, you can use:: - [alice@localhost sage]$ sage --package create foo \ - --version 1.3 \ - --tarball FoO-VERSION.tar.gz \ - --type experimental + [alice@localhost sage]$ ./sage --package create foo \ + --version 1.3 \ + --tarball FoO-VERSION.tar.gz \ + --type experimental to create ``$SAGE_ROOT/build/pkgs/foo/package-version.txt``, ``checksums.ini``, and ``type`` in one step. @@ -1061,36 +1061,47 @@ You can skip the manual downloading of the upstream tarball by using the additional argument ``--upstream-url``. This command will also set the ``upstream_url`` field in ``checksums.ini`` described above. -For Python packages available from PyPI, you can use:: +For Python packages available from PyPI, use a PURL (Package URL, +see `PEP 725 `_):: - [alice@localhost sage]$ sage --package create scikit_spatial --pypi \ - --type optional + [alice@localhost sage]$ ./sage --package create pkg:pypi/scikit-spatial \ + --type optional -This automatically downloads the most recent version from PyPI and also -obtains most of the necessary information by querying PyPI. In particular, -the ``SPKG.rst`` file is created as a copy of the package's README file. +An equivalent command uses the SPKG name of the new package:: + [alice@localhost sage]$ ./sage --package create scikit_spatial --pypi \ + --type optional -The ``dependencies`` file may need editing (watch out for warnings regarding -``--no-deps`` that Sage issues during installation of the package!). +Either of these two commands automatically downloads the most recent version +from PyPI and also obtains most of the necessary information by querying PyPI. +In particular, the ``SPKG.rst`` file is created as a copy of the package's +README file. + +By default, when the package is available as a platform-independent +wheel, the ``sage --package`` creates a ``wheel`` package. In this case, +the ``dependencies`` file is automatically generated from the information +on PyPI, but may still need some manual editing. + +For ``normal`` and ``pip`` packages, the ``dependencies`` file is initialized +to the bare minimum and will need manual editing. (Watch out for warnings +regarding ``--no-deps`` that Sage issues during installation of the package!) Also you may want to set lower and upper bounds for acceptable package versions in the file ``version_requirements.txt``. (Make sure that the version in ``package-version.txt`` falls within this acceptable version range!) -By default, when the package is available as a platform-independent -wheel, the ``sage --package`` creates a wheel package. To create a normal package -instead (for example, when the package requires patching), you can use:: +To create a ``normal`` package instead of a ``wheel`` package (for example, when the +package requires patching), you can use:: - [alice@localhost sage]$ sage --package create scikit_spatial --pypi \ - --source normal \ - --type optional + [alice@localhost sage]$ ./sage --package create pkg:pypi/scikit-spatial \ + --source normal \ + --type optional -To create a pip package rather than a normal or wheel package, you can use:: +To create a ``pip`` package rather than a ``normal`` or ``wheel`` package, you can use:: - [alice@localhost sage]$ sage --package create scikit_spatial --pypi \ - --source pip \ - --type optional + [alice@localhost sage]$ ./sage --package create pkg:pypi/scikit-spatial \ + --source pip \ + --type optional When the package already exists, ``sage --package create`` overwrites it. @@ -1101,14 +1112,14 @@ Updating packages to a new version A package that has the ``upstream_url`` information can be updated by simply typing:: - [alice@localhost sage]$ sage --package update numpy 3.14.59 + [alice@localhost sage]$ ./sage --package update openblas 0.3.79 which will automatically download the archive and update the -information in ``build/pkgs/numpy/``. +information in ``build/pkgs/openblas/``. For Python packages available from PyPI, there is another shortcut:: - [alice@localhost sage]$ sage --package update-latest matplotlib + [alice@localhost sage]$ ./sage --package update-latest pkg:pypi/matplotlib Updating matplotlib: 3.3.0 -> 3.3.1 Downloading tarball to ...matplotlib-3.3.1.tar.bz2 [...............................................................] @@ -1122,10 +1133,10 @@ version range! If you pass the switch ``--commit``, the script will run ``git commit`` for you. -If you prefer to make update a package ``foo`` by making manual +If you prefer to update a package ``foo`` by making manual changes to the files in ``build/pkgs/foo``, you will need to run:: - [alice@localhost sage]$ sage --package fix-checksum foo + [alice@localhost sage]$ ./sage --package fix-checksum foo which will modify the ``checksums.ini`` file with the correct checksums. @@ -1138,7 +1149,7 @@ The command ``sage --package metrics`` computes machine-readable aggregated metrics for all packages in the Sage distribution or a given list of packages:: - [alice@localhost sage]$ sage --package metrics + [alice@localhost sage]$ ./sage --package metrics has_file_distros_arch_txt=181 has_file_distros_conda_txt=289 has_file_distros_debian_txt=172 @@ -1212,20 +1223,20 @@ Sage (``FoO-1.3.tar.gz`` in the example of section Now you can install the package using:: - [alice@localhost sage]$ sage -i package_name + [alice@localhost sage]$ ./sage -i package_name or:: - [alice@localhost sage]$ sage -f package_name + [alice@localhost sage]$ ./sage -f package_name to force a reinstallation. If your package contains a ``spkg-check`` script (see :ref:`section-spkg-check`) it can be run with:: - [alice@localhost sage]$ sage -i -c package_name + [alice@localhost sage]$ ./sage -i -c package_name or:: - [alice@localhost sage]$ sage -f -c package_name + [alice@localhost sage]$ ./sage -f -c package_name If all went fine, open a PR with the code under :sage_root:`build/pkgs`.