gri-plot 0.2.6__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.
Files changed (60) hide show
  1. gri_plot-0.2.7/.gen_api_summary.py +592 -0
  2. {gri_plot-0.2.6 → gri_plot-0.2.7}/.gitignore +9 -0
  3. {gri_plot-0.2.6 → gri_plot-0.2.7}/LICENSE +1 -1
  4. {gri_plot-0.2.6 → gri_plot-0.2.7}/PKG-INFO +15 -2
  5. {gri_plot-0.2.6 → gri_plot-0.2.7}/README.md +13 -0
  6. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/__init__.py +14 -12
  7. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/_heatmap_image.py +45 -14
  8. gri_plot-0.2.7/gri_plot/docs/api_summary.md +402 -0
  9. gri_plot-0.2.7/gri_plot/docs/overview.md +212 -0
  10. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/figure_map.py +35 -28
  11. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/cone.py +7 -23
  12. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/cylinder.py +7 -18
  13. {gri_plot-0.2.6 → gri_plot-0.2.7}/pyproject.toml +23 -14
  14. gri_plot-0.2.7/report.xml +1 -0
  15. gri_plot-0.2.7/test/test_api_summary.py +21 -0
  16. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_figure_map.py +33 -1
  17. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_heatmap_image.py +60 -1
  18. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/test_cone.py +15 -0
  19. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/test_cylinder.py +15 -0
  20. {gri_plot-0.2.6 → gri_plot-0.2.7}/.hatch_build.py +0 -0
  21. {gri_plot-0.2.6 → gri_plot-0.2.7}/CONTRIBUTING.md +0 -0
  22. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/basemap.py +0 -0
  23. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/data/natural_earth_boundaries.npz +0 -0
  24. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/figure3d.py +0 -0
  25. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/frames.py +0 -0
  26. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/observables/__init__.py +0 -0
  27. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/observables/aoa.py +0 -0
  28. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/observables/los.py +0 -0
  29. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/observables/range_sphere.py +0 -0
  30. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/plot_ellipse.py +0 -0
  31. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/polylines.py +0 -0
  32. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/py.typed +0 -0
  33. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/scatter.py +0 -0
  34. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/scatter_map.py +0 -0
  35. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/__init__.py +0 -0
  36. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/ellipse.py +0 -0
  37. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/ellipsoid.py +0 -0
  38. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/meshgen.py +0 -0
  39. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/shapes/sphere.py +0 -0
  40. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/surfaces/__init__.py +0 -0
  41. {gri_plot-0.2.6 → gri_plot-0.2.7}/gri_plot/surfaces/mesh.py +0 -0
  42. {gri_plot-0.2.6 → gri_plot-0.2.7}/scripts/build_basemap_data.py +0 -0
  43. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/__init__.py +0 -0
  44. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_basemap.py +0 -0
  45. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_figure3d.py +0 -0
  46. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_frames.py +0 -0
  47. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_observables/__init__.py +0 -0
  48. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_observables/test_aoa.py +0 -0
  49. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_observables/test_los.py +0 -0
  50. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_observables/test_range_sphere.py +0 -0
  51. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_plot_ellipse.py +0 -0
  52. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_plot_scattermap.py +0 -0
  53. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_polylines.py +0 -0
  54. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_scatter.py +0 -0
  55. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/__init__.py +0 -0
  56. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/test_ellipse.py +0 -0
  57. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/test_ellipsoid.py +0 -0
  58. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_shapes/test_sphere.py +0 -0
  59. {gri_plot-0.2.6 → gri_plot-0.2.7}/test/test_surfaces/__init__.py +0 -0
  60. {gri_plot-0.2.6 → 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
@@ -27,6 +28,13 @@ coverage.xml
27
28
  CLAUDE.md
28
29
  AGENTS.md
29
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/*
37
+
30
38
  ### Credentials ###
31
39
  .env*
32
40
  *.key
@@ -34,3 +42,4 @@ AGENTS.md
34
42
  *.p12
35
43
  .cdsapirc
36
44
  .ecmwfdatastoresrc
45
+ public/
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 GeoSol Research Inc.
3
+ Copyright (c) 2025-2026 GeoSol Research Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: gri-plot
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: 3D geolocation visualization and 2D plotting utilities built on Plotly
5
5
  Project-URL: Homepage, https://geosolresearch.com
6
6
  Project-URL: Repository, https://gitlab.com/geosol-foss/python/gri-plot
@@ -33,6 +33,19 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  This library renders parametric geometric shapes (ellipsoids, spheres, cones, cylinders) and observable surfaces that inherit from them (AOA cones, line-of-sight rays, range spheres) as interactive 3D meshes. It also provides shorthand functions for 2D scatter plots and geographic maps.
35
35
 
36
+ ## Documentation
37
+
38
+ The wheel ships its documentation inside the package, in `gri_plot/docs/`,
39
+ so it is available wherever the package is installed:
40
+
41
+ - `overview.md` -- conventions, error ellipses, 3D shapes and measurement
42
+ surfaces, 3D figures, and maps
43
+ - `api_summary.md` -- every public class and signature (generated)
44
+
45
+ Print the directory with
46
+ `python -c "import gri_plot, pathlib; print(pathlib.Path(gri_plot.__file__).parent / 'docs')"`.
47
+ Every example in those files is run by the test suite.
48
+
36
49
  ## Installation
37
50
 
38
51
  ```bash
@@ -8,6 +8,19 @@
8
8
 
9
9
  This library renders parametric geometric shapes (ellipsoids, spheres, cones, cylinders) and observable surfaces that inherit from them (AOA cones, line-of-sight rays, range spheres) as interactive 3D meshes. It also provides shorthand functions for 2D scatter plots and geographic maps.
10
10
 
11
+ ## Documentation
12
+
13
+ The wheel ships its documentation inside the package, in `gri_plot/docs/`,
14
+ so it is available wherever the package is installed:
15
+
16
+ - `overview.md` -- conventions, error ellipses, 3D shapes and measurement
17
+ surfaces, 3D figures, and maps
18
+ - `api_summary.md` -- every public class and signature (generated)
19
+
20
+ Print the directory with
21
+ `python -c "import gri_plot, pathlib; print(pathlib.Path(gri_plot.__file__).parent / 'docs')"`.
22
+ Every example in those files is run by the test suite.
23
+
11
24
  ## Installation
12
25
 
13
26
  ```bash