plesty-sdk 0.0.2.dev6__tar.gz → 0.0.2.dev10__tar.gz

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.
Files changed (29) hide show
  1. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/.gitignore +3 -0
  2. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/PKG-INFO +4 -1
  3. plesty_sdk-0.0.2.dev10/plesty_sdk/cli.py +26 -0
  4. plesty_sdk-0.0.2.dev10/plesty_sdk/commands/__init__.py +1 -0
  5. plesty_sdk-0.0.2.dev10/plesty_sdk/commands/build.py +243 -0
  6. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/__init__.py +2 -0
  7. plesty_sdk-0.0.2.dev10/plesty_sdk/commands/docs/generate.py +259 -0
  8. plesty_sdk-0.0.2.dev10/plesty_sdk/commands/license.py +191 -0
  9. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/pyproject.toml +6 -0
  10. plesty_sdk-0.0.2.dev6/plesty_sdk/cli.py +0 -32
  11. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/.gitlab-ci.yml +0 -0
  12. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/COPYING +0 -0
  13. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/COPYING.LESSER +0 -0
  14. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/LICENSE +0 -0
  15. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/README.md +0 -0
  16. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/docs/index.md +0 -0
  17. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/docs/toc.yaml +0 -0
  18. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/docs/usage/index.md +0 -0
  19. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/__init__.py +0 -0
  20. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/docs-build-gitlab-ci.yml +0 -0
  21. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/logos/plesty_icon.png +0 -0
  22. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/logos/plesty_logo.png +0 -0
  23. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/ruff.toml +0 -0
  24. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/cache.py +0 -0
  25. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/check.py +0 -0
  26. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/deploy.py +0 -0
  27. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/serve.py +0 -0
  28. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/sphinx_builder.py +0 -0
  29. {plesty_sdk-0.0.2.dev6 → plesty_sdk-0.0.2.dev10}/plesty_sdk/context.py +0 -0
@@ -7,3 +7,6 @@ uv.lock
7
7
  build/
8
8
  dist/
9
9
  *.egg-info/
10
+
11
+ logs/
12
+ *.log
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plesty-sdk
3
- Version: 0.0.2.dev6
3
+ Version: 0.0.2.dev10
4
4
  Summary: Command-line application for developing Python modules compliant with the Plesty standard.
5
5
  Author: Plesty Development Team
6
6
  License-File: COPYING
@@ -9,6 +9,7 @@ License-File: LICENSE
9
9
  Requires-Python: >=3.12
10
10
  Requires-Dist: click>=3.8
11
11
  Requires-Dist: livereload>=2.7.1
12
+ Requires-Dist: md2pdf>=3.1.1
12
13
  Requires-Dist: myst-parser>=5.0.0
13
14
  Requires-Dist: pydata-sphinx-theme==0.17.1
14
15
  Requires-Dist: ruff>=0.14
@@ -19,6 +20,8 @@ Requires-Dist: sphinx-external-toc>=1.1.0
19
20
  Requires-Dist: sphinx>=9.1.0
20
21
  Requires-Dist: sphinxcontrib-mermaid>=2.0.0
21
22
  Requires-Dist: watchfiles>=1.1.1
23
+ Provides-Extra: pdf
24
+ Requires-Dist: md2pdf>=3.1.1; extra == 'pdf'
22
25
  Description-Content-Type: text/markdown
23
26
 
24
27
  # plesty-sdk - Setting the Coding Standard
