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.
Files changed (74) hide show
  1. builders/__init__.py +0 -0
  2. builders/base_builder.py +17 -0
  3. builders/file_builder.py +31 -0
  4. builders/folder_builder.py +30 -0
  5. builders/python_tools_builder.py +64 -0
  6. cli/__init__.py +3 -0
  7. cli/command.py +52 -0
  8. cli/commands/__init__.py +32 -0
  9. cli/commands/component_command.py +177 -0
  10. cli/commands/config_command.py +174 -0
  11. cli/commands/create_command.py +161 -0
  12. cli/commands/list_command.py +40 -0
  13. cli/commands/version_command.py +29 -0
  14. cli/dispatcher.py +61 -0
  15. cli/parser.py +84 -0
  16. components/__init__.py +1 -0
  17. components/base_component.py +30 -0
  18. components/component_context.py +21 -0
  19. components/component_installer.py +44 -0
  20. components/component_manifest.py +102 -0
  21. components/component_metadata.py +50 -0
  22. components/component_registry.py +74 -0
  23. components/component_state.py +243 -0
  24. components/component_validation.py +88 -0
  25. components/github_actions_component.py +75 -0
  26. components/pytest_component.py +40 -0
  27. components/ruff_component.py +40 -0
  28. config/__init__.py +0 -0
  29. config/default_structure.py +18 -0
  30. config/user_config.py +238 -0
  31. config/version.py +16 -0
  32. core/__init__.py +0 -0
  33. core/environment_builder.py +33 -0
  34. core/git_builder.py +90 -0
  35. core/project_generator.py +125 -0
  36. core/requirements_installer.py +50 -0
  37. core/vscode_builder.py +103 -0
  38. forgepy_cli-1.0.0.dist-info/METADATA +176 -0
  39. forgepy_cli-1.0.0.dist-info/RECORD +74 -0
  40. forgepy_cli-1.0.0.dist-info/WHEEL +5 -0
  41. forgepy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  42. forgepy_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  43. forgepy_cli-1.0.0.dist-info/top_level.txt +8 -0
  44. main.py +20 -0
  45. models/__init__.py +1 -0
  46. models/project_config.py +87 -0
  47. templates/__init__.py +0 -0
  48. templates/app_template.py +20 -0
  49. templates/basic/basic_files.py +23 -0
  50. templates/basic/basic_template.py +32 -0
  51. templates/changelog_template.py +15 -0
  52. templates/cli/cli_files.py +70 -0
  53. templates/cli/cli_template.py +55 -0
  54. templates/env_template.py +20 -0
  55. templates/gitignore_template.py +27 -0
  56. templates/library/library_files.py +28 -0
  57. templates/library/library_template.py +56 -0
  58. templates/license_template.py +21 -0
  59. templates/pyproject_template.py +15 -0
  60. templates/readme_template.py +15 -0
  61. templates/requirements_template.py +13 -0
  62. templates/template_engine/base_template.py +58 -0
  63. templates/template_engine/file_template.py +76 -0
  64. templates/template_engine/package_name.py +30 -0
  65. templates/template_engine/template_context.py +24 -0
  66. templates/template_engine/template_files.py +10 -0
  67. templates/template_engine/template_metadata.py +59 -0
  68. templates/template_engine/template_registry.py +107 -0
  69. templates/template_manager.py +58 -0
  70. templates/vscode/__init__.py +0 -0
  71. templates/vscode/extensions_template.py +32 -0
  72. templates/vscode/launch_template.py +41 -0
  73. templates/vscode/settings_template.py +37 -0
  74. 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
@@ -0,0 +1,18 @@
1
+ DEFAULT_FOLDERS = [
2
+ "assets",
3
+ "config",
4
+ "database",
5
+ "models",
6
+ "services",
7
+ "ui",
8
+ "tests",
9
+ "logs",
10
+ "exports",
11
+ ]
12
+
13
+ DEFAULT_FILES = [
14
+ "README.md",
15
+ ".gitignore",
16
+ "requirements.txt",
17
+ "app.py",
18
+ ]