From 2435a4a09837ef0e91e2fa1cc6869498311525cc Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 31 Mar 2021 18:49:27 -0500 Subject: [PATCH 1/9] Build: allow to install packages with apt --- docs/config-file/v2.rst | 24 ++++++- readthedocs/config/config.py | 66 +++++++++++++++-- readthedocs/config/models.py | 2 +- readthedocs/config/tests/test_config.py | 51 +++++++++++++ readthedocs/projects/tasks.py | 18 ++++- .../rtd_tests/fixtures/spec/v2/schema.yml | 4 ++ readthedocs/rtd_tests/tests/test_celery.py | 71 ++++++++++++++++++- readthedocs/settings/base.py | 1 + 8 files changed, 227 insertions(+), 10 deletions(-) diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index 59f96896b44..262242e88e9 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -64,7 +64,7 @@ This is to avoid typos and provide feedback on invalid configurations. .. contents:: :local: - :depth: 1 + :depth: 3 version ~~~~~~~ @@ -298,6 +298,9 @@ Configuration for the documentation build process. build: image: latest + apt_packages: + - mysql-client + - cmatrix python: version: 3.7 @@ -318,6 +321,25 @@ as defined here: * `stable `_: :buildpyversions:`stable` * `latest `_: :buildpyversions:`latest` +build.apt_packages +`````````````````` + +List of `APT packages`_ to install. + +.. _APT packages: https://packages.ubuntu.com/ + +:Type: ``list`` +:Default: ``[]`` + +.. code-block:: yaml + + version: 2 + + build: + apt_packages: + - mysql-client + - cmatrix + sphinx ~~~~~~ diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 9166d171fc8..126fd156b0e 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -64,6 +64,7 @@ SUBMODULES_INVALID = 'submodules-invalid' INVALID_KEYS_COMBINATION = 'invalid-keys-combination' INVALID_KEY = 'invalid-key' +INVALID_NAME = 'invalid-name' LATEST_CONFIGURATION_VERSION = 2 @@ -124,7 +125,9 @@ def _get_display_key(self): # Checks for patterns similar to `python.install.0.requirements` # if matched change to `python.install[0].requirements` using backreference. return re.sub( - r'^(python\.install)(\.)(\d+)(\.\w+)$', r'\1[\3]\4', self.key + r'^([a-zA-Z_.-]+)\.(\d+)([a-zA-Z_.-]*)$', + r'\1[\2]\3', + self.key ) @@ -622,7 +625,10 @@ def conda(self): @property def build(self): """The docker image used by the builders.""" - return Build(**self._config['build']) + return Build( + apt_packages=[], + **self._config['build'], + ) @property def doctype(self): @@ -745,12 +751,60 @@ def validate_build(self): ), ) - # Allow to override specific project - config_image = self.defaults.get('build_image') - if config_image: - build['image'] = config_image + # Allow to override specific project + config_image = self.defaults.get('build_image') + if config_image: + build['image'] = config_image + + with self.catch_validation_error('build.apt_packages'): + raw_packages = self._raw_config.get('build', {}).get('apt_packages', []) + validate_list(raw_packages) + # Transform to a dict, so is easy to validate individual entries. + self._raw_config.setdefault('build', {})['apt_packages'] = ( + list_to_dict(raw_packages) + ) + + build['apt_packages'] = [ + self.validate_apt_package(index) + for index in range(len(raw_packages)) + ] + if not raw_packages: + self.pop_config('build.apt_packages') + return build + def validate_apt_package(self, index): + """ + Validate the package name to avoid injections of extra options. + + Packages names can be a regex pattern. + We just validate that they aren't interpreted as an option or file. + """ + key = f'build.apt_packages.{index}' + package = self.pop_config(key) + with self.catch_validation_error(key): + validate_string(package) + package = package.strip() + invalid_starts = [ + # Don't allow to inject extra options. + '-', + '\\', + # Don't allow to install from a path. + '/', + '.', + ] + for start in invalid_starts: + if package.startswith(start): + self.error( + key=key, + message=( + 'Invalid package name. ' + f'Package can\'t start with {start}', + ), + code=INVALID_NAME, + ) + return package + def validate_python(self): """ Validates the python key. diff --git a/readthedocs/config/models.py b/readthedocs/config/models.py index 998da8498be..5fd09187d04 100644 --- a/readthedocs/config/models.py +++ b/readthedocs/config/models.py @@ -28,7 +28,7 @@ def as_dict(self): class Build(Base): - __slots__ = ('image',) + __slots__ = ('image', 'apt_packages') class Python(Base): diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index a2834c78305..63ed174f407 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -26,6 +26,7 @@ CONFIG_REQUIRED, CONFIG_SYNTAX_INVALID, INVALID_KEY, + INVALID_NAME, PYTHON_INVALID, VERSION_INVALID, ) @@ -748,6 +749,7 @@ def test_as_dict(tmpdir): }, 'build': { 'image': 'readthedocs/build:latest', + 'apt_packages': [], }, 'conda': None, 'sphinx': { @@ -935,6 +937,54 @@ def test_build_image_check_invalid_type(self, value): build.validate() assert excinfo.value.key == 'build.image' + @pytest.mark.parametrize( + 'value', + [ + [], + ['cmatrix'], + ['mysql', 'cmatrix', 'postgresql-dev'], + ['mysql', 'cmatrix', 'postgresql=1.2.3'], + ], + ) + def test_build_apt_packages_check_valid(self, value): + build = self.get_build_config({'build': {'apt_packages': value}}) + build.validate() + assert build.build.apt_packages == value + + @pytest.mark.parametrize( + 'value', + [3, 'string', {}], + ) + def test_build_apt_packages_invalid_type(self, value): + build = self.get_build_config({'build': {'apt_packages': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'build.apt_packages' + + @pytest.mark.parametrize( + 'error_index, value', + [ + (0, ['/', 'cmatrix']), + (1, ['cmatrix', '-q']), + (1, ['cmatrix', ' -q']), + (1, ['cmatrix', '\\-q']), + (1, ['cmatrix', '--quiet']), + (1, ['cmatrix', ' --quiet']), + (2, ['cmatrix', 'quiet', './package.deb']), + (2, ['cmatrix', 'quiet', ' ./package.deb ']), + (2, ['cmatrix', 'quiet', '/home/user/package.deb']), + (2, ['cmatrix', 'quiet', ' /home/user/package.deb']), + (2, ['cmatrix', 'quiet', '../package.deb']), + (2, ['cmatrix', 'quiet', ' ../package.deb']), + ], + ) + def test_build_apt_packages_invalid_value(self, error_index, value): + build = self.get_build_config({'build': {'apt_packages': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == f'build.apt_packages.{error_index}' + assert excinfo.value.code == INVALID_NAME + @pytest.mark.parametrize('value', [3, [], 'invalid']) def test_python_check_invalid_types(self, value): build = self.get_build_config({'python': value}) @@ -2072,6 +2122,7 @@ def test_as_dict(self, tmpdir): }, 'build': { 'image': 'readthedocs/build:latest', + 'apt_packages': [], }, 'conda': None, 'sphinx': { diff --git a/readthedocs/projects/tasks.py b/readthedocs/projects/tasks.py index 64d52de6432..03495e755c0 100644 --- a/readthedocs/projects/tasks.py +++ b/readthedocs/projects/tasks.py @@ -794,7 +794,7 @@ def run_build(self, record): environment=self.build_env, ) with self.project.repo_nonblockinglock(version=self.version): - self.setup_python_environment() + self.setup_build() # TODO the build object should have an idea of these states, # extend the model to include an idea of these outcomes @@ -1152,6 +1152,10 @@ def update_app_instances( search_ignore=self.config.search.ignore, ) + def setup_build(self): + self.install_system_dependencies() + self.setup_python_environment() + def setup_python_environment(self): """ Build the virtualenv and install the project into it. @@ -1177,6 +1181,18 @@ def setup_python_environment(self): if self.project.has_feature(Feature.LIST_PACKAGES_INSTALLED_ENV): self.python_env.list_packages_installed() + def install_system_dependencies(self): + packages = self.config.build.apt_packages + if packages: + self.build_env.run( + 'apt-get', 'update', '-y', '-q', + user=settings.RTD_BUILD_SUPER_USER, + ) + self.build_env.run( + 'apt-get', 'install', '-y', '-q', *packages, + user=settings.RTD_BUILD_SUPER_USER, + ) + def build_docs(self): """ Wrapper to all build functions. diff --git a/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml b/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml index f88db8025d1..70907d34e14 100644 --- a/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml +++ b/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml @@ -50,6 +50,10 @@ build: # Note: it can be overridden by a project image: enum('stable', 'latest', required=False) + # List of packages to be installed with apt-get + # Default: [] + apt_packages: list(str(), required=False) + python: # The Python version (this depends on the build image) # Default: '3' diff --git a/readthedocs/rtd_tests/tests/test_celery.py b/readthedocs/rtd_tests/tests/test_celery.py index 6f684f3b0d3..ba3229a51fb 100644 --- a/readthedocs/rtd_tests/tests/test_celery.py +++ b/readthedocs/rtd_tests/tests/test_celery.py @@ -2,6 +2,7 @@ import shutil from os.path import exists from tempfile import mkdtemp +from unittest import mock from unittest.mock import MagicMock, patch from allauth.socialaccount.models import SocialAccount @@ -17,7 +18,11 @@ LATEST, ) from readthedocs.builds.models import Build, Version -from readthedocs.doc_builder.environments import LocalBuildEnvironment +from readthedocs.config.config import BuildConfigV2 +from readthedocs.doc_builder.environments import ( + BuildEnvironment, + LocalBuildEnvironment, +) from readthedocs.doc_builder.exceptions import VersionLockedError from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation from readthedocs.projects import tasks @@ -469,3 +474,67 @@ def test_send_build_status_no_remote_repo_or_social_account_gitlab(self, send_bu send_build_status.assert_not_called() self.assertEqual(Message.objects.filter(user=self.eric).count(), 1) + + @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_python_environment', new=MagicMock) + @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.build_docs', new=MagicMock) + @patch('readthedocs.doc_builder.environments.BuildEnvironment.update_build', new=MagicMock) + @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_vcs', new=MagicMock) + @patch.object(BuildEnvironment, 'run') + @patch('readthedocs.doc_builder.config.load_config') + def test_install_apt_packages(self, load_config, run): + config = BuildConfigV2( + {}, + { + 'version': 2, + 'build': { + 'apt_packages': [ + 'clangd', + 'cmatrix', + ], + }, + }, + source_file='readthedocs.yml', + ) + config.validate() + load_config.return_value = config + + version = self.project.versions.first() + build = get( + Build, + project=self.project, + version=version, + ) + with mock_api(self.repo): + result = tasks.update_docs_task.delay( + version.pk, + build_pk=build.pk, + record=False, + intersphinx=False, + ) + self.assertTrue(result.successful()) + + self.assertEqual(run.call_count, 2) + apt_update = run.call_args_list[0] + apt_install = run.call_args_list[1] + self.assertEqual( + apt_update, + mock.call( + 'apt-get', + 'update', + '-y', + '-q', + user='root:root', + ) + ) + self.assertEqual( + apt_install, + mock.call( + 'apt-get', + 'install', + '-y', + '-q', + 'clangd', + 'cmatrix', + user='root:root', + ) + ) diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index 31c921a7361..f0e0269cb59 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -433,6 +433,7 @@ def TEMPLATES(self): # instance to avoid file permissions issues. # https://docs.docker.com/engine/reference/run/#user RTD_DOCKER_USER = 'docs:docs' + RTD_BUILD_SUPER_USER = 'root:root' RTD_DOCKER_COMPOSE = False From d3558272c33b701444aee566c8cc02114b62e2d1 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 6 Apr 2021 12:43:21 -0500 Subject: [PATCH 2/9] Apply suggestions from code review Co-authored-by: Eric Holscher <25510+ericholscher@users.noreply.github.com> --- docs/config-file/v2.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index 262242e88e9..c1225566353 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -325,6 +325,8 @@ build.apt_packages `````````````````` List of `APT packages`_ to install. +Our build servers run Ubuntu, with the default set of package repositories installed. +We don't currently support PPA's or other custom repositories. .. _APT packages: https://packages.ubuntu.com/ From f6116fc4f9efb7622832c16267f213ba8499bfc7 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 6 Apr 2021 14:48:11 -0500 Subject: [PATCH 3/9] Updates from review --- docs/config-file/v2.rst | 9 ++++-- docs/guides/reproducible-builds.rst | 2 +- readthedocs/config/config.py | 37 +++++++++++++++------- readthedocs/config/models.py | 3 ++ readthedocs/config/tests/test_config.py | 7 ++-- readthedocs/projects/tasks.py | 16 ++++++++-- readthedocs/rtd_tests/tests/test_celery.py | 9 +++--- 7 files changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index c1225566353..d737cd0437a 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -325,8 +325,8 @@ build.apt_packages `````````````````` List of `APT packages`_ to install. -Our build servers run Ubuntu, with the default set of package repositories installed. -We don't currently support PPA's or other custom repositories. +Our build servers run Ubuntu 18.04, with the default set of package repositories installed. +We don't currently support PPA's or other custom repositories. .. _APT packages: https://packages.ubuntu.com/ @@ -342,6 +342,11 @@ We don't currently support PPA's or other custom repositories. - mysql-client - cmatrix +.. note:: + + When possible avoid installing Python packages using apt (``python3-numpy`` for example), + :ref:`use pip or Conda instead `. + sphinx ~~~~~~ diff --git a/docs/guides/reproducible-builds.rst b/docs/guides/reproducible-builds.rst index 485ec1ec2a1..0e0ed854b2f 100644 --- a/docs/guides/reproducible-builds.rst +++ b/docs/guides/reproducible-builds.rst @@ -173,6 +173,6 @@ or our Conda docs about :ref:`environment files Date: Tue, 6 Apr 2021 15:01:12 -0500 Subject: [PATCH 4/9] Rename setting --- readthedocs/projects/tasks.py | 4 ++-- readthedocs/settings/base.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/readthedocs/projects/tasks.py b/readthedocs/projects/tasks.py index 7cf0cb3dc69..a00ec585ede 100644 --- a/readthedocs/projects/tasks.py +++ b/readthedocs/projects/tasks.py @@ -1197,12 +1197,12 @@ def install_system_dependencies(self): if packages: self.build_env.run( 'apt-get', 'update', '--assume-yes', '--quiet', - user=settings.RTD_BUILD_SUPER_USER, + user=settings.RTD_DOCKER_SUPER_USER, ) # put ``--`` to end all command arguments. self.build_env.run( 'apt-get', 'install', '--assume-yes', '--quiet', '--', *packages, - user=settings.RTD_BUILD_SUPER_USER, + user=settings.RTD_DOCKER_SUPER_USER, ) def build_docs(self): diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index f0e0269cb59..6319da62aa8 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -433,7 +433,7 @@ def TEMPLATES(self): # instance to avoid file permissions issues. # https://docs.docker.com/engine/reference/run/#user RTD_DOCKER_USER = 'docs:docs' - RTD_BUILD_SUPER_USER = 'root:root' + RTD_DOCKER_SUPER_USER = 'root:root' RTD_DOCKER_COMPOSE = False From b443949b1588f135eacc3a7658e9564d2cf73870 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 6 Apr 2021 15:06:00 -0500 Subject: [PATCH 5/9] Reduce mocks --- readthedocs/rtd_tests/tests/test_celery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/readthedocs/rtd_tests/tests/test_celery.py b/readthedocs/rtd_tests/tests/test_celery.py index c24fc580597..b675d81d923 100644 --- a/readthedocs/rtd_tests/tests/test_celery.py +++ b/readthedocs/rtd_tests/tests/test_celery.py @@ -477,7 +477,6 @@ def test_send_build_status_no_remote_repo_or_social_account_gitlab(self, send_bu @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_python_environment', new=MagicMock) @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.build_docs', new=MagicMock) - @patch('readthedocs.doc_builder.environments.BuildEnvironment.update_build', new=MagicMock) @patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_vcs', new=MagicMock) @patch.object(BuildEnvironment, 'run') @patch('readthedocs.doc_builder.config.load_config') From e5923a1e7a58cd321059b579eb81a9ce7439fb20 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 6 Apr 2021 15:13:36 -0500 Subject: [PATCH 6/9] Linter --- readthedocs/config/config.py | 2 +- readthedocs/config/models.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index c981ec1b49c..2074308efda 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -785,7 +785,7 @@ def validate_apt_package(self, index): We validate that they aren't interpreted as an option or file. See https://manpages.ubuntu.com/manpages/xenial/man8/apt-get.8.html - and https://www.debian.org/doc/manuals/debian-reference/ch02.en.html#_debian_package_file_names + and https://www.debian.org/doc/manuals/debian-reference/ch02.en.html#_debian_package_file_names # noqa for allowed chars in packages names. """ key = f'build.apt_packages.{index}' diff --git a/readthedocs/config/models.py b/readthedocs/config/models.py index 68c5120c667..714f9b80328 100644 --- a/readthedocs/config/models.py +++ b/readthedocs/config/models.py @@ -30,8 +30,9 @@ class Build(Base): __slots__ = ('image', 'apt_packages') - def __init__(self, apt_packages=[], **kwargs): - super().__init__(apt_packages=apt_packages, **kwargs) + def __init__(self, **kwargs): + kwargs.setdefault('apt_packages', []) + super().__init__(**kwargs) class Python(Base): From 5f8874bcefbfc5423d6205c9341b622258773736 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Apr 2021 18:30:50 -0500 Subject: [PATCH 7/9] Keep it simple for now, don't allow versions/distributions --- readthedocs/config/config.py | 7 ++----- readthedocs/config/tests/test_config.py | 9 +++++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 2074308efda..5b8793154f9 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -780,10 +780,7 @@ def validate_apt_package(self, index): """ Validate the package name to avoid injections of extra options. - Packages names can contain a regex pattern, - or use the ``package=version``/``package/distribution`` syntax. We validate that they aren't interpreted as an option or file. - See https://manpages.ubuntu.com/manpages/xenial/man8/apt-get.8.html and https://www.debian.org/doc/manuals/debian-reference/ch02.en.html#_debian_package_file_names # noqa for allowed chars in packages names. @@ -810,8 +807,8 @@ def validate_apt_package(self, index): ), code=INVALID_NAME, ) - # List of valid chars in packages names + regex chars + separators. - pattern = re.compile(r'^[a-zA-Z0-9^]+[a-zA-Z0-9.+?$*/=-]*$') + # List of valid chars in packages names. + pattern = re.compile(r'^[a-zA-Z0-9]+[a-zA-Z0-9.+-]*$') if not pattern.match(package): self.error( key=key, diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index cf553edf0e5..c4bbe6dc069 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -943,8 +943,6 @@ def test_build_image_check_invalid_type(self, value): [], ['cmatrix'], ['Mysql', 'cmatrix', 'postgresql-dev'], - ['mysql', 'cmatrix$', 'postgresql=1.2.3'], - ['^mysql-*', 'cmatrix/bionic', 'postgresql=1.2.3'], ], ) def test_build_apt_packages_check_valid(self, value): @@ -979,6 +977,13 @@ def test_build_apt_packages_invalid_type(self, value): (2, ['cmatrix', 'quiet', ' ../package.deb']), (1, ['one', '$two']), (1, ['one', 'non-ascíí']), + # We don't allow regex for now. + (1, ['mysql', 'cmatrix$']), + (0, ['^mysql-*', 'cmatrix$']), + # We don't allow specifying versions for now. + (0, ['postgresql=1.2.3']), + # We don't allow specifying distributions for now. + (0, ['cmatrix/bionic']), ], ) def test_build_apt_packages_invalid_value(self, error_index, value): From ecd0183c9a6e3186fa17ca04b74eb60a866f485e Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 27 Apr 2021 18:35:56 -0500 Subject: [PATCH 8/9] Update examples --- docs/config-file/v2.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index 07abe91f51f..2de0411ae94 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -304,8 +304,8 @@ Configuration for the documentation build process. build: image: latest apt_packages: - - mysql-client - - cmatrix + - libclang + - cmake python: version: 3.7 @@ -344,8 +344,8 @@ We don't currently support PPA's or other custom repositories. build: apt_packages: - - mysql-client - - cmatrix + - libclang + - cmake .. note:: From f735a2dd9132218dbbedf7860e0b2ca107c17932 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 12 May 2021 12:50:30 -0500 Subject: [PATCH 9/9] Simplify regex --- readthedocs/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 5b8793154f9..c1a3c4c8029 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -808,7 +808,7 @@ def validate_apt_package(self, index): code=INVALID_NAME, ) # List of valid chars in packages names. - pattern = re.compile(r'^[a-zA-Z0-9]+[a-zA-Z0-9.+-]*$') + pattern = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9.+-]*$') if not pattern.match(package): self.error( key=key,