@@ -0,0 +1,26 @@
1
+ """CLI tool for generating and updating license headers."""
2
+
3
+ #
4
+ # SPDX-FileCopyrightText: 2026 Plesty Development Team
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ # Author: Yunshuang Yuan
7
+ # Email: yunshuang.yuan@fkp.uni-hannover.de
8
+ #
9
+
10
+ import click
11
+
12
+ from .commands.build import build_command
13
+ from .commands.check import check_command
14
+ from .commands.docs import docs_command
15
+ from .commands.license import license_command
16
+
17
+
18
+ @click.group("plesty")
19
+ def app():
20
+ """The Plesty CLI tool."""
21
+
22
+
23
+ app.add_command(check_command)
24
+ app.add_command(docs_command)
25
+ app.add_command(license_command)
26
+ app.add_command(build_command)
@@ -0,0 +1 @@
1
+ """Plesty CLI command modules."""
@@ -0,0 +1,243 @@
1
+ """CLI command for creating a new Plesty device project from template."""
2
+
3
+ #
4
+ # SPDX-FileCopyrightText: 2026 Plesty Development Team
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ # Author: Yunshuang Yuan
7
+ # Email: yunshuang.yuan@fkp.uni-hannover.de
8
+ #
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+
21
+ DEVICE_TEMPLATE_REPO_URL = "https://gitlab.com/plesty/templates/plesty-demo-device.git"
22
+ EXPERIMENT_TEMPLATE_REPO_URL = "https://gitlab.com/plesty/templates/plesty-demo-experiment.git"
23
+ DEVICE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
24
+
25
+
26
+ def _parse_device_name(device_name: str) -> tuple[str, str, str]:
27
+ """Validate device name and return naming variants."""
28
+ if not DEVICE_NAME_PATTERN.match(device_name):
29
+ raise click.ClickException(
30
+ "Invalid device name. Use lowercase letters, numbers, and '_' only "
31
+ "(for example: demo or power_meter)."
32
+ )
33
+
34
+ module_snake = device_name
35
+ module_title = " ".join(part.capitalize() for part in module_snake.split("_"))
36
+ module_pascal = "".join(part.capitalize() for part in module_snake.split("_"))
37
+ return module_snake, module_title, module_pascal
38
+
39
+
40
+ def _clone_template(template_repo_url: str, destination: Path) -> None:
41
+ """Clone template repository to a destination folder."""
42
+ try:
43
+ subprocess.run(
44
+ ["git", "clone", "--depth", "1", template_repo_url, str(destination)],
45
+ check=True,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ except subprocess.CalledProcessError as exc:
50
+ raise click.ClickException(
51
+ f"Failed to clone template repository: {exc.stderr.strip() or exc.stdout.strip()}"
52
+ ) from exc
53
+
54
+
55
+ def _rename_paths(root: Path, replacements: list[tuple[str, str]]) -> None:
56
+ """Rename files and directories if their names contain template tokens."""
57
+ all_paths = sorted(root.rglob("*"), key=lambda p: len(p.parts), reverse=True)
58
+ for path in all_paths:
59
+ name = path.name
60
+ new_name = name
61
+ for source, target in replacements:
62
+ new_name = new_name.replace(source, target)
63
+ if new_name != name:
64
+ path.rename(path.with_name(new_name))
65
+
66
+
67
+ def _replace_text_tokens(root: Path, replacements: list[tuple[str, str]]) -> None:
68
+ """Replace template tokens inside text files."""
69
+ for path in root.rglob("*"):
70
+ if not path.is_file():
71
+ continue
72
+ try:
73
+ text = path.read_text(encoding="utf-8")
74
+ except (UnicodeDecodeError, OSError):
75
+ continue
76
+
77
+ new_text = text
78
+ for source, target in replacements:
79
+ new_text = new_text.replace(source, target)
80
+
81
+ if new_text != text:
82
+ path.write_text(new_text, encoding="utf-8")
83
+
84
+
85
+ def _update_pyproject_author(pyproject_path: Path, author: str | None, email: str | None) -> None:
86
+ """Update author fields in pyproject.toml if values are provided."""
87
+ if author is None and email is None:
88
+ return
89
+
90
+ text = pyproject_path.read_text(encoding="utf-8")
91
+ pattern = re.compile(
92
+ r"authors\s*=\s*\[\s*\{\s*name\s*=\s*\"(?P<name>[^\"]*)\"\s*,\s*email\s*=\s*\"(?P<email>[^\"]*)\"\s*\}\s*\]",
93
+ flags=re.S,
94
+ )
95
+ matched = pattern.search(text)
96
+ if not matched:
97
+ raise click.ClickException("Could not find [project].authors entry in pyproject.toml.")
98
+
99
+ new_name = author if author is not None else matched.group("name")
100
+ new_email = email if email is not None else matched.group("email")
101
+
102
+ replacement = (
103
+ "authors = [\n"
104
+ f" {{ name = \"{new_name}\", email = \"{new_email}\" }}\n"
105
+ "]"
106
+ )
107
+ updated = pattern.sub(replacement, text, count=1)
108
+ pyproject_path.write_text(updated, encoding="utf-8")
109
+
110
+
111
+ def _scaffold_from_template(
112
+ *,
113
+ template_repo_url: str,
114
+ project_name: str,
115
+ module_name: str,
116
+ outpath: Path,
117
+ path_replacements: list[tuple[str, str]],
118
+ content_replacements: list[tuple[str, str]],
119
+ author: str | None,
120
+ email: str | None,
121
+ ) -> Path:
122
+ """Create a project from template with path/content/token replacements."""
123
+ output_dir = outpath.resolve()
124
+ output_dir.mkdir(parents=True, exist_ok=True)
125
+ target_dir = output_dir / project_name
126
+
127
+ if target_dir.exists():
128
+ raise click.ClickException(f"Target path already exists: {target_dir}")
129
+
130
+ with tempfile.TemporaryDirectory(prefix="plesty-build-") as tmp_dir:
131
+ template_dir = Path(tmp_dir) / "template"
132
+ _clone_template(template_repo_url, template_dir)
133
+
134
+ # Keep generated project clean from template git history.
135
+ shutil.rmtree(template_dir / ".git", ignore_errors=True)
136
+
137
+ shutil.copytree(template_dir, target_dir)
138
+
139
+ _rename_paths(target_dir, path_replacements)
140
+ _replace_text_tokens(target_dir, content_replacements)
141
+ _update_pyproject_author(target_dir / "pyproject.toml", author=author, email=email)
142
+
143
+ click.echo(f"Initialized project at: {target_dir}")
144
+ click.echo(f"Python module name: {module_name}")
145
+ return target_dir
146
+
147
+
148
+ @click.group(name="build", short_help="Scaffold new Plesty projects from templates.")
149
+ def build_command() -> None:
150
+ """Build projects from predefined Plesty templates."""
151
+
152
+
153
+ @build_command.command(name="device", short_help="Initialize a new Plesty device project.")
154
+ @click.option("--name", "device_name", required=True, help="Device/module name (for example: power_meter).")
155
+ @click.option(
156
+ "--outpath",
157
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
158
+ default=Path("."),
159
+ show_default=True,
160
+ help="Output directory where the generated project folder will be created.",
161
+ )
162
+ @click.option("--author", default=None, help="Author name for pyproject.toml.")
163
+ @click.option("--email", default=None, help="Author email for pyproject.toml.")
164
+ def build_device_command(
165
+ device_name: str,
166
+ outpath: Path,
167
+ author: str | None,
168
+ email: str | None,
169
+ ) -> None:
170
+ """Create a new project in format plesty-[device_name]-device."""
171
+ module_snake, module_title, module_pascal = _parse_device_name(device_name)
172
+ project_name = f"plesty-{module_snake}-device"
173
+
174
+ content_replacements = [
175
+ ("PlestyDemoDevice", f"Plesty{module_pascal}Device"),
176
+ ("plesty_demo_device", module_snake),
177
+ ("plesty-demo-device", project_name),
178
+ ("Plesty Demo Device", f"Plesty {module_title} Device"),
179
+ ]
180
+
181
+ path_replacements = [
182
+ ("plesty_demo_device", module_snake),
183
+ ("plesty-demo-device", project_name),
184
+ ]
185
+
186
+ _scaffold_from_template(
187
+ template_repo_url=DEVICE_TEMPLATE_REPO_URL,
188
+ project_name=project_name,
189
+ module_name=module_snake,
190
+ outpath=outpath,
191
+ path_replacements=path_replacements,
192
+ content_replacements=content_replacements,
193
+ author=author,
194
+ email=email,
195
+ )
196
+
197
+
198
+ @build_command.command(name="experiment", short_help="Initialize a new Plesty experiment project.")
199
+ @click.option("--name", "experiment_name", required=True, help="Experiment/module name (for example: imaging_scan).")
200
+ @click.option(
201
+ "--outpath",
202
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
203
+ default=Path("."),
204
+ show_default=True,
205
+ help="Output directory where the generated project folder will be created.",
206
+ )
207
+ @click.option("--author", default=None, help="Author name for pyproject.toml.")
208
+ @click.option("--email", default=None, help="Author email for pyproject.toml.")
209
+ def build_experiment_command(
210
+ experiment_name: str,
211
+ outpath: Path,
212
+ author: str | None,
213
+ email: str | None,
214
+ ) -> None:
215
+ """Create a new project in format plesty-[experiment_name]-experiment."""
216
+ module_snake, module_title, _ = _parse_device_name(experiment_name)
217
+ project_name = f"plesty-{module_snake}-experiment"
218
+
219
+ content_replacements = [
220
+ ("plesty-demo-experiment", project_name),
221
+ ("Plesty Demo Experiment", f"Plesty {module_title} Experiment"),
222
+ # Some template fields currently use device tokens; normalize those too.
223
+ ("Plesty Demo Device", f"Plesty {module_title} Experiment"),
224
+ ("plesty_demo_device", module_snake),
225
+ ("demo", module_snake),
226
+ ]
227
+
228
+ path_replacements = [
229
+ ("plesty-demo-experiment", project_name),
230
+ ("plesty_demo_device", module_snake),
231
+ ("demo", module_snake),
232
+ ]
233
+
234
+ _scaffold_from_template(
235
+ template_repo_url=EXPERIMENT_TEMPLATE_REPO_URL,
236
+ project_name=project_name,
237
+ module_name=module_snake,
238
+ outpath=outpath,
239
+ path_replacements=path_replacements,
240
+ content_replacements=content_replacements,
241
+ author=author,
242
+ email=email,
243
+ )
@@ -20,6 +20,7 @@
20
20
  import click
21
21
 
22
22
  from plesty_sdk.commands.docs.deploy import deploy_command
23
+ from plesty_sdk.commands.docs.generate import generate_command
23
24
  from plesty_sdk.commands.docs.serve import serve_command
24
25
  from plesty_sdk.context import ApplicationContext, pass_context
25
26
 
@@ -32,4 +33,5 @@ def docs_command(context: ApplicationContext):
32
33
 
33
34
 
34
35
  docs_command.add_command(deploy_command)
36
+ docs_command.add_command(generate_command)
35
37
  docs_command.add_command(serve_command)
@@ -0,0 +1,259 @@
1
+
2
+ """Generate default device documentation files for external Plesty devices."""
3
+
4
+ # Author: Yunshuang Yuan
5
+ # Email: yunshuang.yuan@fkp.uni-hannover.de
6
+
7
+ from importlib import import_module
8
+ from pathlib import Path
9
+ import inspect
10
+ import sys
11
+ import tempfile
12
+
13
+ import click
14
+
15
+ from plesty_sdk.context import ApplicationContext, pass_context
16
+
17
+
18
+ PDF_COMMON_CSS = """
19
+ body {
20
+ font-family: "DejaVu Sans", "Noto Sans", sans-serif;
21
+ font-size: 10pt;
22
+ line-height: 1.35;
23
+ }
24
+
25
+ table {
26
+ width: 100%;
27
+ border-collapse: collapse;
28
+ table-layout: fixed;
29
+ font-size: 8.5pt;
30
+ }
31
+
32
+ th,
33
+ td {
34
+ border: 1px solid #d0d7de;
35
+ padding: 4px 6px;
36
+ vertical-align: top;
37
+ overflow-wrap: anywhere;
38
+ }
39
+
40
+ pre,
41
+ code {
42
+ white-space: pre-wrap;
43
+ overflow-wrap: anywhere;
44
+ }
45
+ """
46
+
47
+
48
+ def _pdf_page_css(style: str) -> str:
49
+ """Return page-level CSS tuned for each generated markdown style."""
50
+ if style == 'md':
51
+ # Wide table docs are easier to read in landscape with tighter margins.
52
+ page_css = "@page { size: A4 landscape; margin: 8mm; }"
53
+ else:
54
+ page_css = "@page { size: A4 portrait; margin: 12mm; }"
55
+
56
+ return f"{page_css}\n{PDF_COMMON_CSS}"
57
+
58
+
59
+ def _replace_device_doc_label(file_path: Path, module_name: str) -> None:
60
+ """Replace generic device label in generated docs with module-specific text."""
61
+ text = file_path.read_text(encoding='utf-8')
62
+ text = text.replace("Device Doc", f"Device Doc: {module_name}")
63
+ file_path.write_text(text, encoding='utf-8')
64
+
65
+
66
+ def _prepare_markdown_for_pdf(markdown_text: str, style: str) -> str:
67
+ """Normalize markdown so md2pdf renders parameter lists reliably."""
68
+ text = markdown_text.replace("\r\n", "\n")
69
+ if style == 'short':
70
+ # Ensure parameter bullets are parsed as list items in PDF renderer.
71
+ text = text.replace("Parameters:\n - ", "Parameters:\n\n- ")
72
+ text = text.replace("\n - ", "\n- ")
73
+ return text
74
+
75
+
76
+ def _discover_modules_from_init(device_root: Path) -> list[str]:
77
+ """Discover importable package names from __init__.py files under a device root."""
78
+ modules: list[str] = []
79
+ for init_file in device_root.glob("**/__init__.py"):
80
+ rel = init_file.relative_to(device_root)
81
+ package_parts = rel.parts[:-1]
82
+ if not package_parts:
83
+ continue
84
+ module = ".".join(package_parts)
85
+ if module not in modules:
86
+ modules.append(module)
87
+ modules.sort(key=lambda x: (x.count('.'), x))
88
+ return modules
89
+
90
+
91
+ @click.command(
92
+ name='generate',
93
+ short_help="Generate default docs files from an external device's DocDevice.",
94
+ )
95
+ @click.argument(
96
+ 'device_root',
97
+ required=True,
98
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
99
+ )
100
+ @click.option(
101
+ '--module-name',
102
+ default=None,
103
+ metavar='<MODULE>',
104
+ help=(
105
+ "Python module containing DocDevice. "
106
+ "Defaults to folder name with '-' replaced by '_' (e.g. ape_pulse_slicer)."
107
+ ),
108
+ )
109
+ @click.option(
110
+ '--output-dir',
111
+ default=None,
112
+ type=click.Path(path_type=Path),
113
+ metavar='<PATH>',
114
+ help="Directory for generated docs. Defaults to <device_root>/docs.",
115
+ )
116
+ @click.option(
117
+ '--with-pdf/--no-pdf',
118
+ default=False,
119
+ show_default=True,
120
+ help='Also generate PDF files from the generated Markdown files.',
121
+ )
122
+ @pass_context
123
+ def generate_command(
124
+ context: ApplicationContext,
125
+ device_root: Path,
126
+ module_name: str | None,
127
+ output_dir: Path | None,
128
+ with_pdf: bool,
129
+ ):
130
+ """Generate short.md, table.md and googledoc.py for an external device package.
131
+
132
+ DEVICE_ROOT should point to a package root such as:
133
+ /PLESTY/ape-pulse-slicer
134
+ """
135
+ del context # command does not require project context settings
136
+
137
+ device_root = device_root.resolve()
138
+ inferred_module_name = device_root.name.replace('-', '_')
139
+ module_name = module_name or inferred_module_name
140
+ output_path = output_dir.resolve() if output_dir is not None else device_root / 'docs'
141
+ output_path.mkdir(parents=True, exist_ok=True)
142
+
143
+ if module_name == inferred_module_name and not (device_root / module_name).exists():
144
+ click.secho(
145
+ (
146
+ "Warning: device structure does not match standard layout "
147
+ f"(<device_root>/{module_name} not found). "
148
+ "Falling back to inferred module import. "
149
+ "Use --module-name to override."
150
+ ),
151
+ fg='yellow',
152
+ err=True,
153
+ )
154
+
155
+ if str(device_root) not in sys.path:
156
+ sys.path.insert(0, str(device_root))
157
+
158
+ try:
159
+ module = import_module(module_name)
160
+ except Exception as exc:
161
+ click.secho(
162
+ (
163
+ f"Warning: Failed to import module '{module_name}' from '{device_root}': {exc}. "
164
+ "Trying fallback package discovery from __init__.py files."
165
+ ),
166
+ fg='yellow',
167
+ err=True,
168
+ )
169
+ module = None
170
+ for candidate in _discover_modules_from_init(device_root):
171
+ try:
172
+ module = import_module(candidate)
173
+ module_name = candidate
174
+ click.secho(
175
+ f"Warning: Falling back to discovered module '{module_name}'.",
176
+ fg='yellow',
177
+ err=True,
178
+ )
179
+ break
180
+ except Exception:
181
+ continue
182
+ if module is None:
183
+ raise click.ClickException(
184
+ "Failed to import module for documentation generation. "
185
+ "Provide --module-name explicitly."
186
+ )
187
+
188
+ if hasattr(module, 'DocDevice'):
189
+ doc_device = getattr(module, 'DocDevice')
190
+ elif hasattr(module, 'Device'):
191
+ click.secho(
192
+ (
193
+ f"Warning: Module '{module_name}' does not expose 'DocDevice'. "
194
+ "Falling back to 'Device' for documentation generation."
195
+ ),
196
+ fg='yellow',
197
+ err=True,
198
+ )
199
+ doc_device = getattr(module, 'Device')
200
+ else:
201
+ raise click.ClickException(
202
+ f"Module '{module_name}' does not expose 'DocDevice' or 'Device'."
203
+ )
204
+
205
+ if inspect.isclass(doc_device):
206
+ try:
207
+ doc_device = doc_device()
208
+ except Exception as exc:
209
+ raise click.ClickException(
210
+ f"Failed to instantiate '{module_name}' documentation device: {exc}"
211
+ )
212
+
213
+ if not hasattr(doc_device, 'summary') or not callable(getattr(doc_device, 'summary')):
214
+ raise click.ClickException(
215
+ f"'{module_name}.DocDevice' must provide a callable 'summary' method."
216
+ )
217
+
218
+ targets = {
219
+ 'short': output_path / 'short.md',
220
+ 'md': output_path / 'table.md',
221
+ }
222
+
223
+ md2pdf_callable = None
224
+ if with_pdf:
225
+ try:
226
+ from md2pdf.core import md2pdf
227
+ except ImportError as exc:
228
+ raise click.ClickException(
229
+ "Python package 'md2pdf' is not installed in the current environment. "
230
+ "Install it first (for example: pip install md2pdf or pip install 'plesty-sdk[pdf]')."
231
+ ) from exc
232
+ md2pdf_callable = md2pdf
233
+
234
+ for style, file_path in targets.items():
235
+ doc_device.summary(style=style, filename=str(file_path))
236
+ _replace_device_doc_label(file_path, module_name)
237
+ click.echo(f"Generated {file_path}")
238
+
239
+ if with_pdf:
240
+ try:
241
+ pdf_path = file_path.with_suffix('.pdf')
242
+ markdown_text = file_path.read_text(encoding='utf-8')
243
+ markdown_text = _prepare_markdown_for_pdf(markdown_text, style)
244
+ with tempfile.TemporaryDirectory(prefix='plesty-md2pdf-') as tmp_dir:
245
+ css_path = Path(tmp_dir) / f'{style}.css'
246
+ css_path.write_text(_pdf_page_css(style), encoding='utf-8')
247
+ md2pdf_callable(
248
+ pdf=pdf_path,
249
+ raw=markdown_text,
250
+ css=css_path,
251
+ base_url=file_path.parent,
252
+ extras=['tables', 'fenced_code'],
253
+ )
254
+ except Exception as exc:
255
+ raise click.ClickException(
256
+ f"Failed to generate PDF for '{file_path.name}': {exc}"
257
+ )
258
+
259
+ click.echo(f"Generated {pdf_path}")
@@ -0,0 +1,191 @@
1
+ """Commands for maintaining SPDX license headers in project sources."""
2
+
3
+ # Copyright (C) 2025, 2026 Christopher Borchers, fvrehlinger, Maximilian
4
+ # Heller, Michael Zopf (names in alphabetic order wrt. surnames)
5
+ #
6
+ # This file is part of plesty-sdk which is part of the Plesty library.
7
+ #
8
+ # plesty-sdk is free software: you can redistribute it and/or modify it under
9
+ # the terms of the GNU Lesser General Public License as published by the Free
10
+ # Software Foundation, either version 3 of the License, or (at your option) any
11
+ # later version.
12
+ #
13
+ # plesty-sdk is distributed in the hope that it will be useful, but WITHOUT ANY
14
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
15
+ # PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU Lesser General Public License along
18
+ # with plesty-sdk. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ import tomllib
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import click
30
+
31
+ from plesty_sdk.context import ApplicationContext, pass_context
32
+
33
+
34
+ @click.group("license")
35
+ @pass_context
36
+ def license_command(context: ApplicationContext):
37
+ """Update and apply SPDX license headers in source files."""
38
+ del context
39
+
40
+
41
+ @license_command.command(name="update", short_help="Update and apply SPDX headers.")
42
+ @click.argument(
43
+ "project_root",
44
+ required=False,
45
+ default=Path("."),
46
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
47
+ )
48
+ @pass_context
49
+ def update_command(context: ApplicationContext, project_root: Path):
50
+ """Update `spdx.tmpl` author metadata and apply license headers.
51
+
52
+ This command:
53
+ 0. Uses `<PROJECT_ROOT>` (or current directory if omitted).
54
+ 1. Reads the first author entry from `pyproject.toml`.
55
+ 2. Reads the first maintainer entry from `pyproject.toml`.
56
+ 3. Updates `Author:` and `Email:` entries in `spdx.tmpl`.
57
+ 4. Runs `licenseheaders` over Python files in the project.
58
+ """
59
+ root = project_root.resolve()
60
+ pyproject_toml = context.pyproject_toml if root == Path.cwd() else _read_pyproject_toml(root)
61
+ project_section = _get_project_section(pyproject_toml)
62
+ author_name, author_email = _get_author_identity(project_section)
63
+ maintainer_name = _get_maintainer_name(project_section)
64
+
65
+ template_path = root / "spdx.tmpl"
66
+ _update_spdx_template(template_path, author_name, author_email)
67
+
68
+ _run_licenseheaders(template_path, maintainer_name, root)
69
+ click.echo("License headers updated successfully.")
70
+
71
+
72
+ def _read_pyproject_toml(project_root: Path) -> dict[str, Any]:
73
+ """Read pyproject.toml from a given project root directory."""
74
+ pyproject_path = project_root / "pyproject.toml"
75
+ if not pyproject_path.is_file():
76
+ raise click.ClickException(
77
+ f"No pyproject.toml found in project root: {project_root}"
78
+ )
79
+
80
+ with open(pyproject_path, "rb") as file:
81
+ return tomllib.load(file)
82
+
83
+
84
+ def _get_project_section(pyproject_toml: dict[str, Any]) -> dict[str, Any]:
85
+ """Return the `[project]` section from pyproject content."""
86
+ try:
87
+ return pyproject_toml["project"]
88
+ except KeyError as exc:
89
+ raise click.ClickException("Missing [project] section in pyproject.toml.") from exc
90
+
91
+
92
+ def _get_author_identity(project_section: dict[str, Any]) -> tuple[str, str]:
93
+ """Read the first author name and email from `[project].authors`."""
94
+ authors = project_section.get("authors")
95
+ if not isinstance(authors, list) or len(authors) == 0:
96
+ raise click.ClickException(
97
+ "Missing or empty [project].authors in pyproject.toml. "
98
+ "Expected at least one author with name and email."
99
+ )
100
+
101
+ first_author = authors[0]
102
+ if not isinstance(first_author, dict):
103
+ raise click.ClickException("First [project].authors entry must be a table with name and email.")
104
+
105
+ name = first_author.get("name")
106
+ email = first_author.get("email")
107
+ if not isinstance(name, str) or not name.strip():
108
+ raise click.ClickException("First [project].authors entry must define a non-empty name.")
109
+ if not isinstance(email, str) or not email.strip():
110
+ raise click.ClickException("First [project].authors entry must define a non-empty email.")
111
+
112
+ return name.strip(), email.strip()
113
+
114
+
115
+ def _get_maintainer_name(project_section: dict[str, Any]) -> str:
116
+ """Read the first maintainer name from `[project].maintainers`."""
117
+ maintainers = project_section.get("maintainers")
118
+ if not isinstance(maintainers, list) or len(maintainers) == 0:
119
+ raise click.ClickException(
120
+ "Missing or empty [project].maintainers in pyproject.toml. "
121
+ "Expected at least one maintainer with a name."
122
+ )
123
+
124
+ first_maintainer = maintainers[0]
125
+ if not isinstance(first_maintainer, dict):
126
+ raise click.ClickException("First [project].maintainers entry must be a table with a name.")
127
+
128
+ name = first_maintainer.get("name")
129
+ if not isinstance(name, str) or not name.strip():
130
+ raise click.ClickException("First [project].maintainers entry must define a non-empty name.")
131
+
132
+ return name.strip()
133
+
134
+
135
+ def _update_spdx_template(template_path: Path, author_name: str, author_email: str) -> None:
136
+ """Replace `Author:` and `Email:` lines in the SPDX template file."""
137
+ if not template_path.is_file():
138
+ raise click.ClickException(
139
+ f"No spdx template found at {template_path}. "
140
+ "Create `spdx.tmpl` in the project root first."
141
+ )
142
+
143
+ content = template_path.read_text(encoding="utf-8")
144
+ updated = re.sub(r"^Author:\s*.*$", f"Author: {author_name}", content, flags=re.MULTILINE)
145
+ updated = re.sub(r"^Email:\s*.*$", f"Email: {author_email}", updated, flags=re.MULTILINE)
146
+
147
+ if "Author:" not in updated:
148
+ updated = updated.rstrip() + f"\nAuthor: {author_name}\n"
149
+ if "Email:" not in updated:
150
+ updated = updated.rstrip() + f"\nEmail: {author_email}\n"
151
+
152
+ template_path.write_text(updated, encoding="utf-8")
153
+ click.echo(f"Updated {template_path}")
154
+
155
+
156
+ def _run_licenseheaders(template_path: Path, owner: str, project_root: Path) -> None:
157
+ """Run the licenseheaders command with the default Plesty parameters."""
158
+ args = [
159
+ "-t",
160
+ str(template_path),
161
+ "-cy",
162
+ "-o",
163
+ owner,
164
+ "-E",
165
+ ".py",
166
+ "-x",
167
+ ".venv",
168
+ "build",
169
+ "dist",
170
+ ".git",
171
+ ]
172
+
173
+ commands = [
174
+ ["licenseheaders", *args],
175
+ [sys.executable, "-m", "licenseheaders", *args],
176
+ ]
177
+
178
+ errors: list[str] = []
179
+ for command in commands:
180
+ result = subprocess.run(command, capture_output=True, text=True, cwd=project_root)
181
+ if result.returncode == 0:
182
+ if result.stdout.strip():
183
+ click.echo(result.stdout.strip())
184
+ return
185
+ stderr = (result.stderr or "").strip()
186
+ errors.append(f"$ {' '.join(command)}\n{stderr}")
187
+
188
+ raise click.ClickException(
189
+ "Failed to run licenseheaders. Ensure it is installed in the current environment.\n\n"
190
+ + "\n\n".join(errors)
191
+ )
@@ -17,9 +17,15 @@ dependencies = [
17
17
  "sphinx-external-toc>=1.1.0",
18
18
  "sphinxcontrib-mermaid>=2.0.0",
19
19
  "watchfiles>=1.1.1",
20
+ "md2pdf>=3.1.1"
20
21
  ]
21
22
  dynamic = ["version"]
22
23
 
24
+ [project.optional-dependencies]
25
+ pdf = [
26
+ "md2pdf>=3.1.1",
27
+ ]
28
+
23
29
  [project.scripts]
24
30
  plesty = "plesty_sdk.cli:app"
25
31
 
@@ -1,32 +0,0 @@
1
- """CLI tool."""
2
-
3
- # Copyright (C) 2025, 2026 Christopher Borchers, fvrehlinger, Maximilian
4
- # Heller, Michael Zopf (names in alphabetic order wrt. surnames)
5
- #
6
- # This file is part of plesty-sdk which is part of the Plesty library.
7
- #
8
- # plesty-sdk is free software: you can redistribute it and/or modify it under
9
- # the terms of the GNU Lesser General Public License as published by the Free
10
- # Software Foundation, either version 3 of the License, or (at your option) any
11
- # later version.
12
- #
13
- # plesty-sdk is distributed in the hope that it will be useful, but WITHOUT ANY
14
- # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
15
- # PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
16
- #
17
- # You should have received a copy of the GNU Lesser General Public License along
18
- # with plesty-sdk. If not, see <https://www.gnu.org/licenses/>.
19
-
20
- import click
21
-
22
- from plesty_sdk.commands.check import check_command
23
- from plesty_sdk.commands.docs import docs_command
24
-
25
-
26
- @click.group("plesty")
27
- def app():
28
- """The Plesty CLI tool."""
29
-
30
-
31
- app.add_command(check_command)
32
- app.add_command(docs_command)