ub-needs-exchange 1.0.0__tar.gz
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.
- ub_needs_exchange-1.0.0/LICENSE +21 -0
- ub_needs_exchange-1.0.0/PKG-INFO +88 -0
- ub_needs_exchange-1.0.0/README.rst +59 -0
- ub_needs_exchange-1.0.0/pyproject.toml +52 -0
- ub_needs_exchange-1.0.0/src/ub_needs_exchange/__init__.py +62 -0
- ub_needs_exchange-1.0.0/src/ub_needs_exchange/py.typed +0 -0
- ub_needs_exchange-1.0.0/src/ub_needs_exchange/reader.py +308 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 useblocks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ub-needs-exchange
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The record of the needs.json exchange format, and its reader, for the sphinx-needs family and ubCode
|
|
5
|
+
Keywords: needs.json,sphinx-needs,ubcode,requirements,exchange format
|
|
6
|
+
Author-email: team useblocks <info@useblocks.com>
|
|
7
|
+
Requires-Python: >=3.11,<4
|
|
8
|
+
Description-Content-Type: text/x-rst
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Documentation
|
|
21
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Project-URL: Changelog, https://github.com/useblocks/sphinx-needs/blob/master/packages/ub-needs-exchange/docs/changelog.rst
|
|
25
|
+
Project-URL: Issues, https://github.com/useblocks/sphinx-needs/issues
|
|
26
|
+
Project-URL: Repository, https://github.com/useblocks/sphinx-needs
|
|
27
|
+
Import-Name: ub_needs_exchange
|
|
28
|
+
|
|
29
|
+
ub-needs-exchange
|
|
30
|
+
=================
|
|
31
|
+
|
|
32
|
+
A library for the sphinx-needs family, which the extensions pull in -- not a Sphinx
|
|
33
|
+
extension, and nothing to add to ``conf.py``.
|
|
34
|
+
|
|
35
|
+
It is the record of the **needs.json** exchange format: the file sphinx-needs' ``needs``
|
|
36
|
+
builder writes, its ``needimport`` directive and ``needs_external_needs`` option read, and
|
|
37
|
+
ubCode both writes and reads. It holds the part of that format more than one tool reads,
|
|
38
|
+
so that it is read one way:
|
|
39
|
+
|
|
40
|
+
- **the envelope** -- the ``project``, the ``current_version``, the ``versions``, and a
|
|
41
|
+
``format_version``;
|
|
42
|
+
- **selecting a version** -- the one asked for, or the file's current one;
|
|
43
|
+
- **the records** -- each keyed by its id, with the legacy ``description`` read as
|
|
44
|
+
``content`` and the version's ``needs_schema`` defaults merged under it.
|
|
45
|
+
|
|
46
|
+
Standard library only: no Sphinx, no docutils, no sibling distribution. A converter or a
|
|
47
|
+
build action that runs without the documentation toolchain can use it.
|
|
48
|
+
|
|
49
|
+
Reading a file
|
|
50
|
+
--------------
|
|
51
|
+
|
|
52
|
+
.. code-block:: python
|
|
53
|
+
|
|
54
|
+
from ub_needs_exchange import ExchangeError, load
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
version = load("needs.json").select_version(None) # or a version's name
|
|
58
|
+
except ExchangeError as err:
|
|
59
|
+
err.code # what is wrong, e.g. "version_not_found"
|
|
60
|
+
err.message # for a person
|
|
61
|
+
else:
|
|
62
|
+
records = version.records()
|
|
63
|
+
for record in records:
|
|
64
|
+
record.key # the need's id
|
|
65
|
+
record.fields # its fields, schema defaults merged under them
|
|
66
|
+
version.diagnostics # findings about the version -- report them, or not
|
|
67
|
+
[d for r in records for d in r.diagnostics] # findings about each record
|
|
68
|
+
|
|
69
|
+
Every refusal is an ``ExchangeError`` whose ``code`` names the rule that was broken; every
|
|
70
|
+
finding is a ``Diagnostic`` with a ``code``, the ``path`` of what was found in the file,
|
|
71
|
+
and a ``message``.
|
|
72
|
+
|
|
73
|
+
Consumers decide the policy
|
|
74
|
+
---------------------------
|
|
75
|
+
|
|
76
|
+
This package decides nothing a consumer has reason to decide differently: which fields a
|
|
77
|
+
project defines and which of a record's keys it takes, the ids it accepts, the prefix it
|
|
78
|
+
adds, how a record becomes a need, and whether a finding deserves a warning are each
|
|
79
|
+
tool's own.
|
|
80
|
+
|
|
81
|
+
The contract
|
|
82
|
+
------------
|
|
83
|
+
|
|
84
|
+
``design/exchange-contract.md`` is the normative specification, and
|
|
85
|
+
``tests/fixtures/needs_json_reading_conformance.toml`` is its executable half: a corpus of
|
|
86
|
+
needs.json files and expected results that this package's suite runs, and that ubCode is
|
|
87
|
+
to vendor and run against its own readers.
|
|
88
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
ub-needs-exchange
|
|
2
|
+
=================
|
|
3
|
+
|
|
4
|
+
A library for the sphinx-needs family, which the extensions pull in -- not a Sphinx
|
|
5
|
+
extension, and nothing to add to ``conf.py``.
|
|
6
|
+
|
|
7
|
+
It is the record of the **needs.json** exchange format: the file sphinx-needs' ``needs``
|
|
8
|
+
builder writes, its ``needimport`` directive and ``needs_external_needs`` option read, and
|
|
9
|
+
ubCode both writes and reads. It holds the part of that format more than one tool reads,
|
|
10
|
+
so that it is read one way:
|
|
11
|
+
|
|
12
|
+
- **the envelope** -- the ``project``, the ``current_version``, the ``versions``, and a
|
|
13
|
+
``format_version``;
|
|
14
|
+
- **selecting a version** -- the one asked for, or the file's current one;
|
|
15
|
+
- **the records** -- each keyed by its id, with the legacy ``description`` read as
|
|
16
|
+
``content`` and the version's ``needs_schema`` defaults merged under it.
|
|
17
|
+
|
|
18
|
+
Standard library only: no Sphinx, no docutils, no sibling distribution. A converter or a
|
|
19
|
+
build action that runs without the documentation toolchain can use it.
|
|
20
|
+
|
|
21
|
+
Reading a file
|
|
22
|
+
--------------
|
|
23
|
+
|
|
24
|
+
.. code-block:: python
|
|
25
|
+
|
|
26
|
+
from ub_needs_exchange import ExchangeError, load
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
version = load("needs.json").select_version(None) # or a version's name
|
|
30
|
+
except ExchangeError as err:
|
|
31
|
+
err.code # what is wrong, e.g. "version_not_found"
|
|
32
|
+
err.message # for a person
|
|
33
|
+
else:
|
|
34
|
+
records = version.records()
|
|
35
|
+
for record in records:
|
|
36
|
+
record.key # the need's id
|
|
37
|
+
record.fields # its fields, schema defaults merged under them
|
|
38
|
+
version.diagnostics # findings about the version -- report them, or not
|
|
39
|
+
[d for r in records for d in r.diagnostics] # findings about each record
|
|
40
|
+
|
|
41
|
+
Every refusal is an ``ExchangeError`` whose ``code`` names the rule that was broken; every
|
|
42
|
+
finding is a ``Diagnostic`` with a ``code``, the ``path`` of what was found in the file,
|
|
43
|
+
and a ``message``.
|
|
44
|
+
|
|
45
|
+
Consumers decide the policy
|
|
46
|
+
---------------------------
|
|
47
|
+
|
|
48
|
+
This package decides nothing a consumer has reason to decide differently: which fields a
|
|
49
|
+
project defines and which of a record's keys it takes, the ids it accepts, the prefix it
|
|
50
|
+
adds, how a record becomes a need, and whether a finding deserves a warning are each
|
|
51
|
+
tool's own.
|
|
52
|
+
|
|
53
|
+
The contract
|
|
54
|
+
------------
|
|
55
|
+
|
|
56
|
+
``design/exchange-contract.md`` is the normative specification, and
|
|
57
|
+
``tests/fixtures/needs_json_reading_conformance.toml`` is its executable half: a corpus of
|
|
58
|
+
needs.json files and expected results that this package's suite runs, and that ubCode is
|
|
59
|
+
to vendor and run against its own readers.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ub-needs-exchange"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "The record of the needs.json exchange format, and its reader, for the sphinx-needs family and ubCode"
|
|
5
|
+
authors = [{ name = "team useblocks", email = "info@useblocks.com" }]
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
readme = "README.rst"
|
|
8
|
+
requires-python = ">=3.11,<4"
|
|
9
|
+
# EMPTY, and that is the package's whole contract with its consumers: `json` is the
|
|
10
|
+
# standard library, and a dependency here -- Sphinx above all -- would reach every tool
|
|
11
|
+
# that reads a needs.json, including the ones that run with no documentation toolchain.
|
|
12
|
+
# `tests/test_imports.py` fences the import graph, and the `toolchain-free` CI job runs the
|
|
13
|
+
# suite in an environment with no Sphinx in it
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = [
|
|
16
|
+
"needs.json",
|
|
17
|
+
"sphinx-needs",
|
|
18
|
+
"ubcode",
|
|
19
|
+
"requirements",
|
|
20
|
+
"exchange format",
|
|
21
|
+
]
|
|
22
|
+
# NOT `Framework :: Sphinx`: nothing here depends on Sphinx
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 5 - Production/Stable",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Programming Language :: Python :: 3.14",
|
|
35
|
+
"Topic :: Documentation",
|
|
36
|
+
"Topic :: Software Development :: Documentation",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Repository = "https://github.com/useblocks/sphinx-needs"
|
|
42
|
+
Issues = "https://github.com/useblocks/sphinx-needs/issues"
|
|
43
|
+
Changelog = "https://github.com/useblocks/sphinx-needs/blob/master/packages/ub-needs-exchange/docs/changelog.rst"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["flit_core >=3.4,<5"]
|
|
47
|
+
build-backend = "flit_core.buildapi"
|
|
48
|
+
|
|
49
|
+
# Deliberately NO `[tool.flit.sdist]`: flit's default sdist is the module, its `py.typed`
|
|
50
|
+
# marker, README, LICENSE and this file, and nothing else. Everything else -- ruff, ty,
|
|
51
|
+
# pytest and the dependency groups -- is the ROOT's, and
|
|
52
|
+
# `tools/src/sn_tools/check_workspace.py` refuses those tables here.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The record of the needs.json exchange format, and its reader.
|
|
2
|
+
|
|
3
|
+
One implementation of reading the file every tool of the sphinx-needs family exchanges
|
|
4
|
+
needs through -- for sphinx-needs' ``needimport`` and ``needs_external_needs``, and for
|
|
5
|
+
ubCode, which is to be held to the same behaviour through the conformance corpus
|
|
6
|
+
in this package's tests. Standard library only: no Sphinx, no docutils, no sibling
|
|
7
|
+
distribution.
|
|
8
|
+
|
|
9
|
+
It decides no policy. Which fields a project defines, which ids it accepts, whether a
|
|
10
|
+
finding is worth a warning, and how a record becomes a need are each consumer's to
|
|
11
|
+
decide; this package returns what it found and raises an error for a file no record can
|
|
12
|
+
be read from.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ub_needs_exchange.reader import (
|
|
16
|
+
CURRENT_VERSION_MISSING,
|
|
17
|
+
DEFAULTS_REMOVED_WITHOUT_SCHEMA,
|
|
18
|
+
FORMAT_VERSION,
|
|
19
|
+
FORMAT_VERSION_INVALID,
|
|
20
|
+
FORMAT_VERSION_UNSUPPORTED,
|
|
21
|
+
ID_MISMATCH,
|
|
22
|
+
INVALID_JSON,
|
|
23
|
+
NEEDS_MISSING,
|
|
24
|
+
NOT_AN_OBJECT,
|
|
25
|
+
RECORD_NOT_OBJECT,
|
|
26
|
+
VERSION_NOT_FOUND,
|
|
27
|
+
VERSIONS_MISSING,
|
|
28
|
+
Diagnostic,
|
|
29
|
+
Document,
|
|
30
|
+
ExchangeError,
|
|
31
|
+
Record,
|
|
32
|
+
Version,
|
|
33
|
+
load,
|
|
34
|
+
map_legacy_keys,
|
|
35
|
+
parse,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "1.0.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"CURRENT_VERSION_MISSING",
|
|
42
|
+
"DEFAULTS_REMOVED_WITHOUT_SCHEMA",
|
|
43
|
+
"FORMAT_VERSION",
|
|
44
|
+
"FORMAT_VERSION_INVALID",
|
|
45
|
+
"FORMAT_VERSION_UNSUPPORTED",
|
|
46
|
+
"ID_MISMATCH",
|
|
47
|
+
"INVALID_JSON",
|
|
48
|
+
"NEEDS_MISSING",
|
|
49
|
+
"NOT_AN_OBJECT",
|
|
50
|
+
"RECORD_NOT_OBJECT",
|
|
51
|
+
"VERSIONS_MISSING",
|
|
52
|
+
"VERSION_NOT_FOUND",
|
|
53
|
+
"Diagnostic",
|
|
54
|
+
"Document",
|
|
55
|
+
"ExchangeError",
|
|
56
|
+
"Record",
|
|
57
|
+
"Version",
|
|
58
|
+
"__version__",
|
|
59
|
+
"load",
|
|
60
|
+
"map_legacy_keys",
|
|
61
|
+
"parse",
|
|
62
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""The needs exchange file (``needs.json``): reading its envelope.
|
|
2
|
+
|
|
3
|
+
A needs exchange file holds one or more *versions* of a project's needs::
|
|
4
|
+
|
|
5
|
+
{"current_version": "1.0", "versions": {"1.0": {"needs": {"<id>": {...}}, ...}}}
|
|
6
|
+
|
|
7
|
+
This module reads that envelope, selects a version, and hands out the version's need
|
|
8
|
+
records. It is standard library only and knows nothing of the project that reads the
|
|
9
|
+
file: it does not log, it does not know which fields a project defines, and it decides
|
|
10
|
+
no policy. What it finds is returned as :class:`Diagnostic` values -- a code, the
|
|
11
|
+
dotted JSON path of what was found, and a message -- for the reader to report as it
|
|
12
|
+
sees fit; a file no record can be read from raises :class:`ExchangeError`.
|
|
13
|
+
|
|
14
|
+
The rules it reads by are written once, in the package's contract,
|
|
15
|
+
``design/exchange-contract.md``: the envelope and its ``format_version`` (section 3),
|
|
16
|
+
selecting a version (4), a record -- the key is its id, the legacy ``description`` --
|
|
17
|
+
(6), the schema defaults (7), every code (8), and the file's ids as its namespace, which
|
|
18
|
+
a reader that prefixes them follows (11). The corpus
|
|
19
|
+
``tests/fixtures/needs_json_reading_conformance.toml`` pins them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
"CURRENT_VERSION_MISSING",
|
|
32
|
+
"DEFAULTS_REMOVED_WITHOUT_SCHEMA",
|
|
33
|
+
"FORMAT_VERSION",
|
|
34
|
+
"FORMAT_VERSION_INVALID",
|
|
35
|
+
"FORMAT_VERSION_UNSUPPORTED",
|
|
36
|
+
"ID_MISMATCH",
|
|
37
|
+
"INVALID_JSON",
|
|
38
|
+
"NEEDS_MISSING",
|
|
39
|
+
"NOT_AN_OBJECT",
|
|
40
|
+
"RECORD_NOT_OBJECT",
|
|
41
|
+
"VERSIONS_MISSING",
|
|
42
|
+
"VERSION_NOT_FOUND",
|
|
43
|
+
"Diagnostic",
|
|
44
|
+
"Document",
|
|
45
|
+
"ExchangeError",
|
|
46
|
+
"Record",
|
|
47
|
+
"Version",
|
|
48
|
+
"load",
|
|
49
|
+
"map_legacy_keys",
|
|
50
|
+
"parse",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
FORMAT_VERSION = 1
|
|
54
|
+
"""The format version this reader reads; a file with no ``format_version`` is of it."""
|
|
55
|
+
|
|
56
|
+
# the codes of an ExchangeError: no record can be read from the source
|
|
57
|
+
INVALID_JSON = "invalid_json"
|
|
58
|
+
"""The file cannot be read, is not UTF-8, or is not valid JSON."""
|
|
59
|
+
NOT_AN_OBJECT = "not_an_object"
|
|
60
|
+
"""The top level is not a JSON object."""
|
|
61
|
+
FORMAT_VERSION_INVALID = "format_version_invalid"
|
|
62
|
+
"""``format_version`` is present, and is not an integer of 1 or more."""
|
|
63
|
+
VERSIONS_MISSING = "versions_missing"
|
|
64
|
+
"""``versions`` is absent or not an object."""
|
|
65
|
+
CURRENT_VERSION_MISSING = "current_version_missing"
|
|
66
|
+
"""No version is requested, and ``current_version`` is absent, not a string, or empty
|
|
67
|
+
(and the file has no version of that empty name)."""
|
|
68
|
+
VERSION_NOT_FOUND = "version_not_found"
|
|
69
|
+
"""The version asked for (requested, or the current one) is not a key of ``versions``."""
|
|
70
|
+
NEEDS_MISSING = "needs_missing"
|
|
71
|
+
"""The selected version has no ``needs`` object."""
|
|
72
|
+
|
|
73
|
+
# the codes of a Diagnostic: the source is read, and this is reported
|
|
74
|
+
RECORD_NOT_OBJECT = "record_not_object"
|
|
75
|
+
"""A value of ``needs`` is not an object; that record is skipped."""
|
|
76
|
+
ID_MISMATCH = "id_mismatch"
|
|
77
|
+
"""A record's ``id`` differs from its key; the key is its id."""
|
|
78
|
+
FORMAT_VERSION_UNSUPPORTED = "format_version_unsupported"
|
|
79
|
+
"""``format_version`` is above :data:`FORMAT_VERSION`; the file is read as that format."""
|
|
80
|
+
DEFAULTS_REMOVED_WITHOUT_SCHEMA = "defaults_removed_without_schema"
|
|
81
|
+
"""The version was written without its default values, and carries no ``needs_schema``
|
|
82
|
+
to restore them from."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ExchangeError(Exception):
|
|
86
|
+
"""A needs exchange file no record can be read from.
|
|
87
|
+
|
|
88
|
+
:param code: What is wrong, one of the module's error codes.
|
|
89
|
+
:param message: What is wrong, for a person.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, code: str, message: str) -> None:
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.code = code
|
|
95
|
+
self.message = message
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class Diagnostic:
|
|
100
|
+
"""Something found in a file that is read nonetheless."""
|
|
101
|
+
|
|
102
|
+
code: str
|
|
103
|
+
"""What was found, one of the module's diagnostic codes."""
|
|
104
|
+
path: str
|
|
105
|
+
"""The dotted JSON path of what was found, e.g. ``versions.1.0.needs.REQ_1.id``."""
|
|
106
|
+
message: str
|
|
107
|
+
"""What was found, for a person."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class Record:
|
|
112
|
+
"""One need record of a version."""
|
|
113
|
+
|
|
114
|
+
key: str
|
|
115
|
+
"""The key of the record in the version's ``needs``: the need's id."""
|
|
116
|
+
fields: dict[str, Any]
|
|
117
|
+
"""The record's fields, its ``id`` the key; the reader's own copy."""
|
|
118
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
119
|
+
"""What was found in this record."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class Version:
|
|
124
|
+
"""A selected version of a document."""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
"""The version's key in ``versions``."""
|
|
128
|
+
data: Mapping[str, Any]
|
|
129
|
+
"""The version's object, as the file has it."""
|
|
130
|
+
diagnostics: tuple[Diagnostic, ...] = field(default=())
|
|
131
|
+
"""What was found in the version, beside its records: the records skipped, and
|
|
132
|
+
stripped defaults with no schema to restore them."""
|
|
133
|
+
|
|
134
|
+
def schema_defaults(self) -> dict[str, Any]:
|
|
135
|
+
"""The ``default`` of each property of the version's ``needs_schema``."""
|
|
136
|
+
schema = self.data.get("needs_schema")
|
|
137
|
+
properties = schema.get("properties") if isinstance(schema, Mapping) else None
|
|
138
|
+
if not isinstance(properties, Mapping):
|
|
139
|
+
return {}
|
|
140
|
+
return {
|
|
141
|
+
name: spec["default"]
|
|
142
|
+
for name, spec in properties.items()
|
|
143
|
+
if isinstance(spec, Mapping) and "default" in spec
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def records(self, apply_schema_defaults: bool = True) -> list[Record]:
|
|
147
|
+
"""The version's records, in the file's order.
|
|
148
|
+
|
|
149
|
+
The key of each is its id, its legacy keys are mapped, and the schema defaults
|
|
150
|
+
are merged under it (contract sections 6 and 7). A value of ``needs`` that is
|
|
151
|
+
not an object is not a record (it is reported in :attr:`diagnostics`). Each
|
|
152
|
+
record's ``fields`` is a new dict, but its values are not copied: a list or
|
|
153
|
+
object value is the document's own, and a default is the same object in every
|
|
154
|
+
record it fills. A reader replaces a value rather than changing it in place.
|
|
155
|
+
(Copying them costs more than reading the file: about a second for a needs.json
|
|
156
|
+
of twenty thousand needs.)
|
|
157
|
+
|
|
158
|
+
:param apply_schema_defaults: Merge the ``needs_schema`` defaults under each
|
|
159
|
+
record.
|
|
160
|
+
"""
|
|
161
|
+
defaults = self.schema_defaults() if apply_schema_defaults else {}
|
|
162
|
+
needs: Mapping[str, Any] = self.data["needs"]
|
|
163
|
+
result: list[Record] = []
|
|
164
|
+
for key, value in needs.items():
|
|
165
|
+
if not isinstance(value, Mapping):
|
|
166
|
+
continue
|
|
167
|
+
fields = {**defaults, **value}
|
|
168
|
+
diagnostics: list[Diagnostic] = []
|
|
169
|
+
if "id" in value and value["id"] != key:
|
|
170
|
+
diagnostics.append(
|
|
171
|
+
Diagnostic(
|
|
172
|
+
ID_MISMATCH,
|
|
173
|
+
_path("versions", self.name, "needs", key, "id"),
|
|
174
|
+
f"the need keyed {key!r} has the id {value['id']!r}; "
|
|
175
|
+
"the key is its id",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
fields["id"] = key
|
|
179
|
+
map_legacy_keys(fields)
|
|
180
|
+
result.append(Record(key, fields, tuple(diagnostics)))
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class Document:
|
|
186
|
+
"""A parsed needs exchange file."""
|
|
187
|
+
|
|
188
|
+
data: Mapping[str, Any]
|
|
189
|
+
"""The top-level object, as the file has it."""
|
|
190
|
+
|
|
191
|
+
def select_version(self, requested: str | None) -> Version:
|
|
192
|
+
"""The version ``requested``, or the file's ``current_version`` (contract
|
|
193
|
+
sections 3 and 4).
|
|
194
|
+
|
|
195
|
+
:raises ExchangeError: If ``format_version`` is not an integer of 1 or more,
|
|
196
|
+
``versions`` is missing, no version is requested and ``current_version``
|
|
197
|
+
names none, the version is not in the file, or it has no ``needs`` object.
|
|
198
|
+
"""
|
|
199
|
+
diagnostics: list[Diagnostic] = []
|
|
200
|
+
if "format_version" in self.data:
|
|
201
|
+
stated = self.data["format_version"]
|
|
202
|
+
# a JSON ``true`` is a Python ``bool``, which is an ``int``: not a version
|
|
203
|
+
if isinstance(stated, bool) or not isinstance(stated, int) or stated < 1:
|
|
204
|
+
raise ExchangeError(
|
|
205
|
+
FORMAT_VERSION_INVALID,
|
|
206
|
+
f"'format_version' is {stated!r}, not an integer of 1 or more",
|
|
207
|
+
)
|
|
208
|
+
if stated > FORMAT_VERSION:
|
|
209
|
+
diagnostics.append(
|
|
210
|
+
Diagnostic(
|
|
211
|
+
FORMAT_VERSION_UNSUPPORTED,
|
|
212
|
+
_path("format_version"),
|
|
213
|
+
f"the file is of format version {stated}; this reader reads "
|
|
214
|
+
f"format version {FORMAT_VERSION}, and reads the file as that",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
versions = self.data.get("versions")
|
|
218
|
+
if not isinstance(versions, Mapping):
|
|
219
|
+
raise ExchangeError(
|
|
220
|
+
VERSIONS_MISSING, "'versions' is missing or not an object"
|
|
221
|
+
)
|
|
222
|
+
if requested is None:
|
|
223
|
+
current = self.data.get("current_version")
|
|
224
|
+
# an empty name is a version's name when the file has one by it: the
|
|
225
|
+
# writer names the version after the project's ``version``, which may be
|
|
226
|
+
# unset
|
|
227
|
+
if not isinstance(current, str) or not (current or current in versions):
|
|
228
|
+
raise ExchangeError(
|
|
229
|
+
CURRENT_VERSION_MISSING,
|
|
230
|
+
"no version is requested and 'current_version' is missing, "
|
|
231
|
+
"empty or not a string",
|
|
232
|
+
)
|
|
233
|
+
requested = current
|
|
234
|
+
if requested not in versions:
|
|
235
|
+
raise ExchangeError(
|
|
236
|
+
VERSION_NOT_FOUND,
|
|
237
|
+
f"Version {requested!r} not found in the file: {list(versions)}",
|
|
238
|
+
)
|
|
239
|
+
data = versions[requested]
|
|
240
|
+
if not isinstance(data, Mapping) or not isinstance(data.get("needs"), Mapping):
|
|
241
|
+
raise ExchangeError(
|
|
242
|
+
NEEDS_MISSING, f"Version {requested} has no 'needs' object"
|
|
243
|
+
)
|
|
244
|
+
diagnostics.extend(
|
|
245
|
+
Diagnostic(
|
|
246
|
+
RECORD_NOT_OBJECT,
|
|
247
|
+
_path("versions", requested, "needs", key),
|
|
248
|
+
f"the need {key!r} is not an object; it is skipped",
|
|
249
|
+
)
|
|
250
|
+
for key, value in data["needs"].items()
|
|
251
|
+
if not isinstance(value, Mapping)
|
|
252
|
+
)
|
|
253
|
+
if data.get("needs_defaults_removed") is True and "needs_schema" not in data:
|
|
254
|
+
diagnostics.append(
|
|
255
|
+
Diagnostic(
|
|
256
|
+
DEFAULTS_REMOVED_WITHOUT_SCHEMA,
|
|
257
|
+
_path("versions", requested, "needs_defaults_removed"),
|
|
258
|
+
f"Version {requested} was written without its default values "
|
|
259
|
+
"('needs_defaults_removed') and has no 'needs_schema' to restore "
|
|
260
|
+
"them from; the reading project's defaults are used",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return Version(requested, data, tuple(diagnostics))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse(text: str | bytes) -> Document:
|
|
267
|
+
"""Parse a needs exchange file's text (``bytes`` are decoded as UTF-8).
|
|
268
|
+
|
|
269
|
+
:raises ExchangeError: If it is not UTF-8, not JSON, or not a JSON object.
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(text, bytes):
|
|
272
|
+
try:
|
|
273
|
+
text = text.decode("utf-8")
|
|
274
|
+
except UnicodeDecodeError as err:
|
|
275
|
+
raise ExchangeError(INVALID_JSON, f"not valid UTF-8: {err}") from err
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(text)
|
|
278
|
+
except json.JSONDecodeError as err:
|
|
279
|
+
raise ExchangeError(INVALID_JSON, f"not valid JSON: {err}") from err
|
|
280
|
+
if not isinstance(data, dict):
|
|
281
|
+
raise ExchangeError(NOT_AN_OBJECT, "the top level is not a JSON object")
|
|
282
|
+
return Document(data)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load(path: str | Path) -> Document:
|
|
286
|
+
"""Read and parse a needs exchange file (UTF-8).
|
|
287
|
+
|
|
288
|
+
:raises ExchangeError: If the file cannot be read, or :func:`parse` refuses it.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
292
|
+
except UnicodeDecodeError as err:
|
|
293
|
+
raise ExchangeError(INVALID_JSON, f"not valid UTF-8: {err}") from err
|
|
294
|
+
except OSError as err:
|
|
295
|
+
raise ExchangeError(INVALID_JSON, f"cannot be read: {err}") from err
|
|
296
|
+
return parse(text)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def map_legacy_keys(fields: dict[str, Any]) -> None:
|
|
300
|
+
"""Rename a record's legacy keys in place: a ``description`` is the ``content``
|
|
301
|
+
when that is absent or empty (sphinx-needs before 4.0 wrote ``description``;
|
|
302
|
+
contract section 6.3)."""
|
|
303
|
+
if "description" in fields and not fields.get("content"):
|
|
304
|
+
fields["content"] = fields.pop("description")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _path(*parts: str) -> str:
|
|
308
|
+
return ".".join(parts)
|