• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Qiskit / ecosystem / 36701865090

30 Sep 2026 10:20AM UTC coverage: 88.068%. First build
36701865090

Pull #1396

github

web-flow
Merge c23f64918 into 2bb27893c
Pull Request #1396: New section`[python.<name>]`: pip-installable repos

930 of 948 new or added lines in 18 files covered. (98.1%)

4694 of 5330 relevant lines covered (88.07%)

0.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.43
/ecosystem/python.py
1
# This code is part of Qiskit.
2
#
3
# (C) Copyright IBM 2026.
4
#
5
# This code is licensed under the Apache License, Version 2.0. You may
6
# obtain a copy of this license in the LICENSE.txt file in the root directory
7
# of this source tree or at https://www.apache.org/licenses/LICENSE-2.0.
8
#
9
# Any modifications or derivative works of this code must retain this
10
# copyright notice, and modified files need to carry a notice indicating
11
# that they have been altered from the originals.
12

13
"""Python source-tree package section.
1 ✔
14

15
The metadata a Python project declares in its own repository, for projects that
16
are installable with `pip install git+<repo>` but have no release in a registry.
17
This is a sibling of `PyPIData`, not a parent or a child of it: a PyPI section
18
describes an immutable published artifact, while this one describes whatever the
19
default branch says today.
20

21
Values come from the three manifests `pip` would feed to setuptools, in the
22
precedence order setuptools itself documents:
23

24
    pyproject.toml `[project]`  >  setup.cfg `[metadata]`/`[options]`  >  setup.py
25

26
`setup.py` is *parsed*, never executed: this code runs over ~200 third-party
27
submissions in CI, so `exec` is not on the table. Keyword arguments that are not
28
literals cannot be read statically; they are reported in `deferred` rather than
29
guessed at.
30
"""
31

32
from ast import parse as ast_parse, walk as ast_walk, Call, Name, Attribute
1 ✔
33
from ast import literal_eval
1 ✔
34
from os import getenv
1 ✔
35
from configparser import ConfigParser, Error as ConfigParserError
1 ✔
36
from io import StringIO
1 ✔
37
from json import loads as json_loads
1 ✔
38
import tomllib
1 ✔
39

40
from packaging.requirements import Requirement, InvalidRequirement
1 ✔
41
from packaging.utils import canonicalize_name
1 ✔
42

43
from .license import License
1 ✔
44
from .serializable import JsonSerializable
1 ✔
45
from .error_handling import EcosystemError, logger
1 ✔
46
from .qiskit_requirement import QiskitRequirementMixin
1 ✔
47
from .request import request_json
1 ✔
48

49
#: Sentinel for "not computed yet", so that a cached None is not recomputed.
50
_UNSET = object()
1 ✔
51

52
#: Manifests that can carry packaging metadata, in precedence order.
53
MANIFESTS = ("pyproject.toml", "setup.cfg", "setup.py")
1 ✔
54

55

56
def parse_setup_cfg(text: str) -> dict:
1 ✔
57
    """Parses a setup.cfg into a plain dict of sections."""
58
    parser = ConfigParser()
1 ✔
59
    try:
1 ✔
60
        parser.read_file(StringIO(text))
1 ✔
61
    except ConfigParserError as err:
1 ✔
62
        logger.warning("setup.cfg is not parseable: %s", err)
1 ✔
63
        return {}
1 ✔
64
    return {section: dict(parser[section]) for section in parser.sections()}
1 ✔
65

66

67
def parse_setup_py(text: str) -> dict:
1 ✔
68
    """Statically reads the `setup()` call of a setup.py.
69

70
    Returns `{"literals": {...}, "deferred": [...]}`, where `deferred` holds the
71
    keyword arguments whose value is computed at build time (a variable, a
72
    function call, an f-string) and so cannot be known without running the file.
73
    Never executes the module.
74
    """
75
    result = {"literals": {}, "deferred": []}
1 ✔
76
    try:
1 ✔
77
        tree = ast_parse(text)
1 ✔
78
    except SyntaxError as err:
1 ✔
79
        logger.warning("setup.py is not parseable: %s", err)
1 ✔
80
        return result
1 ✔
81

82
    for node in ast_walk(tree):
1 ✔
83
        if not isinstance(node, Call):
1 ✔
84
            continue
1 ✔
85
        func = node.func
1 ✔
86
        is_setup = (isinstance(func, Name) and func.id == "setup") or (
1 ✔
87
            isinstance(func, Attribute) and func.attr == "setup"
88
        )
