Skip to content

Commit 4d550ad

Browse files
authored
fix: normalize package extras (#671)
ALL names of package extras are normalized, according to spec <https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization> --------- Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com>
1 parent 2ac3f21 commit 4d550ad

File tree

79 files changed

+37208
-1859
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+37208
-1859
lines changed

cyclonedx_py/_internal/environment.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def __finalize_dependencies(self, bom: 'Bom', all_components: 'T_AllComponents')
202202
req_component.properties.update(
203203
Property(
204204
name=PropertyName.PackageExtra.value,
205-
value=extra
205+
value=normalize_packagename(extra)
206206
) for extra in req.extras
207207
)
208208
bom.register_dependency(component, component_deps)

cyclonedx_py/_internal/pipenv.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from os import getenv
2222
from os.path import join
2323
from textwrap import dedent
24-
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Set, Tuple
24+
from typing import TYPE_CHECKING, Any, Dict, FrozenSet, Generator, List, Optional, Set, Tuple
2525

2626
from cyclonedx.exception.model import InvalidUriException, UnknownHashTypeException
2727
from cyclonedx.model import ExternalReference, ExternalReferenceType, HashType, Property, XsUri
@@ -132,10 +132,10 @@ def __call__(self, *, # type:ignore[override]
132132

133133
return self._make_bom(rc,
134134
json_loads(lock.read()),
135-
lock_groups)
135+
frozenset(lock_groups))
136136

137137
def _make_bom(self, root_c: Optional['Component'],
138-
locker: 'NameDict', use_groups: Set[str]) -> 'Bom':
138+
locker: 'NameDict', use_groups: FrozenSet[str]) -> 'Bom':
139139
self._logger.debug('use_groups: %r', use_groups)
140140

141141
bom = make_bom()
@@ -181,10 +181,12 @@ def _make_bom(self, root_c: Optional['Component'],
181181
name=PropertyName.PipenvCategory.value,
182182
value=group_name
183183
))
184-
component.properties.update(Property(
185-
name=PropertyName.PackageExtra.value,
186-
value=package_extra
187-
) for package_extra in package_data.get('extras', ()))
184+
component.properties.update(
185+
Property(
186+
name=PropertyName.PackageExtra.value,
187+
value=normalize_packagename(package_extra)
188+
) for package_extra in package_data.get('extras', ())
189+
)
188190

189191
return bom
190192

cyclonedx_py/_internal/poetry.py

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from itertools import chain
2222
from os.path import join
2323
from textwrap import dedent
24-
from typing import TYPE_CHECKING, Any, Dict, Generator, Iterable, List, Set, Tuple
24+
from typing import TYPE_CHECKING, Any, Dict, FrozenSet, Generator, Iterable, List, Tuple
2525

2626
from cyclonedx.exception.model import InvalidUriException, UnknownHashTypeException
2727
from cyclonedx.model import ExternalReference, ExternalReferenceType, HashType, Property, XsUri
@@ -44,21 +44,19 @@
4444
from cyclonedx.model.bom import Bom
4545
from cyclonedx.model.component import ComponentType
4646

47-
NameDict = Dict[str, Any]
47+
T_NameDict = Dict[str, Any]
48+
T_LockData = Dict[str, List['_LockEntry']]
4849

4950

5051
@dataclass
5152
class _LockEntry:
5253
name: str
5354
component: Component
54-
dependencies: Dict[str, 'NameDict'] # keys MUST go through `normalize_packagename()`
55+
dependencies: Dict[str, 'T_NameDict'] # keys MUST go through `normalize_packagename()`
5556
extras: Dict[str, List[str]] # keys MUST go through `normalize_packagename()`
5657
added2bom: bool
5758

5859

