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,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)
|