hydroloom 0.1.0__py3-none-any.whl

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.
hydroloom/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Utilities to weave hydrologic fabrics."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ from hydroloom._hy import (
6
+ align_names,
7
+ as_hy,
8
+ capabilities,
9
+ hy_reverse,
10
+ is_dendritic,
11
+ is_hy,
12
+ name_definitions,
13
+ register_names,
14
+ representation,
15
+ )
16
+ from hydroloom.network import (
17
+ add_toids,
18
+ add_topo_sort,
19
+ make_index_ids,
20
+ navigate_network_dfs,
21
+ sort_network,
22
+ )
23
+
24
+ __version__ = _version("hydroloom")
25
+
26
+ __all__ = [
27
+ "add_toids",
28
+ "add_topo_sort",
29
+ "align_names",
30
+ "as_hy",
31
+ "capabilities",
32
+ "hy_reverse",
33
+ "is_dendritic",
34
+ "is_hy",
35
+ "make_index_ids",
36
+ "name_definitions",
37
+ "navigate_network_dfs",
38
+ "register_names",
39
+ "representation",
40
+ "sort_network",
41
+ ]
@@ -0,0 +1,310 @@
1
+ """Compose numpydoc docstrings from the R documentation (SPEC A-13).
2
+
3
+ roxygen is the one authored source for argument meanings. ``dev/docstrings.R``
4
+ writes ``docstrings.json`` from ``man/``; :func:`doc` reads it at import and
5
+ writes the decorated function's ``__doc__``. Every composed field passes
6
+ through :data:`SUBSTITUTIONS`, the R-phrasing table, in order.
7
+
8
+ The composed docstring holds, in numpydoc order: the summary from ``title``;
9
+ the extended summary from ``description`` then ``details``; Parameters from
10
+ ``arguments``, restricted to and ordered by the Python signature; Returns from
11
+ ``value``; Notes holding each transferred Rd section as a paragraph led by its
12
+ title, then a line that identifies the R function the text comes from; and
13
+ the Examples block of the function's own hand-written ``__doc__``, the only
14
+ part of that ``__doc__`` kept. No See Also is written: the R function is not a
15
+ Python object numpydoc can link.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import inspect
22
+ import json
23
+ import re
24
+ from collections.abc import Callable
25
+ from importlib.resources import files
26
+ from typing import Any, TypeVar
27
+
28
+ F = TypeVar("F", bound=Callable[..., Any])
29
+
30
+ # R phrasing (a regular expression) -> Python phrasing, applied in order to
31
+ # every composed field. `\A` anchors a pattern to the start of a field, where
32
+ # roxygen puts an argument's R type; the Python type goes in the numpydoc type
33
+ # slot instead. tests/python/test_docstrings.py counts the fires of each pair
34
+ # over the whole of docstrings.json; a pair that never fires is not kept.
35
+ SUBSTITUTIONS: tuple[tuple[str, str], ...] = (
36
+ # R-only facts (A-13, P-4). The roxygen states each one in a sentence or
37
+ # paragraph of its own that starts "In R,"; these two pairs drop them, with
38
+ # the space or blank line before, and run first so no phrasing pair
39
+ # rewrites R text. A paragraph that opens with "In R," is R-only through
40
+ # its end, with any list that follows it, bullet (`- `) or numbered
41
+ # (`1. `). An "In R," sentence inside a paragraph ends at its first `.`,
42
+ # `?` or `!` followed by whitespace or the end of the field, so it must
43
+ # not contain an abbreviation such as "e.g. " or "i.e. ";
44
+ # tests/python/test_docstrings.py enforces this over docstrings.json.
45
+ (r"(?s)(?:\A|\n\n)In R,.*?(?=\n\n(?!- |\d+\. )|\Z)", ""),
46
+ (r" In R,[^\n]*?[.?!](?=\s|\Z)", ""),
47
+ # The A-13 examples.
48
+ (r"logical if TRUE", "if `True`"),
49
+ (r"logical if `TRUE`", "if `True`"),
50
+ # R type words opening an argument or value.
51
+ (r"\Alogical\. ", ""),
52
+ (r"\Alogical ", ""),
53
+ (r"\Aboolean ", ""),
54
+ (r"\Acharacter or numeric ", "text or number "),
55
+ (r"\Acharacter or factor vector, or NULL\. ", ""),
56
+ (r"\Acharacter or NULL\. ", ""),
57
+ (r"\Acharacter\. ", ""),
58
+ (r"\Acharacter ", ""),
59
+ (r"\Anumeric\. ", ""),
60
+ (r"\Anumeric ", ""),
61
+ (r"\Ainteger ", ""),
62
+ (r"\Aunits distance ", "distance "),
63
+ (r"\Acrs\. ", ""),
64
+ (r"\Adata\.frame or list\. ", ""),
65
+ (r"\Adata\.frame\. ", ""),
66
+ (
67
+ r"\Avector of the same length as `nrow\(x\)` containing ",
68
+ "one value per row of `network`, containing ",
69
+ ),
70
+ (r"\Avector ", "sequence "),
71
+ # Spatial classes.
72
+ (
73
+ r"sfc_POINT simple feature geometry list of length `nrow\(indexes\)`",
74
+ "point geometry column with one point per row of `indexes`",
75
+ ),
76
+ (r"sf sf data\.frame", "table with geometry"),
77
+ (r"sf data\.frame or sfc geometry", "table with geometry or geometry column"),
78
+ (r"sf data\.frame or sfc", "table with geometry or geometry column"),
79
+ (r"sf data\.frame", "table with geometry"),
80
+ (r"sf data\.table", "table with geometry"),
81
+ (r"sf or sfc", "table with geometry or geometry column"),
82
+ (r"an sf point geometry column", "a point geometry column"),
83
+ (r"a sf object", "a table with geometry"),
84
+ (r"\bsf geometry", "geometry"),
85
+ (r"When sfc is provided", "When a geometry column is provided"),
86
+ (r"the return value is sfc", "the return value is a geometry column"),
87
+ (r"\bsfc geometry\b", "geometry column"),
88
+ (r"\bsfc\b", "geometry column"),
89
+ # Tables.
90
+ (r"data,frame", "table"),
91
+ (r"\bdataframe\b", "table"),
92
+ (r"data\.frame", "table"),
93
+ (r"an `hy` object", "a network table"),
94
+ (r"`hy` object", "network table"),
95
+ (r"\bhy object", "network table"),
96
+ (r"an `hy`", "a network table"),
97
+ (r"required attributes", "required columns"),
98
+ (r"requires attributes", "requires columns"),
99
+ # Column types, in the vocabulary's type names.
100
+ (r"\(character\)", "(text)"),
101
+ (r"\(character:", "(text:"),
102
+ (r"\(logical\)", "(flag)"),
103
+ (r"a logical flag", "a flag"),
104
+ (r"a logical value", "a flag"),
105
+ (r"logical columns", "flag columns"),
106
+ # Logical, missing, and null values, as Python writes them.
107
+ (r"`(\w+) = TRUE`", r"`\1=True`"),
108
+ (r"`(\w+) = FALSE`", r"`\1=False`"),
109
+ (r"`TRUE`", "`True`"),
110
+ (r"`FALSE`", "`False`"),
111
+ (r"(?<!= )\bTRUE\b", "`True`"),
112
+ (r"(?<!= )\bFALSE\b", "`False`"),
113
+ (r"an `NA`", "a missing"),
114
+ (r"`NA`", "missing"),
115
+ (r"If missing or NULL", "If not given"),
116
+ (r"When non-NULL", "When given"),
117
+ (r"When NULL", "When not given"),
118
+ (r"is NULL", "is `None`"),
119
+ (r"Default NULL", "Default `None`"),
120
+ (r"or NULL", "or `None`"),
121
+ (r"`Inf`", "infinity"),
122
+ # R call syntax, and R functions with a differently named Python counterpart.
123
+ # Backticked `x` is the network everywhere but check_valid, whose `x` is
124
+ # the lines operand (A-9, phase 3).
125
+ (r"(?<!CRS of )`x`", "`network`"),
126
+ (
127
+ r"c\(UM, DM, UT, DD\) or equivalently c\(upmain, downmain, up, down\)",
128
+ "UM, DM, UT, DD or equivalently upmain, downmain, up, down",
129
+ ),
130
+ (r"`hy\(\)`", "`as_hy()`"),
131
+ (r"`hy_network_type\(\)`", "`representation()`"),
132
+ (r"`hy_capabilities\(\)`", "`capabilities()`"),
133
+ (r"`is\.hy\(\)`", "`is_hy()`"),
134
+ (r"\bhydroloom_name_definitions\b", "`name_definitions()`"),
135
+ # Details follow the description in the extended summary, so a "See
136
+ # details" inside the description (accumulate_downstream) is left alone.
137
+ (r"(?i)\b(s)ee details\b(?! for required)", r"\1ee the description above"),
138
+ )
139
+
140
+ _COMPILED = tuple((re.compile(pattern), repl) for pattern, repl in SUBSTITUTIONS)
141
+
142
+ # Python-only arguments (A-9) have no roxygen text; theirs comes from the
143
+ # Signature section of the function's description. Keyed by alias.
144
+ PYTHON_ARGUMENTS: dict[str, dict[str, str]] = {
145
+ "hy": {
146
+ "id_col": "the input column to take as `id`, in place of any column "
147
+ "hydroloom would choose by its known source names; `None` lets "
148
+ "hydroloom choose.",
149
+ "toid_col": "the input column to take as `toid`, in place of any "
150
+ "column hydroloom would choose by its known source names; `None` lets "
151
+ "hydroloom choose.",
152
+ },
153
+ "hy_reverse": {
154
+ "source": "the dataset whose column names to rename to: `nhdplus`, "
155
+ "`nhdplushr`, `3dhp`, or `hydrofabric`. `None` gives each hydroloom "
156
+ "name the alias most recently registered for it, in lowercase, and "
157
+ "leaves a name with no registered alias as it is.",
158
+ },
159
+ }
160
+
161
+
162
+ # A-9: the first positional argument is `network` wherever R's `x` is a network.
163
+ _DEFAULT_R_NAMES = {"network": "x"}
164
+
165
+ _EXAMPLES = re.compile(r"^Examples\n-{8,}\n", re.MULTILINE)
166
+
167
+
168
+ def substitute(text: str) -> tuple[str, list[int]]:
169
+ """Apply the substitution table to one field.
170
+
171
+ Returns the substituted text and the number of fires of each pair, in
172
+ table order.
173
+ """
174
+ counts = []
175
+ for pattern, repl in _COMPILED:
176
+ text, n = pattern.subn(repl, text)
177
+ counts.append(n)
178
+
179
+ return text, counts
180
+
181
+
182
+ @functools.cache
183
+ def r_docs() -> dict[str, dict[str, Any]]:
184
+ """Read ``docstrings.json``, keyed by Rd alias."""
185
+ resource = files("hydroloom.data").joinpath("docstrings.json")
186
+ docs: dict[str, dict[str, Any]] = json.loads(resource.read_text(encoding="utf-8"))
187
+
188
+ return docs
189
+
190
+
191
+ def _field(text: str, *, period: bool = False) -> str:
192
+ # Substitute, then capitalize the first letter unless the first word is an
193
+ # identifier; numpydoc wants summary, parameter, and return text to end
194
+ # with a period.
195
+ text = substitute(text)[0].strip()
196
+ first = text.split(maxsplit=1)[0] if text else ""
197
+ if text[:1].isalpha() and text[0].islower() and "_" not in first:
198
+ text = text[0].upper() + text[1:]
199
+ if period and text and text[-1] not in ".?!:":
200
+ text += "."
201
+
202
+ return text
203
+
204
+
205
+ def _indent(text: str) -> str:
206
+ return "\n".join(f" {line}" if line else "" for line in text.splitlines())
207
+
208
+
209
+ def _annotation(annotation: Any) -> str:
210
+ if annotation is inspect.Parameter.empty:
211
+ return ""
212
+ if isinstance(annotation, str):
213
+ return annotation
214
+
215
+ return inspect.formatannotation(annotation)
216
+
217
+
218
+ def _parameter_line(param: inspect.Parameter) -> str:
219
+ kind = _annotation(param.annotation)
220
+ if param.default is not inspect.Parameter.empty:
221
+ kind = (
222
+ f"{kind}, default {param.default!r}"
223
+ if kind
224
+ else f"default {param.default!r}"
225
+ )
226
+
227
+ return f"{param.name} : {kind}" if kind else param.name
228
+
229
+
230
+ def _section(name: str, body: str) -> str:
231
+ return f"{name}\n{'-' * len(name)}\n{body}"
232
+
233
+
234
+ def compose(
235
+ alias: str, func: Callable[..., Any], r_names: dict[str, str] | None = None
236
+ ) -> str:
237
+ """Compose the numpydoc docstring for ``func`` from the Rd entry ``alias``.
238
+
239
+ ``r_names`` maps a Python argument name to the R argument it documents
240
+ where the two differ (A-9). An argument resolves to its ``r_names`` entry,
241
+ then to the R argument of the same name or its `PYTHON_ARGUMENTS` text,
242
+ then to ``x`` for ``network``. A Python argument with no text raises
243
+ ``KeyError``.
244
+ """
245
+ entry = r_docs()[alias]
246
+ r_names = r_names or {}
247
+ r_args = dict(entry.get("arguments") or {})
248
+ r_args.update(PYTHON_ARGUMENTS.get(alias, {}))
249
+ signature = inspect.signature(func)
250
+
251
+ # A field is appended only if text is left after substitution, so a field
252
+ # that is R-only from start to end adds nothing.
253
+ parts = [_field(entry["title"], period=True)]
254
+ for key in ("description", "details"):
255
+ text = _field(entry.get(key) or "")
256
+ if text:
257
+ parts.append(text)
258
+
259
+ params = []
260
+ for param in signature.parameters.values():
261
+ if param.name in r_names:
262
+ r_name = r_names[param.name]
263
+ elif param.name in r_args:
264
+ r_name = param.name
265
+ else:
266
+ r_name = _DEFAULT_R_NAMES.get(param.name, param.name)
267
+ if r_name not in r_args:
268
+ raise KeyError(f"{alias}.{param.name}")
269
+ # PYTHON_ARGUMENTS text already replaces the R text; an R-only
270
+ # argument with no Python text keeps its line and no description.
271
+ text = _field(r_args[r_name], period=True)
272
+ line = _parameter_line(param)
273
+ params.append(f"{line}\n{_indent(text)}" if text else line)
274
+ if params:
275
+ parts.append(_section("Parameters", "\n".join(params)))
276
+
277
+ value = _field(entry.get("value") or "", period=True)
278
+ if value:
279
+ returns = _annotation(signature.return_annotation)
280
+ parts.append(_section("Returns", f"{returns}\n{_indent(value)}"))
281
+
282
+ notes = [
283
+ f"{_field(title)}:\n\n{_field(text)}"
284
+ for title, text in (entry.get("sections") or {}).items()
285
+ if _field(text)
286
+ ]
287
+ notes.append(f"Documentation adapted from `{alias}()` in the hydroloom R package.")
288
+ parts.append(_section("Notes", "\n\n".join(notes)))
289
+
290
+ own = inspect.cleandoc(func.__doc__ or "")
291
+ match = _EXAMPLES.search(own)
292
+ if match:
293
+ parts.append(own[match.start() :].strip())
294
+
295
+ return "\n" + "\n\n".join(parts) + "\n"
296
+
297
+
298
+ def doc(alias: str, /, **r_names: str) -> Callable[[F], F]:
299
+ """Compose the decorated function's docstring from the Rd entry ``alias``.
300
+
301
+ Keyword arguments map a Python argument name to the R argument it
302
+ documents where A-9 renames it, as ``polygons="polys"``.
303
+ """
304
+
305
+ def decorate(func: F) -> F:
306
+ func.__doc__ = compose(alias, func, r_names)
307
+
308
+ return func
309
+
310
+ return decorate