forgepy-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- builders/__init__.py +0 -0
- builders/base_builder.py +17 -0
- builders/file_builder.py +31 -0
- builders/folder_builder.py +30 -0
- builders/python_tools_builder.py +64 -0
- cli/__init__.py +3 -0
- cli/command.py +52 -0
- cli/commands/__init__.py +32 -0
- cli/commands/component_command.py +177 -0
- cli/commands/config_command.py +174 -0
- cli/commands/create_command.py +161 -0
- cli/commands/list_command.py +40 -0
- cli/commands/version_command.py +29 -0
- cli/dispatcher.py +61 -0
- cli/parser.py +84 -0
- components/__init__.py +1 -0
- components/base_component.py +30 -0
- components/component_context.py +21 -0
- components/component_installer.py +44 -0
- components/component_manifest.py +102 -0
- components/component_metadata.py +50 -0
- components/component_registry.py +74 -0
- components/component_state.py +243 -0
- components/component_validation.py +88 -0
- components/github_actions_component.py +75 -0
- components/pytest_component.py +40 -0
- components/ruff_component.py +40 -0
- config/__init__.py +0 -0
- config/default_structure.py +18 -0
- config/user_config.py +238 -0
- config/version.py +16 -0
- core/__init__.py +0 -0
- core/environment_builder.py +33 -0
- core/git_builder.py +90 -0
- core/project_generator.py +125 -0
- core/requirements_installer.py +50 -0
- core/vscode_builder.py +103 -0
- forgepy_cli-1.0.0.dist-info/METADATA +176 -0
- forgepy_cli-1.0.0.dist-info/RECORD +74 -0
- forgepy_cli-1.0.0.dist-info/WHEEL +5 -0
- forgepy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- forgepy_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- forgepy_cli-1.0.0.dist-info/top_level.txt +8 -0
- main.py +20 -0
- models/__init__.py +1 -0
- models/project_config.py +87 -0
- templates/__init__.py +0 -0
- templates/app_template.py +20 -0
- templates/basic/basic_files.py +23 -0
- templates/basic/basic_template.py +32 -0
- templates/changelog_template.py +15 -0
- templates/cli/cli_files.py +70 -0
- templates/cli/cli_template.py +55 -0
- templates/env_template.py +20 -0
- templates/gitignore_template.py +27 -0
- templates/library/library_files.py +28 -0
- templates/library/library_template.py +56 -0
- templates/license_template.py +21 -0
- templates/pyproject_template.py +15 -0
- templates/readme_template.py +15 -0
- templates/requirements_template.py +13 -0
- templates/template_engine/base_template.py +58 -0
- templates/template_engine/file_template.py +76 -0
- templates/template_engine/package_name.py +30 -0
- templates/template_engine/template_context.py +24 -0
- templates/template_engine/template_files.py +10 -0
- templates/template_engine/template_metadata.py +59 -0
- templates/template_engine/template_registry.py +107 -0
- templates/template_manager.py +58 -0
- templates/vscode/__init__.py +0 -0
- templates/vscode/extensions_template.py +32 -0
- templates/vscode/launch_template.py +41 -0
- templates/vscode/settings_template.py +37 -0
- templates/vscode/tasks_template.py +66 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""In-memory registration for component definitions."""
|
|
2
|
+
|
|
3
|
+
from components.base_component import BaseComponent
|
|
4
|
+
from components.component_manifest import ComponentManifest
|
|
5
|
+
from components.component_metadata import ComponentMetadata
|
|
6
|
+
from components.github_actions_component import GitHubActionsComponent
|
|
7
|
+
from components.pytest_component import PytestComponent
|
|
8
|
+
from components.ruff_component import RuffComponent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ComponentRegistry:
|
|
12
|
+
"""Validate and store explicitly registered components."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._components: dict[str, BaseComponent] = {}
|
|
16
|
+
self.register(PytestComponent())
|
|
17
|
+
self.register(RuffComponent())
|
|
18
|
+
self.register(GitHubActionsComponent())
|
|
19
|
+
|
|
20
|
+
def register(self, component: BaseComponent) -> None:
|
|
21
|
+
"""Register one component under its validated metadata name."""
|
|
22
|
+
|
|
23
|
+
if not isinstance(component, BaseComponent):
|
|
24
|
+
raise TypeError("Component must inherit from BaseComponent.")
|
|
25
|
+
|
|
26
|
+
component_name = component.name
|
|
27
|
+
|
|
28
|
+
if not isinstance(component_name, str):
|
|
29
|
+
raise TypeError("Component name must be a string.")
|
|
30
|
+
|
|
31
|
+
if not component_name.strip():
|
|
32
|
+
raise ValueError("Component name must not be empty.")
|
|
33
|
+
|
|
34
|
+
metadata = component.metadata
|
|
35
|
+
|
|
36
|
+
if not isinstance(metadata, ComponentMetadata):
|
|
37
|
+
raise TypeError(
|
|
38
|
+
"Component metadata must be ComponentMetadata."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if component_name != metadata.name:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Component name and metadata name must match."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
manifest = component.manifest
|
|
47
|
+
|
|
48
|
+
if not isinstance(manifest, ComponentManifest):
|
|
49
|
+
raise TypeError(
|
|
50
|
+
"Component manifest must be ComponentManifest."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if component_name in manifest.dependencies:
|
|
54
|
+
raise ValueError("A component must not depend on itself.")
|
|
55
|
+
|
|
56
|
+
if component_name in manifest.conflicts:
|
|
57
|
+
raise ValueError("A component must not conflict with itself.")
|
|
58
|
+
|
|
59
|
+
if component_name in self._components:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Component '{component_name}' is already registered."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self._components[component_name] = component
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> BaseComponent:
|
|
67
|
+
"""Return the component registered under ``name``."""
|
|
68
|
+
|
|
69
|
+
return self._components[name]
|
|
70
|
+
|
|
71
|
+
def list_components(self) -> tuple[BaseComponent, ...]:
|
|
72
|
+
"""Return registered components in registration order."""
|
|
73
|
+
|
|
74
|
+
return tuple(self._components.values())
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Project-local persistence for installed ForgePy component names."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from json import JSONDecodeError
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from components.component_context import ComponentContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
STATE_DIRECTORY_NAME = ".forgepy"
|
|
14
|
+
STATE_FILENAME = "components.json"
|
|
15
|
+
INSTALLED_KEY = "installed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForgePyComponentStateError(Exception):
|
|
19
|
+
"""Base error for project-local component state operations."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ComponentStateFormatError(ForgePyComponentStateError):
|
|
23
|
+
"""Raised when persisted component state has an invalid format."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ComponentStateIOError(ForgePyComponentStateError):
|
|
27
|
+
"""Raised when component state cannot be read or written."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ComponentStateStore:
|
|
31
|
+
"""Load and persist installed component names under one project."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, project_path: Path) -> None:
|
|
34
|
+
context = ComponentContext(project_path=project_path)
|
|
35
|
+
self.project_path = context.project_path
|
|
36
|
+
self.state_directory = self.project_path / STATE_DIRECTORY_NAME
|
|
37
|
+
self.state_path = self.state_directory / STATE_FILENAME
|
|
38
|
+
|
|
39
|
+
def load(self) -> frozenset[str]:
|
|
40
|
+
"""Return installed names, or an empty set when state is absent."""
|
|
41
|
+
|
|
42
|
+
_, state_path = self._resolve_state_paths()
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
content = state_path.read_text(encoding="utf-8")
|
|
46
|
+
data = json.loads(content)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return frozenset()
|
|
49
|
+
except UnicodeDecodeError as error:
|
|
50
|
+
raise ComponentStateFormatError(
|
|
51
|
+
"ForgePy component state is not valid UTF-8 at "
|
|
52
|
+
f"'{self.state_path}'."
|
|
53
|
+
) from error
|
|
54
|
+
except JSONDecodeError as error:
|
|
55
|
+
raise ComponentStateFormatError(
|
|
56
|
+
"ForgePy component state contains malformed JSON at "
|
|
57
|
+
f"'{self.state_path}' "
|
|
58
|
+
f"(line {error.lineno}, column {error.colno})."
|
|
59
|
+
) from error
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raise ComponentStateIOError(
|
|
62
|
+
"ForgePy could not read component state from "
|
|
63
|
+
f"'{self.state_path}': {error}"
|
|
64
|
+
) from error
|
|
65
|
+
|
|
66
|
+
return self._normalize_document(data)
|
|
67
|
+
|
|
68
|
+
def save(self, installed_components: Iterable[str]) -> frozenset[str]:
|
|
69
|
+
"""Validate and atomically persist installed component names."""
|
|
70
|
+
|
|
71
|
+
self.load()
|
|
72
|
+
installed = self._normalize_names(installed_components)
|
|
73
|
+
document = {INSTALLED_KEY: sorted(installed)}
|
|
74
|
+
serialized = json.dumps(
|
|
75
|
+
document,
|
|
76
|
+
indent=4,
|
|
77
|
+
ensure_ascii=False,
|
|
78
|
+
) + "\n"
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
self.state_directory.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
state_directory, state_path = self._resolve_state_paths()
|
|
83
|
+
temporary_path: Path | None = None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with tempfile.NamedTemporaryFile(
|
|
87
|
+
mode="w",
|
|
88
|
+
encoding="utf-8",
|
|
89
|
+
newline="\n",
|
|
90
|
+
dir=state_directory,
|
|
91
|
+
prefix=f".{STATE_FILENAME}.",
|
|
92
|
+
suffix=".tmp",
|
|
93
|
+
delete=False,
|
|
94
|
+
) as temporary_file:
|
|
95
|
+
temporary_path = Path(temporary_file.name)
|
|
96
|
+
resolved_temporary_path = temporary_path.resolve()
|
|
97
|
+
self._require_confined(
|
|
98
|
+
resolved_temporary_path,
|
|
99
|
+
state_directory,
|
|
100
|
+
"temporary component-state file",
|
|
101
|
+
)
|
|
102
|
+
temporary_file.write(serialized)
|
|
103
|
+
temporary_file.flush()
|
|
104
|
+
os.fsync(temporary_file.fileno())
|
|
105
|
+
|
|
106
|
+
temporary_path.replace(state_path)
|
|
107
|
+
finally:
|
|
108
|
+
if temporary_path is not None and temporary_path.exists():
|
|
109
|
+
temporary_path.unlink()
|
|
110
|
+
except OSError as error:
|
|
111
|
+
raise ComponentStateIOError(
|
|
112
|
+
"ForgePy could not save component state to "
|
|
113
|
+
f"'{self.state_path}': {error}"
|
|
114
|
+
) from error
|
|
115
|
+
|
|
116
|
+
return installed
|
|
117
|
+
|
|
118
|
+
def add(self, component_name: str) -> frozenset[str]:
|
|
119
|
+
"""Add one installed name while preserving existing state."""
|
|
120
|
+
|
|
121
|
+
name = self._normalize_names((component_name,))
|
|
122
|
+
installed = self.load().union(name)
|
|
123
|
+
return self.save(installed)
|
|
124
|
+
|
|
125
|
+
def is_installed(self, component_name: str) -> bool:
|
|
126
|
+
"""Return whether one valid component name is installed."""
|
|
127
|
+
|
|
128
|
+
name = next(iter(self._normalize_names((component_name,))))
|
|
129
|
+
return name in self.load()
|
|
130
|
+
|
|
131
|
+
def _resolve_state_paths(self) -> tuple[Path, Path]:
|
|
132
|
+
"""Return state paths only when they remain inside the project."""
|
|
133
|
+
|
|
134
|
+
if self.state_directory.exists() and not self.state_directory.is_dir():
|
|
135
|
+
raise ComponentStateIOError(
|
|
136
|
+
"ForgePy component-state directory must be a directory: "
|
|
137
|
+
f"'{self.state_directory}'."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
project_root = self.project_path.resolve()
|
|
142
|
+
state_directory = self.state_directory.resolve(strict=False)
|
|
143
|
+
state_path = self.state_path.resolve(strict=False)
|
|
144
|
+
except (OSError, RuntimeError) as error:
|
|
145
|
+
raise ComponentStateIOError(
|
|
146
|
+
"ForgePy could not resolve component state paths below "
|
|
147
|
+
f"'{self.project_path}': {error}"
|
|
148
|
+
) from error
|
|
149
|
+
|
|
150
|
+
self._require_confined(
|
|
151
|
+
state_directory,
|
|
152
|
+
project_root,
|
|
153
|
+
"component-state directory",
|
|
154
|
+
)
|
|
155
|
+
self._require_confined(
|
|
156
|
+
state_path,
|
|
157
|
+
state_directory,
|
|
158
|
+
"component-state file",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if state_path.parent != state_directory:
|
|
162
|
+
raise ComponentStateIOError(
|
|
163
|
+
"ForgePy component-state file must resolve directly below "
|
|
164
|
+
f"the state directory: '{state_path}'."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return state_directory, state_path
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _require_confined(
|
|
171
|
+
path: Path,
|
|
172
|
+
parent: Path,
|
|
173
|
+
path_label: str,
|
|
174
|
+
) -> None:
|
|
175
|
+
try:
|
|
176
|
+
path.relative_to(parent)
|
|
177
|
+
except ValueError as error:
|
|
178
|
+
raise ComponentStateIOError(
|
|
179
|
+
f"ForgePy {path_label} resolves outside its required "
|
|
180
|
+
f"location: '{path}'."
|
|
181
|
+
) from error
|
|
182
|
+
|
|
183
|
+
if path == parent:
|
|
184
|
+
raise ComponentStateIOError(
|
|
185
|
+
f"ForgePy {path_label} must be below its required location: "
|
|
186
|
+
f"'{path}'."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def _normalize_document(cls, data: object) -> frozenset[str]:
|
|
191
|
+
if not isinstance(data, Mapping):
|
|
192
|
+
raise ComponentStateFormatError(
|
|
193
|
+
"ForgePy component state must contain a JSON object."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if set(data) != {INSTALLED_KEY}:
|
|
197
|
+
raise ComponentStateFormatError(
|
|
198
|
+
"ForgePy component state must contain only an "
|
|
199
|
+
f"'{INSTALLED_KEY}' field."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
installed = data[INSTALLED_KEY]
|
|
203
|
+
|
|
204
|
+
if not isinstance(installed, list):
|
|
205
|
+
raise ComponentStateFormatError(
|
|
206
|
+
"ForgePy component state 'installed' field must be a list."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
return cls._normalize_names(installed)
|
|
211
|
+
except (TypeError, ValueError) as error:
|
|
212
|
+
raise ComponentStateFormatError(
|
|
213
|
+
"ForgePy component state contains an invalid installed "
|
|
214
|
+
f"component: {error}"
|
|
215
|
+
) from error
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _normalize_names(
|
|
219
|
+
installed_components: Iterable[str],
|
|
220
|
+
) -> frozenset[str]:
|
|
221
|
+
if isinstance(installed_components, (str, bytes)):
|
|
222
|
+
raise TypeError(
|
|
223
|
+
"Installed components must be an iterable of strings."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
installed = tuple(installed_components)
|
|
228
|
+
except TypeError as error:
|
|
229
|
+
raise TypeError(
|
|
230
|
+
"Installed components must be an iterable of strings."
|
|
231
|
+
) from error
|
|
232
|
+
|
|
233
|
+
for name in installed:
|
|
234
|
+
if not isinstance(name, str):
|
|
235
|
+
raise TypeError(
|
|
236
|
+
"Installed components must contain only strings."
|
|
237
|
+
)
|
|
238
|
+
if not name.strip():
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"Installed component names must not be empty."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return frozenset(installed)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Stateless pre-install validation for component relationships."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from components.base_component import BaseComponent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ComponentValidationError(ValueError):
|
|
9
|
+
"""Report unsatisfied direct relationships for one component."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
component_name: str,
|
|
14
|
+
missing_dependencies: tuple[str, ...] = (),
|
|
15
|
+
active_conflicts: tuple[str, ...] = (),
|
|
16
|
+
) -> None:
|
|
17
|
+
self.component_name = component_name
|
|
18
|
+
self.missing_dependencies = missing_dependencies
|
|
19
|
+
self.active_conflicts = active_conflicts
|
|
20
|
+
|
|
21
|
+
failures: list[str] = []
|
|
22
|
+
|
|
23
|
+
if missing_dependencies:
|
|
24
|
+
failures.append(
|
|
25
|
+
"missing dependencies: "
|
|
26
|
+
f"{', '.join(missing_dependencies)}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if active_conflicts:
|
|
30
|
+
failures.append(
|
|
31
|
+
"active conflicts: "
|
|
32
|
+
f"{', '.join(active_conflicts)}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
super().__init__(
|
|
36
|
+
f"Component '{component_name}' cannot be installed; "
|
|
37
|
+
f"{'; '.join(failures)}."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_component(
|
|
42
|
+
component: BaseComponent,
|
|
43
|
+
installed_components: Iterable[str],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Validate direct dependencies and conflicts against explicit state."""
|
|
46
|
+
|
|
47
|
+
installed = _installed_component_names(installed_components)
|
|
48
|
+
manifest = component.manifest
|
|
49
|
+
missing_dependencies = tuple(
|
|
50
|
+
name
|
|
51
|
+
for name in manifest.dependencies
|
|
52
|
+
if name not in installed
|
|
53
|
+
)
|
|
54
|
+
active_conflicts = tuple(
|
|
55
|
+
name
|
|
56
|
+
for name in manifest.conflicts
|
|
57
|
+
if name in installed
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if missing_dependencies or active_conflicts:
|
|
61
|
+
raise ComponentValidationError(
|
|
62
|
+
component_name=component.name,
|
|
63
|
+
missing_dependencies=missing_dependencies,
|
|
64
|
+
active_conflicts=active_conflicts,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _installed_component_names(
|
|
69
|
+
installed_components: Iterable[str],
|
|
70
|
+
) -> frozenset[str]:
|
|
71
|
+
if isinstance(installed_components, (str, bytes)):
|
|
72
|
+
raise TypeError(
|
|
73
|
+
"Installed components must be an iterable of strings."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
installed = tuple(installed_components)
|
|
78
|
+
except TypeError as error:
|
|
79
|
+
raise TypeError(
|
|
80
|
+
"Installed components must be an iterable of strings."
|
|
81
|
+
) from error
|
|
82
|
+
|
|
83
|
+
if not all(isinstance(name, str) for name in installed):
|
|
84
|
+
raise TypeError(
|
|
85
|
+
"Installed components must contain only strings."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return frozenset(installed)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Built-in GitHub Actions CI component."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from components.base_component import BaseComponent
|
|
6
|
+
from components.component_context import ComponentContext
|
|
7
|
+
from components.component_manifest import ComponentManifest
|
|
8
|
+
from components.component_metadata import ComponentMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitHubActionsComponent(BaseComponent):
|
|
12
|
+
"""Add a minimal Python CI workflow to an existing project."""
|
|
13
|
+
|
|
14
|
+
_METADATA = ComponentMetadata(
|
|
15
|
+
name="github-actions",
|
|
16
|
+
description="GitHub Actions CI for an existing Python project.",
|
|
17
|
+
version="0.1.0",
|
|
18
|
+
author="ForgePy",
|
|
19
|
+
tags=("ci", "github-actions", "python"),
|
|
20
|
+
)
|
|
21
|
+
_MANIFEST = ComponentManifest(
|
|
22
|
+
files=(Path(".github/workflows/ci.yml"),),
|
|
23
|
+
)
|
|
24
|
+
_WORKFLOW = (
|
|
25
|
+
"name: CI\n"
|
|
26
|
+
"\n"
|
|
27
|
+
"on:\n"
|
|
28
|
+
" push:\n"
|
|
29
|
+
" pull_request:\n"
|
|
30
|
+
"\n"
|
|
31
|
+
"jobs:\n"
|
|
32
|
+
" test:\n"
|
|
33
|
+
" runs-on: ubuntu-latest\n"
|
|
34
|
+
" steps:\n"
|
|
35
|
+
" - uses: actions/checkout@v4\n"
|
|
36
|
+
" - uses: actions/setup-python@v5\n"
|
|
37
|
+
" with:\n"
|
|
38
|
+
' python-version: "3.12"\n'
|
|
39
|
+
" - name: Install CI tools\n"
|
|
40
|
+
" run: python -m pip install pytest ruff\n"
|
|
41
|
+
" - name: Run Ruff\n"
|
|
42
|
+
" run: ruff check .\n"
|
|
43
|
+
" - name: Run pytest\n"
|
|
44
|
+
" run: pytest\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
return self._METADATA.name
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def metadata(self) -> ComponentMetadata:
|
|
53
|
+
return self._METADATA
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def manifest(self) -> ComponentManifest:
|
|
57
|
+
return self._MANIFEST
|
|
58
|
+
|
|
59
|
+
def install(self, context: ComponentContext) -> None:
|
|
60
|
+
target_path = context.project_path / self._MANIFEST.files[0]
|
|
61
|
+
project_root = context.project_path.resolve()
|
|
62
|
+
resolved_target = target_path.resolve(strict=False)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
resolved_target.relative_to(project_root)
|
|
66
|
+
except ValueError as error:
|
|
67
|
+
raise OSError(
|
|
68
|
+
"GitHub Actions workflow target resolves outside the "
|
|
69
|
+
f"project: '{resolved_target}'."
|
|
70
|
+
) from error
|
|
71
|
+
|
|
72
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
with target_path.open("x", encoding="utf-8", newline="\n") as file:
|
|
75
|
+
file.write(self._WORKFLOW)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Built-in pytest project-support component."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from components.base_component import BaseComponent
|
|
6
|
+
from components.component_context import ComponentContext
|
|
7
|
+
from components.component_manifest import ComponentManifest
|
|
8
|
+
from components.component_metadata import ComponentMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PytestComponent(BaseComponent):
|
|
12
|
+
"""Add an isolated pytest configuration to an existing project."""
|
|
13
|
+
|
|
14
|
+
_METADATA = ComponentMetadata(
|
|
15
|
+
name="pytest",
|
|
16
|
+
description="Pytest configuration for an existing Python project.",
|
|
17
|
+
version="0.1.0",
|
|
18
|
+
author="ForgePy",
|
|
19
|
+
tags=("testing", "pytest"),
|
|
20
|
+
)
|
|
21
|
+
_MANIFEST = ComponentManifest(files=(Path("pytest.ini"),))
|
|
22
|
+
_CONFIGURATION = "[pytest]\ntestpaths = tests\n"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return self._METADATA.name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def metadata(self) -> ComponentMetadata:
|
|
30
|
+
return self._METADATA
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def manifest(self) -> ComponentManifest:
|
|
34
|
+
return self._MANIFEST
|
|
35
|
+
|
|
36
|
+
def install(self, context: ComponentContext) -> None:
|
|
37
|
+
target_path = context.project_path / self._MANIFEST.files[0]
|
|
38
|
+
|
|
39
|
+
with target_path.open("x", encoding="utf-8", newline="\n") as file:
|
|
40
|
+
file.write(self._CONFIGURATION)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Built-in Ruff project-support component."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from components.base_component import BaseComponent
|
|
6
|
+
from components.component_context import ComponentContext
|
|
7
|
+
from components.component_manifest import ComponentManifest
|
|
8
|
+
from components.component_metadata import ComponentMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuffComponent(BaseComponent):
|
|
12
|
+
"""Add an isolated Ruff configuration to an existing project."""
|
|
13
|
+
|
|
14
|
+
_METADATA = ComponentMetadata(
|
|
15
|
+
name="ruff",
|
|
16
|
+
description="Ruff configuration for an existing Python project.",
|
|
17
|
+
version="0.1.0",
|
|
18
|
+
author="ForgePy",
|
|
19
|
+
tags=("linting", "ruff"),
|
|
20
|
+
)
|
|
21
|
+
_MANIFEST = ComponentManifest(files=(Path("ruff.toml"),))
|
|
22
|
+
_CONFIGURATION = 'line-length = 88\ntarget-version = "py312"\n'
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return self._METADATA.name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def metadata(self) -> ComponentMetadata:
|
|
30
|
+
return self._METADATA
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def manifest(self) -> ComponentManifest:
|
|
34
|
+
return self._MANIFEST
|
|
35
|
+
|
|
36
|
+
def install(self, context: ComponentContext) -> None:
|
|
37
|
+
target_path = context.project_path / self._MANIFEST.files[0]
|
|
38
|
+
|
|
39
|
+
with target_path.open("x", encoding="utf-8", newline="\n") as file:
|
|
40
|
+
file.write(self._CONFIGURATION)
|
config/__init__.py
ADDED
|
File without changes
|