GeezerVisonThe World Wide Web, restored to 800 pixels of glory. |
| Home|About|Gaming cabinet|Archives|How it works|Starter sites |
GeezerVison is showing github.com — csspin-python/src/csspin_python/python_sbom.py at master · cslab/csspin-python Expand file tree/ Copy pathpython_sbom.pyMore file actions 412 lines (356 loc) · 14.2 KB / Copy pathpython_sbom.pyFile metadata and controls412 lines (356 loc) · 14.2 KB 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 # -*- mode: python; coding: utf-8 -*- # # Copyright (C) 2026 CONTACT Software GmbH # https://www.contact-software.com/ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Module implementing the python_sbom plugin for csspin""" import email.utils import json import sys import sysconfig from importlib.metadata import metadata as _pkg_metadata from importlib.metadata import version as _pkg_version from subprocess import DEVNULL, PIPE from tempfile import TemporaryDirectory from urllib.parse import quote, urlencode, urlparse from csspin import ( Verbosity, backtick, config, die, exists, info, memoizer, rmtree, sh, task, ) from csspin.tree import ConfigTree from packaging.requirements import Requirement from packaging.utils import canonicalize_name from packaging.version import Version from path import Path defaults = config( cyclonedx_bom_version="7.5.0", project_paths=["{spin.project_root}"], repository_url="{python.index_url}", spec_version="1.7", requires=config(spin=["csspin_python.python"], python=["setuptools"]), ) @task("python-sbom", when="sbom:build") def sbom(cfg: ConfigTree) -> None: """ Create the SBOMs for Python projects defined in via 'python_sbom.project_paths'. This task assumes that the current package defines its dependencies that are to be included in the SBOM via the "thirdparty" extra. If there is no such extra, the SBOM generation is skipped. """ from csspin_python.python import get_project_metadata stderr = PIPE if cfg.verbosity > Verbosity.NORMAL else DEVNULL for project_path in cfg.python_sbom.project_paths: if not exists(project_path): die(f"Project path '{project_path}' does not exist.") project_path = Path(project_path).absolute() metadata = get_project_metadata( project_path, cfg.python.index_url, tuple(cfg.python.extra_index_urls) ) project_name, project_version = metadata.get("name"), metadata.get("version") file_name = _predict_wheel_filename( project_path, project_name, project_version, cfg.python.python ) third_party_deps = _collect_thirdparty_deps( metadata.get("requires_dist", set()), python_version=cfg.python.version ) sbom_json = json.loads(_run_cyclonedx(cfg, third_party_deps, stderr)) _enrich_sbom( sbom_json, metadata, third_party_deps, cfg.python_sbom.repository_url, file_name, ) _mark_components_external(sbom_json) _write_sbom(cfg, sbom_json, project_name) def cleanup(cfg: ConfigTree) -> None: """Get rid of all generated .cdx.json files and the cyclonedx-bom venv.""" for cdx_file in cfg.spin.project_root.glob("*.python_sbom.cdx.json"): rmtree(cdx_file) rmtree(cfg.spin.project_root / ".spin" / "venv_csspin_python__python_sbom") # ---- Internals --------------------------------------------------------------- def _ensure_cyclonedx_venv(cfg: ConfigTree, binary_dir: str, quiet: str | None) -> Path: """Return the cyclonedx-bom interpreter path, (re)creating the venv if needed. We install cyclonedx-bom into a persistent venv since defining it as a dependency of csspin-python itself doesn't work at this moment. See https://github.com/CycloneDX/cyclonedx-python/issues/1045 """ venv_cdx = cfg.spin.project_root / ".spin" / "venv_csspin_python__python_sbom" interpreter_cdx = venv_cdx / binary_dir / "python" + cfg.platform.exe requested_version = cfg.python_sbom.cyclonedx_bom_version memo_key = f"cyclonedx-bom=={requested_version}" memo_file = venv_cdx / "csspin_python_sbom.memo" if venv_cdx.exists(): with memoizer(memo_file) as memo: if memo.check(memo_key): info( f"Reusing existing cyclonedx-bom {requested_version} from {venv_cdx}" ) return interpreter_cdx info( f"cyclonedx-bom version mismatch (wanted={requested_version}), " f"recreating {venv_cdx}" ) rmtree(venv_cdx) sh(cfg.python.interpreter, "-m", "venv", venv_cdx) sh( interpreter_cdx, "-m", "pip", quiet, "--disable-pip-version-check", "install", "--index-url", cfg.python.index_url, *[f"--extra-index-url={url}" for url in cfg.python.extra_index_urls], "cyclonedx-bom==" + requested_version, ) with memoizer(memo_file) as memo: memo.add(memo_key) return interpreter_cdx def _run_cyclonedx(cfg: ConfigTree, third_party_deps: set[str], stderr: int) -> str: """ Install third-party deps into a temp venv and return the CycloneDX JSON. """ binary_dir = "Scripts" if sys.platform == "win32" else "bin" quiet = None if cfg.verbosity > Verbosity.NORMAL else "-q" interpreter_cdx = _ensure_cyclonedx_venv(cfg, binary_dir, quiet) with TemporaryDirectory() as tmp_dir: venv = Path(tmp_dir) / "venv" interpreter = venv / binary_dir / "python" + cfg.platform.exe # Run --upgrade-deps and set PIP_CONSTRAINT to force pip and setuptools # to have the version from the constraint files, if they are present. # Necessary as python3.11 still has setuptools in the env created by # venv. PIP_INDEX_URL and PIP_EXTRA_INDEX_URL point the upgrade at the # configured indexes, because venv has no index option. sh( cfg.python.interpreter, "-m", "venv", "--upgrade-deps", venv, env={ "PIP_CONSTRAINT": " ".join(cfg.python.constraints), "PIP_INDEX_URL": cfg.python.index_url, "PIP_EXTRA_INDEX_URL": " ".join(cfg.python.extra_index_urls), }, ) if third_party_deps: sh( interpreter, "-m", "pip", quiet, "install", "--index-url", cfg.python.index_url, *[f"--extra-index-url={url}" for url in cfg.python.extra_index_urls], *[ f"--constraint={constraint}" for constraint in cfg.python.constraints ], *third_party_deps, stderr=stderr, ) sh( interpreter, "-m", "pip", quiet, "uninstall", "-y", "pip", ) return backtick( # type: ignore[no-any-return] interpreter_cdx, "-m", "cyclonedx_py", "environment", "--spec-version", cfg.python_sbom.spec_version, venv, stderr=stderr, ) def _write_sbom(cfg: ConfigTree, sbom_json: dict, project_name: str) -> None: """Write the CycloneDX JSON document to the output file.""" platform_tag = sysconfig.get_platform().replace("-", "_") output_file = cfg.spin.project_root / ( f"{project_name}.{platform_tag}.python_sbom.cdx.json" ) with open(output_file, "w", encoding="utf-8") as f: json.dump(sbom_json, f, indent=2, sort_keys=True) info(f"Generated Python SBOM successfully ({output_file})") def _parse_authors(author_name: str, author_email: str) -> str: """Parse RFC 2822 Author-email metadata into 'name (email)' format.""" if not author_email: die("Project metadata has no Author-email field.") return "" entries = email.utils.getaddresses([author_email]) if len(entries) == 1 and not entries[0][0] and author_name: entries = [(author_name, entries[0][1])] for name, addr in entries: if not name: die(f"Author entry '{addr}' has no name; all authors require a name.") return "" if not addr: die(f"Author entry '{name}' has no email; all authors require an email.") return "" return ", ".join(f"{name} ({addr})" for name, addr in entries) def _repository_base_url(index_url: str) -> str: """Reduce a pip index URL to the repository base URL a purl expects.""" parsed = urlparse(index_url) segments = [segment for segment in parsed.path.split("/") if segment] if segments and segments[-1] in ("simple", "+simple"): segments.pop() return parsed._replace( netloc=parsed.netloc.rsplit("@", 1)[-1], # drop basic-auth credentials path="/".join([""] + segments), ).geturl() def _build_purl(name: str, version: str, repository_url: str, file_name: str) -> str: """Build the pypi Package URL for a project, per tn-0058. ``repository_url`` is only added when the package isn't published to public pypi.org, since type, name and version are unique there already. """ purl = f"pkg:pypi/{canonicalize_name(name)}@{quote(version, safe=':')}" qualifiers = {"file_name": file_name} if urlparse(repository_url).hostname != "pypi.org": qualifiers["repository_url"] = _repository_base_url(repository_url) return f"{purl}?{urlencode(sorted(qualifiers.items()), safe=':')}" def _predict_wheel_filename( project_path: str, name: str, version: str, interpreter: str ) -> str: """ Predict the wheel filename ``python:wheel`` will produce for ``project_path``, without building it. ``name`` and ``version`` come from :func:`get_project_metadata`. ``interpreter`` must be ``project_path``'s own, since it owns the tags. Cached since the prediction is identical for every caller within the same process. """ script = Path(__file__).parent / "_predict_wheel_filename.py" return backtick(interpreter, script, project_path, name, version).strip() # type: ignore[no-any-return] # noqa: E501 def _build_primary_component( metadata: dict, repository_url: str, file_name: str ) -> tuple[dict, str]: """Build the CycloneDX primary component dict and its bom-ref from project metadata.""" if not (name := metadata.get("name")): die("Project metadata is missing 'name'.") if not (version := metadata.get("version")): die("Project metadata is missing 'version'.") if not (license_id := metadata.get("license")): die("Project metadata is missing 'license'.") purl = _build_purl(str(name), str(version), repository_url, file_name) authors = _parse_authors( author_name=metadata.get("author", "").strip(), author_email=metadata.get("author_email", "").strip(), ) primary_ref = f"{name}=={version}" component = { "author": authors, "bom-ref": primary_ref, "licenses": [{"expression": license_id}], "name": name, "purl": purl, "type": "application", "version": version, } return component, primary_ref def _enrich_sbom( sbom_json: dict, metadata: dict, third_party_deps: set[str], repository_url: str, file_name: str, ) -> None: """Add the primary component and its dependency entry to the CycloneDX document.""" component, primary_ref = _build_primary_component( metadata, repository_url, file_name ) existing_metadata = sbom_json.get("metadata", {}) existing_tools = existing_metadata.get("tools", {}) existing_tool_components = ( existing_tools if isinstance(existing_tools, list) else existing_tools.get("components", []) ) sbom_json["metadata"] = { **existing_metadata, "component": component, "tools": { "components": existing_tool_components + [ { "description": "Python SBOM plugin for csspin", "licenses": [ { "expression": _pkg_metadata("csspin-python")[ "License-Expression" ] } ], "name": "csspin-python", "supplier": { "name": "CONTACT Software GmbH", "url": [ "https://www.contact-software.com/", "https://pypi.org/project/csspin-python/", ], }, "type": "application", "version": _pkg_version("csspin-python"), } ] }, } direct_dep_names = {Requirement(dep).name.lower() for dep in third_party_deps} direct_dep_refs = sorted( comp["bom-ref"] for comp in sbom_json.get("components", []) if comp.get("name", "").lower() in direct_dep_names and "bom-ref" in comp ) dependencies = sbom_json.get("dependencies", []) dependencies.append({"dependsOn": direct_dep_refs, "ref": primary_ref}) sbom_json["dependencies"] = dependencies def _mark_components_external(sbom_json: dict) -> None: """ Flag the third-party components as external, for CycloneDX 1.7 and newer. """ if Version(sbom_json["specVersion"]) < Version("1.7"): return for component in sbom_json.get("components", []): component["isExternal"] = True def _collect_thirdparty_deps(requires_dist: list, python_version: str) -> set[str]: """Extract 'thirdparty' dependency specifiers from project metadata.""" import platform env = { "sys_platform": sys.platform, "extra": "thirdparty", "platform_system": platform.system(), "python_version": python_version, } dependencies = set() for require in requires_dist: req = Requirement(require) if req.marker and req.marker.evaluate(environment=env): dependencies.add(req.name + str(req.specifier)) return dependencies |
| You are visitor 01402503 — GeezerVison does not store the pages you look up. Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required. |