csspin-python 4.1.0rc2__py3-none-any.whl → 6.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ # -*- mode: python; coding: utf-8 -*-
2
+ #
3
+ # Copyright (C) 2026 CONTACT Software GmbH
4
+ # https://www.contact-software.com/
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ """
19
+ Predict the wheel filename ``setup.py bdist_wheel`` would produce, without
20
+ building it::
21
+
22
+ python _predict_wheel_filename.py /path/to/project NAME VERSION
23
+
24
+ Must run in the subprocess environment to have setuptools in the environment and
25
+ to have the correct Python version and platform tags that the wheel is built
26
+ for.
27
+ """
28
+
29
+ import io
30
+ import os
31
+ import sys
32
+ import warnings
33
+ from contextlib import redirect_stdout
34
+
35
+
36
+ def predict_wheel_filename(project_path: str, name: str, version: str) -> str:
37
+ """Return the predicted wheel filename for ``project_path``."""
38
+ os.chdir(project_path)
39
+
40
+ # setup.py is exec'd here and free to print, so keep its chatter off the
41
+ # stdout the caller parses. stderr stays, so tracebacks still surface.
42
+ with warnings.catch_warnings(), redirect_stdout(io.StringIO()):
43
+ warnings.simplefilter("ignore")
44
+ if os.path.exists("setup.py"):
45
+ from distutils.core import run_setup # pylint: disable=deprecated-module
46
+
47
+ dist = run_setup(
48
+ "setup.py", script_args=["bdist_wheel"], stop_after="commandline"
49
+ )
50
+ else:
51
+ from setuptools.dist import Distribution
52
+
53
+ dist = Distribution({"script_args": ["bdist_wheel"]})
54
+ dist.parse_config_files()
55
+
56
+ dist.metadata.name = name
57
+ dist.metadata.version = version
58
+
59
+ cmd = dist.get_command_obj("bdist_wheel")
60
+ cmd.ensure_finalized()
61
+ python_tag, abi_tag, plat_tag = cmd.get_tag() # type: ignore[attr-defined]
62
+ dist_name = cmd.wheel_dist_name # type: ignore[attr-defined]
63
+
64
+ return f"{dist_name}-{python_tag}-{abi_tag}-{plat_tag}.whl"
65
+
66
+
67
+ if __name__ == "__main__":
68
+ print(predict_wheel_filename(*sys.argv[1:4]))
csspin_python/pytest.py CHANGED
@@ -28,7 +28,6 @@ defaults = config(
28
28
  "--cov-reset",
29
29
  "--cov",
30
30
  "--cov-report=term",
31
- "--cov-report=html",
32
31
  "--cov-report=xml:{pytest.coverage_report}",
33
32
  ],
34
33
  coverage_report="python-pytest-coverage.xml",
csspin_python/python.py CHANGED
@@ -81,6 +81,8 @@ from functools import cache
81
81
  from subprocess import CalledProcessError, check_output
82
82
  from textwrap import dedent, indent
83
83
  from typing import Generator, Iterable, Type, Union
84
+ from urllib.error import HTTPError
85
+ from urllib.parse import urljoin, urlsplit
84
86
 
85
87
  try:
86
88
  from typing import Self # type: ignore[attr-defined]
@@ -162,11 +164,6 @@ defaults = config(
162
164
  enabled=False,
163
165
  memo="{spin.spin_dir}/aws_auth.memo",
164
166
  key_duration=3600 * 10, # 10 hours
165
- static_oidc=False,
166
- index="16.0/simple",
167
- # Need to set client secret to empty string, otherwise the string "None"
168
- # would be handled as secret and obfucsated in logs.
169
- client_secret="", # nosec: B106
170
167
  ),
171
168
  index_url="https://pypi.org/simple",
172
169
  skip_js_build=None,
@@ -187,11 +184,13 @@ def wheel(
187
184
  ) -> None:
188
185
  """Build a wheel of the current project and any additional wheels."""
189
186
  setenv(PIP_INDEX_URL=cfg.python.index_url)
187
+ if cfg.python.extra_index_urls:
188
+ setenv(PIP_EXTRA_INDEX_URL=" ".join(cfg.python.extra_index_urls))
190
189
  search_paths = paths or cfg.python.build_wheels
191
190
  for build_path in {Path(path).absolute() for path in search_paths}:
192
191
  try:
193
192
  echo("Building PEP 517-like wheel")
194
- sh(
193
+ cmd = [
195
194
  "python",
196
195
  "-m",
197
196
  "build",
@@ -199,20 +198,28 @@ def wheel(
199
198
  build_path,
200
199
  "-o",
201
200
  "{spin.project_root}/dist",
202
- )
201
+ ]
202
+ if cfg.verbosity <= Verbosity.NORMAL:
203
+ cmd.append("--config-setting=quiet=true")
204
+ sh(*cmd)
203
205
  except Abort:
204
206
  echo("Building does not seem to work, use legacy setup.py style")
205
207
  with cd(build_path):
206
- sh(
208
+ cmd = [
207
209
  "python",
208
210
  "setup.py",
209
- None if cfg.verbosity > Verbosity.NORMAL else "-v" "build",
211
+ ]
212
+ if cfg.verbosity <= Verbosity.NORMAL:
213
+ cmd.append("-q")
214
+ cmd += [
215
+ "build",
210
216
  "-b",
211
217
  "{spin.project_root}/build",
212
218
  "bdist_wheel",
213
219
  "-d",
214
220
  "{spin.project_root}/dist",
215
- )
221
+ ]
222
+ sh(*cmd)
216
223
 
217
224
 
218
225
  @task()
@@ -297,8 +304,13 @@ def _check_venv( # pylint: disable=too-many-return-statements
297
304
  cfg: ConfigTree,
298
305
  ) -> bool:
299
306
  """
300
- Checks whether the venv is actually a venv compatible
301
- with our configuration and not just some dir.
307
+ Checks whether the venv is actually a venv compatible with our configuration
308
+ and not just some dir.
309
+
310
+ Uses absolute interpreter paths and runs *outside* the subprocess environment
311
+ on purpose: it must work while inspecting an existing venv to decide whether a
312
+ ``python.version`` change requires rebuilding it -- i.e. before the new
313
+ interpreter is provisioned.
302
314
  """
303
315
  try:
304
316
  python_version = (
@@ -402,6 +414,11 @@ def configure(cfg: ConfigTree) -> None:
402
414
  if cfg.python.aws_auth.enabled:
403
415
  _check_aws_token_validity(cfg)
404
416
 
417
+ # Provide spin's subprocess environment (see python_env). Registered here
418
+ # rather than in init so it is also available to the provision and
419
+ # finalize_provision hooks of plugins depending on this one.
420
+ cfg.spin.subprocess_environment = lambda: python_env(cfg)
421
+
405
422
 
406
423
  def init(cfg: ConfigTree) -> None:
407
424
  """Initialize the python plugin"""
@@ -413,32 +430,85 @@ def init(cfg: ConfigTree) -> None:
413
430
  " project. You might want to run spin with the 'provision'"
414
431
  " task."
415
432
  )
416
- venv_init(cfg)
417
-
418
-
419
- # We won't activate more than once.
420
- ACTIVATED = False
421
433
 
422
434
 
423
435
  def venv_init(cfg: ConfigTree) -> None:
424
436
  """Activate the virtual environment"""
425
- global ACTIVATED # pylint: disable=global-statement
426
- if os.environ.get("VIRTUAL_ENV", "") != cfg.python.venv and not ACTIVATED:
427
- activate_this = cfg.python.scriptdir / "activate_this.py"
428
- if not exists(activate_this):
429
- die(
430
- f"{cfg.python.venv} does not exist. You may want to provision"
431
- " it using 'spin provision'"
432
- )
433
- if sys.platform == "win32":
434
- echo(f"{cfg.python.scriptdir}\\activate.ps1")
435
- else:
436
- echo(f". {cfg.python.scriptdir}/activate")
437
- with open(activate_this, encoding="utf-8") as file:
438
- exec( # pylint: disable=exec-used # nosec
439
- file.read(), {"__file__": activate_this}
440
- )
441
- ACTIVATED = True
437
+ activate_this = cfg.python.scriptdir / "activate_this.py"
438
+ if not exists(activate_this):
439
+ die(
440
+ f"{cfg.python.venv} does not exist. You may want to provision"
441
+ " it using 'spin provision'"
442
+ )
443
+ if sys.platform == "win32":
444
+ echo(f"{cfg.python.scriptdir}\\activate.ps1")
445
+ else:
446
+ echo(f". {cfg.python.scriptdir}/activate")
447
+ with open(activate_this, encoding="utf-8") as file:
448
+ exec( # pylint: disable=exec-used # nosec
449
+ file.read(), {"__file__": activate_this}
450
+ )
451
+
452
+
453
+ @contextmanager
454
+ def python_env(cfg: ConfigTree) -> Generator:
455
+ """
456
+ Context manager to activate the subprocess/python virtual environment and
457
+ deactivate it once done.
458
+
459
+ Registered as ``spin.subprocess_environment`` by the plugin's
460
+ :py:func:`configure` hook, so ``csspin.sh`` activates the virtual environment
461
+ around the commands it spawns. Activation is conditional: while the virtual
462
+ environment has not been created yet (e.g. early during ``spin provision``)
463
+ this is a no-op and the command runs in spin's in-process environment; once
464
+ the venv exists it is activated. Saving and restoring
465
+ ``os.environ``, ``sys.path`` and the ``sys`` prefix attributes makes nesting
466
+ safe -- an explicit ``with python_env(cfg):`` block whose inner ``csspin.sh``
467
+ re-enters this context.
468
+
469
+ See the "The subprocess environment" section of csspin's plugin development
470
+ guide for the general concept.
471
+ """
472
+ if not exists(cfg.python.scriptdir / "activate_this.py"):
473
+ # The virtual environment has not been provisioned (yet); run the command
474
+ # in spin's in-process environment instead of activating.
475
+ yield
476
+ else:
477
+
478
+ def interpolate_environ_value(value: str) -> str:
479
+ if not value:
480
+ return ""
481
+ value = str(value)
482
+ keys = re.findall(r"{(?P<key>\w+?)}", value)
483
+ for key in keys:
484
+ if key in os.environ:
485
+ value = value.replace(f"{{{key}}}", os.environ[key])
486
+ return value
487
+
488
+ missing = object()
489
+ old_env = os.environ.copy()
490
+ old_sys_path = sys.path.copy()
491
+ old_sys_prefix = sys.prefix
492
+ old_real_prefix = getattr(sys, "real_prefix", missing)
493
+ try:
494
+ for name, value in EXPORTS:
495
+ os.environ[name] = interpolate_environ_value(value)
496
+
497
+ venv_init(cfg)
498
+ yield
499
+ finally:
500
+ echo(f"deactivate {cfg.python.venv}")
501
+ os.environ.clear()
502
+ os.environ.update(old_env)
503
+ sys.path = old_sys_path
504
+ sys.prefix = old_sys_prefix
505
+ # activate_this.py sets sys.real_prefix on activation; restore it (or
506
+ # remove it) so the attribute does not leak past this context.
507
+ if old_real_prefix is missing:
508
+ if hasattr(sys, "real_prefix"):
509
+ delattr(sys, "real_prefix")
510
+ else:
511
+ setattr(sys, "real_prefix", old_real_prefix)
442
512
 
443
513
 
444
514
  class ActivateScriptPatcher(abc.ABC):
@@ -721,7 +791,9 @@ class PythonActivate(ActivateScriptPatcher):
721
791
 
722
792
 
723
793
  @cache
724
- def get_project_metadata(project_path: str, index_url: str) -> dict: # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: E501
794
+ def get_project_metadata( # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: E501
795
+ project_path: str, index_url: str, extra_index_urls: tuple[str, ...] = ()
796
+ ) -> dict:
725
797
  """
726
798
  Retrieve project metadata of ``project_path`` via ``python -m build
727
799
  --metadata``.
@@ -730,12 +802,16 @@ def get_project_metadata(project_path: str, index_url: str) -> dict: # type: ig
730
802
  every caller within the same process.
731
803
  """
732
804
  setenv(PIP_INDEX_URL=index_url)
805
+ if extra_index_urls:
806
+ setenv(PIP_EXTRA_INDEX_URL=" ".join(extra_index_urls))
733
807
  kwargs = {}
734
808
  if CONFIG.verbosity < Verbosity.INFO:
735
809
  kwargs["stderr"] = subprocess.DEVNULL
736
810
  raw_metadata = backtick(
737
811
  "python", "-m", "build", "--metadata", project_path, **kwargs
738
812
  )
813
+ if extra_index_urls:
814
+ setenv(PIP_EXTRA_INDEX_URL=None)
739
815
  setenv(PIP_INDEX_URL=None)
740
816
 
741
817
  if raw_metadata:
@@ -975,7 +1051,6 @@ def venv_provision( # pylint: disable=too-many-branches,missing-function-docstr
975
1051
  info("Provisioning venv '{python.venv}'")
976
1052
  cfg.python.provisioner.provision_venv(cfg)
977
1053
 
978
- # This sets PATH to the venv
979
1054
  init(cfg)
980
1055
 
981
1056
  _configure_pipconf(cfg)
@@ -1055,21 +1130,78 @@ def _configure_pipconf(cfg: ConfigTree, update: bool = False) -> None:
1055
1130
  "index_url" in config_parser["global"] or "index-url" in config_parser["global"]
1056
1131
  ):
1057
1132
  config_parser["global"]["index_url"] = interpolate1(cfg.python.index_url)
1133
+
1134
+ # Extra index urls are only configured via 'python.extra_index_urls', not
1135
+ # 'python.pipconf', so drop whatever the pipconf template declares rather
1136
+ # than merging it in.
1137
+ for key in ("extra_index_url", "extra-index-url"):
1138
+ config_parser["global"].pop(key, None)
1139
+
1140
+ if cfg.python.extra_index_urls:
1141
+ config_parser["global"]["extra_index_url"] = "\n".join(
1142
+ interpolate1(url) for url in cfg.python.extra_index_urls
1143
+ )
1058
1144
  with open(_get_pipconf(cfg), mode="w", encoding="utf-8") as fd:
1059
1145
  config_parser.write(fd)
1060
1146
 
1061
1147
 
1062
1148
  def _obfuscate_index_url(index_url: str) -> None:
1063
- """Add the CodeArtifact token to the secrets."""
1149
+ """Add the credentials of an index URL to the secrets."""
1064
1150
 
1065
1151
  from csspin import secrets
1066
1152
 
1067
- secrets.add(index_url.split(":")[2].split("@")[0]) # Codeartifact token
1153
+ if password := urlsplit(index_url).password:
1154
+ secrets.add(password)
1068
1155
 
1069
1156
 
1070
- def _check_aws_token_validity( # pylint: disable=too-many-locals
1071
- cfg: ConfigTree,
1072
- ) -> None:
1157
+ def _merge_extra_index_urls(existing: Iterable[str], new: list[str]) -> list[str]:
1158
+ """
1159
+ Merge `new` extra index URLs into `existing`, preserving order and dropping
1160
+ duplicates. Used so that a user-configured ``python.extra_index_urls``
1161
+ survives aws_auth resolving its own CodeArtifact-backed extra indexes.
1162
+ """
1163
+ merged = list(existing)
1164
+ for url in new:
1165
+ if url not in merged:
1166
+ merged.append(url)
1167
+ return merged
1168
+
1169
+
1170
+ def _resolve_extra_index_urls(cfg: ConfigTree, index_base_url: str) -> list[str]:
1171
+ """
1172
+ Resolve ``python.aws_auth.extra_indexes`` into full, authenticated
1173
+ CodeArtifact index URLs, obfuscating each token along the way.
1174
+ """
1175
+
1176
+ extra_index_urls = []
1177
+ for extra_index in cfg.python.aws_auth.extra_indexes:
1178
+ extra_index_url = urljoin(index_base_url + "/", interpolate1(extra_index))
1179
+ # Kinda redundant since index_url carries the same secret and is already
1180
+ # obfuscated. So this noop here just exists to make the obfucation
1181
+ # explicit.
1182
+ _obfuscate_index_url(extra_index_url)
1183
+ extra_index_urls.append(extra_index_url)
1184
+ return extra_index_urls
1185
+
1186
+
1187
+ def _apply_resolved_index_urls(cfg: ConfigTree, index_base_url: str) -> None:
1188
+ """
1189
+ Set ``python.index_url`` and merge-resolve ``python.extra_index_urls`` from
1190
+ `index_base_url`, the CodeArtifact domain/repository base shared by the
1191
+ primary index and any ``aws_auth.extra_indexes``. Shared by both the
1192
+ fresh-token and cached-token paths of ``_check_aws_token_validity``.
1193
+ """
1194
+
1195
+ index_url = urljoin(index_base_url + "/", interpolate1(cfg.python.aws_auth.index))
1196
+ cfg.python.index_url = index_url
1197
+ _obfuscate_index_url(index_url)
1198
+ cfg.python.extra_index_urls = _merge_extra_index_urls(
1199
+ cfg.python.extra_index_urls,
1200
+ _resolve_extra_index_urls(cfg, index_base_url),
1201
+ )
1202
+
1203
+
1204
+ def _check_aws_token_validity(cfg: ConfigTree) -> None:
1073
1205
  """
1074
1206
  If csspin-python[aws_auth] is installed, we can use csaccess to get the
1075
1207
  CodeArtifact authentication token.
@@ -1085,66 +1217,39 @@ def _check_aws_token_validity( # pylint: disable=too-many-locals
1085
1217
 
1086
1218
  import time
1087
1219
 
1088
- if not (
1089
- client_secret := (
1090
- interpolate1(cfg.python.aws_auth.client_secret)
1091
- or os.getenv("CS_AWS_OIDC_CLIENT_SECRET")
1092
- )
1093
- ):
1094
- die(
1095
- "Please provide a client secret for CodeArtifact access via"
1096
- " 'python.aws_auth.client_secret'."
1097
- )
1098
-
1099
1220
  current_time = int(time.time())
1100
1221
  timestamp_key = "aws_auth_timestamp"
1101
1222
 
1102
1223
  with memoizer(cfg.python.aws_auth.memo) as memo:
1103
- for item in memo.items():
1104
- if isinstance(item, str) and item.startswith(f"{timestamp_key}:"):
1105
- last_time = int(item.split(":", 1)[1])
1224
+ if memo.items() and (item := memo.items()[0]):
1225
+ if not isinstance(item, tuple):
1226
+ memo.clear()
1227
+ else:
1228
+ _, last_time, index_base_url = item
1106
1229
  if current_time - last_time < int(
1107
1230
  interpolate1(cfg.python.aws_auth.key_duration)
1108
1231
  ):
1109
- pipconf = _get_pipconf(cfg)
1110
- config_parser = configparser.ConfigParser()
1111
- config_parser.read(pipconf)
1112
- info(f"Using existing index URL from {pipconf}.")
1113
-
1114
- if index_url := (
1115
- config_parser["global"].get("index_url")
1116
- or config_parser["global"].get("index-url")
1117
- ):
1118
- cfg.python.index_url = index_url
1119
- _obfuscate_index_url(index_url)
1120
- break
1121
- memo.items().remove(item)
1122
- else:
1123
- info("Updating Codeartifact token.")
1124
- from urllib.error import HTTPError
1125
- from urllib.parse import urljoin
1126
-
1127
- opts = {
1128
- "client_secret": client_secret,
1129
- "static_oidc": interpolate1(cfg.python.aws_auth.static_oidc).lower()
1130
- == "true",
1131
- }
1132
- if cfg.python.aws_auth.client_id:
1133
- opts["client_id"] = interpolate1(cfg.python.aws_auth.client_id)
1134
- if cfg.python.aws_auth.role_arn:
1135
- opts["aws_role_arn"] = interpolate1(cfg.python.aws_auth.role_arn)
1232
+ info("Using cached CodeArtifact token.")
1233
+ _apply_resolved_index_urls(cfg, index_base_url)
1234
+ return
1235
+ memo.clear()
1136
1236
 
1137
- try:
1138
- index_base_url = get_ca_pypi_url_programmatic(**opts)
1139
- except HTTPError as e:
1140
- die(f"Failed to establish CodeArtifact connection: {e}")
1237
+ info("Updating Codeartifact token.")
1141
1238
 
1142
- index_url = urljoin(
1143
- index_base_url + "/", interpolate1(cfg.python.aws_auth.index)
1144
- )
1145
- cfg.python.index_url = index_url
1146
- _obfuscate_index_url(index_url)
1239
+ opts = {}
1240
+
1241
+ if cfg.python.aws_auth.client_id:
1242
+ opts["client_id"] = interpolate1(cfg.python.aws_auth.client_id)
1243
+ if cfg.python.aws_auth.role_arn:
1244
+ opts["aws_role_arn"] = interpolate1(cfg.python.aws_auth.role_arn)
1245
+
1246
+ try:
1247
+ index_base_url = get_ca_pypi_url_programmatic(**opts)
1248
+ except HTTPError as e:
1249
+ die(f"Failed to establish CodeArtifact connection: {e}")
1250
+
1251
+ _apply_resolved_index_urls(cfg, index_base_url)
1147
1252
 
1148
- if exists(cfg.python.venv):
1149
- _configure_pipconf(cfg, update=True)
1150
- memo.add(f"{timestamp_key}:{current_time}")
1253
+ if exists(cfg.python.venv):
1254
+ _configure_pipconf(cfg, update=True)
1255
+ memo.add((timestamp_key, current_time, index_base_url))
@@ -25,6 +25,7 @@ from importlib.metadata import metadata as _pkg_metadata
25
25
  from importlib.metadata import version as _pkg_version
26
26
  from subprocess import DEVNULL, PIPE
27
27
  from tempfile import TemporaryDirectory
28
+ from urllib.parse import quote, urlencode, urlparse
28
29
 
29
30
  from csspin import (
30
31
  Verbosity,
@@ -40,12 +41,14 @@ from csspin import (
40
41
  )
41
42
  from csspin.tree import ConfigTree
42
43
  from packaging.requirements import Requirement
44
+ from packaging.utils import canonicalize_name
43
45
  from path import Path
44
46
 
45
47
  defaults = config(
46
48
  cyclonedx_bom_version="7.3.0",
47
49
  project_paths=["{spin.project_root}"],
48
- requires=config(spin=["csspin_python.python"]),
50
+ repository_url="{python.index_url}",
51
+ requires=config(spin=["csspin_python.python"], python=["setuptools"]),
49
52
  )
50
53
 
51
54
 
@@ -68,13 +71,26 @@ def sbom(cfg: ConfigTree) -> None:
68
71
  die(f"Project path '{project_path}' does not exist.")
69
72
 
70
73
  project_path = Path(project_path).absolute()
71
- metadata = get_project_metadata(project_path, cfg.python.index_url)
74
+ metadata = get_project_metadata(
75
+ project_path, cfg.python.index_url, tuple(cfg.python.extra_index_urls)
76
+ )
77
+ project_name, project_version = metadata.get("name"), metadata.get("version")
78
+
79
+ file_name = _predict_wheel_filename(
80
+ project_path, project_name, project_version, cfg.python.python
81
+ )
72
82
  third_party_deps = _collect_thirdparty_deps(
73
83
  metadata.get("requires_dist", set()), python_version=cfg.python.version
74
84
  )
75
85
  sbom_json = json.loads(_run_cyclonedx(cfg, third_party_deps, stderr))
76
- _enrich_sbom(sbom_json, metadata, third_party_deps)
77
- _write_sbom(cfg, sbom_json, metadata.get("name"))
86
+ _enrich_sbom(
87
+ sbom_json,
88
+ metadata,
89
+ third_party_deps,
90
+ cfg.python_sbom.repository_url,
91
+ file_name,
92
+ )
93
+ _write_sbom(cfg, sbom_json, project_name)
78
94
 
79
95
 
80
96
  def cleanup(cfg: ConfigTree) -> None:
@@ -114,7 +130,7 @@ def _ensure_cyclonedx_venv(cfg: ConfigTree, binary_dir: str, quiet: str | None)
114
130
  )
115
131
  rmtree(venv_cdx)
116
132
 
117
- sh(cfg.python.interpreter, "-m", "venv", venv_cdx)
133
+ sh(cfg.python.interpreter, "-m", "venv", venv_cdx, use_subprocess_environment=False)
118
134
  sh(
119
135
  interpreter_cdx,
120
136
  "-m",
@@ -124,7 +140,9 @@ def _ensure_cyclonedx_venv(cfg: ConfigTree, binary_dir: str, quiet: str | None)
124
140
  "install",
125
141
  "--index-url",
126
142
  cfg.python.index_url,
143
+ *[f"--extra-index-url={url}" for url in cfg.python.extra_index_urls],
127
144
  "cyclonedx-bom==" + requested_version,
145
+ use_subprocess_environment=False,
128
146
  )
129
147
  with memoizer(memo_file) as memo:
130
148
  memo.add(memo_key)
@@ -143,7 +161,7 @@ def _run_cyclonedx(cfg: ConfigTree, third_party_deps: set[str], stderr: int) ->
143
161
  with TemporaryDirectory() as tmp_dir:
144
162
  venv = Path(tmp_dir) / "venv"
145
163
  interpreter = venv / binary_dir / "python" + cfg.platform.exe
146
- sh(cfg.python.interpreter, "-m", "venv", venv)
164
+ sh(cfg.python.interpreter, "-m", "venv", venv, use_subprocess_environment=False)
147
165
  if third_party_deps:
148
166
  sh(
149
167
  interpreter,
@@ -153,15 +171,34 @@ def _run_cyclonedx(cfg: ConfigTree, third_party_deps: set[str], stderr: int) ->
153
171
  "install",
154
172
  "--index-url",
155
173
  cfg.python.index_url,
174
+ *[f"--extra-index-url={url}" for url in cfg.python.extra_index_urls],
156
175
  *[
157
176
  f"--constraint={constraint}"
158
177
  for constraint in cfg.python.constraints
159
178
  ],
160
179
  *third_party_deps,
180
+ use_subprocess_environment=False,
161
181
  stderr=stderr,
162
182
  )
163
- sh(interpreter, "-m", "pip", quiet, "uninstall", "-y", "pip")
164
- return backtick(interpreter_cdx, "-m", "cyclonedx_py", "environment", venv, stderr=stderr) # type: ignore[no-any-return] # noqa: E501
183
+ sh(
184
+ interpreter,
185
+ "-m",
186
+ "pip",
187
+ quiet,
188
+ "uninstall",
189
+ "-y",
190
+ "pip",
191
+ use_subprocess_environment=False,
192
+ )
193
+ return backtick( # type: ignore[no-any-return]
194
+ interpreter_cdx,
195
+ "-m",
196
+ "cyclonedx_py",
197
+ "environment",
198
+ venv,
199
+ use_subprocess_environment=False,
200
+ stderr=stderr,
201
+ )
165
202
 
166
203
 
167
204
  def _write_sbom(cfg: ConfigTree, sbom_json: dict, project_name: str) -> None:
@@ -197,7 +234,52 @@ def _parse_authors(author_name: str, author_email: str) -> str:
197
234
  return ", ".join(f"{name} ({addr})" for name, addr in entries)
198
235
 
199
236
 
200
- def _build_primary_component(metadata: dict) -> tuple[dict, str]:
237
+ def _repository_base_url(index_url: str) -> str:
238
+ """Reduce a pip index URL to the repository base URL a purl expects."""
239
+ parsed = urlparse(index_url)
240
+ segments = [segment for segment in parsed.path.split("/") if segment]
241
+ if segments and segments[-1] in ("simple", "+simple"):
242
+ segments.pop()
243
+ return parsed._replace(
244
+ netloc=parsed.netloc.rsplit("@", 1)[-1], # drop basic-auth credentials
245
+ path="/".join([""] + segments),
246
+ ).geturl()
247
+
248
+
249
+ def _build_purl(name: str, version: str, repository_url: str, file_name: str) -> str:
250
+ """Build the pypi Package URL for a project, per tn-0058.
251
+
252
+ ``repository_url`` is only added when the package isn't published to
253
+ public pypi.org, since type, name and version are unique there already.
254
+ """
255
+ purl = f"pkg:pypi/{canonicalize_name(name)}@{quote(version, safe=':')}"
256
+
257
+ qualifiers = {"file_name": file_name}
258
+ if urlparse(repository_url).hostname != "pypi.org":
259
+ qualifiers["repository_url"] = _repository_base_url(repository_url)
260
+
261
+ return f"{purl}?{urlencode(sorted(qualifiers.items()), safe=':')}"
262
+
263
+
264
+ def _predict_wheel_filename(
265
+ project_path: str, name: str, version: str, interpreter: str
266
+ ) -> str:
267
+ """
268
+ Predict the wheel filename ``python:wheel`` will produce for
269
+ ``project_path``, without building it.
270
+
271
+ ``name`` and ``version`` come from :func:`get_project_metadata`.
272
+ ``interpreter`` must be ``project_path``'s own, since it owns the tags.
273
+ Cached since the prediction is identical for every caller within the same
274
+ process.
275
+ """
276
+ script = Path(__file__).parent / "_predict_wheel_filename.py"
277
+ return backtick(interpreter, script, project_path, name, version).strip() # type: ignore[no-any-return] # noqa: E501
278
+
279
+
280
+ def _build_primary_component(
281
+ metadata: dict, repository_url: str, file_name: str
282
+ ) -> tuple[dict, str]:
201
283
  """Build the CycloneDX primary component dict and its bom-ref from project metadata."""
202
284
  if not (name := metadata.get("name")):
203
285
  die("Project metadata is missing 'name'.")
@@ -206,6 +288,7 @@ def _build_primary_component(metadata: dict) -> tuple[dict, str]:
206
288
  if not (license_id := metadata.get("license")):
207
289
  die("Project metadata is missing 'license'.")
208
290
 
291
+ purl = _build_purl(str(name), str(version), repository_url, file_name)
209
292
  authors = _parse_authors(
210
293
  author_name=metadata.get("author", "").strip(),
211
294
  author_email=metadata.get("author_email", "").strip(),
@@ -216,6 +299,7 @@ def _build_primary_component(metadata: dict) -> tuple[dict, str]:
216
299
  "bom-ref": primary_ref,
217
300
  "licenses": [{"expression": license_id}],
218
301
  "name": name,
302
+ "purl": purl,
219
303
  "type": "application",
220
304
  "version": version,
221
305
  }
@@ -226,9 +310,13 @@ def _enrich_sbom(
226
310
  sbom_json: dict,
227
311
  metadata: dict,
228
312
  third_party_deps: set[str],
313
+ repository_url: str,
314
+ file_name: str,
229
315
  ) -> None:
230
316
  """Add the primary component and its dependency entry to the CycloneDX document."""
231
- component, primary_ref = _build_primary_component(metadata)
317
+ component, primary_ref = _build_primary_component(
318
+ metadata, repository_url, file_name
319
+ )
232
320
  existing_metadata = sbom_json.get("metadata", {})
233
321
  existing_tools = existing_metadata.get("tools", {})
234
322
  existing_tool_components = (
@@ -16,3 +16,8 @@ python_sbom:
16
16
  help: |
17
17
  Version of the cyclonedx-bom package to use for generating the
18
18
  SBOM.
19
+ repository_url:
20
+ type: str
21
+ help: |
22
+ URL of the package index used for the primary component's
23
+ Package URL. Defaults to 'python.index_url'.
@@ -116,6 +116,13 @@ python:
116
116
  The index url from where packages should get installed into the
117
117
  venv. This value will also be put into cfg.python.pipconf, if
118
118
  not already mentioned there.
119
+ extra_index_urls:
120
+ type: list
121
+ help: |
122
+ Additional package indexes to install from, alongside
123
+ 'index_url'. If 'aws_auth.extra_indexes' is set, its
124
+ resolved, authenticated CodeArtifact URLs are merged into
125
+ this list.
119
126
  build_wheels:
120
127
  type: list
121
128
  help: |
@@ -141,22 +148,17 @@ python:
141
148
  Time in seconds defining how long the plugin should
142
149
  consider the authentication token as valid before
143
150
  issuing a new one.
144
- static_oidc:
145
- type: bool
146
- help: |
147
- Whether to static OIDC when authenticating with AWS
148
- CodeArtifact.
149
151
  index:
150
152
  type: str
151
- help: The CodeArtifact repository index (e.g. "16.0/simple").
153
+ help: The CodeArtifact repository index (e.g. "2026.2/simple").
154
+ extra_indexes:
155
+ type: list
156
+ help: |
157
+ Additional CodeArtifact repository indexes to
158
+ authenticate against, alongside 'index'.
152
159
  client_id:
153
160
  type: str
154
161
  help: The OIDC client ID to use.
155
- client_secret:
156
- type: secret
157
- help: |
158
- The OIDC client secret to use, defaults to the
159
- environment variable 'CS_AWS_OIDC_CLIENT_SECRET'.
160
162
  role_arn:
161
163
  type: str
162
164
  help: The role ARN to assume when authenticating.
@@ -161,16 +161,26 @@ class SimpleUvProvisioner(SimpleProvisioner):
161
161
  self._uv_cmd("pip", "install", "pip")
162
162
 
163
163
 
164
+ def _set_extra_index_url(toml_content: dict, extra_index_urls: list[str]) -> None:
165
+ """Set or drop the 'extra-index-url' key in `toml_content` in place."""
166
+ if extra_index_urls:
167
+ toml_content["extra-index-url"] = extra_index_urls
168
+ else:
169
+ toml_content.pop("extra-index-url", None)
170
+
171
+
164
172
  def _configure_uv_toml(cfg: ConfigTree) -> None:
165
173
  """
166
174
  Create a config file for uv, similar to the pip.conf of
167
175
  csspin_python.python, since `uv` pip won't respect the pip.conf.
176
+
177
+ 'index-url'/'extra-index-url' declared in the 'uv_provisioner.uv_toml'
178
+ template are ignored; 'python.index_url'/'python.extra_index_urls' are
179
+ the only supported way to configure these.
168
180
  """
169
181
  toml_content = tomllib.loads(cfg.uv_provisioner.uv_toml or "")
170
- if "index-url" not in toml_content:
171
- toml_content["index-url"] = cfg.python.index_url
172
- else:
173
- toml_content["index-url"] = toml_content.get("index-url", cfg.python.index_url)
182
+ toml_content["index-url"] = cfg.python.index_url
183
+ _set_extra_index_url(toml_content, cfg.python.extra_index_urls)
174
184
 
175
185
  with open(cfg.uv_provisioner.uv_toml_path, mode="wb") as fd:
176
186
  tomli_w.dump(toml_content, fd)
@@ -178,12 +188,20 @@ def _configure_uv_toml(cfg: ConfigTree) -> None:
178
188
 
179
189
  def _update_index_url_in_toml(cfg: ConfigTree) -> None:
180
190
  """
181
- Update the index-url in the uv.toml in case it changed.
191
+ Update the index-url and extra-index-url in the uv.toml in case they
192
+ changed.
182
193
  """
183
194
  if (uv_toml_path := interpolate1(Path(cfg.uv_provisioner.uv_toml_path))).exists():
184
195
  with open(uv_toml_path, mode="rb") as fd:
185
196
  toml_content = tomllib.load(fd)
197
+ changed = False
186
198
  if toml_content.get("index-url") != cfg.python.index_url:
187
199
  toml_content["index-url"] = cfg.python.index_url
200
+ changed = True
201
+
202
+ if toml_content.get("extra-index-url", []) != cfg.python.extra_index_urls:
203
+ _set_extra_index_url(toml_content, cfg.python.extra_index_urls)
204
+ changed = True
205
+ if changed:
188
206
  with open(uv_toml_path, mode="wb") as fd:
189
207
  tomli_w.dump(toml_content, fd)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csspin-python
3
- Version: 4.1.0rc2
3
+ Version: 6.0.0
4
4
  Summary: Plugin-package for csspin providing Python related plugins
5
5
  Author-email: CONTACT Software GmbH <info@contact-software.com>
6
6
  Maintainer-email: Waleri Enns <waleri.enns@contact-software.com>, Benjamin Thomas Schwertfeger <benjaminthomas.schwertfeger@contact-software.com>, Fabian Hafer <fabian.hafer@contact-software.com>
@@ -27,7 +27,7 @@ License-File: LICENSE
27
27
  Requires-Dist: platformdirs~=4.3.8
28
28
  Requires-Dist: virtualenv
29
29
  Provides-Extra: aws-auth
30
- Requires-Dist: csaccess>=0.1.0; extra == "aws-auth"
30
+ Requires-Dist: csaccess>=0.3.0; extra == "aws-auth"
31
31
  Provides-Extra: uv
32
32
  Requires-Dist: tomli; python_version < "3.11" and extra == "uv"
33
33
  Requires-Dist: tomli-w; extra == "uv"
@@ -0,0 +1,24 @@
1
+ csspin_python/_predict_wheel_filename.py,sha256=sz09QpDOPQesG-QAANjH607QSkS3PFo0qxPifG1y85o,2399
2
+ csspin_python/behave.py,sha256=iJZeyIqB7V_NzTdLTZldNY9W_GGwCWkXe6WY69wpDqs,4997
3
+ csspin_python/behave_schema.yaml,sha256=8qoOCK-uTmwgRRW29urgK0X_kgn0zO0X34v89bvii2w,1241
4
+ csspin_python/debugpy.py,sha256=v0ZZopv5TNoSaFf2kiePsw9OmhBpjfOBFh0u71jTcnQ,962
5
+ csspin_python/debugpy_schema.yaml,sha256=BeH30nSirDYctkdhS9xMXUG5htj3PED_ZjmxPG5WRUc,364
6
+ csspin_python/devpi.py,sha256=o7O06Dw-xt47y-M74TShcl9MUMaW78aFmw6i6ISYwoU,2351
7
+ csspin_python/devpi_schema.yaml,sha256=2gPATWjVcfvCTrGZX2FK6wH8hh9KS0XzZ35JvZeJGEU,487
8
+ csspin_python/playwright.py,sha256=oFfphLqa4AB6K9vasCUFHN0kFXu63n3ocrsqVuRp4-0,5102
9
+ csspin_python/playwright_schema.yaml,sha256=TSeR16YHa7m7bfO59F2eMV-jXcglluTJdEpUeL16saY,1178
10
+ csspin_python/pytest.py,sha256=vSLtmS-kNO0NPo_tszfEm5zGwJb6Yb5IGS-hA7CNguo,4544
11
+ csspin_python/pytest_schema.yaml,sha256=tzXtdF6MvGC9v59EVRJFfLeMMHqPsXcFXy2zJtRECBI,1535
12
+ csspin_python/python.py,sha256=uoWCm_X2_yGaOdnxAb_h2Mr8RjLd5MjuVL4cPF15E2E,41973
13
+ csspin_python/python_sbom.py,sha256=AT_dFEKoghZTVkkTECz0cOo-w3AZAYAgROx4prhAHKw,13603
14
+ csspin_python/python_sbom_schema.yaml,sha256=QysysjGJ6n1WEoLBqYHcdbojuEomUvL8QG4ZSWQW2_8,739
15
+ csspin_python/python_schema.yaml,sha256=RwJgbyIRenj18-s60HmqkwWCGtd6G1acpJ9TDI9QjBQ,6448
16
+ csspin_python/radon.py,sha256=uFqm6FEi5oWj-_XVaAm3s9cam0cUmr1_FwRf40K6xWs,1876
17
+ csspin_python/radon_schema.yaml,sha256=rlRzXw5z4XbjOVznRiUxWGP4E9hx1Jm-gGw1iQiYzE0,548
18
+ csspin_python/uv_provisioner.py,sha256=2M1I8PqWTCUxqaOeJI5rchmly6t-MnOPQfIx-kKzdQU,6757
19
+ csspin_python/uv_provisioner_schema.yaml,sha256=Y8ZNC2OMnhR8Us3WUXAXK9hMjqGWAKFJB2puX4X5XNQ,727
20
+ csspin_python-6.0.0.dist-info/licenses/LICENSE,sha256=4MAecetnRTQw5DlHtiikDSzKWO1xVLwzM5_DsPMYlnE,10172
21
+ csspin_python-6.0.0.dist-info/METADATA,sha256=a-NIDfoER1W6_3YNyaxS_RKPKLXmWp6tk-uApcS6Lr4,5254
22
+ csspin_python-6.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
+ csspin_python-6.0.0.dist-info/top_level.txt,sha256=QSeglMEGbFu1z4L6MCQYwo01NgL0KojWvC4rzgMQ8gU,14
24
+ csspin_python-6.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,25 +0,0 @@
1
- csspin_python/behave.py,sha256=iJZeyIqB7V_NzTdLTZldNY9W_GGwCWkXe6WY69wpDqs,4997
2
- csspin_python/behave_schema.yaml,sha256=8qoOCK-uTmwgRRW29urgK0X_kgn0zO0X34v89bvii2w,1241
3
- csspin_python/debugpy.py,sha256=v0ZZopv5TNoSaFf2kiePsw9OmhBpjfOBFh0u71jTcnQ,962
4
- csspin_python/debugpy_schema.yaml,sha256=BeH30nSirDYctkdhS9xMXUG5htj3PED_ZjmxPG5WRUc,364
5
- csspin_python/devpi.py,sha256=o7O06Dw-xt47y-M74TShcl9MUMaW78aFmw6i6ISYwoU,2351
6
- csspin_python/devpi_schema.yaml,sha256=2gPATWjVcfvCTrGZX2FK6wH8hh9KS0XzZ35JvZeJGEU,487
7
- csspin_python/playwright.py,sha256=oFfphLqa4AB6K9vasCUFHN0kFXu63n3ocrsqVuRp4-0,5102
8
- csspin_python/playwright_schema.yaml,sha256=TSeR16YHa7m7bfO59F2eMV-jXcglluTJdEpUeL16saY,1178
9
- csspin_python/pytest.py,sha256=N9YaU_ouQab0PFPf46HLE7Vg4JeoZW4dzVD7EevqJ1U,4573
10
- csspin_python/pytest_schema.yaml,sha256=tzXtdF6MvGC9v59EVRJFfLeMMHqPsXcFXy2zJtRECBI,1535
11
- csspin_python/python.py,sha256=yqH1eG6mqRJomu-F99cB74PFaUzbSoG_dhpUjYwr1xE,37725
12
- csspin_python/python_sbom.py,sha256=8NVZ6uVUz7rU3VhEoXJ_wHLCdv4BIV6Zjz1ZRa9nQhc,10488
13
- csspin_python/python_sbom_schema.yaml,sha256=VCxY9E2AG_fS00dvIYe6Fbvz1maPjpY0zyZK1Z0wJu4,538
14
- csspin_python/python_schema.yaml,sha256=pgVVjByUYjxQWek7aFmjQzRwmq2ROLvHYgwGPMrT9sM,6351
15
- csspin_python/radon.py,sha256=uFqm6FEi5oWj-_XVaAm3s9cam0cUmr1_FwRf40K6xWs,1876
16
- csspin_python/radon_schema.yaml,sha256=rlRzXw5z4XbjOVznRiUxWGP4E9hx1Jm-gGw1iQiYzE0,548
17
- csspin_python/uv_provisioner.py,sha256=1e-_Sb39JrqNWyaUNeBX59R5tutXLJ1ZsT7urCN1U0I,6044
18
- csspin_python/uv_provisioner_schema.yaml,sha256=Y8ZNC2OMnhR8Us3WUXAXK9hMjqGWAKFJB2puX4X5XNQ,727
19
- csspin_python-4.1.0rc2.dist-info/licenses/LICENSE,sha256=4MAecetnRTQw5DlHtiikDSzKWO1xVLwzM5_DsPMYlnE,10172
20
- csspin_python-4.1.0rc2.dist-info/METADATA,sha256=v7TeI32ffR2KghQaNg2Yi1nXefTReG2Pr6iI3D8LKM0,5257
21
- csspin_python-4.1.0rc2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
22
- csspin_python-4.1.0rc2.dist-info/scm_file_list.json,sha256=Q47_SEZYR492yYYWq_DlPPp-64AFlvHaOz7APY0i8j0,2902
23
- csspin_python-4.1.0rc2.dist-info/scm_version.json,sha256=r-bmiPdloFp64jPWB2hS3VjSJvF_ljCIL5y985ukLKY,163
24
- csspin_python-4.1.0rc2.dist-info/top_level.txt,sha256=QSeglMEGbFu1z4L6MCQYwo01NgL0KojWvC4rzgMQ8gU,14
25
- csspin_python-4.1.0rc2.dist-info/RECORD,,
@@ -1,78 +0,0 @@
1
- {
2
- "files": [
3
- "spinfile.yaml",
4
- "README.rst",
5
- ".pre-commit-config.yaml",
6
- "CONTRIBUTING.md",
7
- "requirements-dev.txt",
8
- "LICENSE",
9
- ".gitignore",
10
- ".gitlab-ci.yml",
11
- ".prettierrc.yaml",
12
- "pyproject.toml",
13
- ".readthedocs.yaml",
14
- "doc/virtual_environment.rst",
15
- "doc/development.rst",
16
- "doc/relnotes.rst",
17
- "doc/index.rst",
18
- "doc/links.rst",
19
- "doc/conf.py",
20
- "doc/installation.rst",
21
- "doc/plugins/playwright.rst",
22
- "doc/plugins/uv_provisioner.rst",
23
- "doc/plugins/debugpy.rst",
24
- "doc/plugins/devpi.rst",
25
- "doc/plugins/python.rst",
26
- "doc/plugins/python_sbom.rst",
27
- "doc/plugins/pytest.rst",
28
- "doc/plugins/behave.rst",
29
- "doc/plugins/radon.rst",
30
- "tests/conftest.py",
31
- "tests/unit/test_uv_provisioner.py",
32
- "tests/unit/test_python_sbom.py",
33
- "tests/unit/test_python.py",
34
- "tests/integration/test_provisioning.py",
35
- "tests/integration/conftest.py",
36
- "tests/integration/fixtures/activation_script/test_env.ps1",
37
- "tests/integration/fixtures/activation_script/test_env.sh",
38
- "tests/integration/fixtures/activation_script/plugins/dummy.py",
39
- "tests/integration/yamls/pytest.yaml",
40
- "tests/integration/yamls/uv_provisioner_use.yaml",
41
- "tests/integration/yamls/pytest_with_playwright.yaml",
42
- "tests/integration/yamls/python_version.yaml",
43
- "tests/integration/yamls/playwright.yaml",
44
- "tests/integration/yamls/devpi.yaml",
45
- "tests/integration/yamls/python_use.yaml",
46
- "tests/integration/yamls/behave.yaml",
47
- "tests/integration/yamls/radon.yaml",
48
- "tests/integration/yamls/python_activation_scripts.yaml",
49
- "tests/integration/yamls/uv_provisioner.yaml",
50
- "tests/acceptance/test_aws_auth.py",
51
- "tests/acceptance/python_sbom/spinfile.yaml",
52
- "tests/acceptance/python_sbom/test_sbom.py",
53
- "tests/acceptance/python_sbom/pyproject.toml",
54
- "tests/acceptance/python_constraints/test_constraints.py",
55
- "tests/acceptance/python_constraints/spinfile.yaml",
56
- "tests/acceptance/python_constraints/constraints.txt",
57
- "tests/acceptance/yamls/python_aws_auth.yaml",
58
- "src/csspin_python/debugpy_schema.yaml",
59
- "src/csspin_python/devpi_schema.yaml",
60
- "src/csspin_python/uv_provisioner_schema.yaml",
61
- "src/csspin_python/pytest.py",
62
- "src/csspin_python/playwright_schema.yaml",
63
- "src/csspin_python/behave.py",
64
- "src/csspin_python/pytest_schema.yaml",
65
- "src/csspin_python/uv_provisioner.py",
66
- "src/csspin_python/radon.py",
67
- "src/csspin_python/python_sbom_schema.yaml",
68
- "src/csspin_python/python_sbom.py",
69
- "src/csspin_python/devpi.py",
70
- "src/csspin_python/debugpy.py",
71
- "src/csspin_python/playwright.py",
72
- "src/csspin_python/behave_schema.yaml",
73
- "src/csspin_python/python.py",
74
- "src/csspin_python/python_schema.yaml",
75
- "src/csspin_python/radon_schema.yaml",
76
- ".gitlab/merge_request_templates/default.md"
77
- ]
78
- }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "4.1.0rc2",
3
- "distance": 0,
4
- "node": "gcaf72582381496c091ef82854c9703c0c9583259",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-06-23"
8
- }