From 4fdd194a26c41ff99fbb7f5c48c623cff7353030 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 13 Sep 2025 16:57:22 +0100 Subject: [PATCH 1/3] Start work on supporting Import-Name & Import-Namespace metadata --- flit_core/flit_core/common.py | 10 +++++- flit_core/flit_core/config.py | 53 +++++++++++++++++------------ flit_core/tests_core/test_config.py | 2 ++ 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/flit_core/flit_core/common.py b/flit_core/flit_core/common.py index 393e0f87..b70a0a25 100644 --- a/flit_core/flit_core/common.py +++ b/flit_core/flit_core/common.py @@ -350,8 +350,10 @@ class Metadata: provides_extra = () license_files = () dynamic = () + import_name = () + import_namespace = () - metadata_version = "2.4" + metadata_version = "2.5" def __init__(self, data): data = data.copy() @@ -441,6 +443,12 @@ def write_metadata_file(self, fp): normalised_extra = normalise_core_metadata_name(extra) fp.write(f'Provides-Extra: {normalised_extra}\n') + for name in self.import_name: + fp.write(f'Import-Name: {name}') + + for name in self.import_namespace: + fp.write(f'Import-Namespace: {name}') + if self.description is not None: fp.write(f'\n{self.description}\n') diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index 8cb31562..4166182c 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -33,26 +33,6 @@ class ConfigError(ValueError): 'dev-requires' } -metadata_allowed_fields = { - 'module', - 'author', - 'author-email', - 'maintainer', - 'maintainer-email', - 'home-page', - 'license', - 'keywords', - 'requires-python', - 'dist-name', - 'description-file', - 'requires-extra', -} | metadata_list_fields - -metadata_required_fields = { - 'module', - 'author', -} - pep621_allowed_fields = { 'name', 'version', @@ -72,8 +52,18 @@ class ConfigError(ValueError): 'dependencies', 'optional-dependencies', 'dynamic', + 'import-names', # PEP 794 + 'import-namespaces' +} + +allowed_dynamic_fields = { + 'version', + 'description', + 'import-names', + 'import-namespaces' } + default_license_files_globs = ['COPYING*', 'LICEN[CS]E*', 'NOTICE*', 'AUTHORS*'] license_files_allowed_chars = re.compile(r'^[\w\-\.\/\*\?\[\]]+$') @@ -119,6 +109,15 @@ def prep_toml_config(d, path): if 'name' in module_tbl: loaded_cfg.module = module_tbl['name'] + if 'import-names' not in d['project']: + loaded_cfg.metadata['import_name'] = [loaded_cfg.module] + + if 'import-nameespaces' not in d['project']: + namespace_parts = loaded_cfg.module.split('.')[:-1] + loaded_cfg.metadata['import_namespace'] = [ + '.'.join(namespace_parts[:i]) for i in range(1, len(namespace_parts) + 1) + ] + unknown_sections = set(dtool) - {'module', 'sdist', 'external-data'} unknown_sections = [s for s in unknown_sections if not s.lower().startswith('x-')] if unknown_sections: @@ -577,13 +576,23 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: if reqs_noextra: lc.reqs_by_extra['.none'] = reqs_noextra + if 'import-names' in proj: # PEP 794 + _check_list_of_str(proj, 'import-names') + md_dict['import_name'] = proj['import-names'] + + if 'import-namespaces' in proj: + _check_list_of_str(proj, 'import-namespaces') + md_dict['import_namespace'] = proj['import-namespaces'] + if 'dynamic' in proj: _check_list_of_str(proj, 'dynamic') dynamic = set(proj['dynamic']) - unrec_dynamic = dynamic - {'version', 'description'} + unrec_dynamic = dynamic - allowed_dynamic_fields if unrec_dynamic: raise ConfigError( - "flit only supports dynamic metadata for 'version' & 'description'" + "flit only supports dynamic metadata for:" + ', '.join( + sorted(allowed_dynamic_fields) + ) ) if dynamic.intersection(proj): raise ConfigError( diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index 9c7f4f42..786420fb 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -16,6 +16,8 @@ def test_load_toml(): def test_load_toml_ns(): inf = config.read_flit_config(samples_dir / 'ns1-pkg' / 'pyproject.toml') assert inf.module == 'ns1.pkg' + assert inf.metadata['import_name'] == ['ns1.pkg'] + assert inf.metadata['import_namespace'] == ['ns1'] def test_load_normalization(): inf = config.read_flit_config(samples_dir / 'normalization' / 'pyproject.toml') From 9d508ba9d54b65f4baff3357d0d68c14367dda20 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 13 Sep 2025 17:58:03 +0100 Subject: [PATCH 2/3] More checks for manually specified import-names & import-namespaces --- flit_core/flit_core/config.py | 56 +++++++++++++++++++++++++---- flit_core/tests_core/test_config.py | 13 +++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index 4166182c..b5ec1813 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -109,14 +109,33 @@ def prep_toml_config(d, path): if 'name' in module_tbl: loaded_cfg.module = module_tbl['name'] - if 'import-names' not in d['project']: + if 'import-names' in d['project']: + import_names_from_config = [ + s.split(';')[0] for s in loaded_cfg.metadata['import_name'] + ] + if import_names_from_config != [loaded_cfg.module]: + raise ConfigError( + f"Specified import-names {import_names_from_config} do not match " + f"the module present ({loaded_cfg.module})" + ) + else: loaded_cfg.metadata['import_name'] = [loaded_cfg.module] - if 'import-nameespaces' not in d['project']: - namespace_parts = loaded_cfg.module.split('.')[:-1] - loaded_cfg.metadata['import_namespace'] = [ - '.'.join(namespace_parts[:i]) for i in range(1, len(namespace_parts) + 1) + namespace_parts = loaded_cfg.module.split('.')[:-1] + nspkgs_from_mod_name = [ + '.'.join(namespace_parts[:i]) for i in range(1, len(namespace_parts) + 1) + ] + if 'import-namespaces' in d['project']: + nspkgs_from_config = [ + s.split(';')[0] for s in loaded_cfg.metadata['import_namespace'] ] + if set(nspkgs_from_config) != set(nspkgs_from_mod_name): + raise ConfigError( + f"Specified import-namespaces {nspkgs_from_config} do not match " + f"the namespace packages present ({nspkgs_from_mod_name})" + ) + else: + loaded_cfg.metadata['import_namespace'] = nspkgs_from_mod_name unknown_sections = set(dtool) - {'module', 'sdist', 'external-data'} unknown_sections = [s for s in unknown_sections if not s.lower().startswith('x-')] @@ -330,6 +349,25 @@ def normalize_pkg_name(name: str) -> str: return name.replace('-','_') +def normalize_import_name(name: str) -> str: + if ';' in name: + name, annotation = name.split(';', 1) + name = name.rstrip() + annotation = annotation.lstrip() + if annotation != 'private': + raise ConfigError( + f"{annotation!r} for import name {name!r} is not allowed " + "(the only valid annotation is 'private')" + ) + else: + annotation = None + + if not all(p.isidentifier() for p in name.split('.')): + raise ConfigError(f"{name!r} is not a valid import name") + + return f"{name}; {annotation}" if annotation else name + + def read_pep621_metadata(proj, path) -> LoadedConfig: lc = LoadedConfig() md_dict = lc.metadata @@ -578,11 +616,15 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: if 'import-names' in proj: # PEP 794 _check_list_of_str(proj, 'import-names') - md_dict['import_name'] = proj['import-names'] + md_dict['import_name'] = [ + normalize_import_name(s) for s in proj['import-names'] + ] if 'import-namespaces' in proj: _check_list_of_str(proj, 'import-namespaces') - md_dict['import_namespace'] = proj['import-namespaces'] + md_dict['import_namespace'] = [ + normalize_import_name(s) for s in proj['import-namespaces'] + ] if 'dynamic' in proj: _check_list_of_str(proj, 'dynamic') diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index 786420fb..eaf6dd3a 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -390,6 +390,19 @@ def test_pep621_license_files(proj_license_files, files): assert info.metadata['license_files'] == files +@pytest.mark.parametrize(('proj_extra', 'err_match'), [ + ({"import-names": ["foo-bar"]}, "not a valid"), + ({"import-names": ["foobar; public"]}, "private"), + ({"import-names": ["module2"]}, "not match"), + ({"import-namespaces": ["namespace1"]}, "not match"), +]) +def test_import_names_errors(proj_extra, err_match): + d = {'project': {'name': 'module1', 'version': '1.0', 'description': 'x'}} + d['project'].update(proj_extra) + with pytest.raises(config.ConfigError, match=err_match): + config.prep_toml_config(d, samples_dir) + + def test_old_style_metadata(): with pytest.raises(config.ConfigError, match=re.escape("[tool.flit.metadata]")): config.read_flit_config(samples_dir / 'module1-old-metadata.toml') From d9176a28ef93b3cf196f6e46f5c07f68790c2457 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 13 Sep 2025 18:04:54 +0100 Subject: [PATCH 3/3] Document import-names & import-namespaces in config --- doc/pyproject_toml.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/pyproject_toml.rst b/doc/pyproject_toml.rst index 06bc4bb1..d663e690 100644 --- a/doc/pyproject_toml.rst +++ b/doc/pyproject_toml.rst @@ -29,6 +29,7 @@ Version constraints: - For now, all packages should specify ``<5``, so they won't be impacted by changes in the next major version. +- ``import-names`` and ``import-namespaces`` require ``flit_core >=4``. - ``license-files`` and license expressions in the ``license`` field require ``flit_core >=3.11``. - :ref:`pyproject_toml_project` requires ``flit_core >=3.2``. @@ -113,6 +114,15 @@ classifiers A list of `Trove classifiers `_. Add ``Private :: Do Not Upload`` into the list to prevent a private package from being uploaded to PyPI by accident. +import-names + A list containing the importable module name in this package. You don't + normally need to supply this manually, but you can specify it with a + ``; private`` suffix to record that the module is not intended for public use. + This does not stop anyone importing it. +import-namespaces + A list of import names in this package which are namespace packages. Like + ``import-names``, Flit will supply this metadata automatically if you use + namespace packages. dependencies & optional-dependencies See :ref:`pyproject_project_dependencies`. urls