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,161 @@
1
+ """
2
+ ==================================================
3
+ ForgePy
4
+ Module : Create Command
5
+ ==================================================
6
+ """
7
+
8
+ import subprocess
9
+ from argparse import ArgumentParser, Namespace
10
+
11
+ from cli.command import Command
12
+ from config.user_config import ConfigStore, ForgePyConfigError
13
+ from core.project_generator import (
14
+ ProjectGenerator,
15
+ ProjectPreflightError,
16
+ UnknownProjectTemplateError,
17
+ )
18
+
19
+
20
+ class CreateCommand(Command):
21
+ """
22
+ Membuat project baru melalui ProjectGenerator.
23
+ """
24
+
25
+ name = "create"
26
+ summary = "Create a new Python project."
27
+ description = (
28
+ "Create a Python project from a registered ForgePy template. "
29
+ "ForgePy prompts for an omitted project name and resolves omitted "
30
+ "location or template values from user configuration before using "
31
+ "the existing interactive or basic fallback."
32
+ )
33
+
34
+ def __init__(
35
+ self,
36
+ store: ConfigStore | None = None,
37
+ ) -> None:
38
+ self._store = store
39
+
40
+ def configure_parser(
41
+ self,
42
+ parser: ArgumentParser,
43
+ ) -> None:
44
+ parser.add_argument(
45
+ "project_name",
46
+ nargs="?",
47
+ metavar="PROJECT_NAME",
48
+ help=(
49
+ "Name of the project to create. "
50
+ "ForgePy prompts for it when omitted."
51
+ ),
52
+ )
53
+
54
+ parser.add_argument(
55
+ "--location",
56
+ "-l",
57
+ metavar="PATH",
58
+ help=(
59
+ "Existing parent directory for the new project. "
60
+ "When omitted, ForgePy uses configured default_location "
61
+ "before prompting."
62
+ ),
63
+ )
64
+
65
+ parser.add_argument(
66
+ "--template",
67
+ "-t",
68
+ metavar="NAME",
69
+ help=(
70
+ "Registered project template to use. When omitted, "
71
+ "ForgePy uses configured default_template, then basic."
72
+ ),
73
+ )
74
+
75
+ def execute(self, args: Namespace) -> int:
76
+ location = getattr(
77
+ args,
78
+ "location",
79
+ None,
80
+ )
81
+ template_name = getattr(
82
+ args,
83
+ "template",
84
+ None,
85
+ )
86
+
87
+ user_config: dict[str, str] = {}
88
+
89
+ if location is None or template_name is None:
90
+ try:
91
+ user_config = self._get_store().load()
92
+ except ForgePyConfigError as error:
93
+ print(f"[ERROR] {error}")
94
+ print(
95
+ "[INFO] Run 'python main.py config reset' or supply "
96
+ "both --location and --template explicitly."
97
+ )
98
+ return 1
99
+
100
+ project_name = getattr(
101
+ args,
102
+ "project_name",
103
+ None,
104
+ )
105
+
106
+ if not project_name:
107
+ project_name = input(
108
+ "Project Name : "
109
+ ).strip()
110
+
111
+ if location is None:
112
+ location = user_config[
113
+ "default_location"
114
+ ]
115
+
116
+ if not location:
117
+ location = input(
118
+ "Location : "
119
+ ).strip()
120
+
121
+ if template_name is None:
122
+ template_name = user_config[
123
+ "default_template"
124
+ ]
125
+
126
+ template_name = template_name or "basic"
127
+
128
+ if not project_name:
129
+ print("[ERROR] Nama project tidak boleh kosong.")
130
+ return 1
131
+
132
+ if not location:
133
+ print("[ERROR] Lokasi project tidak boleh kosong.")
134
+ return 1
135
+
136
+ generator = ProjectGenerator()
137
+
138
+ try:
139
+ generator.create(
140
+ project_name=project_name,
141
+ location=location,
142
+ template_name=template_name,
143
+ )
144
+ except UnknownProjectTemplateError:
145
+ print(f"[ERROR] Unknown project template: '{template_name}'.")
146
+ return 1
147
+ except (
148
+ ProjectPreflightError,
149
+ OSError,
150
+ subprocess.SubprocessError,
151
+ ) as error:
152
+ print(f"[ERROR] Project creation failed: {error}")
153
+ return 1
154
+
155
+ return 0
156
+
157
+ def _get_store(self) -> ConfigStore:
158
+ if self._store is None:
159
+ self._store = ConfigStore()
160
+
161
+ return self._store
@@ -0,0 +1,40 @@
1
+ """
2
+ ==================================================
3
+ ForgePy
4
+ Module : List Command
5
+ ==================================================
6
+ """
7
+
8
+ from argparse import Namespace
9
+
10
+ from cli.command import Command
11
+ from templates.template_engine.template_registry import TemplateRegistry
12
+
13
+
14
+ class ListCommand(Command):
15
+ """
16
+ Menampilkan seluruh template yang tersedia.
17
+ """
18
+
19
+ name = "list"
20
+ summary = "List registered project templates."
21
+ description = "List every project template currently registered in ForgePy."
22
+
23
+ def execute(self, args: Namespace) -> int:
24
+ del args
25
+
26
+ registry = TemplateRegistry()
27
+ template_metadata = registry.list_metadata()
28
+
29
+ print("=" * 40)
30
+ print(" ForgePy Templates ")
31
+ print("=" * 40)
32
+
33
+ if not template_metadata:
34
+ print("Belum ada template yang terdaftar.")
35
+ return 0
36
+
37
+ for metadata in template_metadata:
38
+ print(f"- {metadata.name}: {metadata.description}")
39
+
40
+ return 0
@@ -0,0 +1,29 @@
1
+ """
2
+ ==================================================
3
+ ForgePy
4
+ Module : Version Command
5
+ ==================================================
6
+ """
7
+
8
+ import platform
9
+ from argparse import Namespace
10
+
11
+ from cli.command import Command
12
+ from config.version import APP_NAME, VERSION
13
+
14
+
15
+ class VersionCommand(Command):
16
+ """
17
+ Menampilkan informasi versi ForgePy.
18
+ """
19
+
20
+ name = "version"
21
+ summary = "Show ForgePy and Python version information."
22
+ description = "Show the configured ForgePy version and active Python version."
23
+
24
+ def execute(self, args: Namespace) -> int:
25
+ del args
26
+
27
+ print(f"{APP_NAME} v{VERSION}")
28
+ print(f"Python {platform.python_version()}")
29
+ return 0
cli/dispatcher.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ ==================================================
3
+ ForgePy
4
+ Module : CLI Dispatcher
5
+ ==================================================
6
+ """
7
+
8
+ from argparse import Namespace
9
+ from collections.abc import Iterable
10
+
11
+ from cli.command import Command
12
+ from cli.commands import DEFAULT_COMMAND, create_commands
13
+
14
+
15
+ class Dispatcher:
16
+ """
17
+ Meneruskan argumen CLI ke command yang sesuai.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ commands: Iterable[Command] | None = None,
23
+ ) -> None:
24
+ registered_commands = tuple(
25
+ create_commands()
26
+ if commands is None
27
+ else commands
28
+ )
29
+
30
+ for command in registered_commands:
31
+ command.validate_registration()
32
+
33
+ self.commands: dict[str, Command] = {
34
+ command.name: command
35
+ for command in registered_commands
36
+ }
37
+
38
+ if len(self.commands) != len(registered_commands):
39
+ raise ValueError("Nama command harus unik.")
40
+
41
+ if DEFAULT_COMMAND not in self.commands:
42
+ raise ValueError(
43
+ f"Default command '{DEFAULT_COMMAND}' belum terdaftar."
44
+ )
45
+
46
+ def dispatch(self, args: Namespace) -> int:
47
+ # Menjaga kompatibilitas:
48
+ # `python main.py` langsung membuka wizard create.
49
+ command_name = getattr(
50
+ args,
51
+ "command",
52
+ None,
53
+ ) or DEFAULT_COMMAND
54
+
55
+ command = self.commands.get(command_name)
56
+
57
+ if command is None:
58
+ print(f"[ERROR] Command tidak dikenal: {command_name}")
59
+ return 1
60
+
61
+ return command.execute(args)
cli/parser.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ ==================================================
3
+ ForgePy
4
+ Module : CLI Parser
5
+ ==================================================
6
+ """
7
+
8
+ import argparse
9
+ from argparse import Namespace
10
+ from collections.abc import Iterable
11
+
12
+ from cli.command import Command
13
+ from cli.commands import create_commands
14
+
15
+
16
+ class Parser:
17
+ """
18
+ Membaca command dan argumen dari terminal.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ commands: Iterable[Command] | None = None,
24
+ ) -> None:
25
+ self.commands = tuple(
26
+ create_commands()
27
+ if commands is None
28
+ else commands
29
+ )
30
+
31
+ self.parser = argparse.ArgumentParser(
32
+ prog="forgepy",
33
+ description=(
34
+ "Create structured Python projects and prepare their "
35
+ "development tooling with ForgePy."
36
+ ),
37
+ epilog=(
38
+ "Run 'python main.py COMMAND --help' for command-specific "
39
+ "usage from this repository."
40
+ ),
41
+ )
42
+
43
+ # Default untuk mode interaktif:
44
+ # python main.py
45
+ self.parser.set_defaults(
46
+ project_name=None,
47
+ location=None,
48
+ template=None,
49
+ )
50
+
51
+ self._register_commands()
52
+
53
+ def _register_commands(self) -> None:
54
+ subparsers = self.parser.add_subparsers(
55
+ dest="command",
56
+ title="commands",
57
+ description="Available ForgePy commands",
58
+ metavar="COMMAND",
59
+ )
60
+
61
+ for command in self.commands:
62
+ command.validate_registration()
63
+
64
+ command_parser = subparsers.add_parser(
65
+ command.name,
66
+ help=command.summary,
67
+ description=command.description,
68
+ )
69
+
70
+ command.configure_parser(command_parser)
71
+
72
+ def parse(self) -> Namespace:
73
+ """
74
+ Membaca argumen dari terminal.
75
+ """
76
+
77
+ return self.parser.parse_args()
78
+
79
+ def show_help(self) -> None:
80
+ """
81
+ Menampilkan halaman bantuan.
82
+ """
83
+
84
+ self.parser.print_help()
components/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Independent component contracts, registry, and built-in definitions."""
@@ -0,0 +1,30 @@
1
+ """Minimal contract for component definitions and installation."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from components.component_context import ComponentContext
6
+ from components.component_manifest import ComponentManifest
7
+ from components.component_metadata import ComponentMetadata
8
+
9
+
10
+ class BaseComponent(ABC):
11
+ """Expose component identity, metadata, and installation behavior."""
12
+
13
+ @property
14
+ @abstractmethod
15
+ def name(self) -> str:
16
+ """Return the stable component registration name."""
17
+
18
+ @property
19
+ @abstractmethod
20
+ def metadata(self) -> ComponentMetadata:
21
+ """Return descriptive metadata for this component."""
22
+
23
+ @property
24
+ @abstractmethod
25
+ def manifest(self) -> ComponentManifest:
26
+ """Return declarative installation properties for this component."""
27
+
28
+ @abstractmethod
29
+ def install(self, context: ComponentContext) -> None:
30
+ """Install the component into the context's existing project."""
@@ -0,0 +1,21 @@
1
+ """Validated project information supplied to component installations."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ComponentContext:
9
+ """Identify the existing project directory a component may modify."""
10
+
11
+ project_path: Path
12
+
13
+ def __post_init__(self) -> None:
14
+ if not isinstance(self.project_path, Path):
15
+ raise TypeError("Component project path must be a Path.")
16
+
17
+ if not self.project_path.exists():
18
+ raise ValueError("Component project path must exist.")
19
+
20
+ if not self.project_path.is_dir():
21
+ raise ValueError("Component project path must be a directory.")
@@ -0,0 +1,44 @@
1
+ """Minimal orchestration for installing one registered component."""
2
+
3
+ from pathlib import Path
4
+
5
+ from components.component_context import ComponentContext
6
+ from components.component_registry import ComponentRegistry
7
+ from components.component_state import ComponentStateStore
8
+ from components.component_validation import validate_component
9
+
10
+
11
+ class ComponentInstallationError(Exception):
12
+ """Base error for component installation orchestration."""
13
+
14
+
15
+ class ComponentAlreadyInstalledError(ComponentInstallationError):
16
+ """Raised when project-local state already records a component."""
17
+
18
+
19
+ class ComponentInstaller:
20
+ """Coordinate one explicit component installation in fixed order."""
21
+
22
+ def __init__(self, registry: ComponentRegistry | None = None) -> None:
23
+ self._registry = (
24
+ ComponentRegistry()
25
+ if registry is None
26
+ else registry
27
+ )
28
+
29
+ def install(self, name: str, project_path: Path) -> None:
30
+ """Install and then record one component for an existing project."""
31
+
32
+ component = self._registry.get(name)
33
+ context = ComponentContext(project_path=project_path)
34
+ state_store = ComponentStateStore(project_path)
35
+ installed_components = state_store.load()
36
+
37
+ if component.name in installed_components:
38
+ raise ComponentAlreadyInstalledError(
39
+ f"Component '{component.name}' is already installed."
40
+ )
41
+
42
+ validate_component(component, installed_components)
43
+ component.install(context)
44
+ state_store.add(component.name)
@@ -0,0 +1,102 @@
1
+ """Declarative installation properties for ForgePy components."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ComponentManifest:
10
+ """Describe files and component relationships without resolving them."""
11
+
12
+ files: tuple[Path, ...] = ()
13
+ dependencies: tuple[str, ...] = ()
14
+ conflicts: tuple[str, ...] = ()
15
+
16
+ def __post_init__(self) -> None:
17
+ files = self._normalize_files(self.files)
18
+ dependencies = self._normalize_names(
19
+ self.dependencies,
20
+ "dependencies",
21
+ )
22
+ conflicts = self._normalize_names(self.conflicts, "conflicts")
23
+
24
+ object.__setattr__(self, "files", files)
25
+ object.__setattr__(self, "dependencies", dependencies)
26
+ object.__setattr__(self, "conflicts", conflicts)
27
+
28
+ @staticmethod
29
+ def _normalize_files(files: Iterable[Path]) -> tuple[Path, ...]:
30
+ if isinstance(files, (str, bytes)):
31
+ raise TypeError(
32
+ "Component manifest files must be an iterable of Paths."
33
+ )
34
+
35
+ try:
36
+ normalized = tuple(files)
37
+ except TypeError as error:
38
+ raise TypeError(
39
+ "Component manifest files must be an iterable of Paths."
40
+ ) from error
41
+
42
+ for path in normalized:
43
+ if not isinstance(path, Path):
44
+ raise TypeError(
45
+ "Component manifest files must contain only Paths."
46
+ )
47
+ if path == Path() or not str(path).strip():
48
+ raise ValueError(
49
+ "Component manifest file paths must not be empty."
50
+ )
51
+ if path.is_absolute():
52
+ raise ValueError(
53
+ "Component manifest file paths must be project-relative."
54
+ )
55
+ if ".." in path.parts:
56
+ raise ValueError(
57
+ "Component manifest file paths must not contain '..'."
58
+ )
59
+
60
+ ComponentManifest._reject_duplicates(normalized, "files")
61
+ return normalized
62
+
63
+ @staticmethod
64
+ def _normalize_names(
65
+ names: Iterable[str],
66
+ field_name: str,
67
+ ) -> tuple[str, ...]:
68
+ if isinstance(names, (str, bytes)):
69
+ raise TypeError(
70
+ f"Component manifest {field_name} must be an iterable "
71
+ "of strings."
72
+ )
73
+
74
+ try:
75
+ normalized = tuple(names)
76
+ except TypeError as error:
77
+ raise TypeError(
78
+ f"Component manifest {field_name} must be an iterable "
79
+ "of strings."
80
+ ) from error
81
+
82
+ for name in normalized:
83
+ if not isinstance(name, str):
84
+ raise TypeError(
85
+ f"Component manifest {field_name} must contain only "
86
+ "strings."
87
+ )
88
+ if not name.strip():
89
+ raise ValueError(
90
+ f"Component manifest {field_name} entries must not be "
91
+ "empty."
92
+ )
93
+
94
+ ComponentManifest._reject_duplicates(normalized, field_name)
95
+ return normalized
96
+
97
+ @staticmethod
98
+ def _reject_duplicates(values: tuple[object, ...], field_name: str) -> None:
99
+ if len(values) != len(set(values)):
100
+ raise ValueError(
101
+ f"Component manifest {field_name} must not contain duplicates."
102
+ )
@@ -0,0 +1,50 @@
1
+ """Descriptive metadata for ForgePy components."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ComponentMetadata:
8
+ """Describe a component without defining installation behavior."""
9
+
10
+ name: str
11
+ description: str
12
+ version: str
13
+ author: str
14
+ tags: tuple[str, ...]
15
+
16
+ def __post_init__(self) -> None:
17
+ for field_name in (
18
+ "name",
19
+ "description",
20
+ "version",
21
+ "author",
22
+ ):
23
+ if not isinstance(getattr(self, field_name), str):
24
+ raise TypeError(
25
+ f"Component metadata {field_name} must be a string."
26
+ )
27
+
28
+ if not self.name.strip():
29
+ raise ValueError(
30
+ "Component metadata name must not be empty."
31
+ )
32
+
33
+ if isinstance(self.tags, (str, bytes)):
34
+ raise TypeError(
35
+ "Component metadata tags must be an iterable of strings."
36
+ )
37
+
38
+ try:
39
+ normalized_tags = tuple(self.tags)
40
+ except TypeError as error:
41
+ raise TypeError(
42
+ "Component metadata tags must be an iterable of strings."
43
+ ) from error
44
+
45
+ if not all(isinstance(tag, str) for tag in normalized_tags):
46
+ raise TypeError(
47
+ "Component metadata tags must contain only strings."
48
+ )
49
+
50
+ object.__setattr__(self, "tags", normalized_tags)