Skip to content

isqx.mkdocs¤

extension ¤

A griffe extension to dynamically collect isqx definitions/details.

Details are injected into the docstrings of attributes and functions in the documentation generated by mkdocstrings-python.

Note

You must install isqx with the docs extra optional dependencies to use this module.

logger ¤

logger = get_logger(__name__)

IsqxExtensionConfig ¤

IsqxExtensionConfig(
    definitions: tuple[str, ...] = (),
    details: tuple[str, ...] = (),
)

definitions ¤

definitions: tuple[str, ...] = ()

details ¤

details: tuple[str, ...] = ()

get_templates_path ¤

get_templates_path() -> Path

See: https://mkdocstrings.github.io/usage/handlers/#handler-extensions

Source code in src/isqx/mkdocs/extension.py
 98
 99
100
def get_templates_path() -> Path:
    """See: https://mkdocstrings.github.io/usage/handlers/#handler-extensions"""
    return PATH_PLUGIN / "templates"

Where ¤

Where(
    symbol: str,
    description: StrFragment | tuple[StrFragment, ...],
    unit: UnitExprJson | None,
)

symbol ¤

symbol: str

description ¤

description: StrFragment | tuple[StrFragment, ...]

unit ¤

unit: UnitExprJson | None

KaTeXWhere ¤

KaTeXWhere(
    katex: str, where: tuple[Where, ...] | None = None
)

katex ¤

katex: str

where ¤

where: tuple[Where, ...] | None = None

SymbolDetail ¤

SymbolDetail(
    katex: str,
    where: tuple[Where, ...] | None = None,
    remarks: str | None = None,
)

Bases: KaTeXWhere

remarks ¤

remarks: str | None = None

katex ¤

katex: str

where ¤

where: tuple[Where, ...] | None = None

EquationDetail ¤

EquationDetail(
    katex: str,
    where: tuple[Where, ...] | None = None,
    assumptions: tuple[
        StrFragment | tuple[StrFragment, ...], ...
    ]
    | None = None,
)

Bases: KaTeXWhere

assumptions ¤

assumptions: (
    tuple[StrFragment | tuple[StrFragment, ...], ...] | None
) = None

katex ¤

katex: str

where ¤

where: tuple[Where, ...] | None = None

WikidataDetail ¤

WikidataDetail(qcode: str)

qcode ¤

qcode: str

Quantity ¤

Quantity(value: str, unit: UnitExprJson)

value ¤

value: str

unit ¤

unit: UnitExprJson

DefinitionPath ¤

DefinitionPath: TypeAlias = str

PublicApiPath ¤

PublicApiPath: TypeAlias = str

QtyKindDetail ¤

QtyKindDetail(
    parent: str | None = None,
    unit_si_coherent: UnitExprJson | None = None,
    tags: tuple[str, ...] = tuple(),
    wikidata: list[WikidataDetail] = list(),
    symbols: list[SymbolDetail] = list(),
    equations: list[EquationDetail] = list(),
)

Several quantities can share the same unit. Two cases:

  • Explicit inheritance: when a quantity is defined by subscripting another, like POTENTIAL_ENERGY = ENERGY["potential"]: this is a strong signal of a parent-child relationship. we can detect this during the griffe AST walking
  • Implicit grouping: some quantities are related by their physical dimension but not defined via subscripting (e.g., SPEED and VELOCITY). we can group them by their underlying SI dimension. the hierarchy can be inferred from the tags.

parent ¤

parent: str | None = None

unit_si_coherent ¤

unit_si_coherent: UnitExprJson | None = None

tags ¤

tags: tuple[str, ...] = field(default_factory=tuple)

wikidata ¤

wikidata: list[WikidataDetail] = field(default_factory=list)

symbols ¤

symbols: list[SymbolDetail] = field(default_factory=list)

equations ¤

equations: list[EquationDetail] = field(
    default_factory=list
)

IsqxExtension ¤

IsqxExtension(config: Mapping[str, Any] | None = None)

Bases: Extension

Source code in src/isqx/mkdocs/extension.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def __init__(
    self,
    config: Mapping[str, Any] | None = None,
):
    self.config = (
        IsqxExtensionConfig(
            definitions=tuple(config.get("definitions", ())),
            details=tuple(config.get("details", ())),
        )
        if config is not None
        else IsqxExtensionConfig()
    )
    self.definitions: Definitions = {}
    self.objects: dict[str, QtyKindDetail] = {}  # by path
    self.possible_parent_maps: dict[str, str] = {}  # child -> parent
    """Note that items in this map are not guaranteed to be isqx objects
    because griffe works with static analysis."""
    self.alias_targets: dict[PublicApiPath, DefinitionPath] = {}
    self.public_api_candidates: dict[
        DefinitionPath, set[PublicApiPath]
    ] = {}

