diff --git a/flit_core/flit_core/common.py b/flit_core/flit_core/common.py index 8bcda3fb..5385c9c0 100644 --- a/flit_core/flit_core/common.py +++ b/flit_core/flit_core/common.py @@ -370,10 +370,6 @@ def __init__(self, data): def _normalise_field_name(self, n): return n.lower().replace('-', '_') - def _normalise_core_metadata_name(self, name): - # Normalized Names (PEP 503) - return re.sub(r"[-_.]+", "-", name).lower() - def _extract_extras(self, req): match = re.search(r'\[([^]]*)\]', req) if match: @@ -385,7 +381,7 @@ def _extract_extras(self, req): def _normalise_requires_dist(self, req): extras = self._extract_extras(req) if extras: - normalised_extras = [self._normalise_core_metadata_name(extra) for extra in extras] + normalised_extras = [normalise_core_metadata_name(extra) for extra in extras] normalised_extras_str = ', '.join(normalised_extras) normalised_req = re.sub(r'\[([^]]*)\]', f"[{normalised_extras_str}]", req) return normalised_req @@ -437,7 +433,7 @@ def write_metadata_file(self, fp): fp.write(u'Project-URL: {}\n'.format(url)) for extra in self.provides_extra: - normalised_extra = self._normalise_core_metadata_name(extra) + normalised_extra = normalise_core_metadata_name(extra) fp.write(u'Provides-Extra: {}\n'.format(normalised_extra)) if self.description is not None: @@ -459,6 +455,10 @@ def make_metadata(module, ini_info): return Metadata(md_dict) +def normalise_core_metadata_name(name): + """Normalise a project or extra name (as in PEP 503, also PEP 685)""" + return re.sub(r"[-_.]+", "-", name).lower() + def normalize_dist_name(name: str, version: str) -> str: """Normalizes a name and a PEP 440 version diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index 12929561..a84639d0 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -17,6 +17,7 @@ except ImportError: import tomli as tomllib +from .common import normalise_core_metadata_name from .versionno import normalise_version log = logging.getLogger(__name__) @@ -381,7 +382,31 @@ def _prep_metadata(md_sect, path): # Move dev-requires into requires-extra reqs_noextra = md_dict.pop('requires_dist', []) - res.reqs_by_extra = md_dict.pop('requires_extra', {}) + + reqs_extra = md_dict.pop('requires_extra', {}) + extra_names_by_normed = {} + for e, reqs in reqs_extra.items(): + if not all(isinstance(a, str) for a in reqs): + raise ConfigError( + f'Expected a string list for requires-extra group {e}' + ) + if not name_is_valid(e): + raise ConfigError( + f'requires-extra group name {e!r} is not valid' + ) + enorm = normalise_core_metadata_name(e) + extra_names_by_normed.setdefault(enorm, set()).add(e) + res.reqs_by_extra[enorm] = reqs + + clashing_extra_names = [ + g for g in extra_names_by_normed.values() if len(g) > 1 + ] + if clashing_extra_names: + fmted = ['/'.join(sorted(g)) for g in clashing_extra_names] + raise ConfigError( + f"requires-extra group names clash: {'; '.join(fmted)}" + ) + dev_requires = md_dict.pop('dev_requires', None) if dev_requires is not None: if 'dev' in res.reqs_by_extra: @@ -434,6 +459,8 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: if 'name' not in proj: raise ConfigError('name must be specified in [project] table') _check_type(proj, 'name', str) + if not name_is_valid(proj['name']): + raise ConfigError(f"name {proj['name']} is not valid") md_dict['name'] = proj['name'] lc.module = md_dict['name'].replace('-', '_') @@ -592,13 +619,29 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: raise ConfigError( 'Expected a dict of lists in optional-dependencies field' ) + extra_names_by_normed = {} for e, reqs in optdeps.items(): if not all(isinstance(a, str) for a in reqs): raise ConfigError( 'Expected a string list for optional-dependencies ({})'.format(e) ) + if not name_is_valid(e): + raise ConfigError( + f'optional-dependencies group name {e!r} is not valid' + ) + enorm = normalise_core_metadata_name(e) + extra_names_by_normed.setdefault(enorm, set()).add(e) + lc.reqs_by_extra[enorm] = reqs + + clashing_extra_names = [ + g for g in extra_names_by_normed.values() if len(g) > 1 + ] + if clashing_extra_names: + fmted = ['/'.join(sorted(g)) for g in clashing_extra_names] + raise ConfigError( + f"optional-dependencies group names clash: {'; '.join(fmted)}" + ) - lc.reqs_by_extra = optdeps.copy() md_dict['provides_extra'] = sorted(lc.reqs_by_extra.keys()) md_dict['requires_dist'] = \ @@ -633,6 +676,13 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: return lc + +def name_is_valid(name) -> bool: + return bool(re.match( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", name, re.IGNORECASE + )) + + def pep621_people(people, group_name='author') -> dict: """Convert authors/maintainers from PEP 621 to core metadata fields""" names, emails = [], [] diff --git a/flit_core/tests_core/samples/extras-newstyle.toml b/flit_core/tests_core/samples/extras-newstyle.toml new file mode 100644 index 00000000..43c85ce4 --- /dev/null +++ b/flit_core/tests_core/samples/extras-newstyle.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["flit_core >=2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "module1" +version = "0.1" +description = "Example for testing" +dependencies = ["toml"] + +[project.optional-dependencies] +test = ["pytest"] +cus__Tom = ["requests"] # To test normalisation diff --git a/flit_core/tests_core/samples/extras.toml b/flit_core/tests_core/samples/extras.toml index afdb2214..0b8be7da 100644 --- a/flit_core/tests_core/samples/extras.toml +++ b/flit_core/tests_core/samples/extras.toml @@ -12,4 +12,4 @@ requires = ["toml"] [tool.flit.metadata.requires-extra] test = ["pytest"] -custom = ["requests"] +cus__Tom = ["requests"] # To test normalisation diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index eafb7e99..7d9e2c86 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -81,9 +81,20 @@ def test_extras(): assert requires_dist == { 'toml', 'pytest ; extra == "test"', - 'requests ; extra == "custom"', + 'requests ; extra == "cus-tom"', } - assert set(info.metadata['provides_extra']) == {'test', 'custom'} + assert set(info.metadata['provides_extra']) == {'test', 'cus-tom'} + +def test_extras_newstyle(): + # As above, but with new-style [project] table + info = config.read_flit_config(samples_dir / 'extras-newstyle.toml') + requires_dist = set(info.metadata['requires_dist']) + assert requires_dist == { + 'toml', + 'pytest ; extra == "test"', + 'requests ; extra == "cus-tom"', + } + assert set(info.metadata['provides_extra']) == {'test', 'cus-tom'} def test_extras_dev_conflict(): with pytest.raises(config.ConfigError, match=r'dev-requires'): @@ -141,6 +152,10 @@ def test_bad_include_paths(path, err_match): ({'dynamic': ['version']}, r'dynamic.*\[project\]'), ({'authors': ['thomas']}, r'author.*\bdict'), ({'maintainers': [{'title': 'Dr'}]}, r'maintainer.*title'), + ({'name': 'mödule1'}, r'not valid'), + ({'name': 'module1_'}, r'not valid'), + ({'optional-dependencies': {'x_': []}}, r'not valid'), + ({'optional-dependencies': {'x_a': [], 'X--a': []}}, r'clash'), ]) def test_bad_pep621_info(proj_bad, err_match): proj = {'name': 'module1', 'version': '1.0', 'description': 'x'}