sphinx-doclang 26.7.8__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,31 @@
1
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
2
+ #//| Copyright (c) 15 Feb 2026. All rights are reserved by ASI
3
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
4
+
5
+ #// IMPORT
6
+ # from importlib.resources import files
7
+ from sphinx.application import Sphinx
8
+ from sphinx.util.typing import ExtensionMetadata
9
+
10
+ from ._version import VERSION_STRING
11
+ from .processor import setup_processor, validate_command_configuration_values
12
+ from .manager import TemplateManager
13
+
14
+
15
+ #// RUN
16
+ def setup(app: Sphinx) -> ExtensionMetadata:
17
+ # root = files(__name__)
18
+ # app.config.html_static_path.append(str(root / "_static"))
19
+ # app.add_css_file("styles/localtoc.css")
20
+
21
+ setup_processor(app)
22
+
23
+ validate_command_configuration_values(app)
24
+
25
+ TemplateManager._init_app(app)
26
+
27
+ return {
28
+ "version": VERSION_STRING,
29
+ "parallel_read_safe": True,
30
+ "parallel_write_safe": False,
31
+ }
@@ -0,0 +1,32 @@
1
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
2
+ #//| Copyright (c) 15 Feb 2026. All rights are reserved by ASI
3
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
4
+ __all__ = (
5
+ "MAJOR", "MINOR", "MICRO", "REVISION",
6
+ "S_STABLE", "VERSION_STRING"
7
+ )
8
+
9
+ #//| Version variables
10
+ #//|>--------------------------------------------------------<|
11
+ MAJOR: int = 26
12
+ MINOR: int = 7
13
+ MICRO: int = 8
14
+ VERSION_STRING: str = f"{MAJOR}.{MINOR}.{MICRO}"
15
+
16
+
17
+ #//| Development | Revision
18
+ #//|>--------------------------------------------------------<|
19
+ REVISION: int = 0
20
+ S_STABLE: bool = True
21
+
22
+ # If is not a stable state, update `VERSION_STRING` to reflect that
23
+ if not S_STABLE:
24
+ VERSION_STRING = f"{VERSION_STRING}.dev{max(1, REVISION)}"
25
+ elif REVISION > 0:
26
+ VERSION_STRING = f"{VERSION_STRING}.rev{REVISION}"
27
+
28
+
29
+ #//| exec'd
30
+ #//|>--------------------------------------------------------<|
31
+ __version__: str = VERSION_STRING
32
+
@@ -0,0 +1,62 @@
1
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
2
+ #//| Copyright (c) 18 Feb 2026. All rights are reserved by ASI
3
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
4
+ __all__ = ("DocLangError","InvalidCommandError","InvalidConfigError")
5
+
6
+
7
+ #// LOGIC
8
+ class DocLangError(Exception):
9
+ """Base exception for all DocLang‑related errors."""
10
+
11
+ def __init__(self, message: str, *args) -> None:
12
+ super().__init__(f"[DocLang Error] {message}", *args)
13
+
14
+ def note(self, title: str, message: str|list[str], indent: int = 0) -> None:
15
+ """
16
+ Attach a formatted note to the current error.
17
+
18
+ Notes provide additional context, suggestions or multi‑line guidance
19
+ that appears alongside the main error message in Sphinx output.
20
+
21
+ :param title: A short label describing the purpose of the note.
22
+ :param message: Either a single string or a list of strings.
23
+ :param indent: Number of spaces to indent multi‑line messages.
24
+
25
+ :return: Nothing.
26
+ """
27
+ # Local variables
28
+ formatted: str = ""
29
+
30
+ # Single-line message
31
+ if isinstance(message, str):
32
+ formatted = message
33
+
34
+ else:
35
+ # One-line list ➜ treat as a normal string
36
+ if len(message) == 1:
37
+ formatted = message[0]
38
+
39
+ # Multi-line list ➜ format with indentation
40
+ else:
41
+ prefix: str = f"\n\t{' ' * (len(title) + 3 + indent)}"
42
+
43
+ for message_index in range(len(message)):
44
+ formatted = f"{formatted}{prefix if message_index else ''}{message[message_index]}"
45
+
46
+ # Only add the note if the message contains meaningful content
47
+ if formatted.strip():
48
+ self.add_note(f"\t[{title}] {formatted}")
49
+
50
+
51
+ class InvalidCommandError(DocLangError):
52
+ """Raised when a DSL command is invalid or cannot be registered."""
53
+
54
+ def __init__(self, message: str) -> None:
55
+ super().__init__(message)
56
+
57
+
58
+ class InvalidConfigError(DocLangError):
59
+ """Raised when attempting to use a configuration value with an invalid signature."""
60
+
61
+ def __init__(self, message: str) -> None:
62
+ super().__init__(message)
@@ -0,0 +1,392 @@
1
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
2
+ #//| Copyright (c) 15 Feb 2026. All rights are reserved by ASI
3
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
4
+ __all__ = ("CommandManager","TemplateManager")
5
+
6
+ #// IMPORT
7
+ from inspect import Parameter
8
+ from inspect import signature as get_signature
9
+ from pathlib import Path
10
+ from typing import Any, Callable, get_type_hints, get_origin, get_args
11
+
12
+ from sphinx.application import Sphinx
13
+
14
+ from .error import InvalidCommandError
15
+
16
+
17
+ #// LOGIC
18
+ class BaseCommandManager:
19
+ """ Manager responsible for registering, validating, and executing doc-string commands. """
20
+
21
+ def __init__(self) -> None:
22
+ # Private variables
23
+ self.__registry: dict[str, Callable] = {}
24
+
25
+ def new(self, name: str) -> Callable:
26
+ """
27
+ Decorator used to register a new DSL command.
28
+
29
+ This method is normalizing ``name`` to lowercase.
30
+
31
+ :param name: The name under which the command will be registered.
32
+ :return: The original function, after registration.
33
+
34
+ :raises InvalidCommandError:
35
+ If a command with the same name already exists.
36
+ """
37
+ return self._make_decorator(name, must_exist=False)
38
+
39
+ def overwrite(self, name: str) -> Callable:
40
+ """
41
+ Decorator used to replace an existing DSL command.
42
+
43
+ This method is normalizing ``name`` to lowercase.
44
+
45
+ :param name: The name of the command to overwrite.
46
+ :return: The original function, after replacement.
47
+
48
+ :raises InvalidCommandError:
49
+ If the command does not already exist.
50
+ """
51
+ return self._make_decorator(name, must_exist=True)
52
+
53
+ def _is_registered(self, name: str) -> bool:
54
+ """
55
+ Check whether a command with the given name is registered.
56
+
57
+ :param name: The command name to check.
58
+
59
+ :return: ``True`` if the command exists, otherwise ``False``.
60
+ """
61
+ return name.lower() in self.__registry
62
+
63
+ def _execute(self, name: str, *args, **kwargs) -> Any:
64
+ """
65
+ Execute a registered command by name.
66
+
67
+ :param name: The name of the command to execute.
68
+ :param args: Positional arguments passed to the command.
69
+ :param kwargs: Keyword arguments passed to the command.
70
+
71
+ :return: The result returned by the command function.
72
+
73
+ :raises KeyError:
74
+ If the command name is not registered.
75
+ Use ``_is_registered()`` to check for existence before calling this method.
76
+ """
77
+ return self.__registry[name.lower()](*args, **kwargs)
78
+
79
+ def _make_decorator(self, name: str, must_exist: bool) -> Callable:
80
+ """
81
+ Internal helper that builds decorator's.
82
+
83
+ This method is normalizing ``name`` to lowercase.
84
+
85
+ :param name: The command name to register or overwrite. The name is normalized to lowercase.
86
+ :param must_exist: If True, the command must already be present in the registry.
87
+
88
+ :return: A decorator that registers the provided function under the given command name.
89
+
90
+ :raises InvalidCommandError:
91
+ If the existence condition is violated or if the subclass hook raises an error.
92
+ """
93
+ def decorator(func: Callable) -> Callable:
94
+ # Local variables
95
+ key: str = name.lower()
96
+ error: InvalidCommandError = InvalidCommandError(
97
+ "Cannot {trying} command {command_name!r} because {reason}.".format(
98
+ command_name=key,
99
+ trying="overwrite" if must_exist else "register",
100
+ reason="it does not exist" if must_exist else "is already registered"
101
+ )
102
+ )
103
+
104
+ # Existence checks
105
+ if must_exist and key not in self.__registry:
106
+ error.note(
107
+ "Suggestion",
108
+ "Make shore the command name is correct or register a new command under this name."
109
+ )
110
+ raise error
111
+
112
+ if not must_exist and key in self.__registry:
113
+ error.note(
114
+ "Suggestion",
115
+ "Use a different name or overwrite the existing command if it does not satisfy your needs."
116
+ )
117
+ raise error
118
+
119
+ # Validate the command before registration
120
+ self._validate_command(key, func)
121
+
122
+ # Register the command
123
+ self.__registry[key] = func
124
+ return func
125
+
126
+ return decorator
127
+
128
+ @staticmethod
129
+ def _validate_command(name: str, func: Callable) -> None:
130
+ """
131
+ Validate that a command function can safely receive arguments from the DSL processor.
132
+
133
+ A valid command must accept both:
134
+ - ``*args`` (var‑positional arguments)
135
+ - ``**kwargs`` (var‑keyword arguments)
136
+
137
+ This ensures that the DSL can pass any combination of positional and keyword arguments
138
+ without causing runtime errors.
139
+
140
+ :param name: The command name being validated.
141
+ :param func: The function associated with the command.
142
+
143
+ :raises InvalidCommandError:
144
+ If the function does not accept ``*args`` or ``**kwargs``.
145
+ """
146
+ # Local variables
147
+ missing_args: str = ""
148
+ suggested_args: list[list[str]] = [["*args"], ["**kwargs"]]
149
+
150
+ # Validate the required arguments
151
+ params: list[Parameter] = list(get_signature(func).parameters.values())
152
+ accepts_args: bool = any(p.kind == Parameter.VAR_POSITIONAL for p in params)
153
+ accepts_kwargs: bool = any(p.kind == Parameter.VAR_KEYWORD for p in params)
154
+
155
+ # If var‑positional arguments is missing
156
+ if not accepts_args:
157
+ missing_args = missing_args[0][0]
158
+
159
+ # If var‑keyword arguments is missing
160
+ if not accepts_kwargs:
161
+ missing_args = f"{missing_args}{"' and '" if missing_args else ""}{suggested_args[1][0]}"
162
+
163
+ # If arguments are missing, generate a helpful error message
164
+ if missing_args:
165
+ # Build a corrected signature suggestion
166
+ for p in params:
167
+ # If one of required arguments is provided, replace the suggested name with the existing one
168
+ if p.kind == Parameter.VAR_POSITIONAL:
169
+ suggested_args[0][0] = f"*{p.name}"
170
+
171
+ elif p.kind == Parameter.VAR_KEYWORD:
172
+ suggested_args[1][0] = f"**{p.name}"
173
+
174
+ # Group the existing arguments
175
+ elif p.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
176
+ # If a default value is provided, collect it as keyword argument along with the default value
177
+ if p.default is not Parameter.empty:
178
+ suggested_args[1].append("=".join([p.name, str(p.default)]))
179
+
180
+ # Otherwise, collect it as positional argument
181
+ else:
182
+ suggested_args[0].append(p.name)
183
+
184
+ # Move *args / **kwargs to the end of each group
185
+ for group in suggested_args:
186
+ first: str = group.pop(0)
187
+ group.append(first)
188
+
189
+ # Raise the error with a suggestion ready for copy‑paste
190
+ error: InvalidCommandError = InvalidCommandError(
191
+ f"Command {name!r} must accept {missing_args!r} to handle all provided arguments."
192
+ )
193
+ error.note(
194
+ "Suggestion",
195
+ [
196
+ f"Update the function signature for command {name!r} to:",
197
+ f"def {func.__name__}({', '.join([arg for group in suggested_args for arg in group])}): ..."
198
+ ],
199
+ 4
200
+ )
201
+ raise error
202
+
203
+ # [Case 1] The annotation exists
204
+ hints: dict[str, Any] = get_type_hints(func)
205
+ msg_err_return: str = f"Command {name!r} must return a string or a list of strings."
206
+ error_return: InvalidCommandError = InvalidCommandError(f"{msg_err_return}")
207
+ need_simulation: bool = False
208
+
209
+ if "return" in hints:
210
+ return_type = hints["return"]
211
+ return_origin = get_origin(return_type)
212
+ return_args = get_args(return_type)
213
+
214
+ # Accept ➜ str
215
+ if return_origin is str: return
216
+
217
+ # Accept ➜ list[str]
218
+ if return_origin is list:
219
+ # List with no type args ➜ simulate
220
+ if not return_args:
221
+ need_simulation = True
222
+
223
+ # Accept only list with all type args as str
224
+ elif all(arg_type is str for arg_type in return_args): return
225
+
226
+ # Raise an error if list have other type args then accepted one
227
+ else:
228
+ error_return.note("Detected", f"Annotation {return_type}", 4)
229
+ raise error_return
230
+
231
+ # Anything else ➜ simulate
232
+ else:
233
+ need_simulation = True
234
+
235
+ if not need_simulation: return
236
+
237
+ # [Case 2] No annotation OR annotation incomplete
238
+ simulate: Any = func(*[f"debug arg {index}" for index in range(100)])
239
+ simulate_type = type(simulate)
240
+
241
+ if simulate_type is str: return
242
+ elif simulate_type is list and all(isinstance(arg, str) for arg in simulate_type): return
243
+
244
+ else:
245
+ error_return.note("Simulated", f"Annotation {simulate_type}", 4)
246
+ raise error_return
247
+
248
+
249
+ class BaseTemplateManager:
250
+ """ Manager responsible for locating, loading and rendering template files. """
251
+
252
+ def __init__(self) -> None:
253
+ super().__init__()
254
+
255
+ # Private variables
256
+ self.__template_paths: list[Path] = []
257
+ self.__map: dict[str, str] = {}
258
+
259
+ @property
260
+ def names(self) -> list[str]:
261
+ """ A list-like string providing a view on the mapped names. """
262
+ return [name for name in self.__map.keys()]
263
+
264
+ @property
265
+ def values(self) -> list[str]:
266
+ """ A list-like string providing a view on the mapped values. """
267
+ return [value for value in self.__map.values()]
268
+
269
+ @property
270
+ def items(self) -> list[tuple[str, str]]:
271
+ """ A list-like tuple providing a view on the mapped names and values. """
272
+ return [(name, value) for name, value in self.__map.items()]
273
+
274
+ def set_multiple_items(self, **kwargs: str) -> None:
275
+ """
276
+ Set multiple mapping items at once.
277
+
278
+ Equivalent to calling ``self[name] = value`` for each provided keyword.
279
+ """
280
+ for key, value in kwargs.items():
281
+ self[key] = value
282
+
283
+ def __contains__(self, name: str) -> bool:
284
+ """ Return True if the ``name`` is mapped, otherwise False. """
285
+ return name.upper() in self.__map
286
+
287
+ def __delitem__(self, name: str) -> None:
288
+ """ Delete the mapped entry for the given name. """
289
+ key: str = name.upper()
290
+
291
+ if key in self.__map:
292
+ del self.__map[key]
293
+
294
+ def __getitem__(self, name: str) -> str:
295
+ """
296
+ Retrieve the value associated with the given name.
297
+
298
+ Returns an empty string if the key is not present.
299
+ """
300
+ return self.__map.get(name.upper(), "")
301
+
302
+ def __setitem__(self, name: str, value: str) -> None:
303
+ """ Mapping the given name to the provided value. """
304
+ self.__map[name.upper()] = value.strip()
305
+
306
+ def _clear_map(self) -> None:
307
+ """ Clear the map. """
308
+ self.__map.clear()
309
+
310
+ def _init_app(self, app: Sphinx) -> None:
311
+ """
312
+ Initialize template search paths based on the active Sphinx application.
313
+
314
+ This method scans all directories listed in ``templates_path`` and collects those that contain a
315
+ ``doclang`` subdirectory. Only these directories are considered valid template roots.
316
+
317
+ :param app: The active Sphinx application instance.
318
+ """
319
+ self.__template_paths.clear()
320
+
321
+ for path in app.config["templates_path"]:
322
+ directory: Path = Path(app.confdir) / path / "doclang"
323
+
324
+ if directory.exists():
325
+ self.__template_paths.append(directory)
326
+
327
+ def _get_file_content(self, *file_path: str, extension: str = "dlt") -> str:
328
+ """
329
+ Retrieve the raw text content of a template by name.
330
+
331
+ The method searches all registered template directories for a file named ``<name>.dlt``.
332
+ If found, the file is read and returned as a UTF‑8 string.
333
+
334
+ :param file_path: The file path without extension.
335
+ :param extension: The file extension.
336
+
337
+ :return: The file content, or an empty string if the file does not exist.
338
+ """
339
+ if not file_path:
340
+ return ""
341
+
342
+ file_dir: list[str] = [*file_path]
343
+ file_name: str = f"{file_dir.pop()}.{extension}"
344
+
345
+ for root in self.__template_paths:
346
+ file: Path = Path(root, *file_dir, file_name)
347
+
348
+ if file.is_file():
349
+ return file.read_text(encoding="UTF-8")
350
+
351
+ return ""
352
+
353
+ def _render_content(self, content: str) -> str:
354
+ """
355
+ Render a template content by performing simple variable substitution.
356
+
357
+ This method replaces all occurrences of ``{{ NAME }}`` with the corresponding stored mapped values.
358
+
359
+ :param content: The template content to render.
360
+
361
+ :return: The rendered content, or an empty string if the template could not be found.
362
+ """
363
+ for name, value in self.items:
364
+ content = content.replace(f"{{{{ {name} }}}}", value)
365
+
366
+ return content
367
+
368
+ def _render_template(self) -> str:
369
+ """
370
+ Render the current template by performing simple variable substitution.
371
+
372
+ This method loads the template content using ``_get_file_content`` and render it using ``_render_content``.
373
+
374
+ :return: The rendered template content, or an empty string if the template could not be found.
375
+ """
376
+ template: str = TemplateManager._get_file_content(self["type"], self["name"])
377
+
378
+ # If the template content is empty try the fallback name
379
+ if template == "":
380
+ template = TemplateManager._get_file_content(self["type"])
381
+
382
+ return self._render_content(template)
383
+
384
+
385
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
386
+ #//| Global instances
387
+ #//|>-----------------------------------------------------------------------------------------------------------------<|
388
+ #: Main command manager
389
+ CommandManager: BaseCommandManager = BaseCommandManager()
390
+
391
+ #: Main template manager
392
+ TemplateManager: BaseTemplateManager = BaseTemplateManager()