plesty-sdk 0.0.2.dev5__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.
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/.gitignore +3 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/PKG-INFO +4 -1
- plesty_sdk-0.0.2.dev10/plesty_sdk/cli.py +26 -0
- plesty_sdk-0.0.2.dev10/plesty_sdk/commands/__init__.py +1 -0
- plesty_sdk-0.0.2.dev10/plesty_sdk/commands/build.py +243 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/__init__.py +2 -0
- plesty_sdk-0.0.2.dev10/plesty_sdk/commands/docs/generate.py +259 -0
- plesty_sdk-0.0.2.dev10/plesty_sdk/commands/license.py +191 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/pyproject.toml +7 -1
- plesty_sdk-0.0.2.dev5/plesty_sdk/cli.py +0 -32
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/.gitlab-ci.yml +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/COPYING +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/COPYING.LESSER +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/LICENSE +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/README.md +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/docs/index.md +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/docs/toc.yaml +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/docs/usage/index.md +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/__init__.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/docs-build-gitlab-ci.yml +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/logos/plesty_icon.png +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/logos/plesty_logo.png +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/assets/ruff.toml +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/cache.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/check.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/deploy.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/serve.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/commands/docs/sphinx_builder.py +0 -0
- {plesty_sdk-0.0.2.dev5 → plesty_sdk-0.0.2.dev10}/plesty_sdk/context.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plesty-sdk
|
|
3
|
-
Version: 0.0.2.
|
|
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
|
|
|
@@ -33,7 +39,7 @@ requires = ["hatchling", "versioningit"]
|
|
|
33
39
|
build-backend = "hatchling.build"
|
|
34
40
|
|
|
35
41
|
[tool.hatch.build.targets.wheel]
|
|
36
|
-
packages = ["
|
|
42
|
+
packages = ["plesty_sdk"]
|
|
37
43
|
|
|
38
44
|
[tool.hatch.version]
|
|
39
45
|
source = "versioningit"
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|