config ¤

config = (
    IsqxExtensionConfig(
        definitions=tuple(config.get("definitions", ())),
        details=tuple(config.get("details", ())),
    )
    if config is not None
    else IsqxExtensionConfig()
)

definitions ¤

definitions: Definitions = {}

objects ¤

objects: dict[str, QtyKindDetail] = {}

possible_parent_maps ¤

possible_parent_maps: dict[str, str] = {}

Note that items in this map are not guaranteed to be isqx objects because griffe works with static analysis.

alias_targets ¤

alias_targets: dict[PublicApiPath, DefinitionPath] = {}

public_api_candidates ¤

public_api_candidates: dict[
    DefinitionPath, set[PublicApiPath]
] = {}

on_instance ¤

on_instance(
    *,
    node: ast.AST | ObjectNode,
    obj: Object,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None
Source code in src/isqx/mkdocs/extension.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def on_instance(
    self,
    *,
    node: ast.AST | ObjectNode,
    obj: Object,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None:
    inject_citation_into_docstring(obj, agent)

    # collect strict parent-child relationships
    if (
        isinstance(obj, Attribute)
        and isinstance(obj.value, ExprSubscript)
        and isinstance(
            parent_expr := obj.value.left, (ExprName, ExprAttribute)
        )
    ):
        parent_path = parent_expr.canonical_path
        if parent_path.split(".", 1)[0] in ("typing", "typing_extensions"):
            # heuristic: we captured something invalid like Union[str, int]
            return
        self.possible_parent_maps[obj.canonical_path] = parent_path

on_module_instance ¤

on_module_instance(
    *,
    node: ast.AST | ObjectNode,
    mod: Module,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None

Populate definitions from the current module being visited using runtime analysis, if it is matches the path under the configured definitions list.

Note that if module a contains FOO: Annotated[float, isqx.M] = 1.0 and module b contains from a import FOO, then self.definitions will contain both (which points to the same object):

  • a.FOO: Definition(value=1.0, annotated_metadata=M)
  • b.FOO: Definition(value=1.0, annotated_metadata=None)

which is not ideal. When griffe visits b, on_alias will make sure the definition for b.FOO is removed.

Source code in src/isqx/mkdocs/extension.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def on_module_instance(
    self,
    *,
    node: ast.AST | ObjectNode,
    mod: Module,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None:
    """Populate definitions from the current module being visited using
    runtime analysis, if it is matches the path under the configured
    definitions list.

    Note that if module `a` contains `FOO: Annotated[float, isqx.M] = 1.0`
    and module `b` contains `from a import FOO`, then `self.definitions`
    will contain both (which points to the same object):

    - `a.FOO`: Definition(value=1.0, annotated_metadata=M)
    - `b.FOO`: Definition(value=1.0, annotated_metadata=None)

    which is not ideal. When `griffe` visits `b`, `on_alias` will make sure
    the definition for `b.FOO` is removed.
    """
    if mod.path not in self.config.definitions:
        return
    module_rt = dynamic_import(mod.path)
    metadata = dict(module_attribute_metadata(module_rt))
    self.definitions |= {
        f"{mod.path}.{name}": Definition(obj, metadata.get(name))
        for name, obj in inspect.getmembers(module_rt)
        if isinstance(obj, _ARGS_DEFINITION)
    }

on_alias_instance ¤

on_alias_instance(
    *,
    node: ast.AST | ObjectNode,
    alias: Alias,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None
Source code in src/isqx/mkdocs/extension.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def on_alias_instance(
    self,
    *,
    node: ast.AST | ObjectNode,
    alias: Alias,
    agent: Visitor | Inspector,
    **kwargs: Any,
) -> None:
    target_path = _get_alias_definition_path(alias)
    if target_path is not None:
        _register_public_api_candidate(
            alias.path,
            target_path,
            alias_targets=self.alias_targets,
            public_api_candidates=self.public_api_candidates,
        )
    # todo: actually we probably shouldn't do this
    if alias.path in self.definitions:
        del self.definitions[alias.path]

on_package ¤

on_package(
    *, pkg: Module, loader: GriffeLoader, **kwargs: Any
) -> None
Source code in src/isqx/mkdocs/extension.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def on_package(
    self, *, pkg: Module, loader: GriffeLoader, **kwargs: Any
) -> None:
    _collect_alias_candidates(
        pkg_path=pkg.path,
        modules_collection=loader.modules_collection,
        alias_targets=self.alias_targets,
        public_api_candidates=self.public_api_candidates,
    )
    logger.info(f"loaded {len(self.definitions)} definitions")
    path_resolver = _PathResolver(
        alias_targets=self.alias_targets,
        public_api_candidates=self.public_api_candidates,
    )
    public_definitions = _build_public_unit_paths(
        self.definitions,
        path_resolver,
    )
    unit_decls = build_unit_decl_table(
        {
            path: definition.value
            for path, definition in self.definitions.items()
            if isinstance(definition.value, _ARGS_EXPR)
        },
        public_definitions=public_definitions,
    )
    serialized_units = serialize_unit_decls(unit_decls=unit_decls)

    for defs_path in self.config.details:
        for path, item in _process_details_module(
            defs_path,
            loader,
            self.definitions,
            path_resolver,
            unit_decls,
        ):
            objects = self.objects.setdefault(path, QtyKindDetail())
            attr_target: Attribute = loader.modules_collection[path]
            isqx_extras = _get_or_init_isqx_extras(attr_target)
            isqx_extras["units"] = serialized_units

            if isinstance(item, WikidataDetail):
                objects.wikidata.append(item)
                isqx_extras["wikidata"].append(item)
            elif isinstance(item, SymbolDetail):
                objects.symbols.append(item)
                isqx_extras["symbols"].append(item)
            elif isinstance(item, EquationDetail):
                objects.equations.append(item)
                isqx_extras["details"].append(item)

    # set the parent (if found). would be nice for jinja to also show this
    # but we focus on the frontend vis for now.
    for child_path, parent_path in self.possible_parent_maps.items():
        if child_path in self.objects and parent_path in self.objects:
            child_object_detail = self.objects[child_path]
            child_object_detail.parent = parent_path

    for path, details in self.objects.items():
        if path not in self.definitions:
            continue
        definition = self.definitions[path]
        if not isinstance(v := definition.value, QtyKind):
            continue
        details.unit_si_coherent = serialize_unit_expr(
            v.unit_si_coherent,
            unit_decls=unit_decls,
        )
        if v.tags is not None:
            details.tags = tuple(str(t) for t in v.tags)  # str for now

write_objects ¤

write_objects(output_dir: str | Path) -> Path
Source code in src/isqx/mkdocs/extension.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
def write_objects(self, output_dir: str | Path) -> Path:
    path_resolver = _PathResolver(
        alias_targets=self.alias_targets,
        public_api_candidates=self.public_api_candidates,
    )
    public_definitions = _build_public_unit_paths(
        self.definitions,
        path_resolver,
    )
    unit_decls = build_unit_decl_table(
        {
            path: definition.value
            for path, definition in self.definitions.items()
            if isinstance(definition.value, _ARGS_EXPR)
        },
        public_definitions=public_definitions,
    )
    constants_by_definition_path: dict[str, Quantity] = {}
    for path, definition in self.definitions.items():
        if not isinstance(definition.value, (LazyProduct, *_ARGS_NUMBER)):
            continue
        value = definition.value
        value_str = str(
            value.to_approx() if isinstance(value, LazyProduct) else value
        )
        meta = definition.annotated_metadata
        if meta is None or meta.unit is None:
            logger.warning(
                f"no unit found for {path} in annotated metadata"
            )
            continue  # dimensionless quantities should be annotated anyways
        constants_by_definition_path[path] = Quantity(
            value=value_str,
            unit=serialize_unit_expr(
                meta.unit,
                unit_decls=unit_decls,
            ),
        )

    qty_kinds = _remap_public_api_indexed_dict(
        self.objects,
        path_resolver=path_resolver,
        remap_value=lambda details: _remap_qty_kind_detail(
            details,
            path_resolver,
        ),
    )
    constants = _remap_public_api_indexed_dict(
        constants_by_definition_path,
        path_resolver=path_resolver,
        remap_value=lambda quantity: _remap_quantity(
            quantity,
            path_resolver,
        ),
    )
    units = serialize_unit_decls(unit_decls=unit_decls)

    json_path = Path(output_dir) / "objects.json"
    output_data = {
        "qtyKinds": {
            path: to_dict(details) for path, details in qty_kinds.items()
        },
        "constants": {
            path: to_dict(details) for path, details in constants.items()
        },
        "units": units,
    }
    json_path.parent.mkdir(parents=True, exist_ok=True)
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(output_data, f)
    logger.info(f"wrote objects to {json_path}")
    return json_path

write_objects_from_config ¤

write_objects_from_config(
    config_file_path: str,
    output_dir: str | Path | None = None,
) -> Path

Write objects.json explicitly after a Zensical docs build.

objects.json used to be emitted as a side effect of the Griffe walk performed inside the mkdocstrings rendering path. That was fragile under Zensical: each build starts by deleting the whole site directory, while warm cached builds may skip the expensive markdown and API extraction stages. The build still succeeds, but custom generated artifacts from those skipped stages disappear from the final site.

Zensical currently regenerates a small fixed set of built in artifacts such as objects.inv, but it does not expose a supported hook for repository owned custom non-page artifacts like this file. Therefore just docs-build must call this helper explicitly after zensical build so both cold and warm builds deterministically recreate site/assets/objects.json.

Source code in src/isqx/mkdocs/extension.py
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def write_objects_from_config(
    config_file_path: str,
    output_dir: str | Path | None = None,
) -> Path:
    """Write `objects.json` explicitly after a Zensical docs build.

    `objects.json` used to be emitted as a side effect of the Griffe walk
    performed inside the mkdocstrings rendering path. That was fragile under
    Zensical: each build starts by deleting the whole site directory, while warm
    cached builds may skip the expensive markdown and API extraction stages.
    The build still succeeds, but custom generated artifacts from those skipped
    stages disappear from the final site.

    Zensical currently regenerates a small fixed set of built in artifacts such
    as `objects.inv`, but it does not expose a supported hook for repository
    owned custom non-page artifacts like this file. Therefore `just docs-build`
    must call this helper explicitly after `zensical build` so both cold and
    warm builds deterministically recreate `site/assets/objects.json`.
    """
    from zensical.config import parse_config

    config = parse_config(config_file_path)
    ext_config = _get_extension_config_from_site_config(config)
    extension = IsqxExtension(config=asdict(ext_config))

    root_dir = Path(config_file_path).resolve().parent
    search_paths = [
        str(root_dir.joinpath(path).resolve())
        for path in _get_python_handler_paths(config)
    ]
    loader = GriffeLoader(
        extensions=load_extensions(extension),
        search_paths=search_paths,
    )

    for package_name in _get_top_level_packages(ext_config):
        loader.load(package_name, try_relative_path=False)

    if output_dir is None:
        output_dir = root_dir / config["site_dir"] / "assets"
    assert output_dir is not None
    return extension.write_objects(output_dir)

to_dict ¤

to_dict(obj: Any) -> dict[str, Any]
Source code in src/isqx/mkdocs/extension.py
515
516
def to_dict(obj: Any) -> dict[str, Any]:
    return asdict(obj, dict_factory=lambda x: {k: v for (k, v) in x if v})

Definition ¤

Bases: NamedTuple

value ¤

annotated_metadata ¤

annotated_metadata: AnnotatedMetadata | None

Definitions ¤

Definitions: TypeAlias = dict[DefinitionPath, Definition]

parse_katex_where_static ¤

parse_katex_where_static(
    katex_where_rt: HasKaTeXWhere,
    katex_where_st: str | Expr,
) -> tuple[str, ExprDict | None]
Source code in src/isqx/mkdocs/extension.py
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
def parse_katex_where_static(
    katex_where_rt: HasKaTeXWhere,
    katex_where_st: str | Expr,
) -> tuple[str, ExprDict | None]:
    # working with static analysis is very annoying, we have to handle kwargs
    # and defaults. using a bunch of asserts for now.
    assert isinstance(katex_where_st, ExprCall), katex_where_st
    katex_st = katex_where_st.arguments[0]
    katex_st_value = (
        katex_st.value if isinstance(katex_st, ExprKeyword) else katex_st
    )
    assert isinstance(katex_st_value, str), katex_st_value
    katex_st_value = (
        katex_st_value.removeprefix("'")
        .removeprefix('"')
        .removesuffix("'")
        .removesuffix('"')
        .replace("\\\\", "\\")
    )  # regex is probably better but eh
    if not len(katex_where_st.arguments) > 1:
        return katex_st_value, None
    where_st = katex_where_st.arguments[1]
    where_st_value = where_st
    if isinstance(where_st, ExprKeyword):
        if where_st.name != "where":
            return katex_st_value, None
        where_st_value = where_st.value
    assert isinstance(where_st_value, ExprDict), where_st_value
    assert katex_where_rt.katex == katex_st_value, (
        f"`griffe` skipped an entry!"
        "\nthis can happen in two keys in the dictionary refer to the same "
        "value. check the definitions."
        f"\nruntime value {katex_where_rt.katex}\ndoes not match griffe: {katex_st_value}"
    )
    return katex_st_value, where_st_value

parse_where ¤

parse_where(
    key_rt: DetailKey | Callable[..., DetailKey],
    detail_rt: HasKaTeXWhere,
    where_st: ExprDict,
    *,
    self_path: str,
    definitions: Definitions,
    path_resolver: _PathResolver,
    unit_decls: UnitRegistry,
    mut_self_symbols: list[str],
) -> Generator[Where, None, None]
Source code in src/isqx/mkdocs/extension.py
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
def parse_where(
    key_rt: DetailKey | Callable[..., DetailKey],
    detail_rt: HasKaTeXWhere,
    where_st: ExprDict,
    *,
    self_path: str,
    definitions: Definitions,
    path_resolver: _PathResolver,
    unit_decls: UnitRegistry,
    mut_self_symbols: list[str],
) -> Generator[Where, None, None]:
    if detail_rt.where is None:
        return
    for (w_rt_k, w_rt_v), w_st_v in zip(
        detail_rt.where.items(),
        where_st.values,
    ):
        description = _parse_fragments(w_rt_v, w_st_v, self_path)
        unit_expr: IsqxExpr | None = None
        frags_for_unit_inference = (
            w_rt_v if isinstance(w_rt_v, tuple) else (w_rt_v,)
        )
        frags_st_for_unit_inference = (
            w_st_v.elements
            if isinstance(w_rt_v, tuple) and isinstance(w_st_v, ExprTuple)
            else (w_st_v,)
        )
        for frag, frag_st in zip(
            frags_for_unit_inference,
            frags_st_for_unit_inference,
        ):
            if isinstance(frag, (str, Anchor)):
                continue
            if isinstance(frag, _RefSelf):
                mut_self_symbols.append(w_rt_k)
                if _is_detail_key(key_rt):
                    unit_expr = _get_unit_expr(
                        key_rt,
                        definition_path=path_resolver.to_definition_path(
                            self_path
                        ),
                        definitions=definitions,
                    )
            else:
                unit_expr = _get_unit_expr(
                    frag,
                    definition_path=path_resolver.to_definition_path(
                        _get_fragment_definition_path(frag, frag_st)
                    ),
                    definitions=definitions,
                )
            if unit_expr is not None:
                break

        unit: UnitExprJson | None
        if unit_expr is None:
            unit = None
        else:
            unit = serialize_unit_expr(
                unit_expr,
                unit_decls=unit_decls,
            )

        yield Where(
            symbol=w_rt_k,
            description=description,
            unit=unit,
        )

screaming_to_normal ¤

screaming_to_normal(s: str, i: int) -> str
Source code in src/isqx/mkdocs/extension.py
1077
1078
1079
1080
def screaming_to_normal(s: str, i: int) -> str:
    # a better way would be to parse the docstring but that might be too verbose
    normalized = s.split(".")[-1].lower().replace("_", " ")
    return normalized.capitalize() if i == 0 else normalized

inject_citation_into_docstring ¤

inject_citation_into_docstring(
    obj: Object, agent: Visitor | Inspector
) -> None
Source code in src/isqx/mkdocs/extension.py
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
def inject_citation_into_docstring(
    obj: Object, agent: Visitor | Inspector
) -> None:
    if not obj.canonical_path.startswith("isqx._citations."):
        return
    # quick hack to put runtime value into the docstring
    doc_str = dynamic_import(obj.path)
    if not isinstance(doc_str, str):
        logger.warning(
            f"expected citation {obj.path} to be a string, got {type(doc_str)}"
        )
        return
    obj.docstring = Docstring(
        doc_str,
        parent=obj,
        parser=agent.docstring_parser,
        parser_options=agent.docstring_options,
    )