89
        if not is_setup:
1 ✔
90
            continue
1 ✔
91
        for keyword in node.keywords:
1 ✔
92
            if keyword.arg is None:  # **kwargs
1 ✔
NEW
93
                continue
×
94
            try:
1 ✔
95
                result["literals"][keyword.arg] = literal_eval(keyword.value)
1 ✔
96
            except (ValueError, SyntaxError):
1 ✔
97
                result["deferred"].append(keyword.arg)
1 ✔
98
        return result  # first setup() call wins
1 ✔
99
    return result
1 ✔
100

101

102
class PythonData(
1 ✔
103
    QiskitRequirementMixin, JsonSerializable
104
):  # pylint: disable=too-many-public-methods
105
    """
106
    The packaging metadata a Python project declares in its own source tree.
107

108
    Most of the public surface is one small read-only property per metadata field,
109
    which is what takes it past the method limit.
110
    """
111

112
    dict_keys = [
1 ✔
113
        "package_name",
114
        "version",
115
        "license",
116
        "description",
117
        "requires_python",
118
        "requires_qiskit",
119
        "compatible_with_qiskit_v1",
120
        "compatible_with_qiskit_v2",
121
        "highest_supported_qiskit_release_date",
122
        "highest_supported_qiskit_version",
123
        "build_backend",
124
        "path",
125
        "source",
126
        "deferred",
127
    ]
128

129
    def __init__(
1 ✔
130
        self,
131
        package_name: str = None,
132
        owner: str = None,
133
        repo: str = None,
134
        path: str = None,
135
        **kwargs,
136
    ):
137
        """
138
        Args:
139
            package_name: Distribution name, when already known (round-tripping a
140
                stored section). Otherwise it is discovered by `update_json`.
141
            owner: GitHub owner, needed to fetch. Not serialized: it lives in the
142
                member's `[github]` section.
143
            repo: GitHub repository name. Not serialized, as above.
144
            path: Directory holding the manifests, when they are not at the repo
145
                root. Serialized, because it cannot be discovered.
146
        """
147
        self._given_package_name = package_name
1 ✔
148
        self.owner = owner
1 ✔
149
        self.repo = repo
1 ✔
150
        self.path = path
1 ✔
151
        self._kwargs = kwargs or {}
1 ✔
152
        self._pyproject = None
1 ✔
153
        self._setup_cfg = None
1 ✔
154
        self._setup_py = None
1 ✔
155
        self._all_qiskit_versions = None
1 ✔
156
        self._requires_qiskit = _UNSET
1 ✔
157

158
    def __repr__(self):
1 ✔
NEW
159
        return str(self.to_dict())
×
160

161
    def to_dict(self, keys=None) -> dict:
1 ✔
162
        return super().to_dict(keys=keys or PythonData.dict_keys)
1 ✔
163

164
    @classmethod
1 ✔
165
    def from_github(cls, github_data, path: str = None):
1 ✔
166
        """Builds an (unfetched) section from a member's `[github]` section."""
167
        return cls(owner=github_data.owner, repo=github_data.repo, path=path)
1 ✔
168

169
    @classmethod
1 ✔
170
    def from_url(cls, manifest_url):
1 ✔
171
        """Builds an (unfetched) section from the URL of a packaging manifest, like
172
        - https://github.com/<owner>/<repo>/blob/<ref>/pyproject.toml
173
        - https://github.com/<owner>/<repo>/blob/<ref>/<path>/setup.cfg
174

175
        This is how a distribution that is not on a registry gets submitted: the URL
176
        says which repository, and which directory in it, holds the manifests. It is
177
        the only way to declare `path`, which cannot be discovered.
178

179
        Returns None for any other URL, GitHub ones included, so that a link to a
180
        repository or a release stays in `packages` for something else to claim. The
181
        `<ref>` is dropped: manifests are read from the default branch.
182
        """
183
        if "github.com" not in (manifest_url.hostname or ""):
1 ✔
184
            return None
1 ✔
185

186
        parts = [part for part in manifest_url.path.split("/") if part]
1 ✔
187
        if len(parts) < 5 or parts[2] != "blob":
1 ✔
188
            return None
1 ✔
189

190
        if parts[-1] not in MANIFESTS:
1 ✔
191
            raise EcosystemError(
1 ✔
192
                f"{manifest_url} does not point at one of " f"{', '.join(MANIFESTS)}"
193
            )
194