59-
_LockData = Dict[str, List[_LockEntry]]
60-
61-
6260
class GroupsNotFoundError(ValueError):
6361
def __init__(self, groups: Iterable[str]) -> None:
6462
self.__groups = frozenset(groups)
@@ -161,15 +159,18 @@ def __call__(self, *, # type:ignore[override]
161159
po_cfg_group = po_cfg.setdefault('group', {})
162160
po_cfg_group.setdefault('main', {'dependencies': po_cfg.get('dependencies', {})})
163161
po_cfg_group.setdefault('dev', {'dependencies': po_cfg.get('dev-dependencies', {})})
164-
po_cfg_extras = po_cfg.setdefault('extras', {})
162+
po_cfg_extras = po_cfg['extras'] = {
163+
normalize_packagename(en): es
164+
for en, es in po_cfg.get('extras', {}).items()
165+
}
165166

166167
# the group-args shall mimic the ones from poetry, which uses comma-separated lists and multi-use
167168
# values be like: ['foo', 'bar,bazz'] -> ['foo', 'bar', 'bazz']
168-
groups_only_s = set(filter(None, ','.join(groups_only).split(',')))
169-
groups_with_s = set(filter(None, ','.join(groups_with).split(',')))
170-
groups_without_s = set(filter(None, ','.join(groups_without).split(',')))
169+
groups_only_s = frozenset(filter(None, ','.join(groups_only).split(',')))
170+
groups_with_s = frozenset(filter(None, ','.join(groups_with).split(',')))
171+
groups_without_s = frozenset(filter(None, ','.join(groups_without).split(',')))
171172
del groups_only, groups_with, groups_without
172-
groups_not_found = set(
173+
groups_not_found = frozenset(
173174
(gn, srcn) for gns, srcn in [
174175
(groups_only_s, 'only'),
175176
(groups_with_s, 'with'),
@@ -182,27 +183,30 @@ def __call__(self, *, # type:ignore[override]
182183
raise ValueError('some Poetry groups are unknown') from groups_error
183184
del groups_not_found
184185

185-
# values be like: ['foo', 'bar,bazz'] -> ['foo', 'bar', 'bazz']
186-
extras_s = set(filter(None, ','.join(extras).split(',')))
186+
if all_extras:
187+
extras_s = frozenset(po_cfg_extras)
188+
else:
189+
extras_s = frozenset(map(normalize_packagename,
190+
# values be like: ['foo', 'bar,bazz'] -> ['foo', 'bar', 'bazz']
191+
filter(None, ','.join(extras).split(','))))
192+
extras_not_found = extras_s - po_cfg_extras.keys()
193+
if len(extras_not_found) > 0:
194+
extras_error = ExtrasNotFoundError(extras_not_found)
195+
self._logger.error(extras_error)
196+
raise ValueError('some package extras are unknown') from extras_error
197+
del extras_not_found
187198
del extras
188-
extras_defined = set(po_cfg_extras)
189-
extras_not_found = extras_s - extras_defined
190-
if len(extras_not_found) > 0:
191-
extras_error = ExtrasNotFoundError(extras_not_found)
192-
self._logger.error(extras_error)
193-
raise ValueError('some package extras are unknown') from extras_error
194-
del extras_not_found
195199

196200
# the group-args shall mimic the ones from Poetry.
197201
# Poetry handles this pseudo-exclusive-group of args programmatically
198202
if no_dev:
199-
groups = {'main', }
203+
groups = frozenset({'main', })
200204
elif len(groups_only_s) > 0:
201205
groups = groups_only_s
202206
else:
203207
# When used together, `--without` takes precedence over `--with`.
204208
# see https://python-poetry.org/docs/managing-dependencies/#installing-group-dependencies
205-
groups = set(
209+
groups = frozenset(
206210
gn for gn, gc in po_cfg['group'].items()
207211
# all non-optionals and the `with`-whitelisted optionals
208212
if not gc.get('optional') or gn in groups_with_s
@@ -212,12 +216,12 @@ def __call__(self, *, # type:ignore[override]
212216
return self._make_bom(
213217
project, toml_loads(lock.read()),
214218
groups,
215-
extras_defined if all_extras else extras_s,
219+
extras_s,
216220
mc_type,
217221
)
218222

219-
def _make_bom(self, project: 'NameDict', locker: 'NameDict',
220-
use_groups: Set[str], use_extras: Set[str],
223+
def _make_bom(self, project: 'T_NameDict', locker: 'T_NameDict',
224+
use_groups: FrozenSet[str], use_extras: FrozenSet[str],
221225
mc_type: 'ComponentType') -> 'Bom':
222226
self._logger.debug('use_groups: %r', use_groups)
223227
self._logger.debug('use_extras: %r', use_extras)
@@ -228,15 +232,17 @@ def _make_bom(self, project: 'NameDict', locker: 'NameDict',
228232

229233
bom.metadata.component = root_c = poetry2component(po_cfg, type=mc_type)
230234
root_c.bom_ref.value = root_c.name
231-
root_c.properties.update(Property(
232-
name=PropertyName.PackageExtra.value,
233-
value=extra
234-
) for extra in use_extras)
235+
root_c.properties.update(
236+
Property(
237+
name=PropertyName.PackageExtra.value,
238+
value=extra
239+
) for extra in use_extras
240+
)
235241
self._logger.debug('root-component: %r', root_c)
236242
root_d = Dependency(root_c.bom_ref)
237243
bom.dependencies.add(root_d)
238244

239-
lock_data: '_LockData' = {}
245+
lock_data: 'T_LockData' = {}
240246
for lock_entry in self._parse_lock(locker):
241247
_ld = lock_data.setdefault(lock_entry.name, [])
242248
_ldl = len(_ld)
@@ -256,8 +262,8 @@ def _make_bom(self, project: 'NameDict', locker: 'NameDict',
256262
)]
257263
del root_c_nname
258264

259-
use_extras_dep_names = set(map(normalize_packagename,
260-
chain.from_iterable(po_cfg['extras'][e] for e in use_extras)))
265+
use_extras_dep_names = frozenset(map(normalize_packagename,
266+
chain.from_iterable(po_cfg['extras'][e] for e in use_extras)))
261267
for group_name in use_groups:
262268
for dep_name, dep_spec in po_cfg['group'][group_name].get('dependencies', {}).items():
263269
dep_name = normalize_packagename(dep_name)
@@ -282,12 +288,7 @@ def _make_bom(self, project: 'NameDict', locker: 'NameDict',
282288

283289
return bom
284290

285-
def __add_dep(self, bom: 'Bom', lock_entry: _LockEntry, use_extras: Iterable[str], lock_data: '_LockData') -> None:
286-
use_extras = set(map(normalize_packagename, use_extras))
287-
lock_entry.component.properties.update(Property(
288-
name=PropertyName.PackageExtra.value,
289-
value=extra
290-
) for extra in use_extras)
291+
def __add_dep(self, bom: 'Bom', lock_entry: _LockEntry, use_extras: Iterable[str], lock_data: 'T_LockData') -> None:
291292
if lock_entry.added2bom:
292293
self._logger.debug('existing component: %r', lock_entry.component)
293294
lock_entry_dep = None
@@ -313,6 +314,13 @@ def __add_dep(self, bom: 'Bom', lock_entry: _LockEntry, use_extras: Iterable[str
313314
lock_entry_dep.dependencies.add(Dependency(dep_lock_entry.component.bom_ref))
314315
self.__add_dep(bom, dep_lock_entry, dep_spec.get('extras', ()), lock_data)
315316
if use_extras:
317+
use_extras = frozenset(map(normalize_packagename, use_extras))
318+
lock_entry.component.properties.update(
319+
Property(
320+
name=PropertyName.PackageExtra.value,
321+
value=extra
322+
) for extra in use_extras
323+
)
316324
lock_entry_dep = lock_entry_dep \
317325
or next(filter(lambda d: d.ref is lock_entry.component.bom_ref, bom.dependencies))
318326
for req in map(
@@ -329,14 +337,14 @@ def __add_dep(self, bom: 'Bom', lock_entry: _LockEntry, use_extras: Iterable[str
329337
self.__add_dep(bom, dep_lock_entry, req.extras, lock_data)
330338

331339
@staticmethod
332-
def _get_lockfile_version(locker: 'NameDict') -> Tuple[int, ...]:
340+
def _get_lockfile_version(locker: 'T_NameDict') -> Tuple[int, ...]:
333341
return tuple(map(int, locker['metadata'].get('lock-version', '1.0').split('.')))
334342

335-
def _parse_lock(self, locker: 'NameDict') -> Generator[_LockEntry, None, None]:
343+
def _parse_lock(self, locker: 'T_NameDict') -> Generator[_LockEntry, None, None]:
336344
lock_version = self._get_lockfile_version(locker)
337345
self._logger.debug('lock_version: %r', lock_version)
338346
metavar_files = locker.get('metadata', {}).get('files', {}) if lock_version < (2,) else {}
339-
package: 'NameDict'
347+
package: 'T_NameDict'
340348
for package in locker.get('package', []):
341349
package.setdefault('files', metavar_files.get(package['name'], []))
342350
yield _LockEntry(
@@ -356,7 +364,7 @@ def _parse_lock(self, locker: 'NameDict') -> Generator[_LockEntry, None, None]:
356364
__PACKAGE_SRC_VCS = ['git'] # not supported yet: hg, svn
357365
__PACKAGE_SRC_LOCAL = ['file', 'directory']
358366

359-
def __make_component4lock(self, package: 'NameDict') -> 'Component':
367+
def __make_component4lock(self, package: 'T_NameDict') -> 'Component':
360368
source = package.get('source', {})
361369
is_vcs = source.get('type') in self.__PACKAGE_SRC_VCS
362370
is_local = source.get('type') in self.__PACKAGE_SRC_LOCAL
@@ -389,7 +397,7 @@ def __make_component4lock(self, package: 'NameDict') -> 'Component':
389397
) if not is_local else None
390398
)
391399

392-
def __purl_qualifiers4lock(self, package: 'NameDict') -> 'NameDict':
400+
def __purl_qualifiers4lock(self, package: 'T_NameDict') -> 'T_NameDict':
393401
# see https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst
394402
qs = {}
395403

@@ -414,7 +422,7 @@ def __purl_qualifiers4lock(self, package: 'NameDict') -> 'NameDict':
414422

415423
return qs
416424

417-
def __extrefs4lock(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
425+
def __extrefs4lock(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
418426
source_type = package.get('source', {}).get('type', 'legacy')
419427
if 'legacy' == source_type:
420428
yield from self.__extrefs4lock_legacy(package)
@@ -427,7 +435,7 @@ def __extrefs4lock(self, package: 'NameDict') -> Generator['ExternalReference',
427435
elif source_type in self.__PACKAGE_SRC_VCS:
428436
yield from self.__extrefs4lock_vcs(package)
429437

430-
def __extrefs4lock_legacy(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
438+
def __extrefs4lock_legacy(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
431439
source_url = redact_auth_from_url(package.get('source', {}).get('url', 'https://pypi.org/simple'))
432440
for file in package['files']:
433441
try:
@@ -441,7 +449,7 @@ def __extrefs4lock_legacy(self, package: 'NameDict') -> Generator['ExternalRefer
441449
self._logger.debug('skipped dist-extRef for: %r | %r', package['name'], file, exc_info=error)
442450
del error
443451

444-
def __extrefs4lock_url(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
452+
def __extrefs4lock_url(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
445453
try:
446454
yield ExternalReference(
447455
comment='from url',
@@ -452,7 +460,7 @@ def __extrefs4lock_url(self, package: 'NameDict') -> Generator['ExternalReferenc
452460
except (InvalidUriException, UnknownHashTypeException) as error: # pragma: nocover
453461
self._logger.debug('skipped dist-extRef for: %r', package['name'], exc_info=error)
454462

455-
def __extrefs4lock_file(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
463+
def __extrefs4lock_file(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
456464
try:
457465
yield ExternalReference(
458466
comment='from file',
@@ -463,7 +471,7 @@ def __extrefs4lock_file(self, package: 'NameDict') -> Generator['ExternalReferen
463471
except (InvalidUriException, UnknownHashTypeException) as error: # pragma: nocover
464472
self._logger.debug('skipped dist-extRef for: %r', package['name'], exc_info=error)
465473

466-
def __extrefs4lock_directory(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
474+
def __extrefs4lock_directory(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
467475
try:
468476
yield ExternalReference(
469477
comment='from directory',
@@ -474,7 +482,7 @@ def __extrefs4lock_directory(self, package: 'NameDict') -> Generator['ExternalRe
474482
except InvalidUriException as error: # pragma: nocover
475483
self._logger.debug('skipped dist-extRef for: %r', package['name'], exc_info=error)
476484

477-
def __extrefs4lock_vcs(self, package: 'NameDict') -> Generator['ExternalReference', None, None]:
485+
def __extrefs4lock_vcs(self, package: 'T_NameDict') -> Generator['ExternalReference', None, None]:
478486
source = package['source']
479487
vcs_ref = source.get('resolved_reference', source.get('reference', ''))
480488
try:

cyclonedx_py/_internal/requirements.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from itertools import chain
2222
from os import unlink
2323
from textwrap import dedent
24-
from typing import TYPE_CHECKING, Any, Generator, Iterable, Optional, Set
24+
from typing import TYPE_CHECKING, Any, FrozenSet, Generator, Iterable, Optional
2525

2626
from cyclonedx.exception.model import InvalidUriException, UnknownHashTypeException
2727
from cyclonedx.model import ExternalReference, ExternalReferenceType, HashType, Property, XsUri
@@ -33,6 +33,7 @@
3333
from .cli_common import add_argument_mc_type, add_argument_pyproject
3434
from .utils.cdx import make_bom
3535
from .utils.io import io2file
36+
from .utils.packaging import normalize_packagename
3637
from .utils.pyproject import pyproject_file2component
3738
from .utils.secret import redact_auth_from_url
3839

@@ -141,7 +142,7 @@ def _add_components(self, bom: 'Bom', rf: 'RequirementsFile') -> None:
141142
index_url = redact_auth_from_url(reduce(
142143
lambda c, i: i.options.get('index_url') or c, rf.options, self._index_url
143144
).rstrip('/'))
144-
extra_index_urls = set(map(
145+
extra_index_urls = frozenset(map(
145146
lambda u: redact_auth_from_url(u.rstrip('/')),
146147
chain(self._extra_index_urls, chain.from_iterable(
147148
i.options['extra_index_urls'] for i in rf.options if 'extra_index_urls' in i.options
@@ -167,7 +168,7 @@ def __hashes4req(self, req: 'InstallRequirement') -> Generator['HashType', None,
167168
del error
168169

169170
def _make_component(self, req: 'InstallRequirement',
170-
index_url: str, extra_index_urls: Set[str]) -> 'Component':
171+
index_url: str, extra_index_urls: FrozenSet[str]) -> 'Component':
171172
name = req.name
172173
version = req.get_pinned_version or None
173174
hashes = list(self.__hashes4req(req))
@@ -216,12 +217,13 @@ def _make_component(self, req: 'InstallRequirement',
216217
type=ComponentType.LIBRARY,
217218
name=name or 'unknown',
218219
version=version,
219-
purl=PackageURL(type='pypi', name=req.name, version=version,
220-
qualifiers=purl_qualifiers
221-
) if not is_local and name else None,
220+
purl=PackageURL(
221+
type='pypi', name=req.name, version=version,
222+
qualifiers=purl_qualifiers
223+
) if not is_local and name else None,
222224
external_references=external_references,
223225
properties=(Property(
224226
name=PropertyName.PackageExtra.value,
225-
value=extra
227+
value=normalize_packagename(extra)
226228
) for extra in req.extras)
227229
)

tests/_data/infiles/environment/with-extras/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
name = "with-extras"
44
version = "0.1.0"
55
description = "depenndencies with extras"
6+
7+
dependencies = [
8+
"cyclonedx-python-lib[xml-Validation]" # exrra name is expected to be normalized
9+
]

tests/_data/infiles/pipenv/with-extras/Pipfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
sort_pipfile = true
33

44
[packages]
5-
cyclonedx-python-lib = {version = "==5.1.1", extras = ["xml-validation", "json-validation"]}
5+
cyclonedx-python-lib = {version = "==5.1.1", extras = ["xml-Validation", "JSON-validation"]}
66

77
[dev-packages]

0 commit comments

Comments
 (0)