mkdocstrings-matlab 0.3.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.
@@ -0,0 +1,4 @@
1
+ from .mkdocs_material_matlab import MkdocsMaterialMatlabPlugin
2
+
3
+
4
+ __all__ = ["MkdocsMaterialMatlabPlugin"]
@@ -0,0 +1,7 @@
1
+ code.doc-symbol-attribute::after {
2
+ content: "prop" !important;
3
+ }
4
+
5
+ code.doc-symbol-module::after {
6
+ content: "name" !important;
7
+ }
@@ -0,0 +1,20 @@
1
+ from mkdocs.plugins import BasePlugin
2
+ import os
3
+
4
+
5
+ class MkdocsMaterialMatlabPlugin(BasePlugin):
6
+ def on_config(self, config):
7
+ # Ensure the custom CSS file is included in the extra_css list
8
+ css_path = "css/style.css"
9
+ if css_path not in config["extra_css"]:
10
+ config["extra_css"].append(css_path)
11
+ return config
12
+
13
+ def on_post_build(self, *, config):
14
+ # Ensure the custom CSS file is copied to the output directory
15
+ css_src_path = os.path.join(os.path.dirname(__file__), "css", "style.css")
16
+ css_dest_path = os.path.join(config["site_dir"], "css", "style.css")
17
+ os.makedirs(os.path.dirname(css_dest_path), exist_ok=True)
18
+ with open(css_src_path, "rb") as src_file:
19
+ with open(css_dest_path, "wb") as dest_file:
20
+ dest_file.write(src_file.read())
@@ -0,0 +1,5 @@
1
+ """MATLAB handler for mkdocstrings."""
2
+
3
+ from mkdocstrings_handlers.matlab.handler import get_handler
4
+
5
+ __all__ = ["get_handler"]
@@ -0,0 +1,597 @@
1
+ from collections import defaultdict, deque
2
+ from copy import deepcopy
3
+ from pathlib import Path
4
+ from typing import Mapping, Sequence
5
+
6
+ from _griffe.collections import LinesCollection as GLC, ModulesCollection
7
+ from _griffe.docstrings.models import (
8
+ DocstringSectionParameters,
9
+ DocstringSectionReturns,
10
+ DocstringParameter,
11
+ DocstringReturn,
12
+ )
13
+ from _griffe.expressions import Expr
14
+
15
+ from mkdocstrings_handlers.matlab.enums import ParameterKind
16
+ from mkdocstrings_handlers.matlab.models import (
17
+ _ParentGrabber,
18
+ Class,
19
+ Classfolder,
20
+ Docstring,
21
+ DocstringSectionText,
22
+ Function,
23
+ MatlabMixin,
24
+ Object,
25
+ Namespace,
26
+ ROOT,
27
+ )
28
+ from mkdocstrings_handlers.matlab.treesitter import FileParser
29
+
30
+
31
+ __all__ = ["LinesCollection", "PathCollection"]
32
+
33
+
34
+ class LinesCollection(GLC):
35
+ """A simple dictionary containing the modules source code lines."""
36
+
37
+ def __init__(self) -> None:
38
+ """Initialize the collection."""
39
+ self._data: dict[str, list[str]] = {}
40
+
41
+
42
+ class PathGlobber:
43
+ """
44
+ A class to recursively glob paths as MATLAB would do it.
45
+ """
46
+
47
+ def __init__(self, path: Path, recursive: bool = False):
48
+ self._idx = 0
49
+ self._paths: list[Path] = []
50
+ self._glob(path, recursive)
51
+
52
+ def _glob(self, path: Path, recursive: bool = False):
53
+ for member in path.iterdir():
54
+ if (
55
+ member.is_dir()
56
+ and recursive
57
+ and member.stem[0] not in ["+", "@"]
58
+ and member.stem != "private"
59
+ ):
60
+ self._glob(member, recursive=True)
61
+ elif member.is_dir() and member.stem[0] == "+":
62
+ self._paths.append(member)
63
+ self._glob(member)
64
+ elif member.is_dir() and member.stem[0] == "@":
65
+ self._paths.append(member)
66
+ elif (
67
+ member.is_file()
68
+ and member.suffix == ".m"
69
+ and member.name != "Contents.m"
70
+ ):
71
+ self._paths.append(member)
72
+
73
+ def max_stem_length(self) -> int:
74
+ return max(len(path.stem) for path in self._paths)
75
+
76
+ def __len__(self):
77
+ return len(self._paths)
78
+
79
+ def __iter__(self):
80
+ return self
81
+
82
+ def __next__(self):
83
+ try:
84
+ item = self._paths[self._idx]
85
+ except IndexError as err:
86
+ raise StopIteration from err
87
+ self._idx += 1
88
+ return item
89
+
90
+
91
+ class PathCollection(ModulesCollection):
92
+ """
93
+ PathCollection is a class that manages a collection of MATLAB paths and their corresponding models.
94
+
95
+ Attributes:
96
+ config (Mapping): Configuration settings for the PathCollection.
97
+ lines_collection (LinesCollection): An instance of LinesCollection for managing lines.
98
+
99
+ Args:
100
+ matlab_path (Sequence[str | Path]): A list of strings or Path objects representing the MATLAB paths.
101
+ recursive (bool, optional): If True, recursively adds all subdirectories of the given paths to the search path. Defaults to False.
102
+ config (Mapping, optional): Configuration settings for the PathCollection. Defaults to {}.
103
+
104
+ Methods:
105
+ members() -> dict:
106
+ Returns a dictionary of members with their corresponding models.
107
+
108
+ resolve(identifier: str, config: Mapping = {}) -> MatlabMixin | None:
109
+ Resolves the given identifier to a model object.
110
+
111
+ update_model(model: MatlabMixin, config: Mapping) -> MatlabMixin:
112
+ Updates the given model object with the provided configuration.
113
+
114
+ addpath(path: str | Path, to_end: bool = False, recursive: bool = False) -> list[Path]:
115
+ Adds a path to the search path.
116
+
117
+ rm_path(path: str | Path, recursive: bool = False) -> list[Path]:
118
+ Removes a path from the search path and updates the namespace and database accordingly.
119
+
120
+ get_inheritance_diagram(model: Class) -> DocstringSectionText | None:
121
+ Generates an inheritance diagram for the given class model.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ matlab_path: Sequence[str | Path],
127
+ recursive: bool = False,
128
+ config: Mapping = {},
129
+ ) -> None:
130
+ """
131
+ Initialize an instance of PathCollection.
132
+
133
+ Args:
134
+ matlab_path (list[str | Path]): A list of strings or Path objects representing the MATLAB paths.
135
+
136
+ Raises:
137
+ TypeError: If any element in matlab_path is not a string or Path object.
138
+ """
139
+ for path in matlab_path:
140
+ if not isinstance(path, (str, Path)):
141
+ raise TypeError(f"Expected str or Path, got {type(path)}")
142
+
143
+ self._path: deque[Path] = deque()
144
+ self._mapping: dict[str, deque[Path]] = defaultdict(deque)
145
+ self._models: dict[Path, LazyModel] = {}
146
+ self._members: dict[Path, list[tuple[str, Path]]] = defaultdict(list)
147
+
148
+ self.config = config
149
+ self.lines_collection = LinesCollection()
150
+
151
+ for path in matlab_path:
152
+ self.addpath(Path(path), to_end=True, recursive=recursive)
153
+
154
+ @property
155
+ def members(self):
156
+ return {
157
+ identifier: self._models[paths[0]].model()
158
+ for identifier, paths in self._mapping.items()
159
+ }
160
+
161
+ def resolve(
162
+ self,
163
+ identifier: str,
164
+ config: Mapping = {},
165
+ ):
166
+ """
167
+ Resolve an identifier to a MatlabMixin model.
168
+
169
+ This method attempts to resolve a given identifier to a corresponding
170
+ MatlabMixin model using the internal mapping and models. If the identifier
171
+ is not found directly, it will attempt to resolve it by breaking down the
172
+ identifier into parts and resolving each part recursively.
173
+
174
+ Args:
175
+ identifier (str): The identifier to resolve.
176
+ config (Mapping, optional): Configuration options to update the model. Defaults to an empty dictionary.
177
+
178
+ Returns:
179
+ MatlabMixin or None: The resolved MatlabMixin model if found, otherwise None.
180
+ """
181
+
182
+ # Find in global database
183
+ if identifier in self._mapping:
184
+ model = self._models[self._mapping[identifier][0]].model()
185
+ if model is not None:
186
+ model = self.update_model(model, config)
187
+ else:
188
+ model = None
189
+ name_parts = identifier.split(".")
190
+ if len(name_parts) > 1:
191
+ base = self.resolve(".".join(name_parts[:-1]), config=config)
192
+ if base is None or name_parts[-1] not in base.members:
193
+ model = None
194
+ else:
195
+ model = base.members[name_parts[-1]]
196
+ else:
197
+ model = None
198
+
199
+ if isinstance(model, MatlabMixin):
200
+ return model
201
+ return None
202
+
203
+ def update_model(self, model: MatlabMixin, config: Mapping):
204
+ """
205
+ Update the given model based on the provided configuration.
206
+
207
+ This method updates the docstring parser and parser options for the model,
208
+ patches return annotations for MATLAB functions, and optionally creates
209
+ docstring sections from argument blocks. It also recursively updates
210
+ members of the model and handles special cases for class constructors
211
+ and inheritance diagrams.
212
+
213
+ Args:
214
+ model (MatlabMixin): The model to update.
215
+ config (Mapping): The configuration dictionary.
216
+
217
+ Returns:
218
+ MatlabMixin: The updated model.
219
+ """
220
+
221
+ # Update docstring parser and parser options
222
+ if hasattr(model, "docstring") and model.docstring is not None:
223
+ model.docstring.parser = config.get("docstring_style", "google")
224
+ model.docstring.parser_options = config.get("docstring_options", {})
225
+
226
+ # Patch returns annotation
227
+ # In _griffe.docstrings.<parser>.py the function _read_returns_section will enforce an annotation
228
+ # on the return parameter. This annotation is grabbed from the parent. For MATLAB is is invalid.
229
+ # Thus the return annotation needs to be patched back to a None.
230
+ if (
231
+ isinstance(model, Function)
232
+ and model.docstring is not None
233
+ and any(
234
+ isinstance(doc, DocstringSectionReturns)
235
+ for doc in model.docstring.parsed
236
+ )
237
+ ):
238
+ section = next(
239
+ doc
240
+ for doc in model.docstring.parsed
241
+ if isinstance(doc, DocstringSectionReturns)
242
+ )
243
+ for returns in section.value:
244
+ if not isinstance(returns.annotation, Expr):
245
+ returns.annotation = None
246
+
247
+ # Create parameters and returns sections from argument blocks
248
+ if (
249
+ isinstance(model, Function)
250
+ and model.docstring is not None
251
+ and config.get("create_from_argument_blocks", True)
252
+ ):
253
+ docstring_parameters = any(
254
+ isinstance(doc, DocstringSectionParameters)
255
+ for doc in model.docstring.parsed
256
+ )
257
+ docstring_returns = any(
258
+ isinstance(doc, DocstringSectionReturns)
259
+ for doc in model.docstring.parsed
260
+ )
261
+
262
+ if not docstring_parameters and model.parameters:
263
+ arguments_parameters = any(
264
+ param.docstring is not None for param in model.parameters
265
+ )
266
+ else:
267
+ arguments_parameters = False
268
+
269
+ if not docstring_returns and model.returns:
270
+ arguments_returns = any(
271
+ ret.docstring is not None for ret in model.returns
272
+ )
273
+ else:
274
+ arguments_returns = False
275
+
276
+ document_parameters = not docstring_parameters and arguments_parameters
277
+ document_returns = not docstring_returns and arguments_returns
278
+
279
+ if document_parameters:
280
+ parameters = DocstringSectionParameters(
281
+ [
282
+ DocstringParameter(
283
+ name=param.name,
284
+ value=str(param.default)
285
+ if param.default is not None
286
+ else None,
287
+ annotation=param.annotation,
288
+ description=param.docstring.value
289
+ if param.docstring is not None
290
+ else "",
291
+ )
292
+ for param in model.parameters
293
+ if param.kind is not ParameterKind.keyword_only
294
+ ]
295
+ )
296
+
297
+ keywords = DocstringSectionParameters(
298
+ [
299
+ DocstringParameter(
300
+ name=param.name,
301
+ value=str(param.default)
302
+ if param.default is not None
303
+ else None,
304
+ annotation=param.annotation,
305
+ description=param.docstring.value
306
+ if param.docstring is not None
307
+ else "",
308
+ )
309
+ for param in model.parameters
310
+ if param.kind is ParameterKind.keyword_only
311
+ ],
312
+ title="Keyword Arguments:",
313
+ )
314
+ model.docstring._extra_sections.append(parameters)
315
+ model.docstring._extra_sections.append(keywords)
316
+
317
+ if document_returns:
318
+ returns = DocstringSectionReturns(
319
+ [
320
+ DocstringReturn(
321
+ name=param.name,
322
+ value=str(param.default)
323
+ if param.default is not None
324
+ else None,
325
+ annotation=param.annotation,
326
+ description=param.docstring.value
327
+ if param.docstring is not None
328
+ else "",
329
+ )
330
+ for param in model.returns or []
331
+ ]
332
+ )
333
+ model.docstring._extra_sections.append(returns)
334
+
335
+ for member in getattr(model, "members", {}).values():
336
+ self.update_model(member, config)
337
+
338
+ if (
339
+ isinstance(model, Class)
340
+ and config.get("merge_constructor_into_class", False)
341
+ and model.name in model.members
342
+ and model.members[model.name].docstring is not None
343
+ ):
344
+ model = deepcopy(model)
345
+ constructor = model.members.pop(model.name)
346
+ if constructor.docstring is not None:
347
+ if model.docstring is None:
348
+ model.docstring = Docstring("", parent=model)
349
+ model.docstring._extra_sections.extend(constructor.docstring.parsed)
350
+
351
+ if (
352
+ isinstance(model, Class)
353
+ and config.get("show_inheritance_diagram", False)
354
+ and (
355
+ (
356
+ model.docstring is not None
357
+ and "Inheritance Diagram" not in model.docstring.parsed
358
+ )
359
+ or model.docstring is None
360
+ )
361
+ ):
362
+ diagram = self.get_inheritance_diagram(model)
363
+ if diagram is not None:
364
+ model = deepcopy(model)
365
+ if model.docstring is None:
366
+ model.docstring = Docstring("", parent=model)
367
+ model.docstring._extra_sections.append(diagram)
368
+
369
+ return model
370
+
371
+ def addpath(self, path: str | Path, to_end: bool = False, recursive: bool = False):
372
+ """
373
+ Add a path to the search path.
374
+
375
+ Args:
376
+ path (str | Path): The path to be added.
377
+ to_end (bool, optional): Whether to add the path to the end of the search path. Defaults to False.
378
+
379
+ Returns:
380
+ list[Path]: The previous search path before adding the new path.
381
+ """
382
+ if isinstance(path, str):
383
+ path = Path(path)
384
+
385
+ if path in self._path:
386
+ self._path.remove(path)
387
+
388
+ if to_end:
389
+ self._path.append(path)
390
+ else:
391
+ self._path.appendleft(path)
392
+
393
+ members = PathGlobber(path, recursive=recursive)
394
+ for member in members:
395
+ model = LazyModel(member, self)
396
+ self._models[member] = model
397
+ self._mapping[model.name].append(member)
398
+ self._members[path].append((model.name, member))
399
+
400
+ def rm_path(self, path: str | Path, recursive: bool = False):
401
+ """
402
+ Removes a path from the search path and updates the namespace and database accordingly.
403
+
404
+ Args:
405
+ path (str | Path): The path to be removed from the search path.
406
+ recursive (bool, optional): If True, recursively removes all subdirectories of the given path from the search path. Defaults to False.
407
+
408
+ Returns:
409
+ list[Path]: The previous search path before the removal.
410
+
411
+ """
412
+ if isinstance(path, str):
413
+ path = Path(path)
414
+
415
+ if path not in self._path:
416
+ return list(self._path)
417
+
418
+ self._path.remove(path)
419
+
420
+ for name, member in self._members.pop(path):
421
+ self._mapping[name].remove(member)
422
+ self._models.pop(member)
423
+
424
+ if recursive:
425
+ for subdir in [item for item in self._path if _is_subdirectory(path, item)]:
426
+ self.rm_path(subdir, recursive=False)
427
+
428
+ def get_inheritance_diagram(self, model: Class) -> DocstringSectionText | None:
429
+ def get_id(str: str) -> str:
430
+ return str.replace(".", "_")
431
+
432
+ def get_nodes(model: Class, nodes: set[str] = set()) -> set[str]:
433
+ nodes.add(f" {get_id(model.name)}[{model.name}]")
434
+ for base in [str(base) for base in model.bases]:
435
+ super = self.resolve(base)
436
+ if super is None:
437
+ nodes.add(f" {get_id(base)}[{base}]")
438
+ else:
439
+ if isinstance(super, Class):
440
+ get_nodes(super, nodes)
441
+ return nodes
442
+
443
+ def get_links(model: Class, links: set[str] = set()) -> set[str]:
444
+ for base in [str(base) for base in model.bases]:
445
+ super = self.resolve(base)
446
+ if super is None:
447
+ links.add(f" {get_id(base)} --> {get_id(model.name)}")
448
+ else:
449
+ links.add(f" {get_id(super.name)} --> {get_id(model.name)}")
450
+ if isinstance(super, Class):
451
+ get_links(super, links)
452
+ return links
453
+
454
+ nodes = get_nodes(model)
455
+ if len(nodes) == 1:
456
+ return None
457
+
458
+ nodes_str = "\n".join(list(nodes))
459
+ links_str = "\n".join(list(get_links(model)))
460
+ section = f"## Inheritance Diagram\n\n```mermaid\nflowchart TB\n{nodes_str}\n{links_str}\n```"
461
+
462
+ return DocstringSectionText(section, title="Inheritance Diagram")
463
+
464
+
465
+ def _is_subdirectory(parent_path: Path, child_path: Path) -> bool:
466
+ try:
467
+ child_path.relative_to(parent_path)
468
+ except ValueError:
469
+ return False
470
+ else:
471
+ return True
472
+
473
+
474
+ class LazyModel:
475
+ """
476
+ A class to lazily collect and model MATLAB objects from a given path.
477
+
478
+ Methods:
479
+ is_class_folder: Checks if the path is a class folder.
480
+ is_namespace: Checks if the path is a namespace.
481
+ is_in_namespace: Checks if the path is within a namespace.
482
+ name: Returns the name of the MATLAB object, including namespace if applicable.
483
+ model: Collects and returns the MATLAB object model..
484
+ """
485
+
486
+ def __init__(self, path: Path, path_collection: PathCollection):
487
+ self._path: Path = path
488
+ self._model: MatlabMixin | None = None
489
+ self._path_collection: PathCollection = path_collection
490
+ self._lines_collection: LinesCollection = path_collection.lines_collection
491
+
492
+ @property
493
+ def is_class_folder(self) -> bool:
494
+ return self._path.is_dir() and self._path.name[0] == "@"
495
+
496
+ @property
497
+ def is_namespace(self) -> bool:
498
+ return self._path.name[0] == "+"
499
+
500
+ @property
501
+ def is_in_namespace(self) -> bool:
502
+ return self._path.parent.name[0] == "+"
503
+
504
+ @property
505
+ def name(self):
506
+ if self.is_in_namespace:
507
+ parts = list(self._path.parts)
508
+ item = len(parts) - 2
509
+ nameparts = []
510
+ while item >= 0:
511
+ if parts[item][0] != "+":
512
+ break
513
+ nameparts.append(parts[item][1:])
514
+ item -= 1
515
+ nameparts.reverse()
516
+ namespace = ".".join(nameparts) + "."
517
+ else:
518
+ namespace = ""
519
+
520
+ if self.is_class_folder or self.is_namespace:
521
+ name = namespace + self._path.name[1:]
522
+ else:
523
+ name = namespace + self._path.stem
524
+
525
+ if self.is_namespace:
526
+ return "+" + name
527
+ else:
528
+ return name
529
+
530
+ def model(self):
531
+ if not self._path.exists():
532
+ return None
533
+
534
+ if self._model is None:
535
+ if self.is_class_folder:
536
+ self._model = self._collect_classfolder(self._path)
537
+ elif self.is_namespace:
538
+ self._model = self._collect_namespace(self._path)
539
+ else:
540
+ self._model = self._collect_path(self._path)
541
+ if self._model is not None:
542
+ self._model.parent = self._collect_parent(self._path.parent)
543
+ return self._model
544
+
545
+ def _collect_parent(self, path: Path) -> Object | _ParentGrabber:
546
+ if self.is_in_namespace:
547
+ parent = _ParentGrabber(
548
+ lambda: self._path_collection._models[path].model() or ROOT
549
+ )
550
+ else:
551
+ parent = ROOT
552
+ return parent
553
+
554
+ def _collect_path(self, path: Path) -> MatlabMixin:
555
+ file = FileParser(path)
556
+ model = file.parse(path_collection=self._path_collection)
557
+ self._lines_collection[path] = file.content.split("\n")
558
+ return model
559
+
560
+ def _collect_classfolder(self, path: Path) -> Classfolder | None:
561
+ classfile = path / (path.name[1:] + ".m")
562
+ if not classfile.exists():
563
+ return None
564
+ model = self._collect_path(classfile)
565
+ if not isinstance(model, Classfolder):
566
+ return None
567
+ for member in path.iterdir():
568
+ if (
569
+ member.is_file()
570
+ and member.suffix == ".m"
571
+ and member.name != "Contents.m"
572
+ and member != classfile
573
+ ):
574
+ method = self._collect_path(member)
575
+ method.parent = model
576
+ model.members[method.name] = method
577
+ return model
578
+
579
+ def _collect_namespace(self, path: Path) -> Namespace | None:
580
+ name = self.name[1:].split(".")[-1]
581
+ model = Namespace(name, filepath=path, path_collection=self._path_collection)
582
+
583
+ for member in path.iterdir():
584
+ if member.is_dir() and member.name[0] in ["+", "@"]:
585
+ submodel = self._path_collection._models[member].model()
586
+ if submodel is not None:
587
+ model.members[submodel.name] = submodel
588
+
589
+ elif member.is_file() and member.suffix == ".m":
590
+ if member.name == "Contents.m":
591
+ contentsfile = self._collect_path(member)
592
+ contentsfile.docstring = model.docstring
593
+ else:
594
+ submodel = self._path_collection._models[member].model()
595
+ if submodel is not None:
596
+ model.members[submodel.name] = submodel
597
+ return model
@@ -0,0 +1,35 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ParameterKind(str, Enum):
5
+ """
6
+ An enumeration representing different kinds of function parameters.
7
+
8
+ Attributes:
9
+ positional (str): Positional-only parameter.
10
+ optional (str): Optional parameter.
11
+ keyword_only (str): Keyword-only parameter.
12
+ var_keyword (str): Variadic keyword parameter.
13
+ """
14
+
15
+ positional_only = "positional-only"
16
+ optional = "optional"
17
+ keyword_only = "keyword-only"
18
+ var_keyword = "variadic keyword"
19
+
20
+
21
+ class AccessEnum(str, Enum):
22
+ """
23
+ An enumeration representing different access levels for MATLAB code elements.
24
+
25
+ Attributes:
26
+ public (str): Represents public access level.
27
+ protected (str): Represents protected access level.
28
+ private (str): Represents private access level.
29
+ immutable (str): Represents immutable access level.
30
+ """
31
+
32
+ public = "public"
33
+ protected = "protected"
34
+ private = "private"
35
+ immutable = "immutable"