195
        return cls(owner=parts[0], repo=parts[1], path="/".join(parts[4:-1]) or None)
1 ✔
196

197
    @property
1 ✔
198
    def key(self):
1 ✔
199
        """The `[python.<key>]` key this section belongs under.
200

201
        The distribution name, once it is known. A section built from a URL does not
202
        know it yet: it is declared inside the repository, not in the URL. The
203
        repository name stands in until `update_json` reads a manifest, and
204
        `Member.update_python` re-keys the section then.
205

206
        The stand-in carries `path`, because a monorepo can declare several
207
        distributions and they would otherwise share one key and overwrite each
208
        other before any of them is fetched.
209
        """
210
        if self.package_name:
1 ✔
NEW
211
            return self.package_name
×
212
        if not self.repo:
1 ✔
NEW
213
            return None
×
214
        stem = f"{self.repo}-{self.path}" if self.path else self.repo
1 ✔
215
        return canonicalize_name(stem.replace("/", "-"))
1 ✔
216

217
    # ---------------------------------------------------------------- fetching
218

219
    def update_json(self):
1 ✔
220
        """
221
        Fetches the packaging manifests from the default branch of
222
        `github.com/{self.owner}/{self.repo}`:
223
          - pyproject.toml
224
          - setup.cfg
225
          - setup.py
226
        """
227
        if not self.owner or not self.repo:
1 ✔
228
            raise EcosystemError(
1 ✔
229
                "PythonData needs owner and repo to fetch; "
230
                "build it with PythonData.from_github()"
231
            )
232
        present = self._request_listing()
1 ✔
233
        parsers = {
1 ✔
234
            "pyproject.toml": tomllib.loads,
235
            "setup.cfg": parse_setup_cfg,
236
            "setup.py": parse_setup_py,
237
        }
238
        fetched = {
1 ✔
239
            filename: self._request_manifest(filename, parsers[filename])
240
            for filename in MANIFESTS
241
            if filename in present
242
        }
243
        self._pyproject = fetched.get("pyproject.toml")
1 ✔
244
        self._setup_cfg = fetched.get("setup.cfg")
1 ✔
245
        self._setup_py = fetched.get("setup.py")
1 ✔
246
        self._requires_qiskit = _UNSET
1 ✔
247

248
    @property
1 ✔
249
    def _contents_url(self):
1 ✔
250
        """Contents API endpoint for the directory holding the manifests."""
251
        directory = f"{self.path.strip('/')}/" if self.path else ""
1 ✔
252
        return f"api.github.com/repos/{self.owner}/{self.repo}/contents/{directory}"
1 ✔
253

254
    def _request_listing(self):
1 ✔
255
        """Names of the files in the manifest directory.
256

257
        Listing first means a project without, say, a setup.cfg costs no request
258
        for it. Asking for each manifest blindly would raise (and log an error)
259
        three times for a repository that has none, which is a normal thing for
260
        a repository to be.
261
        """
262
        listing = request_json(
1 ✔
263
            self._contents_url,
264
            parser=lambda text: {"entries": json_loads(text)},
265
            token=getenv("GH_TOKEN"),
266
        )
267
        return {
1 ✔
268
            entry["name"]
269
            for entry in listing["entries"]
270
            if isinstance(entry, dict) and entry.get("type") == "file"
271
        }
272

273
    def _request_manifest(self, filename, parser):
1 ✔
274
        """Fetches one manifest as raw text.
275

276
        The `raw` media type makes the contents API return the file itself
277
        instead of a JSON envelope with base64, so `parser` can be a plain
278
        text parser. `request_json` caches for a day via requests_cache.
279
        """
280
        return request_json(
1 ✔
281
            f"{self._contents_url}{filename}",
282
            headers={"Accept": "application/vnd.github.raw"},
283
            content_handler=lambda content: content.decode("utf-8"),
284
            parser=parser,
285
            token=getenv("GH_TOKEN"),
286
        )
287

288
    @property
1 ✔
289
    def fetched(self):
1 ✔
290
        """True once `update_json` has found at least one manifest."""
291
        return any((self._pyproject, self._setup_cfg, self._setup_py))
1 ✔
292

293
    # ------------------------------------------------------ raw manifest views
294

295
    @property
1 ✔
296
    def project(self) -> dict:
1 ✔
297
        """pyproject.toml `[project]`, the authoritative source when present."""
298
        return (self._pyproject or {}).get("project") or {}
1 ✔
299

300
    @property
1 ✔
301
    def metadata(self) -> dict:
1 ✔
302
        """setup.cfg `[metadata]`."""
303
        return (self._setup_cfg or {}).get("metadata") or {}
1 ✔
304

305
    @property
1 ✔
306
    def options(self) -> dict:
1 ✔
307
        """setup.cfg `[options]`, which is where its dependencies live."""
308
        return (self._setup_cfg or {}).get("options") or {}
1 ✔
309

310
    @property
1 ✔
311
    def setup_kwargs(self) -> dict:
1 ✔
312
        """The literal keyword arguments of the `setup()` call."""
313
        return (self._setup_py or {}).get("literals") or {}
1 ✔
314

315
    def _declared(self, project_key, cfg_key=None, setup_key=None):
1 ✔
316
        """First value declared across the manifests, in precedence order.
317

318
        Falls back to the stored value, so a section loaded from a toml file
319
        keeps working with no network.
320
        """
321
        cfg_key = cfg_key or project_key
1 ✔
322
        setup_key = setup_key or cfg_key
1 ✔
323
        for source in (
1 ✔
324
            self.project.get(project_key),
325
            self.metadata.get(cfg_key),
326
            self.options.get(cfg_key),
327
            self.setup_kwargs.get(setup_key),
328
        ):
329
            if source:
1 ✔
330
                return source
1 ✔
331
        return None
1 ✔
332

333
    # ----------------------------------------------------------------- fields
334

335
    @property
1 ✔
336
    def package_name(self):
1 ✔
337
        """Distribution name, as `pip` would record it."""
338
        name = (
1 ✔
339
            self._declared("name")
340
            or self._given_package_name
341
            or self._kwargs.get("package_name")
342
        )
343
        if not name:
1 ✔
344
            return None
1 ✔
345
        try:
1 ✔
346
            return canonicalize_name(name, validate=True)
1 ✔
NEW
347
        except Exception:  # pylint: disable=broad-except
×
NEW
348
            logger.warning("%r is not a valid distribution name", name)
×
NEW
349
            return None
×
350

351
    @property
1 ✔
352
    def version(self):
1 ✔
353
        """Declared version. None when the backend computes it at build time."""
354
        if "version" in self.dynamic:
1 ✔
355
            return None
1 ✔
356
        declared = self._declared("version")
1 ✔
357
        if isinstance(declared, str):
1 ✔
358
            return declared
1 ✔
359
        return self._kwargs.get("version")
1 ✔
360

361
    @property
1 ✔
362
    def description(self):
1 ✔
363
        """One-line summary."""
364
        return self._declared("description") or self._kwargs.get("description")
1 ✔
365

366
    @property
1 ✔
367
    def requires_python(self):
1 ✔
368
        """The `python_requires` specifier."""
369
        return self._declared("requires-python", "python_requires") or self._kwargs.get(
1 ✔
370
            "requires_python"
371
        )
372

373
    @property
1 ✔
374
    def build_backend(self):
1 ✔
375
        """PEP 517 build backend. None when the project declares no build system.
376

377
        Absence is meaningful: `pip` then falls back to a synthesized setuptools
378
        backend, which is the legacy path.
379
        """
380
        declared = (self._pyproject or {}).get("build-system", {}).get("build-backend")
1 ✔
381
        return declared or self._kwargs.get("build_backend")
1 ✔
382

383
    @property
1 ✔
384
    def dynamic(self) -> list:
1 ✔
385
        """Fields pyproject.toml hands back to the build backend (PEP 621)."""
386
        return self.project.get("dynamic") or []
1 ✔
387

388
    @property
1 ✔
389
    def deferred(self) -> list:
1 ✔
390
        """Fields that are declared somewhere but not statically readable.
391

392
        The union of PEP 621 `dynamic` and the non-literal `setup()` keyword
393
        arguments. A checker needs this to tell "no license declared" (a
394
        finding) from "license computed at build time" (not a finding).
395
        """
396
        if not self.fetched:
1 ✔
397
            return self._kwargs.get("deferred") or []
1 ✔
398
        deferred = list(self.dynamic) + ((self._setup_py or {}).get("deferred") or [])
1 ✔
399
        return sorted(set(deferred))
1 ✔
400

401
    @property
1 ✔
402
    def source(self) -> list:
1 ✔
403
        """Which manifests the values above came from."""
404
        if not self.fetched:
1 ✔
405
            return self._kwargs.get("source") or []
