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.
- ub_project-1.0.0/LICENSE +21 -0
- ub_project-1.0.0/PKG-INFO +103 -0
- ub_project-1.0.0/README.rst +74 -0
- ub_project-1.0.0/pyproject.toml +51 -0
- ub_project-1.0.0/src/ub_project/__init__.py +60 -0
- ub_project-1.0.0/src/ub_project/project.py +258 -0
- ub_project-1.0.0/src/ub_project/py.typed +0 -0
- ub_project-1.0.0/src/ub_project/variant_data.py +164 -0
- ub_project-1.0.0/src/ub_project/variants.py +216 -0
ub_project-1.0.0/LICENSE
ADDED
|
@@ -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
|
+
)
|