ub-project 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.
@@ -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,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: ub-project
3
+ Version: 1.0.0
4
+ Summary: The shared reader for ubproject.toml and its variant data, for the sphinx-needs family and ubCode
5
+ Keywords: ubproject.toml,sphinx-needs,ubcode,configuration,variants
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-project/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_project
28
+
29
+ ub-project
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 shared reader for ``ubproject.toml``, the declarative file that describes a
36
+ project to every useblocks tool: the sphinx-needs family of Sphinx extensions
37
+ (sphinx-needs, sphinx-mounts, sphinx-codelinks, sphinx-test-reports), their command lines,
38
+ and ubCode.
39
+
40
+ It holds the parts of that file more than one tool reads, so that they are read one way:
41
+
42
+ - **finding, loading and anchoring** the file -- the walk up to the repository root (or,
43
+ outside a repository, the distribution root), TOML read with every failure named, a dotted table selected, and relative paths anchored at
44
+ the file's own directory;
45
+ - **the** ``[variants]`` **table**, with the legacy ``[needs] variant_data*`` keys as its
46
+ fallback;
47
+ - **the variant-data merge** -- validate, load from JSON, deep-merge, resolve -- in one copy.
48
+
49
+ Standard library only: no Sphinx, no docutils, no sibling distribution. A converter or a
50
+ build action that runs without the documentation toolchain can use it.
51
+
52
+ The three calls
53
+ ---------------
54
+
55
+ .. code-block:: python
56
+
57
+ from pathlib import Path
58
+
59
+ from ub_project import find_project_config, load_toml, read_variants
60
+
61
+ toml_path = find_project_config(Path.cwd()) # or the path your tool is configured with
62
+ if toml_path is not None:
63
+ result = read_variants(load_toml(toml_path), toml_path)
64
+ result.data # the merged variant map
65
+ result.data_file # the data file, anchored at the TOML's directory, or None
66
+ result.location # "variants", "needs" or None
67
+ result.diagnostics # findings to report -- or not
68
+
69
+ Every hard failure is an ``ProjectConfigError`` whose message names the file and the rule that
70
+ was broken.
71
+
72
+ ``[variants]``
73
+ --------------
74
+
75
+ .. code-block:: toml
76
+
77
+ [variants]
78
+ data_file = "variants.json" # one path, anchored at this file's directory
79
+
80
+ [variants.data] # deep-merged over the file; the inline table wins
81
+ edition = "pro"
82
+ build = { debug = false }
83
+
84
+ If ``[variants]`` declares neither key, the reader falls back to ``[needs] variant_data``
85
+ and ``[needs] variant_data_file``. If both locations are set, ``[variants]`` is read whole
86
+ and every ignored ``[needs]`` key is reported.
87
+
88
+ Consumers decide the policy
89
+ ---------------------------
90
+
91
+ This package decides nothing a consumer has reason to decide differently. **Discovery**
92
+ (walk up, or read the Sphinx ``confdir``), **warnings** (a diagnostic is returned, never
93
+ logged: whether the legacy location deserves one is each tool's call), and **the command
94
+ line** (``-D`` in Sphinx, ``-c`` in ubCode) are all the consumer's.
95
+
96
+ The contract
97
+ ------------
98
+
99
+ ``design/reading-contract.md`` is the normative specification, and
100
+ ``tests/fixtures/ubproject_reading_conformance.toml`` is its executable half: a corpus of
101
+ inputs and expected results that this package's suite runs, and that ubCode is to vendor
102
+ and run against its own reader (its reader does not read ``[variants]`` yet).
103
+
@@ -0,0 +1,74 @@
1
+ ub-project
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 shared reader for ``ubproject.toml``, the declarative file that describes a
8
+ project to every useblocks tool: the sphinx-needs family of Sphinx extensions
9
+ (sphinx-needs, sphinx-mounts, sphinx-codelinks, sphinx-test-reports), their command lines,
10
+ and ubCode.
11
+
12
+ It holds the parts of that file more than one tool reads, so that they are read one way:
13
+
14
+ - **finding, loading and anchoring** the file -- the walk up to the repository root (or,
15
+ outside a repository, the distribution root), TOML read with every failure named, a dotted table selected, and relative paths anchored at
16
+ the file's own directory;
17
+ - **the** ``[variants]`` **table**, with the legacy ``[needs] variant_data*`` keys as its
18
+ fallback;
19
+ - **the variant-data merge** -- validate, load from JSON, deep-merge, resolve -- in one copy.
20
+
21
+ Standard library only: no Sphinx, no docutils, no sibling distribution. A converter or a
22
+ build action that runs without the documentation toolchain can use it.
23
+
24
+ The three calls
25
+ ---------------
26
+
27
+ .. code-block:: python
28
+
29
+ from pathlib import Path
30
+
31
+ from ub_project import find_project_config, load_toml, read_variants
32
+
33
+ toml_path = find_project_config(Path.cwd()) # or the path your tool is configured with
34
+ if toml_path is not None:
35
+ result = read_variants(load_toml(toml_path), toml_path)
36
+ result.data # the merged variant map
37
+ result.data_file # the data file, anchored at the TOML's directory, or None
38
+ result.location # "variants", "needs" or None
39
+ result.diagnostics # findings to report -- or not
40
+
41
+ Every hard failure is an ``ProjectConfigError`` whose message names the file and the rule that
42
+ was broken.
43
+
44
+ ``[variants]``
45
+ --------------
46
+
47
+ .. code-block:: toml
48
+
49
+ [variants]
50
+ data_file = "variants.json" # one path, anchored at this file's directory
51
+
52
+ [variants.data] # deep-merged over the file; the inline table wins
53
+ edition = "pro"
54
+ build = { debug = false }
55
+
56
+ If ``[variants]`` declares neither key, the reader falls back to ``[needs] variant_data``
57
+ and ``[needs] variant_data_file``. If both locations are set, ``[variants]`` is read whole
58
+ and every ignored ``[needs]`` key is reported.
59
+
60
+ Consumers decide the policy
61
+ ---------------------------
62
+
63
+ This package decides nothing a consumer has reason to decide differently. **Discovery**
64
+ (walk up, or read the Sphinx ``confdir``), **warnings** (a diagnostic is returned, never
65
+ logged: whether the legacy location deserves one is each tool's call), and **the command
66
+ line** (``-D`` in Sphinx, ``-c`` in ubCode) are all the consumer's.
67
+
68
+ The contract
69
+ ------------
70
+
71
+ ``design/reading-contract.md`` is the normative specification, and
72
+ ``tests/fixtures/ubproject_reading_conformance.toml`` is its executable half: a corpus of
73
+ inputs and expected results that this package's suite runs, and that ubCode is to vendor
74
+ and run against its own reader (its reader does not read ``[variants]`` yet).
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "ub-project"
3
+ version = "1.0.0"
4
+ description = "The shared reader for ubproject.toml and its variant data, 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: `tomllib` and `json`
10
+ # are the standard library, and a dependency here -- Sphinx above all -- would reach every
11
+ # tool that reads the file, 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
+ "ubproject.toml",
17
+ "sphinx-needs",
18
+ "ubcode",
19
+ "configuration",
20
+ "variants",
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-project/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 (measured). Everything else -- ruff, ty, pytest and the dependency groups --
51
+ # is the ROOT's, and `tools/src/sn_tools/check_workspace.py` refuses those tables here.
@@ -0,0 +1,60 @@
1
+ """The shared reader for ``ubproject.toml`` and its variant data.
2
+
3
+ One implementation of the parts of ``ubproject.toml`` that more than one tool reads -- the
4
+ sphinx-needs family of Sphinx extensions and their command lines, which will depend on it,
5
+ and ubCode, which is to be held to the same behaviour through the conformance corpus in
6
+ this package's tests. Standard library only: no Sphinx, no docutils, no sibling
7
+ distribution.
8
+
9
+ It decides no policy. Discovery (walk up or read the ``confdir``), whether a finding is
10
+ worth a warning, and how ``-D`` / ``-c`` on a command line interact with the file are each
11
+ consumer's to decide; this package returns what it found and raises
12
+ :class:`ProjectConfigError` for what it cannot accept.
13
+ """
14
+
15
+ from ub_project.project import (
16
+ DEFAULT_FILENAME,
17
+ ProjectConfigError,
18
+ anchor,
19
+ find_project_config,
20
+ load_toml,
21
+ select_table,
22
+ table_path,
23
+ )
24
+ from ub_project.variant_data import (
25
+ deep_merge,
26
+ load_variant_data_file,
27
+ resolve_variant_data,
28
+ validate_variant_data,
29
+ )
30
+ from ub_project.variants import (
31
+ VARIANT_DATA_LEGACY_LOCATION,
32
+ VARIANT_DATA_LOCATION,
33
+ VARIANTS_UNKNOWN_KEY,
34
+ Diagnostic,
35
+ VariantsResult,
36
+ read_variants,
37
+ )
38
+
39
+ __version__ = "1.0.0"
40
+
41
+ __all__ = [
42
+ "DEFAULT_FILENAME",
43
+ "VARIANTS_UNKNOWN_KEY",
44
+ "VARIANT_DATA_LEGACY_LOCATION",
45
+ "VARIANT_DATA_LOCATION",
46
+ "Diagnostic",
47
+ "ProjectConfigError",
48
+ "VariantsResult",
49
+ "__version__",
50
+ "anchor",
51
+ "deep_merge",
52
+ "find_project_config",
53
+ "load_toml",
54
+ "load_variant_data_file",
55
+ "read_variants",
56
+ "resolve_variant_data",
57
+ "select_table",
58
+ "table_path",
59
+ "validate_variant_data",
60
+ ]
@@ -0,0 +1,258 @@
1
+ """Find, load, select and anchor: the plumbing every reader of ``ubproject.toml`` repeats.
2
+
3
+ **Nothing in this package may import Sphinx, docutils or any other distribution** -- only
4
+ the standard library. The file describes a project to tools that run with no documentation
5
+ toolchain installed (a converter, a build action, a CI step that only reads the file), and a
6
+ shared reader that pulled Sphinx in would take that away from all of them at once.
7
+
8
+ What this module deliberately does NOT decide is policy. Whether a consumer walks up to
9
+ find the file or reads it from its ``confdir``, whether a missing file is worth a warning,
10
+ whether a ``-D`` on the command line beats a key in the file: those are the consumer's
11
+ decisions, and they differ today for reasons each consumer owns. This module provides the
12
+ mechanisms, and reports through return values and :class:`ProjectConfigError` -- never through
13
+ a logger.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import tomllib
20
+ from collections.abc import Callable, Mapping, Sequence
21
+ from pathlib import Path
22
+
23
+ #: The file name every useblocks tool reads by default.
24
+ DEFAULT_FILENAME = "ubproject.toml"
25
+
26
+ #: Directory entries that end the upward search of :func:`find_project_config`. A
27
+ #: directory carrying one is the repository root, and nothing above it belongs to the
28
+ #: project, so a consumer never adopts the configuration of an unrelated parent.
29
+ #: ``pyproject.toml`` is deliberately *not* a marker here: it marks a Python distribution,
30
+ #: not the project. A ``docs/`` directory with its own ``pyproject.toml``, or a workspace
31
+ #: member (``packages/<name>/pyproject.toml`` with the docs below it), sits *inside* the
32
+ #: project whose shared file is at the repository root, and a marker there would end the
33
+ #: search before it reached the file -- silently. It does bound the walk where there is no
34
+ #: repository at all, see :data:`_DIST_MARKERS`.
35
+ _ROOT_MARKERS = (".git",)
36
+
37
+ #: Directory entries that end the search when *no* :data:`_ROOT_MARKERS` marker exists
38
+ #: anywhere above the starting directory. A tree outside any repository -- an unpacked
39
+ #: sdist, a CI artefact directory, an exported docs tree -- has nothing else to bound the
40
+ #: walk, so it would reach the filesystem root and adopt whatever unrelated file sits
41
+ #: above it. The distribution root is the outermost thing that still belongs to such a
42
+ #: tree.
43
+ _DIST_MARKERS = ("pyproject.toml",)
44
+
45
+ #: A TOML bare key; any other key is written quoted (:func:`render_path`).
46
+ _BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
47
+
48
+ #: The TOML basic-string escapes with a short form; other control characters use ``\\uXXXX``.
49
+ _TOML_ESCAPES = {
50
+ '"': '\\"',
51
+ "\\": "\\\\",
52
+ "\b": "\\b",
53
+ "\t": "\\t",
54
+ "\n": "\\n",
55
+ "\f": "\\f",
56
+ "\r": "\\r",
57
+ }
58
+
59
+ #: ``tomllib.TOMLDecodeError`` bound through an annotation, so that the ``except`` clause
60
+ #: below is typed as precisely as everything else in the module.
61
+ _TOML_DECODE_ERROR: type[Exception] = tomllib.TOMLDecodeError
62
+
63
+
64
+ class ProjectConfigError(Exception):
65
+ """The one exception this package raises, for every hard failure.
66
+
67
+ Each message names the file (or the dotted path inside it) and the rule that was
68
+ broken. A consumer re-raises it in its own vocabulary -- a Sphinx extension as a
69
+ configuration error, a command line as a non-zero exit -- which is why it derives from
70
+ nothing more specific than :class:`Exception`.
71
+ """
72
+
73
+
74
+ def find_project_config(
75
+ start: Path,
76
+ filename: str = DEFAULT_FILENAME,
77
+ report: Callable[[str], None] | None = None,
78
+ ) -> Path | None:
79
+ """Search *start* and its parents for *filename*.
80
+
81
+ The file conventionally sits at the repository root while its consumers run from
82
+ below it -- ``conf.py`` in ``docs/``, a build action from wherever CI invoked it -- so
83
+ anchoring strictly at the caller's own directory would leave the shared file unread
84
+ by one of them, silently.
85
+
86
+ The walk ends at the first directory holding *filename*, or at the project boundary
87
+ when that does not hold the file either. The boundary is the repository root
88
+ (a directory holding ``.git``), or -- outside any repository only -- the distribution
89
+ root (a directory holding ``pyproject.toml``).
90
+
91
+ Whether to call this at all is the consumer's decision: providing the walk is not a
92
+ ruling that every consumer should walk.
93
+
94
+ :param start: Directory to start from. Made absolute -- without resolving symlinks --
95
+ so that a relative path has parents to walk.
96
+ :param filename: The file to look for.
97
+ :param report: Called with one message when the search ends without the file, naming
98
+ the directory whose marker ended it; ``None`` discards it. A missing file is not
99
+ necessarily a problem, but a fruitless search must be diagnosable.
100
+ :return: The file, or ``None`` when the search reached the project boundary or the
101
+ filesystem root without finding one.
102
+ """
103
+ start = start.absolute()
104
+ directories = (start, *start.parents)
105
+ boundary, described = _boundary(directories)
106
+ for directory in directories:
107
+ candidate = directory / filename
108
+ if candidate.is_file():
109
+ return candidate
110
+ if directory == boundary:
111
+ break
112
+ if report is not None:
113
+ report(f"no {filename} in {start} or its parents up to {described}")
114
+ return None
115
+
116
+
117
+ def _boundary(directories: tuple[Path, ...]) -> tuple[Path | None, str]:
118
+ """The directory the upward search must not walk past, and its description.
119
+
120
+ A repository root anywhere above the start wins: inside a repository the only thing
121
+ that bounds the project is the repository itself. Only when there is none does the
122
+ distribution root bound the walk.
123
+ """
124
+ for markers, label in (
125
+ (_ROOT_MARKERS, "repository"),
126
+ (_DIST_MARKERS, "distribution"),
127
+ ):
128
+ for directory in directories:
129
+ marker = _marker(directory, markers)
130
+ if marker is not None:
131
+ return directory, f"the {label} root {directory} (holding {marker})"
132
+ return None, "the filesystem root"
133
+
134
+
135
+ def _marker(directory: Path, markers: tuple[str, ...]) -> str | None:
136
+ """The entry of *markers* that *directory* holds, if any."""
137
+ for marker in markers:
138
+ if (directory / marker).exists():
139
+ return marker
140
+ return None
141
+
142
+
143
+ def load_toml(path: Path) -> dict[str, object]:
144
+ """Parse *path*, reporting every failure as an :class:`ProjectConfigError` naming it.
145
+
146
+ A missing file is a failure here too: whether an absent file is fine is the
147
+ consumer's decision, taken before it calls this.
148
+
149
+ :raises ProjectConfigError: If the file cannot be read, is not UTF-8, or is not valid TOML.
150
+ """
151
+ try:
152
+ with path.open("rb") as handle:
153
+ # tomllib is typed ``-> dict[str, Any]``; everything downstream is ``object``
154
+ data: dict[str, object] = tomllib.load(handle)
155
+ except _TOML_DECODE_ERROR as error:
156
+ msg = f"{path}: invalid TOML: {error}"
157
+ raise ProjectConfigError(msg) from error
158
+ except UnicodeDecodeError as error:
159
+ # tomllib decodes the bytes itself, and a file saved in another encoding raises
160
+ # neither of the two errors around it
161
+ msg = f"{path}: not valid UTF-8 TOML: {error}"
162
+ raise ProjectConfigError(msg) from error
163
+ except OSError as error:
164
+ msg = f"{path}: cannot be read: {error}"
165
+ raise ProjectConfigError(msg) from error
166
+ return data
167
+
168
+
169
+ def _basic_string(key: str) -> str:
170
+ """*key* as a TOML basic string: only ``"``, ``\\`` and control characters escaped.
171
+
172
+ Everything else is written as itself -- ``café`` is ``"café"``, not the ``\\u00e9``
173
+ that ``json.dumps`` would produce -- because that is how the user wrote the key.
174
+ """
175
+ out = []
176
+ for char in key:
177
+ if char in _TOML_ESCAPES:
178
+ out.append(_TOML_ESCAPES[char])
179
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
180
+ out.append(f"\\u{ord(char):04X}")
181
+ else:
182
+ out.append(char)
183
+ return '"' + "".join(out) + '"'
184
+
185
+
186
+ def render_path(segments: Sequence[str]) -> str:
187
+ """Spell a table path the way TOML does: dotted, a segment that is not a bare key quoted.
188
+
189
+ A segment matching ``[A-Za-z0-9_-]+`` is written bare; any other is a TOML basic string
190
+ (:func:`_basic_string`). ``("tool", "acme.docs", "needs")`` renders as
191
+ ``tool."acme.docs".needs`` -- joined naively it would read as four segments, and name
192
+ a table that does not exist.
193
+ """
194
+ return ".".join(
195
+ segment if _BARE_KEY.fullmatch(segment) else _basic_string(segment)
196
+ for segment in segments
197
+ )
198
+
199
+
200
+ def table_path(table: str | Sequence[str]) -> tuple[str, ...]:
201
+ """The segments of a table path, given dotted (``"a.b.c"``) or as a sequence.
202
+
203
+ The sequence form exists for a key that itself contains a dot, which the dotted
204
+ spelling cannot express -- TOML allows ``[tool."acme.docs".needs]``.
205
+
206
+ :raises ValueError: If the path is empty or has an empty segment -- a caller's
207
+ mistake, not a problem with the file.
208
+ """
209
+ segments = tuple(table.split(".")) if isinstance(table, str) else tuple(table)
210
+ if not segments or any(not segment for segment in segments):
211
+ msg = f"invalid table path {table!r}: expected one or more non-empty segments"
212
+ raise ValueError(msg)
213
+ return segments
214
+
215
+
216
+ def select_table(
217
+ data: Mapping[str, object],
218
+ table: str | Sequence[str],
219
+ *,
220
+ source: Path | None = None,
221
+ ) -> dict[str, object] | None:
222
+ """Select the table at *table* inside *data*.
223
+
224
+ :param data: A parsed TOML document, or any table inside one.
225
+ :param table: The path to select, dotted (``"tool.acme.needs"``) or as a sequence of
226
+ keys.
227
+ :param source: The file *data* came from, for error messages only.
228
+ :return: The table, or ``None`` when any segment of the path is absent.
229
+ :raises ProjectConfigError: If a segment is present but is not a table.
230
+ """
231
+ segments = table_path(table)
232
+ current: Mapping[str, object] = data
233
+ for depth, segment in enumerate(segments):
234
+ value = current.get(segment)
235
+ if value is None:
236
+ return None
237
+ if not isinstance(value, dict):
238
+ where = "" if source is None else f"{source}: "
239
+ dotted = render_path(segments[: depth + 1])
240
+ msg = f"{where}[{dotted}] must be a table, got {type(value).__name__}"
241
+ raise ProjectConfigError(msg)
242
+ current = value
243
+ return dict(current)
244
+
245
+
246
+ def anchor(value: str | Path, base: Path) -> Path:
247
+ """Anchor a path read from the file at *base*, the file's own directory.
248
+
249
+ A relative *value* is joined onto *base*; an absolute one is returned untouched.
250
+ Joined, deliberately never ``resolve()``\\ d and never normalised: whether symlinks
251
+ are collapsed, or ``..`` folded away, is the consumer's decision -- one resolves for
252
+ symlink confinement, another keeps the form it was handed -- and the reader must not
253
+ take it behind their back.
254
+ """
255
+ path = Path(value)
256
+ if path.is_absolute():
257
+ return path
258
+ return base / path
File without changes
@@ -0,0 +1,164 @@
1
+ """Variant data: validate it, load it from a JSON file, merge it, resolve it.
2
+
3
+ The one copy of these four functions for the sphinx-needs family, to replace the copies
4
+ sphinx-needs and sphinx-mounts carry once they adopt it (mounts' copy exists so that it
5
+ never depends on sphinx-needs). Measured, the two copies differ in six places, and this
6
+ one takes a side on each:
7
+
8
+ * :func:`resolve_variant_data` always returns a FRESH merged mapping -- never one of its
9
+ arguments -- so that a consumer can store and change it freely at the top level
10
+ (mounts' behaviour; sphinx-needs handed back the inline object when there was no file);
11
+ * the error wording is the set below, each message naming the dotted path (``var.a.b``)
12
+ and the rule it broke;
13
+ * every failure to load the file is an :class:`~ub_project.project.ProjectConfigError`
14
+ (mounts'; sphinx-needs let ``OSError`` and ``UnicodeDecodeError`` escape);
15
+ * an empty path is not "no file": the API takes ``Path | None``, and ``""`` names nothing
16
+ (mounts'; sphinx-needs read ``""`` as no file -- on the ``conf.py``/``-D`` route BOTH
17
+ consumers do, so a consumer maps ``""`` to ``None`` before calling this);
18
+ * :func:`deep_merge` returns plain ``dict`` s at every level it builds, where both copies
19
+ returned the input's own mapping type (``.copy()``) -- unobservable through TOML or JSON,
20
+ which only produce plain dicts;
21
+ * :func:`load_variant_data_file` takes a ``str`` path as well as a ``Path`` (sphinx-needs';
22
+ mounts' copy refused a ``str`` with ``AttributeError``).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from collections.abc import Mapping
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ub_project.project import ProjectConfigError
33
+
34
+ #: The types a leaf value, or every element of an array, may have. ``bool`` is listed
35
+ #: although it is an ``int``: an array is checked by EXACT type, so ``[1, True]`` is mixed.
36
+ _SCALAR_TYPES = (str, bool, int, float)
37
+
38
+ _SCALARS = "str, bool, int or float"
39
+
40
+
41
+ def validate_variant_data(data: object, path: str = "var") -> None:
42
+ """Check that *data* has the shape a variant map is allowed to have.
43
+
44
+ A table with string keys, whose values are scalars (``str``, ``bool``, ``int``,
45
+ ``float``), arrays that are empty or hold scalars of one exact type, or tables of the
46
+ same shape, recursively.
47
+
48
+ :param data: The value to check.
49
+ :param path: The dotted path of *data*, for the error message.
50
+ :raises ProjectConfigError: On the first violation, naming its dotted path.
51
+ """
52
+ if not isinstance(data, dict):
53
+ msg = f"{path}: variant data must be a table, got {type(data).__name__}"
54
+ raise ProjectConfigError(msg)
55
+ for key, value in data.items():
56
+ if not isinstance(key, str):
57
+ msg = f"{path}: keys must be strings, got {type(key).__name__} {key!r}"
58
+ raise ProjectConfigError(msg)
59
+ full = f"{path}.{key}"
60
+ if isinstance(value, dict):
61
+ validate_variant_data(value, full)
62
+ elif isinstance(value, list):
63
+ _validate_array(value, full)
64
+ elif not isinstance(value, _SCALAR_TYPES):
65
+ msg = (
66
+ f"{full}: a value must be a {_SCALARS}, an array or a table, "
67
+ f"got {type(value).__name__}"
68
+ )
69
+ raise ProjectConfigError(msg)
70
+
71
+
72
+ def _validate_array(value: list[Any], path: str) -> None:
73
+ """An array must be empty, or hold scalars of one exact type."""
74
+ if not value:
75
+ return
76
+ first = type(value[0])
77
+ if first not in _SCALAR_TYPES:
78
+ msg = f"{path}: array elements must be a {_SCALARS}, got {first.__name__}"
79
+ raise ProjectConfigError(msg)
80
+ for index, item in enumerate(value):
81
+ if type(item) is not first:
82
+ msg = (
83
+ f"{path}[{index}]: an array must hold one type, expected "
84
+ f"{first.__name__} but got {type(item).__name__}"
85
+ )
86
+ raise ProjectConfigError(msg)
87
+
88
+
89
+ def load_variant_data_file(path: Path | str) -> dict[str, Any]:
90
+ """Load a variant-data JSON file and validate its shape.
91
+
92
+ :param path: The file, already anchored by the caller.
93
+ :return: The validated mapping.
94
+ :raises ProjectConfigError: If the file is missing or unreadable, is not JSON, does not
95
+ hold a JSON object, or holds one of the wrong shape.
96
+ """
97
+ file = Path(path)
98
+ if file.is_dir():
99
+ msg = f"variant data file {file} is a directory"
100
+ raise ProjectConfigError(msg)
101
+ if not file.is_file():
102
+ msg = f"variant data file not found: {file}"
103
+ raise ProjectConfigError(msg)
104
+ try:
105
+ raw: object = json.loads(file.read_text(encoding="utf-8"))
106
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
107
+ msg = f"variant data file {file} is not valid JSON: {error}"
108
+ raise ProjectConfigError(msg) from error
109
+ except OSError as error:
110
+ msg = f"variant data file {file} cannot be read: {error}"
111
+ raise ProjectConfigError(msg) from error
112
+ if not isinstance(raw, dict):
113
+ msg = (
114
+ f"variant data file {file} must hold a JSON object, "
115
+ f"got {type(raw).__name__}"
116
+ )
117
+ raise ProjectConfigError(msg)
118
+ try:
119
+ validate_variant_data(raw)
120
+ except ProjectConfigError as error:
121
+ msg = f"variant data file {file}: {error}"
122
+ raise ProjectConfigError(msg) from error
123
+ return raw
124
+
125
+
126
+ def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
127
+ """Merge *override* into *base*; *override* wins at the leaves.
128
+
129
+ Recurses ONLY when both sides hold a table under the same key. Everything else is a
130
+ wholesale replacement: an array replaces an array, a scalar replaces a table and a
131
+ table a scalar. That rule is what makes the merge idempotent --
132
+ ``deep_merge(base, deep_merge(base, override)) == deep_merge(base, override)`` -- so
133
+ re-merging an already-merged map is a no-op.
134
+
135
+ Neither argument is modified, and the result is a new mapping at every level the merge
136
+ recursed into. (Not a contract, and not something a second reader can reproduce: a
137
+ value taken whole from one side is, in this implementation, that side's object.)
138
+ """
139
+ result = dict(base)
140
+ for key, value in override.items():
141
+ existing = result.get(key)
142
+ if isinstance(existing, dict) and isinstance(value, dict):
143
+ result[key] = deep_merge(existing, value)
144
+ else:
145
+ result[key] = value
146
+ return result
147
+
148
+
149
+ def resolve_variant_data(
150
+ inline: Mapping[str, Any] | None, data_file: Path | None
151
+ ) -> dict[str, Any]:
152
+ """The merged variant map: the file first, the inline table deep-merged on top.
153
+
154
+ :param inline: The inline table; ``None`` and an empty table both mean "none".
155
+ :param data_file: An ALREADY ANCHORED path, or ``None``.
156
+ :return: A fresh mapping, never *inline* itself, even when there is no file.
157
+ :raises ProjectConfigError: If the file or the inline table is malformed.
158
+ """
159
+ base: dict[str, Any] = {}
160
+ if data_file is not None:
161
+ base = load_variant_data_file(data_file)
162
+ if inline:
163
+ validate_variant_data(inline)
164
+ return deep_merge(base, inline or {})
@@ -0,0 +1,216 @@
1
+ """The top-level ``[variants]`` table, with the ``[needs] variant_data*`` fallback.
2
+
3
+ ``[variants]`` holds the project's variant data for every tool that reads it::
4
+
5
+ [variants]
6
+ data_file = "variants.json" # ONE path, a string, anchored at this file's directory
7
+ [variants.data] # deep-merged over the file; the inline table wins
8
+ edition = "pro"
9
+
10
+ Before it existed the same two values lived in the ``[needs]`` table, as
11
+ ``variant_data_file`` and ``variant_data``, and every reader still accepts them there.
12
+ Which location is read is decided WHOLE: both keys come from one table, never one from
13
+ each.
14
+
15
+ ======================================== =================================================
16
+ declared read
17
+ ======================================== =================================================
18
+ ``[variants]`` only ``[variants]``
19
+ ``[needs] variant_data*`` only ``[needs]``, plus ``variant_data_legacy_location``
20
+ both ``[variants]``, plus ``variant_data_location``
21
+ for every ``[needs]`` key it ignores
22
+ neither nothing: an empty map
23
+ ======================================== =================================================
24
+
25
+ A location is DECLARED when it holds at least one of its two keys. A ``[variants]`` table
26
+ holding neither -- empty, or only keys this version does not know -- declares nothing, so
27
+ it cannot switch a project's ``[needs]`` data off.
28
+
29
+ Diagnostics are RETURNED, never logged, and the package takes no side on them: whether
30
+ the legacy location deserves a warning is the consumer's policy (sphinx-needs will warn,
31
+ to move users; ubCode will not, because it supports several sphinx-needs versions at
32
+ once). Hard failures raise :class:`~ub_project.project.ProjectConfigError`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections.abc import Mapping, Sequence
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any, Literal
41
+
42
+ from ub_project.project import (
43
+ ProjectConfigError,
44
+ anchor,
45
+ render_path,
46
+ select_table,
47
+ table_path,
48
+ )
49
+ from ub_project.variant_data import resolve_variant_data
50
+
51
+ #: The top-level table this module reads.
52
+ VARIANTS_TABLE = "variants"
53
+
54
+ #: The keys of ``[variants]``, and their legacy spellings in ``[needs]``, in that order.
55
+ VARIANTS_KEYS = ("data", "data_file")
56
+ LEGACY_KEYS = ("variant_data", "variant_data_file")
57
+
58
+ #: Diagnostic codes. Fixed: consumers suppress and test against them. They are the BARE
59
+ #: subcodes; ubCode is to carry them under its own ``config.`` prefix
60
+ #: (``config.variant_data_location``), and the conformance corpus compares the bare form.
61
+ VARIANT_DATA_LOCATION = "variant_data_location"
62
+ VARIANT_DATA_LEGACY_LOCATION = "variant_data_legacy_location"
63
+ VARIANTS_UNKNOWN_KEY = "variants_unknown_key"
64
+
65
+ Severity = Literal["warning", "info"]
66
+ Location = Literal["variants", "needs"]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class Diagnostic:
71
+ """A non-fatal finding, returned for the consumer to report -- or not.
72
+
73
+ ``code``, ``path`` and ``message`` are those of ubCode's ``ConfigResolutionDiagnostic``
74
+ (whose ``code``, where it has one, carries a ``config.`` prefix), so that the conformance corpus can compare
75
+ the two readers' findings by ``code`` and ``path``; ``severity`` is this package's.
76
+ """
77
+
78
+ code: str
79
+ """One of the fixed codes above."""
80
+ path: str
81
+ """The dotted TOML path the finding is about, e.g. ``needs.variant_data_file``."""
82
+ message: str
83
+ """A sentence for a human; its wording is not part of any contract."""
84
+ severity: Severity
85
+ """``"warning"`` for something the file should change, ``"info"`` otherwise."""
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class VariantsResult:
90
+ """What :func:`read_variants` found."""
91
+
92
+ data: dict[str, Any]
93
+ """The merged variant map: the file, with the inline table deep-merged on top."""
94
+ data_file: Path | None
95
+ """The data file of the location read, anchored at the TOML's directory."""
96
+ location: Location | None
97
+ """The table the data came from, or ``None`` when neither declared any."""
98
+ diagnostics: tuple[Diagnostic, ...]
99
+ """Every non-fatal finding, in a stable order."""
100
+
101
+
102
+ def read_variants(
103
+ root_table: Mapping[str, object],
104
+ toml_path: Path,
105
+ *,
106
+ needs_table: str | Sequence[str] = "needs",
107
+ ) -> VariantsResult:
108
+ """Read the project's variant data from ``[variants]``, or from the legacy location.
109
+
110
+ :param root_table: The parsed TOML document (or the table a consumer's prefix
111
+ selected): ``[variants]`` is read from its top level.
112
+ :param toml_path: The file *root_table* came from. Relative ``data_file`` values are
113
+ anchored at its directory, and error messages name it.
114
+ :param needs_table: Where the legacy keys live inside *root_table*, dotted or as a
115
+ sequence of keys -- ``"tool.acme.needs"`` for a project that nests its sphinx-needs
116
+ configuration under a prefix.
117
+ :raises ProjectConfigError: If a table or key has the wrong type, if the inline data is
118
+ malformed, or if the data file of the location read is missing or malformed.
119
+ """
120
+ needs_path = table_path(needs_table)
121
+ needs_dotted = render_path(needs_path)
122
+
123
+ diagnostics: list[Diagnostic] = []
124
+ # an error, not something to skip, when `variants` is not a table: the name is this
125
+ # contract's, and a `variants = "..."` read as "no variant data" would be the silent
126
+ # vanishing the table exists to end
127
+ variants = select_table(root_table, VARIANTS_TABLE, source=toml_path) or {}
128
+ for key in sorted(set(variants) - set(VARIANTS_KEYS)):
129
+ diagnostics.append(
130
+ Diagnostic(
131
+ code=VARIANTS_UNKNOWN_KEY,
132
+ path=render_path((VARIANTS_TABLE, key)),
133
+ message=(
134
+ f"{toml_path}: ignoring unknown key {key!r} in [{VARIANTS_TABLE}]; "
135
+ f"this version reads {' and '.join(VARIANTS_KEYS)}"
136
+ ),
137
+ severity="warning",
138
+ )
139
+ )
140
+ needs = select_table(root_table, needs_path, source=toml_path) or {}
141
+ # "declared" is "set to a value": TOML has no null, and a Python caller's `None` means
142
+ # absent everywhere else in this package (`select_table` included)
143
+ declared_legacy = [key for key in LEGACY_KEYS if needs.get(key) is not None]
144
+
145
+ if any(variants.get(key) is not None for key in VARIANTS_KEYS):
146
+ location: Location = "variants"
147
+ table, keys, where = variants, VARIANTS_KEYS, f"[{VARIANTS_TABLE}]"
148
+ for key in declared_legacy:
149
+ diagnostics.append(
150
+ Diagnostic(
151
+ code=VARIANT_DATA_LOCATION,
152
+ path=f"{needs_dotted}.{key}",
153
+ message=(
154
+ f"{toml_path}: [{needs_dotted}] {key} is ignored because "
155
+ f"[{VARIANTS_TABLE}] is set, and only one location is read; "
156
+ f"remove the [{needs_dotted}] key"
157
+ ),
158
+ severity="warning",
159
+ )
160
+ )
161
+ elif declared_legacy:
162
+ location = "needs"
163
+ table, keys, where = needs, LEGACY_KEYS, f"[{needs_dotted}]"
164
+ for key in declared_legacy:
165
+ current = VARIANTS_KEYS[LEGACY_KEYS.index(key)]
166
+ diagnostics.append(
167
+ Diagnostic(
168
+ code=VARIANT_DATA_LEGACY_LOCATION,
169
+ path=f"{needs_dotted}.{key}",
170
+ message=(
171
+ f"{toml_path}: variant data is read from its legacy location "
172
+ f"[{needs_dotted}] {key}; [{VARIANTS_TABLE}] {current} is the "
173
+ "current one"
174
+ ),
175
+ severity="info",
176
+ )
177
+ )
178
+ else:
179
+ return VariantsResult(
180
+ data={}, data_file=None, location=None, diagnostics=tuple(diagnostics)
181
+ )
182
+
183
+ inline_key, file_key = keys
184
+ inline = table.get(inline_key)
185
+ if inline is not None and not isinstance(inline, dict):
186
+ msg = (
187
+ f"{toml_path}: {where} {inline_key} must be a table, "
188
+ f"got {type(inline).__name__}"
189
+ )
190
+ raise ProjectConfigError(msg)
191
+ file_value = table.get(file_key)
192
+ data_file: Path | None = None
193
+ if file_value is not None:
194
+ if not isinstance(file_value, str) or not file_value:
195
+ got = (
196
+ repr(file_value)
197
+ if isinstance(file_value, str)
198
+ else type(file_value).__name__
199
+ )
200
+ msg = (
201
+ f"{toml_path}: {where} {file_key} must be one non-empty path string, "
202
+ f"got {got}"
203
+ )
204
+ raise ProjectConfigError(msg)
205
+ data_file = anchor(file_value, toml_path.parent)
206
+ try:
207
+ data = resolve_variant_data(inline, data_file)
208
+ except ProjectConfigError as error:
209
+ msg = f"{toml_path}: {where}: {error}"
210
+ raise ProjectConfigError(msg) from error
211
+ return VariantsResult(
212
+ data=data,
213
+ data_file=data_file,
214
+ location=location,
215
+ diagnostics=tuple(diagnostics),
216
+ )