mkdocstrings-matlab 0.3.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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"