1 ✔
406
        found = {
1 ✔
407
            "pyproject.toml": self._pyproject,
408
            "setup.cfg": self._setup_cfg,
409
            "setup.py": self._setup_py,
410
        }
411
        return [name for name in MANIFESTS if found[name]]
1 ✔
412

413
    @property
1 ✔
414
    def license(self):
1 ✔
415
        """Declared license, normalized to an SPDX id where possible.
416

417
        `[project].license` has two incompatible shapes: a PEP 639 SPDX string,
418
        or the older table with `text` or `file`. A `file` reference names a
419
        file rather than a license, so it is not a license name and is skipped.
420
        """
421
        declared = self.project.get("license")
1 ✔
422
        if isinstance(declared, dict):
1 ✔
423
            declared = declared.get("text")
1 ✔
424
        if not declared:
1 ✔
425
            declared = self.metadata.get("license") or self.setup_kwargs.get("license")
1 ✔
426
        if declared and isinstance(declared, str):
1 ✔
427
            return License(declared)
1 ✔
428

429
        for classifier in self._classifiers():
1 ✔
430
            if classifier.startswith("License :: "):
1 ✔
431
                parts = [part.strip() for part in classifier.split("::")]
1 ✔
432
                if len(parts) == 3:
1 ✔
433
                    # Same trove vocabulary PyPI serves, so normalize it the same way.
434
                    return License(parts[2], "pypi")
1 ✔
435

436
        stored = self._kwargs.get("license")
1 ✔
437
        if stored:
1 ✔
438
            return stored if isinstance(stored, License) else License(str(stored))
1 ✔
439
        return None
1 ✔
440

441
    def _classifiers(self) -> list:
1 ✔
442
        """Trove classifiers, wherever they are declared."""
443
        declared = self.project.get("classifiers") or self.setup_kwargs.get(
1 ✔
444
            "classifiers"
445
        )
446
        if declared:
1 ✔
NEW
447
            return declared
×
448
        # setup.cfg keeps them as an indented multi-line string
449
        return [
1 ✔
450
            line.strip()
451
            for line in (self.metadata.get("classifiers") or "").splitlines()
452
            if line.strip()
453
        ]
454

455
    @property
1 ✔
456
    def dependencies(self) -> list:
1 ✔
457
        """Declared runtime dependencies as PEP 508 strings."""
458
        declared = self.project.get("dependencies")
1 ✔
459
        if declared:
1 ✔
460
            return declared
1 ✔
461
        declared = self.setup_kwargs.get("install_requires")
1 ✔
462
        if declared:
1 ✔
463
            return declared
1 ✔
464
        # setup.cfg keeps install_requires as an indented multi-line string
465
        return [
1 ✔
466
            line.strip()
467
            for line in (self.options.get("install_requires") or "").splitlines()
468
            if line.strip()
469
        ]
470

471
    @property
1 ✔
472
    def requires_qiskit(self):
1 ✔
473
        """String with the specifier for the "qiskit" dependency.
474

475
        None when the project does not depend on Qiskit, *and* when it defers
476
        its dependencies to the build backend — `deferred` tells those apart.
477
        """
478
        if not self.fetched:
1 ✔
479
            return self._kwargs.get("requires_qiskit")
1 ✔
480
        if self._requires_qiskit is not _UNSET:
1 ✔
481
            # The compat properties read this repeatedly, and a miss logs a warning
482
            return self._requires_qiskit
1 ✔
483
        self._requires_qiskit = self._find_requires_qiskit()
1 ✔
484
        return self._requires_qiskit
1 ✔
485

486
    def _find_requires_qiskit(self):
1 ✔
487
        """Looks for a "qiskit" requirement among the declared dependencies."""
488
        for requirement_str in self.dependencies:
1 ✔
489
            try:
1 ✔
490
                requirement = Requirement(requirement_str)
1 ✔
491
            except InvalidRequirement:
1 ✔
492
                logger.warning(
1 ✔
493
                    "%s declares an unparseable requirement: %r",
494
                    self.package_name,
495
                    requirement_str,
496
                )
497
                continue
1 ✔
498
            if canonicalize_name(requirement.name) != "qiskit":
1 ✔
499
                continue
1 ✔
500
            if len(requirement.specifier):
1 ✔
501
                return str(requirement.specifier)
1 ✔
502
            logger.warning(
1 ✔
503
                '%s depends on qiskit but with empty specifier. Forcing one, ">=0"',
504
                self.package_name,
505
            )
506
            return ">=0"
1 ✔
507
        return None
1 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc