gri-plot 0.2.5__tar.gz → 0.2.7__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.
- gri_plot-0.2.7/.gen_api_summary.py +592 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/.gitignore +10 -0
- gri_plot-0.2.7/.hatch_build.py +46 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/CONTRIBUTING.md +24 -17
- {gri_plot-0.2.5 → gri_plot-0.2.7}/LICENSE +1 -1
- {gri_plot-0.2.5 → gri_plot-0.2.7}/PKG-INFO +16 -3
- {gri_plot-0.2.5 → gri_plot-0.2.7}/README.md +14 -1
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/__init__.py +14 -12
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/_heatmap_image.py +45 -14
- gri_plot-0.2.7/gri_plot/docs/api_summary.md +402 -0
- gri_plot-0.2.7/gri_plot/docs/overview.md +212 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/figure_map.py +35 -28
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/cone.py +7 -23
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/cylinder.py +7 -18
- {gri_plot-0.2.5 → gri_plot-0.2.7}/pyproject.toml +28 -13
- gri_plot-0.2.7/report.xml +1 -0
- gri_plot-0.2.7/test/test_api_summary.py +21 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_figure_map.py +33 -1
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_heatmap_image.py +60 -1
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/test_cone.py +15 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/test_cylinder.py +15 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/basemap.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/data/natural_earth_boundaries.npz +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/figure3d.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/frames.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/observables/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/observables/aoa.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/observables/los.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/observables/range_sphere.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/plot_ellipse.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/polylines.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/py.typed +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/scatter.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/scatter_map.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/ellipse.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/ellipsoid.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/meshgen.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/shapes/sphere.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/surfaces/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/gri_plot/surfaces/mesh.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/scripts/build_basemap_data.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_basemap.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_figure3d.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_frames.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_observables/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_observables/test_aoa.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_observables/test_los.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_observables/test_range_sphere.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_plot_ellipse.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_plot_scattermap.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_polylines.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_scatter.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/test_ellipse.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/test_ellipsoid.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_shapes/test_sphere.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_surfaces/__init__.py +0 -0
- {gri_plot-0.2.5 → gri_plot-0.2.7}/test/test_surfaces/test_mesh.py +0 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# ruff: noqa: T201
|
|
2
|
+
"""Generate <package>/docs/api_summary.md from the package's public API.
|
|
3
|
+
|
|
4
|
+
The summary ships inside the wheel so that a reader with only the installed
|
|
5
|
+
package -- no repository, no web -- can see every public name, its signature,
|
|
6
|
+
and the first line of its docstring in one file. `help(name)` has the rest.
|
|
7
|
+
|
|
8
|
+
The public API is the package's `__all__` (or, without one, the non-underscore
|
|
9
|
+
names its `__init__.py` defines or imports from inside the package). Each name
|
|
10
|
+
is traced through the re-export chain to the statement that defines it, and
|
|
11
|
+
listed under the module that exports it -- the path to import it from. An
|
|
12
|
+
exported submodule is expanded the same way, so a package whose API is a set of
|
|
13
|
+
subpackages (`from pkg.conversion import ...`) is covered too.
|
|
14
|
+
|
|
15
|
+
Everything is read with `ast`; nothing is imported. That keeps the script
|
|
16
|
+
dependency-free (it runs before the package's own dependencies are installed)
|
|
17
|
+
and free of import side effects.
|
|
18
|
+
|
|
19
|
+
Output is deterministic -- no dates or versions -- so a regenerated file only
|
|
20
|
+
differs when the API does, and `--check` can be used as a staleness gate. A
|
|
21
|
+
package without a docs/ directory is skipped, so the script (distributed to
|
|
22
|
+
every repo) is inert until a repo's docs are set up.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python .gen_api_summary.py # rewrite docs/api_summary.md if stale
|
|
26
|
+
python .gen_api_summary.py --check # exit 1 if stale, write nothing
|
|
27
|
+
python .gen_api_summary.py --root ../gri-pos # another repo
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import ast
|
|
34
|
+
import sys
|
|
35
|
+
import tomllib
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
ROOT = Path(__file__).resolve().parent
|
|
40
|
+
SUMMARY_NAME = "api_summary.md"
|
|
41
|
+
# Re-export chains deeper than this are treated as unresolvable (and cycles).
|
|
42
|
+
MAX_IMPORT_DEPTH = 10
|
|
43
|
+
|
|
44
|
+
# Dunder methods rendered as the operator or builtin they enable, in this order.
|
|
45
|
+
OPERATORS = {
|
|
46
|
+
"__add__": "+",
|
|
47
|
+
"__sub__": "-",
|
|
48
|
+
"__mul__": "*",
|
|
49
|
+
"__matmul__": "@",
|
|
50
|
+
"__truediv__": "/",
|
|
51
|
+
"__floordiv__": "//",
|
|
52
|
+
"__mod__": "%",
|
|
53
|
+
"__pow__": "**",
|
|
54
|
+
"__neg__": "unary -",
|
|
55
|
+
"__abs__": "abs()",
|
|
56
|
+
"__eq__": "==",
|
|
57
|
+
"__lt__": "<",
|
|
58
|
+
"__le__": "<=",
|
|
59
|
+
"__gt__": ">",
|
|
60
|
+
"__ge__": ">=",
|
|
61
|
+
"__len__": "len()",
|
|
62
|
+
"__iter__": "iter()",
|
|
63
|
+
"__getitem__": "[]",
|
|
64
|
+
"__contains__": "in",
|
|
65
|
+
"__hash__": "hash()",
|
|
66
|
+
"__enter__": "with",
|
|
67
|
+
}
|
|
68
|
+
ORDERING_OPS = ("<", "<=", ">", ">=")
|
|
69
|
+
# Dunders that are public API in their own right and listed as methods.
|
|
70
|
+
LISTED_DUNDERS = ("__call__",)
|
|
71
|
+
ENUM_BASES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
|
|
72
|
+
# Class-level assignments that declare typing machinery, not data fields.
|
|
73
|
+
TYPING_FACTORIES = {"TypeVar", "TypeVarTuple", "ParamSpec", "NewType"}
|
|
74
|
+
# Longest constant value shown inline; anything longer is elided.
|
|
75
|
+
MAX_VALUE_LEN = 60
|
|
76
|
+
|
|
77
|
+
type _Def = ast.FunctionDef | ast.AsyncFunctionDef
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Entry:
|
|
82
|
+
"""One public name, resolved to the node that defines it.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
name: The name as exported.
|
|
86
|
+
path: File of the module that defines it (or of the module itself, for
|
|
87
|
+
an exported submodule).
|
|
88
|
+
exporter: File of the module whose public names include it.
|
|
89
|
+
node: The defining statement, or None if it was not found.
|
|
90
|
+
doc: Attribute docstring of a constant, if any.
|
|
91
|
+
origin: Import source when the name comes from outside the package.
|
|
92
|
+
is_module: True when the name is a submodule rather than an object.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
path: Path
|
|
97
|
+
node: ast.stmt | None
|
|
98
|
+
doc: str | None = None
|
|
99
|
+
origin: str | None = None
|
|
100
|
+
is_module: bool = False
|
|
101
|
+
exporter: Path | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class _Walk:
|
|
106
|
+
"""Parse cache and traversal state for one package."""
|
|
107
|
+
|
|
108
|
+
pkg_dir: Path
|
|
109
|
+
trees: dict[Path, ast.Module] = field(default_factory=dict)
|
|
110
|
+
expanded: set[Path] = field(default_factory=set)
|
|
111
|
+
listed: set[tuple[Path, str]] = field(default_factory=set)
|
|
112
|
+
|
|
113
|
+
def parse(self, path: Path) -> ast.Module:
|
|
114
|
+
if path not in self.trees:
|
|
115
|
+
self.trees[path] = ast.parse(path.read_text(), filename=str(path))
|
|
116
|
+
return self.trees[path]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
"""Write or check the package's docs/api_summary.md."""
|
|
121
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--check",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="exit 1 if the summary is stale instead of rewriting it",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--root",
|
|
129
|
+
type=Path,
|
|
130
|
+
default=ROOT,
|
|
131
|
+
help="repository root (default: the directory holding this script)",
|
|
132
|
+
)
|
|
133
|
+
args = parser.parse_args()
|
|
134
|
+
|
|
135
|
+
root = args.root.resolve()
|
|
136
|
+
dist_name, pkg_dir = _find_package(root)
|
|
137
|
+
if not (pkg_dir / "docs").is_dir():
|
|
138
|
+
# This script ships to every repo from py-common, including ones whose
|
|
139
|
+
# docs/ has not been set up yet; the summary starts once docs/ exists.
|
|
140
|
+
print(f"{pkg_dir.name}/docs/ does not exist; no API summary to maintain.")
|
|
141
|
+
return
|
|
142
|
+
text = render_summary(dist_name, pkg_dir)
|
|
143
|
+
out = pkg_dir / "docs" / SUMMARY_NAME
|
|
144
|
+
current = out.read_text() if out.exists() else None
|
|
145
|
+
if current == text:
|
|
146
|
+
return
|
|
147
|
+
rel = out.relative_to(root)
|
|
148
|
+
if args.check:
|
|
149
|
+
print(f"{rel} is stale; run `python .gen_api_summary.py` and commit it.")
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
out.parent.mkdir(exist_ok=True)
|
|
152
|
+
out.write_text(text)
|
|
153
|
+
print(f"Updated {rel}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_summary(dist_name: str, pkg_dir: Path) -> str:
|
|
157
|
+
"""Render the summary markdown for one package.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
dist_name: Distribution name, eg "gri-nsepoch".
|
|
161
|
+
pkg_dir: Directory of the top-level import package.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The full markdown text, ending in a single newline.
|
|
165
|
+
"""
|
|
166
|
+
walk = _Walk(pkg_dir)
|
|
167
|
+
init = pkg_dir / "__init__.py"
|
|
168
|
+
walk.expanded.add(init)
|
|
169
|
+
entries = _public_entries(init, walk)
|
|
170
|
+
lines = [
|
|
171
|
+
f"# {dist_name} API summary",
|
|
172
|
+
"",
|
|
173
|
+
"Generated from the source by `.gen_api_summary.py`; do not edit by hand.",
|
|
174
|
+
f"Lists every public name exported by `{pkg_dir.name}`, under the module",
|
|
175
|
+
"to import it from, with its signature and the first line of its",
|
|
176
|
+
"docstring. `help(name)` shows the full docstring and examples.",
|
|
177
|
+
]
|
|
178
|
+
sections: dict[Path, list[Entry]] = {}
|
|
179
|
+
for entry in entries:
|
|
180
|
+
sections.setdefault(entry.exporter or entry.path, []).append(entry)
|
|
181
|
+
for path, group in sections.items():
|
|
182
|
+
lines += ["", f"## {_module_name(path, pkg_dir)}"]
|
|
183
|
+
lines += _summary_block(_first_paragraph(ast.get_docstring(walk.parse(path))))
|
|
184
|
+
constants = [e for e in group if _is_constant(e)]
|
|
185
|
+
if constants:
|
|
186
|
+
lines += ["", "### Constants", ""]
|
|
187
|
+
lines += [_constant_line(e) for e in constants]
|
|
188
|
+
for entry in group:
|
|
189
|
+
if not _is_constant(entry):
|
|
190
|
+
lines += ["", *_render_entry(entry)]
|
|
191
|
+
return "\n".join(lines) + "\n"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _find_package(root: Path) -> tuple[str, Path]:
|
|
195
|
+
"""Return the distribution name and import package directory."""
|
|
196
|
+
with (root / "pyproject.toml").open("rb") as fp:
|
|
197
|
+
dist_name = tomllib.load(fp)["project"]["name"]
|
|
198
|
+
pkg_dir = root / dist_name.replace("-", "_")
|
|
199
|
+
if not (pkg_dir / "__init__.py").is_file():
|
|
200
|
+
raise SystemExit(f"No package at {pkg_dir}")
|
|
201
|
+
return dist_name, pkg_dir
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _public_entries(path: Path, walk: _Walk) -> list[Entry]:
|
|
205
|
+
"""Resolve a module's public names, expanding exported submodules."""
|
|
206
|
+
entries: list[Entry] = []
|
|
207
|
+
for name in _public_names(path, walk):
|
|
208
|
+
entry = _resolve(path, name, walk)
|
|
209
|
+
entry.exporter = path
|
|
210
|
+
if not entry.is_module:
|
|
211
|
+
# A name re-exported by two public modules is listed once, under the
|
|
212
|
+
# first (shallowest) module that exports it.
|
|
213
|
+
if (entry.path, name) not in walk.listed:
|
|
214
|
+
walk.listed.add((entry.path, name))
|
|
215
|
+
entries.append(entry)
|
|
216
|
+
elif entry.path not in walk.expanded:
|
|
217
|
+
walk.expanded.add(entry.path)
|
|
218
|
+
entries += _public_entries(entry.path, walk)
|
|
219
|
+
return entries
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _public_names(path: Path, walk: _Walk) -> list[str]:
|
|
223
|
+
"""A module's `__all__`, or its public definitions and in-package imports."""
|
|
224
|
+
tree = walk.parse(path)
|
|
225
|
+
names = _dunder_all(tree)
|
|
226
|
+
if names is not None:
|
|
227
|
+
return names
|
|
228
|
+
names = []
|
|
229
|
+
for node in tree.body:
|
|
230
|
+
if isinstance(node, ast.ImportFrom):
|
|
231
|
+
if _import_target(path, node, walk.pkg_dir) is not None:
|
|
232
|
+
names += [a.asname or a.name for a in node.names if a.name != "*"]
|
|
233
|
+
else:
|
|
234
|
+
names += _defined_names(node)
|
|
235
|
+
return [n for n in names if not n.startswith("_")]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _dunder_all(tree: ast.Module) -> list[str] | None:
|
|
239
|
+
"""Return the literal `__all__` list of a module, if it has one."""
|
|
240
|
+
for node in tree.body:
|
|
241
|
+
if "__all__" in _defined_names(node):
|
|
242
|
+
value = node.value # ty: ignore[unresolved-attribute]
|
|
243
|
+
return [str(v) for v in ast.literal_eval(value)]
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _defined_names(node: ast.stmt) -> list[str]:
|
|
248
|
+
"""Names a top-level statement defines (not imports)."""
|
|
249
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
250
|
+
return [node.name]
|
|
251
|
+
if isinstance(node, ast.TypeAlias) and isinstance(node.name, ast.Name):
|
|
252
|
+
return [node.name.id]
|
|
253
|
+
if isinstance(node, ast.Assign):
|
|
254
|
+
return [t.id for t in node.targets if isinstance(t, ast.Name)]
|
|
255
|
+
if (
|
|
256
|
+
isinstance(node, ast.AnnAssign)
|
|
257
|
+
and node.value is not None
|
|
258
|
+
and isinstance(node.target, ast.Name)
|
|
259
|
+
):
|
|
260
|
+
return [node.target.id]
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _resolve(path: Path, name: str, walk: _Walk, depth: int = 0) -> Entry:
|
|
265
|
+
"""Follow `name` from module `path` to the node that defines it."""
|
|
266
|
+
if depth > MAX_IMPORT_DEPTH:
|
|
267
|
+
return Entry(name, path, None)
|
|
268
|
+
body = walk.parse(path).body
|
|
269
|
+
for idx, node in enumerate(body):
|
|
270
|
+
if name in _defined_names(node):
|
|
271
|
+
return Entry(name, path, node, doc=_attribute_doc(body, idx))
|
|
272
|
+
for node in body:
|
|
273
|
+
if not isinstance(node, ast.ImportFrom):
|
|
274
|
+
continue
|
|
275
|
+
for alias in node.names:
|
|
276
|
+
if (alias.asname or alias.name) != name:
|
|
277
|
+
continue
|
|
278
|
+
target = _import_target(path, node, walk.pkg_dir)
|
|
279
|
+
if target is None:
|
|
280
|
+
return Entry(name, path, None, origin=node.module)
|
|
281
|
+
sub = _submodule(target, alias.name)
|
|
282
|
+
if sub is not None:
|
|
283
|
+
return Entry(name, sub, None, is_module=True)
|
|
284
|
+
entry = _resolve(target, alias.name, walk, depth + 1)
|
|
285
|
+
entry.name = name
|
|
286
|
+
return entry
|
|
287
|
+
return Entry(name, path, None)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _attribute_doc(body: list[ast.stmt], idx: int) -> str | None:
|
|
291
|
+
"""The string literal directly after an assignment (attribute docstring)."""
|
|
292
|
+
if idx + 1 < len(body):
|
|
293
|
+
nxt = body[idx + 1]
|
|
294
|
+
if (
|
|
295
|
+
isinstance(nxt, ast.Expr)
|
|
296
|
+
and isinstance(nxt.value, ast.Constant)
|
|
297
|
+
and isinstance(nxt.value.value, str)
|
|
298
|
+
):
|
|
299
|
+
return nxt.value.value
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _import_target(path: Path, node: ast.ImportFrom, pkg_dir: Path) -> Path | None:
|
|
304
|
+
"""File a `from X import ...` points at, or None if outside the package."""
|
|
305
|
+
if node.level:
|
|
306
|
+
base = path.parent
|
|
307
|
+
for _ in range(node.level - 1):
|
|
308
|
+
base = base.parent
|
|
309
|
+
parts = node.module.split(".") if node.module else []
|
|
310
|
+
else:
|
|
311
|
+
mod = node.module or ""
|
|
312
|
+
if mod != pkg_dir.name and not mod.startswith(pkg_dir.name + "."):
|
|
313
|
+
return None
|
|
314
|
+
base = pkg_dir.parent
|
|
315
|
+
parts = mod.split(".")
|
|
316
|
+
target = base.joinpath(*parts)
|
|
317
|
+
if (target / "__init__.py").is_file():
|
|
318
|
+
return target / "__init__.py"
|
|
319
|
+
if target.with_suffix(".py").is_file():
|
|
320
|
+
return target.with_suffix(".py")
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _submodule(target: Path, name: str) -> Path | None:
|
|
325
|
+
"""If `from pkg import name` names a submodule rather than an object."""
|
|
326
|
+
if target.name != "__init__.py":
|
|
327
|
+
return None
|
|
328
|
+
sub = target.parent / name
|
|
329
|
+
if (sub / "__init__.py").is_file():
|
|
330
|
+
return sub / "__init__.py"
|
|
331
|
+
if sub.with_suffix(".py").is_file():
|
|
332
|
+
return sub.with_suffix(".py")
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _module_name(path: Path, pkg_dir: Path) -> str:
|
|
337
|
+
"""Dotted module name of a file inside the package."""
|
|
338
|
+
parts = list(path.relative_to(pkg_dir.parent).with_suffix("").parts)
|
|
339
|
+
if parts[-1] == "__init__":
|
|
340
|
+
parts.pop()
|
|
341
|
+
return ".".join(parts)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _render_entry(entry: Entry) -> list[str]:
|
|
345
|
+
"""Markdown lines for one public name."""
|
|
346
|
+
node = entry.node
|
|
347
|
+
name = entry.name
|
|
348
|
+
if isinstance(node, ast.ClassDef):
|
|
349
|
+
return _render_class(name, node)
|
|
350
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
351
|
+
sig = f"{name}{_signature(node, drop_first=False)}"
|
|
352
|
+
lines = [f"### {name}", "", "```python", sig, "```"]
|
|
353
|
+
return lines + _summary_block(ast.get_docstring(node))
|
|
354
|
+
if isinstance(node, ast.TypeAlias):
|
|
355
|
+
alias = " ".join(ast.unparse(node).split())
|
|
356
|
+
lines = [f"### {name}", "", "```python", alias, "```"]
|
|
357
|
+
return lines + _summary_block(entry.doc)
|
|
358
|
+
if entry.origin is not None:
|
|
359
|
+
root_pkg = entry.origin.split(".")[0]
|
|
360
|
+
note = f"Re-exported from `{entry.origin}`"
|
|
361
|
+
if root_pkg.startswith("gri_"):
|
|
362
|
+
note += f"; see the `{root_pkg}` package's own docs/{SUMMARY_NAME}"
|
|
363
|
+
return [f"### {name}", "", note + "."]
|
|
364
|
+
return [f"### {name}", "", "(definition not found by the generator)"]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _render_class(name: str, node: ast.ClassDef) -> list[str]:
|
|
368
|
+
"""Markdown lines for a class: constructor, members, operators."""
|
|
369
|
+
bases = ", ".join(ast.unparse(b) for b in node.bases)
|
|
370
|
+
lines = [f"### class {name}" + (f"({bases})" if bases else "")]
|
|
371
|
+
lines += _summary_block(_first_paragraph(ast.get_docstring(node)))
|
|
372
|
+
|
|
373
|
+
methods = _public_methods(node)
|
|
374
|
+
init = methods.pop("__init__", None)
|
|
375
|
+
new = methods.pop("__new__", None)
|
|
376
|
+
init = init or new
|
|
377
|
+
base_names = {ast.unparse(b).split(".")[-1] for b in node.bases}
|
|
378
|
+
fields = _class_fields(node)
|
|
379
|
+
if init is not None:
|
|
380
|
+
lines += ["", "```python", f"{name}{_signature(init)}", "```"]
|
|
381
|
+
elif base_names & ENUM_BASES:
|
|
382
|
+
lines += ["", "Members: " + ", ".join(f"`{f[0]}`" for f in fields)]
|
|
383
|
+
fields = []
|
|
384
|
+
if fields:
|
|
385
|
+
ctor = _has_decorator(node, "dataclass") or "NamedTuple" in base_names
|
|
386
|
+
label = "Fields (the constructor arguments, in order):" if ctor else "Fields:"
|
|
387
|
+
lines += ["", label, ""]
|
|
388
|
+
lines += [f"- `{f[0]}{f[1]}`" + _dash(f[2]) for f in fields]
|
|
389
|
+
|
|
390
|
+
props = [m for m in methods.values() if _has_decorator(m, "property")]
|
|
391
|
+
if props:
|
|
392
|
+
lines += ["", "Properties:", ""]
|
|
393
|
+
lines += [_property_line(p) for p in props]
|
|
394
|
+
plain = [m for m in methods.values() if m not in props]
|
|
395
|
+
if plain:
|
|
396
|
+
lines += ["", "Methods:", ""]
|
|
397
|
+
lines += [_method_line(m) for m in plain]
|
|
398
|
+
ops = _operators(node)
|
|
399
|
+
if ops:
|
|
400
|
+
lines += ["", "Operators: " + ", ".join(f"`{op}`" for op in ops)]
|
|
401
|
+
return lines
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _public_methods(node: ast.ClassDef) -> dict[str, _Def]:
|
|
405
|
+
"""Public methods of a class, last definition wins (skips overload stubs)."""
|
|
406
|
+
methods: dict[str, _Def] = {}
|
|
407
|
+
for item in node.body:
|
|
408
|
+
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
409
|
+
continue
|
|
410
|
+
name = item.name
|
|
411
|
+
public = not name.startswith("_") or name in LISTED_DUNDERS
|
|
412
|
+
if not (public or name in ("__init__", "__new__")):
|
|
413
|
+
continue
|
|
414
|
+
if _has_decorator(item, "overload") or _is_setter(item):
|
|
415
|
+
continue
|
|
416
|
+
methods[name] = item
|
|
417
|
+
return methods
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _class_fields(node: ast.ClassDef) -> list[tuple[str, str, str | None]]:
|
|
421
|
+
"""Class-level annotated or assigned names: (name, ': type = default', doc)."""
|
|
422
|
+
fields: list[tuple[str, str, str | None]] = []
|
|
423
|
+
body = node.body
|
|
424
|
+
for idx, item in enumerate(body):
|
|
425
|
+
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
426
|
+
if item.target.id.startswith("_"):
|
|
427
|
+
continue
|
|
428
|
+
text = f": {ast.unparse(item.annotation)}"
|
|
429
|
+
if item.value is not None:
|
|
430
|
+
text += f" = {_short(ast.unparse(item.value))}"
|
|
431
|
+
fields.append((item.target.id, text, _attribute_doc(body, idx)))
|
|
432
|
+
elif isinstance(item, ast.Assign) and not _is_typing_factory(item.value):
|
|
433
|
+
text = f" = {_short(ast.unparse(item.value))}"
|
|
434
|
+
doc = _attribute_doc(body, idx)
|
|
435
|
+
fields += [
|
|
436
|
+
(t.id, text, doc)
|
|
437
|
+
for t in item.targets
|
|
438
|
+
if isinstance(t, ast.Name) and not t.id.startswith("_")
|
|
439
|
+
]
|
|
440
|
+
return fields
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _is_typing_factory(value: ast.expr) -> bool:
|
|
444
|
+
"""True for `T = TypeVar(...)` and similar typing declarations."""
|
|
445
|
+
if not isinstance(value, ast.Call):
|
|
446
|
+
return False
|
|
447
|
+
return ast.unparse(value.func).split(".")[-1] in TYPING_FACTORIES
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _signature(node: _Def, *, drop_first: bool = True) -> str:
|
|
451
|
+
"""Render `(args) -> ret` in PEP 8 spacing, dropping self/cls if bound."""
|
|
452
|
+
args = node.args
|
|
453
|
+
positional = [*args.posonlyargs, *args.args]
|
|
454
|
+
pad = len(positional) - len(args.defaults)
|
|
455
|
+
defaults: list[ast.expr | None] = [None] * pad + list(args.defaults)
|
|
456
|
+
params = [_param(a, d) for a, d in zip(positional, defaults, strict=True)]
|
|
457
|
+
n_posonly = len(args.posonlyargs)
|
|
458
|
+
if drop_first and positional and not _has_decorator(node, "staticmethod"):
|
|
459
|
+
params.pop(0)
|
|
460
|
+
n_posonly = max(n_posonly - 1, 0)
|
|
461
|
+
if n_posonly:
|
|
462
|
+
params.insert(n_posonly, "/")
|
|
463
|
+
if args.vararg is not None:
|
|
464
|
+
params.append("*" + _param(args.vararg, None))
|
|
465
|
+
elif args.kwonlyargs:
|
|
466
|
+
params.append("*")
|
|
467
|
+
params += [
|
|
468
|
+
_param(a, d) for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=True)
|
|
469
|
+
]
|
|
470
|
+
if args.kwarg is not None:
|
|
471
|
+
params.append("**" + _param(args.kwarg, None))
|
|
472
|
+
ret = f" -> {ast.unparse(node.returns)}" if node.returns is not None else ""
|
|
473
|
+
return f"({', '.join(params)}){ret}"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _param(arg: ast.arg, default: ast.expr | None) -> str:
|
|
477
|
+
"""One parameter: `x`, `x=1`, `x: int`, or `x: int = 1`."""
|
|
478
|
+
text = arg.arg
|
|
479
|
+
if arg.annotation is not None:
|
|
480
|
+
text += f": {ast.unparse(arg.annotation)}"
|
|
481
|
+
if default is not None:
|
|
482
|
+
text += f" = {ast.unparse(default)}"
|
|
483
|
+
elif default is not None:
|
|
484
|
+
text += f"={ast.unparse(default)}"
|
|
485
|
+
return text
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _property_line(node: _Def) -> str:
|
|
489
|
+
"""List item for a property: name, type, summary."""
|
|
490
|
+
ret = f": {ast.unparse(node.returns)}" if node.returns is not None else ""
|
|
491
|
+
return f"- `{node.name}{ret}`" + _dash(ast.get_docstring(node))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _method_line(node: _Def) -> str:
|
|
495
|
+
"""List item for a method: signature, kind, summary."""
|
|
496
|
+
kind = ""
|
|
497
|
+
if _has_decorator(node, "staticmethod"):
|
|
498
|
+
kind = " (staticmethod)"
|
|
499
|
+
elif _has_decorator(node, "classmethod"):
|
|
500
|
+
kind = " (classmethod)"
|
|
501
|
+
sig = f"{node.name}{_signature(node)}"
|
|
502
|
+
return f"- `{sig}`{kind}" + _dash(ast.get_docstring(node))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _operators(node: ast.ClassDef) -> list[str]:
|
|
506
|
+
"""Operators a class defines, in a fixed order."""
|
|
507
|
+
defined = {
|
|
508
|
+
item.name
|
|
509
|
+
for item in node.body
|
|
510
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
511
|
+
}
|
|
512
|
+
ops = [op for dunder, op in OPERATORS.items() if dunder in defined]
|
|
513
|
+
if _has_decorator(node, "total_ordering"):
|
|
514
|
+
ops += [op for op in ORDERING_OPS if op not in ops]
|
|
515
|
+
ops.sort(key=list(OPERATORS.values()).index)
|
|
516
|
+
return ops
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _has_decorator(node: ast.stmt, name: str) -> bool:
|
|
520
|
+
"""True if a def or class carries a decorator whose last name is `name`."""
|
|
521
|
+
for dec in getattr(node, "decorator_list", []):
|
|
522
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
523
|
+
if ast.unparse(target).split(".")[-1] == name:
|
|
524
|
+
return True
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _is_setter(node: _Def) -> bool:
|
|
529
|
+
"""True for `@x.setter` / `@x.deleter` property accessors."""
|
|
530
|
+
return any(
|
|
531
|
+
isinstance(dec, ast.Attribute) and dec.attr in ("setter", "deleter")
|
|
532
|
+
for dec in node.decorator_list
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _is_constant(entry: Entry) -> bool:
|
|
537
|
+
"""True for a module-level assignment (a constant or module variable)."""
|
|
538
|
+
return isinstance(entry.node, (ast.Assign, ast.AnnAssign))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _constant_line(entry: Entry) -> str:
|
|
542
|
+
"""List item for a constant: `NAME: type = value` and its docstring."""
|
|
543
|
+
node = entry.node
|
|
544
|
+
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
545
|
+
raise TypeError(f"{entry.name} is not a constant")
|
|
546
|
+
return f"- `{_constant(entry.name, node)}`" + _dash(entry.doc)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _constant(name: str, node: ast.Assign | ast.AnnAssign) -> str:
|
|
550
|
+
"""Render a module constant as `NAME: type = value`."""
|
|
551
|
+
text = name
|
|
552
|
+
if isinstance(node, ast.AnnAssign):
|
|
553
|
+
text += f": {ast.unparse(node.annotation)}"
|
|
554
|
+
if node.value is not None:
|
|
555
|
+
text += f" = {_short(ast.unparse(node.value))}"
|
|
556
|
+
return text
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _short(value: str) -> str:
|
|
560
|
+
"""Elide a long rendered value."""
|
|
561
|
+
value = " ".join(value.split())
|
|
562
|
+
return value if len(value) <= MAX_VALUE_LEN else "..."
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _first_paragraph(doc: str | None) -> str | None:
|
|
566
|
+
"""The first paragraph of a docstring, joined onto one line."""
|
|
567
|
+
if not doc:
|
|
568
|
+
return None
|
|
569
|
+
return " ".join(doc.strip().split("\n\n")[0].split())
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _summary(doc: str | None) -> str | None:
|
|
573
|
+
"""The summary (first) line of a docstring."""
|
|
574
|
+
if not doc:
|
|
575
|
+
return None
|
|
576
|
+
return doc.strip().splitlines()[0].strip()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _summary_block(doc: str | None) -> list[str]:
|
|
580
|
+
"""A blank line and the docstring summary, or nothing."""
|
|
581
|
+
summary = _summary(doc)
|
|
582
|
+
return ["", summary] if summary else []
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _dash(doc: str | None) -> str:
|
|
586
|
+
"""Suffix ' -- summary' for list items, or empty."""
|
|
587
|
+
summary = _summary(doc)
|
|
588
|
+
return f" -- {summary}" if summary else ""
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
if __name__ == "__main__":
|
|
592
|
+
main()
|
|
@@ -16,6 +16,7 @@ lib64/
|
|
|
16
16
|
.version
|
|
17
17
|
.pytest_cache/
|
|
18
18
|
.mypy_cache/
|
|
19
|
+
.ipynb_checkpoints/
|
|
19
20
|
testreports/
|
|
20
21
|
.VSCodeCounter/
|
|
21
22
|
.coverage
|
|
@@ -25,6 +26,14 @@ coverage.xml
|
|
|
25
26
|
*.stats
|
|
26
27
|
.claude/
|
|
27
28
|
CLAUDE.md
|
|
29
|
+
AGENTS.md
|
|
30
|
+
|
|
31
|
+
### Internal working notes -- plans and todos stay out of the public repos ###
|
|
32
|
+
TODO.md
|
|
33
|
+
PLAN*.md
|
|
34
|
+
|
|
35
|
+
### Local scratch dir -- conventional throwaway working area (contents ignored) ###
|
|
36
|
+
_scratch/*
|
|
28
37
|
|
|
29
38
|
### Credentials ###
|
|
30
39
|
.env*
|
|
@@ -33,3 +42,4 @@ CLAUDE.md
|
|
|
33
42
|
*.p12
|
|
34
43
|
.cdsapirc
|
|
35
44
|
.ecmwfdatastoresrc
|
|
45
|
+
public/
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Custom hatchling build hook that wires up the project's git hooks.
|
|
2
|
+
|
|
3
|
+
`uv sync` installs this project in editable mode, which runs the hatchling
|
|
4
|
+
build backend and therefore this hook. The hook points git's ``core.hooksPath``
|
|
5
|
+
at the tracked ``.githooks`` directory, so a fresh clone gets working git hooks
|
|
6
|
+
on its first sync without a separate install step.
|
|
7
|
+
|
|
8
|
+
This only adjusts local git config and is a no-op outside a git work tree (for
|
|
9
|
+
example, in an isolated sdist/wheel build), so it is safe in CI release builds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHooksBuildHook(BuildHookInterface):
|
|
21
|
+
"""Set ``core.hooksPath`` to the tracked ``.githooks`` directory."""
|
|
22
|
+
|
|
23
|
+
PLUGIN_NAME = "custom"
|
|
24
|
+
|
|
25
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None: # noqa: ARG002
|
|
26
|
+
"""Point git at ``.githooks`` if this build runs inside a work tree."""
|
|
27
|
+
root = Path(self.root)
|
|
28
|
+
git = shutil.which("git")
|
|
29
|
+
if git is None or not (root / ".githooks").is_dir():
|
|
30
|
+
return
|
|
31
|
+
# Only act inside a real git work tree; check=False keeps release
|
|
32
|
+
# builds (sdist/wheel in a non-git temp dir) from failing.
|
|
33
|
+
inside = subprocess.run( # noqa: S603
|
|
34
|
+
[git, "rev-parse", "--is-inside-work-tree"],
|
|
35
|
+
cwd=root,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
if inside.stdout.strip() != "true":
|
|
41
|
+
return
|
|
42
|
+
subprocess.run( # noqa: S603
|
|
43
|
+
[git, "config", "core.hooksPath", ".githooks"],
|
|
44
|
+
cwd=root,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|