pyscaf-core 0.1.0__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.
- pyscaf_core-0.1.0/.gitignore +26 -0
- pyscaf_core-0.1.0/PKG-INFO +10 -0
- pyscaf_core-0.1.0/pyproject.toml +19 -0
- pyscaf_core-0.1.0/src/pyscaf_core/__init__.py +15 -0
- pyscaf_core-0.1.0/src/pyscaf_core/actions/__init__.py +213 -0
- pyscaf_core-0.1.0/src/pyscaf_core/actions/manager.py +176 -0
- pyscaf_core-0.1.0/src/pyscaf_core/cli.py +211 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/__init__.py +82 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/chain.py +163 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/circular_dependency_error.py +2 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/dependency_loader.py +63 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/model.py +37 -0
- pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/tree_walker.py +60 -0
- pyscaf_core-0.1.0/src/pyscaf_core/py.typed +0 -0
- pyscaf_core-0.1.0/src/pyscaf_core/tools/__init__.py +0 -0
- pyscaf_core-0.1.0/src/pyscaf_core/tools/format_toml.py +20 -0
- pyscaf_core-0.1.0/src/pyscaf_core/tools/toml_merge.py +30 -0
- pyscaf_core-0.1.0/tests/actions/__init__.py +0 -0
- pyscaf_core-0.1.0/tests/actions/test_actions.py +131 -0
- pyscaf_core-0.1.0/tests/actions/test_manager.py +202 -0
- pyscaf_core-0.1.0/tests/cli/__init__.py +0 -0
- pyscaf_core-0.1.0/tests/cli/test_cli.py +98 -0
- pyscaf_core-0.1.0/tests/preference_chain/__init__.py +0 -0
- pyscaf_core-0.1.0/tests/preference_chain/test_execution_order.py +153 -0
- pyscaf_core-0.1.0/tests/test_version.py +4 -0
- pyscaf_core-0.1.0/tests/tools/__init__.py +0 -0
- pyscaf_core-0.1.0/tests/tools/test_format_toml.py +22 -0
- pyscaf_core-0.1.0/tests/tools/test_toml_merge.py +76 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
|
|
14
|
+
# Tools cache
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
*.swp
|
|
22
|
+
*.swo
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyscaf-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.12
|
|
5
|
+
Requires-Dist: click>=8.0
|
|
6
|
+
Requires-Dist: pydantic>=2.0
|
|
7
|
+
Requires-Dist: pyyaml>=6.0
|
|
8
|
+
Requires-Dist: questionary>=2.0
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: tomlkit>=0.12
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyscaf-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"pydantic>=2.0",
|
|
7
|
+
"pyyaml>=6.0",
|
|
8
|
+
"tomlkit>=0.12",
|
|
9
|
+
"click>=8.0",
|
|
10
|
+
"questionary>=2.0",
|
|
11
|
+
"rich>=13.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling>=1.26.0"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/pyscaf_core"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"__version__",
|
|
3
|
+
"Action",
|
|
4
|
+
"ActionManager",
|
|
5
|
+
"CLIOption",
|
|
6
|
+
"ChoiceOption",
|
|
7
|
+
"build_cli",
|
|
8
|
+
"cli_option_to_key",
|
|
9
|
+
"make_main",
|
|
10
|
+
]
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from pyscaf_core.actions import Action, ChoiceOption, CLIOption, cli_option_to_key
|
|
14
|
+
from pyscaf_core.actions.manager import ActionManager
|
|
15
|
+
from pyscaf_core.cli import build_cli, make_main
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Action classes for project scaffolding — core engine."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import pkgutil
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pyscaf_core.tools.format_toml import format_toml
|
|
14
|
+
from pyscaf_core.tools.toml_merge import merge_toml_files
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChoiceOption(BaseModel):
|
|
20
|
+
"""Represents a choice option with different display formats for CLI and interactive modes."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
display: str
|
|
24
|
+
value: Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CLIOption(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
type: str = "str"
|
|
30
|
+
help: str | None = None
|
|
31
|
+
default: Any = None
|
|
32
|
+
prompt: str | None = None
|
|
33
|
+
choices: list[ChoiceOption] | None = None
|
|
34
|
+
is_flag: bool | None = None
|
|
35
|
+
multiple: bool | None = None
|
|
36
|
+
required: bool | None = None
|
|
37
|
+
postfill_hook: Callable[[dict[str, str]], dict[str, str]] | None = None
|
|
38
|
+
visible_when: Callable[[dict], bool] | None = None
|
|
39
|
+
|
|
40
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
41
|
+
|
|
42
|
+
def get_choice_keys(self) -> list[str]:
|
|
43
|
+
if self.choices and isinstance(self.choices[0], ChoiceOption):
|
|
44
|
+
return [choice.key for choice in self.choices]
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
def get_choice_displays(self) -> list[str]:
|
|
48
|
+
if self.choices and isinstance(self.choices[0], ChoiceOption):
|
|
49
|
+
return [choice.display for choice in self.choices]
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
def get_choice_values(self) -> list[Any]:
|
|
53
|
+
if self.choices and isinstance(self.choices[0], ChoiceOption):
|
|
54
|
+
return [choice.value for choice in self.choices]
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
def get_choice_by_key(self, key: str) -> Any | None:
|
|
58
|
+
if self.choices and isinstance(self.choices[0], ChoiceOption):
|
|
59
|
+
for choice in self.choices:
|
|
60
|
+
if choice.key == key:
|
|
61
|
+
return choice.value
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def get_choice_by_display(self, display: str) -> Any | None:
|
|
65
|
+
if self.choices and isinstance(self.choices[0], ChoiceOption):
|
|
66
|
+
for choice in self.choices:
|
|
67
|
+
if choice.display == display:
|
|
68
|
+
return choice.value
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def get_default_display(self) -> str | None:
|
|
72
|
+
if self.type == "choice" and self.choices and isinstance(self.default, int):
|
|
73
|
+
if 0 <= self.default < len(self.choices):
|
|
74
|
+
if isinstance(self.choices[0], ChoiceOption):
|
|
75
|
+
return self.choices[self.default].display
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def get_default_value(self) -> Any:
|
|
79
|
+
if self.type == "choice" and self.choices and isinstance(self.default, int):
|
|
80
|
+
if 0 <= self.default < len(self.choices):
|
|
81
|
+
if isinstance(self.choices[0], ChoiceOption):
|
|
82
|
+
return self.choices[self.default].value
|
|
83
|
+
else:
|
|
84
|
+
return self.choices[self.default]
|
|
85
|
+
return self.default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Action:
|
|
89
|
+
"""Abstract base class for all project actions.
|
|
90
|
+
|
|
91
|
+
Actions can:
|
|
92
|
+
1. Generate file/directory skeleton via the skeleton() method
|
|
93
|
+
2. Initialize content/behavior via the init() method
|
|
94
|
+
3. Install dependencies via the install() method
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
depends: set[str] = set()
|
|
98
|
+
run_preferably_after: str | None = None
|
|
99
|
+
cli_options: list[CLIOption] = []
|
|
100
|
+
|
|
101
|
+
def __init_subclass__(cls) -> None:
|
|
102
|
+
if hasattr(cls, "depends") and len(cls.depends) > 1 and not getattr(cls, "run_preferably_after", None):
|
|
103
|
+
raise ValueError(f"Action '{cls.__name__}' has multiple depends but no run_preferably_after")
|
|
104
|
+
|
|
105
|
+
def __init__(self, project_path: str | Path):
|
|
106
|
+
self.project_path = Path(project_path)
|
|
107
|
+
|
|
108
|
+
def skeleton(self, context: dict) -> dict[Path, str | None]:
|
|
109
|
+
"""Define the filesystem skeleton for this action.
|
|
110
|
+
|
|
111
|
+
Returns a dictionary mapping paths to content:
|
|
112
|
+
- None -> directory
|
|
113
|
+
- str -> file with that content
|
|
114
|
+
"""
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
def init(self, context: dict) -> None:
|
|
118
|
+
"""Default: merges config.toml from the action's directory into pyproject.toml."""
|
|
119
|
+
module = importlib.import_module(self.__class__.__module__)
|
|
120
|
+
module_file = module.__file__
|
|
121
|
+
if not module_file:
|
|
122
|
+
raise RuntimeError(f"Module {module} has no __file__ attribute")
|
|
123
|
+
action_dir = Path(module_file).parent
|
|
124
|
+
config_path = action_dir / "config.toml"
|
|
125
|
+
pyproject_path = self.project_path / "pyproject.toml"
|
|
126
|
+
if config_path.exists():
|
|
127
|
+
merge_toml_files(input_path=config_path, output_path=pyproject_path)
|
|
128
|
+
format_toml(pyproject_path)
|
|
129
|
+
logger.info("Merged %s into %s", config_path, pyproject_path)
|
|
130
|
+
|
|
131
|
+
def install(self, context: dict) -> None:
|
|
132
|
+
"""Install dependencies or run post-initialization commands."""
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def create_skeleton(self, context: dict) -> set[Path]:
|
|
136
|
+
"""Create the filesystem skeleton for this action."""
|
|
137
|
+
created_paths: set[Path] = set()
|
|
138
|
+
skeleton = self.skeleton(context)
|
|
139
|
+
|
|
140
|
+
for path, content in skeleton.items():
|
|
141
|
+
full_path = self.project_path / path
|
|
142
|
+
|
|
143
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
if content is None:
|
|
146
|
+
full_path.mkdir(exist_ok=True)
|
|
147
|
+
else:
|
|
148
|
+
if full_path.exists():
|
|
149
|
+
with open(full_path, "a") as f:
|
|
150
|
+
f.write("\n" + content)
|
|
151
|
+
else:
|
|
152
|
+
full_path.write_text(content)
|
|
153
|
+
|
|
154
|
+
created_paths.add(full_path)
|
|
155
|
+
|
|
156
|
+
return created_paths
|
|
157
|
+
|
|
158
|
+
def activate(self, context: dict) -> bool:
|
|
159
|
+
"""Return True if this action should be executed given the current context."""
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cli_option_to_key(cli_option: CLIOption) -> str:
|
|
164
|
+
"""Convert a CLI option name to a context dictionary key."""
|
|
165
|
+
return cli_option.name.lstrip("-").replace("-", "_")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def discover_actions_from_package(package_path: str, package_name: str) -> list[type[Action]]:
|
|
169
|
+
"""Discover Action subclasses by walking a package directory (pkgutil-based).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
package_path: Filesystem path to the package directory
|
|
173
|
+
package_name: Fully qualified Python package name for import
|
|
174
|
+
"""
|
|
175
|
+
actions: list[type[Action]] = []
|
|
176
|
+
for _, module_name, _ in pkgutil.iter_modules([package_path]):
|
|
177
|
+
if module_name in ("base", "manager", "__pycache__"):
|
|
178
|
+
continue
|
|
179
|
+
mod = importlib.import_module(f"{package_name}.{module_name}")
|
|
180
|
+
for attr in dir(mod):
|
|
181
|
+
obj = getattr(mod, attr)
|
|
182
|
+
if isinstance(obj, type) and issubclass(obj, Action) and obj is not Action:
|
|
183
|
+
actions.append(obj)
|
|
184
|
+
return actions
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def discover_actions_from_entry_points(group: str = "pyscaf_core.plugins") -> list[type[Action]]:
|
|
188
|
+
"""Discover Action subclasses registered via importlib.metadata entry points.
|
|
189
|
+
|
|
190
|
+
Each entry point should reference either:
|
|
191
|
+
- A callable that returns a list of Action subclasses
|
|
192
|
+
- A module containing Action subclasses
|
|
193
|
+
"""
|
|
194
|
+
from importlib.metadata import entry_points
|
|
195
|
+
|
|
196
|
+
actions: list[type[Action]] = []
|
|
197
|
+
eps = entry_points().select(group=group)
|
|
198
|
+
for ep in eps:
|
|
199
|
+
obj = ep.load()
|
|
200
|
+
if isinstance(obj, type) and issubclass(obj, Action) and obj is not Action:
|
|
201
|
+
actions.append(obj)
|
|
202
|
+
elif callable(obj):
|
|
203
|
+
result = obj()
|
|
204
|
+
if isinstance(result, list):
|
|
205
|
+
actions.extend(result)
|
|
206
|
+
elif isinstance(result, type) and issubclass(result, Action):
|
|
207
|
+
actions.append(result)
|
|
208
|
+
elif isinstance(obj, type(os)):
|
|
209
|
+
for attr_name in dir(obj):
|
|
210
|
+
attr = getattr(obj, attr_name)
|
|
211
|
+
if isinstance(attr, type) and issubclass(attr, Action) and attr is not Action:
|
|
212
|
+
actions.append(attr)
|
|
213
|
+
return actions
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Project action manager module — core engine."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
|
|
9
|
+
from pyscaf_core.actions import Action, cli_option_to_key
|
|
10
|
+
from pyscaf_core.preference_chain import best_execution_order
|
|
11
|
+
from pyscaf_core.preference_chain.model import Node
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActionManager:
|
|
17
|
+
"""Manager for all project actions."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
project_name: str | Path,
|
|
22
|
+
context: dict[str, Any],
|
|
23
|
+
action_classes: list[type[Action]] | None = None,
|
|
24
|
+
discover: Any = None,
|
|
25
|
+
):
|
|
26
|
+
self.project_path = Path.cwd() / project_name
|
|
27
|
+
logger.info("Project path: %s", self.project_path)
|
|
28
|
+
self.context = context
|
|
29
|
+
self.actions: list[Action] = []
|
|
30
|
+
|
|
31
|
+
if action_classes is not None:
|
|
32
|
+
self._action_classes = action_classes
|
|
33
|
+
elif discover is not None:
|
|
34
|
+
self._action_classes = discover()
|
|
35
|
+
else:
|
|
36
|
+
from pyscaf_core.actions import discover_actions_from_entry_points
|
|
37
|
+
|
|
38
|
+
self._action_classes = discover_actions_from_entry_points()
|
|
39
|
+
|
|
40
|
+
self._determine_actions()
|
|
41
|
+
|
|
42
|
+
def _determine_actions(self) -> None:
|
|
43
|
+
"""Determine which actions to include based on configuration using the preference chain logic."""
|
|
44
|
+
nodes: list[Node] = []
|
|
45
|
+
action_class_by_id: dict[str, type[Action]] = {}
|
|
46
|
+
|
|
47
|
+
for action_cls in self._action_classes:
|
|
48
|
+
action_id = action_cls.__name__.replace("Action", "").lower()
|
|
49
|
+
depends = getattr(action_cls, "depends", set())
|
|
50
|
+
after = getattr(action_cls, "run_preferably_after", None)
|
|
51
|
+
|
|
52
|
+
node = Node(id=action_id, depends=depends, after=after)
|
|
53
|
+
nodes.append(node)
|
|
54
|
+
action_class_by_id[action_id] = action_cls
|
|
55
|
+
|
|
56
|
+
logger.debug("Created %d action nodes", len(nodes))
|
|
57
|
+
|
|
58
|
+
if not nodes:
|
|
59
|
+
self.actions = []
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
order = best_execution_order(nodes)
|
|
63
|
+
|
|
64
|
+
logger.debug("Final action execution order: %s", order)
|
|
65
|
+
|
|
66
|
+
self.actions = [
|
|
67
|
+
action_class_by_id[action_id](self.project_path)
|
|
68
|
+
for action_id in order
|
|
69
|
+
if action_id in action_class_by_id
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def run_postfill_hooks(self, context: dict) -> dict:
|
|
73
|
+
"""Run all postfill hooks for actions in optimal order."""
|
|
74
|
+
for action in self.actions:
|
|
75
|
+
if action.activate(context):
|
|
76
|
+
for opt in action.cli_options:
|
|
77
|
+
context_key = cli_option_to_key(opt)
|
|
78
|
+
if context.get(context_key) is None:
|
|
79
|
+
continue
|
|
80
|
+
if opt.visible_when and not opt.visible_when(context):
|
|
81
|
+
continue
|
|
82
|
+
if opt.postfill_hook:
|
|
83
|
+
context = opt.postfill_hook(context)
|
|
84
|
+
return context
|
|
85
|
+
|
|
86
|
+
def ask_interactive_questions(self, context: dict) -> dict:
|
|
87
|
+
"""Ask all relevant questions for actions in optimal order, updating the context."""
|
|
88
|
+
for action in self.actions:
|
|
89
|
+
if action.activate(context):
|
|
90
|
+
for opt in action.cli_options:
|
|
91
|
+
context_key = cli_option_to_key(opt)
|
|
92
|
+
if context.get(context_key) is not None:
|
|
93
|
+
continue
|
|
94
|
+
if opt.visible_when and not opt.visible_when(context):
|
|
95
|
+
continue
|
|
96
|
+
prompt = opt.prompt or context_key
|
|
97
|
+
if opt.type == "choice":
|
|
98
|
+
default = opt.get_default_value()
|
|
99
|
+
else:
|
|
100
|
+
default = opt.default() if callable(opt.default) else opt.default
|
|
101
|
+
if opt.type == "bool":
|
|
102
|
+
answer = questionary.confirm(prompt, default=bool(default)).ask()
|
|
103
|
+
elif opt.type == "int":
|
|
104
|
+
answer = questionary.text(
|
|
105
|
+
prompt, default=str(default) if default is not None else ""
|
|
106
|
+
).ask()
|
|
107
|
+
answer = int(answer) if answer is not None and answer != "" else None
|
|
108
|
+
elif opt.type == "choice" and opt.choices:
|
|
109
|
+
choices = opt.get_choice_displays()
|
|
110
|
+
default_display = opt.get_default_display()
|
|
111
|
+
|
|
112
|
+
if opt.multiple:
|
|
113
|
+
answer = questionary.checkbox(
|
|
114
|
+
prompt, choices=choices, default=default_display
|
|
115
|
+
).ask()
|
|
116
|
+
else:
|
|
117
|
+
answer = questionary.select(
|
|
118
|
+
prompt, choices=choices, default=default_display
|
|
119
|
+
).ask()
|
|
120
|
+
|
|
121
|
+
if answer:
|
|
122
|
+
if opt.multiple:
|
|
123
|
+
converted_answer = []
|
|
124
|
+
for display in answer:
|
|
125
|
+
for choice in opt.choices:
|
|
126
|
+
if choice.display == display:
|
|
127
|
+
converted_answer.append(choice.key)
|
|
128
|
+
break
|
|
129
|
+
answer = converted_answer
|
|
130
|
+
else:
|
|
131
|
+
for choice in opt.choices:
|
|
132
|
+
if choice.display == answer:
|
|
133
|
+
answer = choice.key
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
answer = questionary.text(
|
|
138
|
+
prompt, default=default if default is not None else ""
|
|
139
|
+
).ask()
|
|
140
|
+
context[context_key] = answer
|
|
141
|
+
if opt.postfill_hook:
|
|
142
|
+
context = opt.postfill_hook(context)
|
|
143
|
+
return context
|
|
144
|
+
|
|
145
|
+
def create_project(self) -> None:
|
|
146
|
+
"""Create the project structure and initialize it."""
|
|
147
|
+
self.project_path.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
logger.info("Creating project at: %s", self.project_path)
|
|
150
|
+
|
|
151
|
+
for action in self.actions:
|
|
152
|
+
if not action.activate(self.context):
|
|
153
|
+
logger.info("Skipping %s", action.__class__.__name__)
|
|
154
|
+
continue
|
|
155
|
+
action_name = action.__class__.__name__
|
|
156
|
+
logger.info("Creating skeleton for: %s", action_name)
|
|
157
|
+
action.create_skeleton(self.context)
|
|
158
|
+
|
|
159
|
+
for action in self.actions:
|
|
160
|
+
if not action.activate(self.context):
|
|
161
|
+
continue
|
|
162
|
+
action_name = action.__class__.__name__
|
|
163
|
+
logger.info("Initializing: %s", action_name)
|
|
164
|
+
action.init(self.context)
|
|
165
|
+
|
|
166
|
+
if not self.context.get("no_install", False):
|
|
167
|
+
for action in self.actions:
|
|
168
|
+
if not action.activate(self.context):
|
|
169
|
+
continue
|
|
170
|
+
action_name = action.__class__.__name__
|
|
171
|
+
logger.info("Installing dependencies for: %s", action_name)
|
|
172
|
+
action.install(self.context)
|
|
173
|
+
else:
|
|
174
|
+
logger.info("Skipping installation.")
|
|
175
|
+
|
|
176
|
+
logger.info("Project creation complete!")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""CLI framework for pyscaf-core based applications."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from pyscaf_core.actions import (
|
|
10
|
+
Action,
|
|
11
|
+
CLIOption,
|
|
12
|
+
cli_option_to_key,
|
|
13
|
+
discover_actions_from_entry_points,
|
|
14
|
+
)
|
|
15
|
+
from pyscaf_core.actions.manager import ActionManager
|
|
16
|
+
from pyscaf_core.preference_chain import best_execution_order
|
|
17
|
+
from pyscaf_core.preference_chain.model import Node
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_option_default(opt: CLIOption) -> Any:
|
|
21
|
+
"""Compute the default value for a CLI option."""
|
|
22
|
+
if opt.type == "choice":
|
|
23
|
+
default_index = opt.default
|
|
24
|
+
if default_index is not None and opt.choices:
|
|
25
|
+
return opt.choices[default_index].key
|
|
26
|
+
return None
|
|
27
|
+
return opt.default() if callable(opt.default) else opt.default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def collect_cli_options(
|
|
31
|
+
action_classes: list[type[Action]] | None = None,
|
|
32
|
+
discover: Any = None,
|
|
33
|
+
) -> list[CLIOption]:
|
|
34
|
+
"""Collect CLI options from all actions in dependency order.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
action_classes: Explicit list of action classes
|
|
38
|
+
discover: Callable returning action classes
|
|
39
|
+
"""
|
|
40
|
+
if action_classes is not None:
|
|
41
|
+
classes = action_classes
|
|
42
|
+
elif discover is not None:
|
|
43
|
+
classes = discover()
|
|
44
|
+
else:
|
|
45
|
+
classes = discover_actions_from_entry_points()
|
|
46
|
+
|
|
47
|
+
deps: list[Node] = []
|
|
48
|
+
action_class_by_id: dict[str, type[Action]] = {}
|
|
49
|
+
for action_cls in classes:
|
|
50
|
+
action_id = action_cls.__name__.replace("Action", "").lower()
|
|
51
|
+
depends = getattr(action_cls, "depends", set())
|
|
52
|
+
after = getattr(action_cls, "run_preferably_after", None)
|
|
53
|
+
|
|
54
|
+
if depends and after is None:
|
|
55
|
+
after = next(iter(depends))
|
|
56
|
+
|
|
57
|
+
node = Node(id=action_id, depends=depends, after=after)
|
|
58
|
+
deps.append(node)
|
|
59
|
+
action_class_by_id[action_id] = action_cls
|
|
60
|
+
|
|
61
|
+
if not deps:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
order = best_execution_order(deps)
|
|
65
|
+
cli_options: list[CLIOption] = []
|
|
66
|
+
for action_id in order:
|
|
67
|
+
action_cls = action_class_by_id[action_id]
|
|
68
|
+
cli_options.extend(getattr(action_cls, "cli_options", []))
|
|
69
|
+
return cli_options
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fill_default_context(
|
|
73
|
+
context: dict,
|
|
74
|
+
action_classes: list[type[Action]] | None = None,
|
|
75
|
+
discover: Any = None,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Fill the context with default values from all actions."""
|
|
78
|
+
if action_classes is not None:
|
|
79
|
+
classes = action_classes
|
|
80
|
+
elif discover is not None:
|
|
81
|
+
classes = discover()
|
|
82
|
+
else:
|
|
83
|
+
classes = discover_actions_from_entry_points()
|
|
84
|
+
|
|
85
|
+
for action_cls in classes:
|
|
86
|
+
if hasattr(action_cls, "cli_options"):
|
|
87
|
+
for opt in action_cls.cli_options:
|
|
88
|
+
name = cli_option_to_key(opt)
|
|
89
|
+
if name not in context or context[name] is None:
|
|
90
|
+
if opt.visible_when and not opt.visible_when(context):
|
|
91
|
+
continue
|
|
92
|
+
context[name] = set_option_default(opt)
|
|
93
|
+
|
|
94
|
+
return context
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def add_dynamic_options(
|
|
98
|
+
command: click.Command,
|
|
99
|
+
action_classes: list[type[Action]] | None = None,
|
|
100
|
+
discover: Any = None,
|
|
101
|
+
) -> click.Command:
|
|
102
|
+
"""Add dynamic Click options from all actions to a command."""
|
|
103
|
+
cli_options = collect_cli_options(action_classes=action_classes, discover=discover)
|
|
104
|
+
for opt in reversed(cli_options):
|
|
105
|
+
param_decls = [opt.name]
|
|
106
|
+
click_opts: dict[str, Any] = {}
|
|
107
|
+
if opt.type == "int":
|
|
108
|
+
click_opts["type"] = int
|
|
109
|
+
elif opt.type == "choice" and opt.choices:
|
|
110
|
+
choice_keys = opt.get_choice_keys()
|
|
111
|
+
click_opts["type"] = click.Choice(choice_keys, case_sensitive=False)
|
|
112
|
+
if opt.multiple:
|
|
113
|
+
click_opts["multiple"] = True
|
|
114
|
+
elif opt.type == "str":
|
|
115
|
+
click_opts["type"] = str
|
|
116
|
+
elif opt.type == "bool":
|
|
117
|
+
click_opts["type"] = click.BOOL
|
|
118
|
+
click_opts["default"] = None
|
|
119
|
+
base_name = opt.name.lstrip("-")
|
|
120
|
+
param_decls[0] = f"--{base_name}/--no-{base_name}"
|
|
121
|
+
|
|
122
|
+
if opt.help:
|
|
123
|
+
click_opts["help"] = opt.help
|
|
124
|
+
if opt.required:
|
|
125
|
+
click_opts["required"] = True
|
|
126
|
+
command = click.option(*param_decls, **click_opts)(command)
|
|
127
|
+
return command
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_cli(
|
|
131
|
+
app_name: str,
|
|
132
|
+
version: str,
|
|
133
|
+
action_classes: list[type[Action]] | None = None,
|
|
134
|
+
discover: Any = None,
|
|
135
|
+
) -> click.Group:
|
|
136
|
+
"""Build a complete Click CLI group for a pyscaf-core based application.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
app_name: Name displayed in CLI help
|
|
140
|
+
version: Version string for --version
|
|
141
|
+
action_classes: Explicit list of action classes (mutually exclusive with discover)
|
|
142
|
+
discover: Callable returning action classes (mutually exclusive with action_classes)
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A click.Group with an 'init' command wired to ActionManager
|
|
146
|
+
"""
|
|
147
|
+
console = Console()
|
|
148
|
+
|
|
149
|
+
def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
|
|
150
|
+
if not value or ctx.resilient_parsing:
|
|
151
|
+
return
|
|
152
|
+
console.print(f"{app_name} version {version}")
|
|
153
|
+
ctx.exit()
|
|
154
|
+
|
|
155
|
+
@click.group()
|
|
156
|
+
@click.version_option(
|
|
157
|
+
version,
|
|
158
|
+
"--version",
|
|
159
|
+
"-V",
|
|
160
|
+
callback=print_version,
|
|
161
|
+
help="Show the version and exit.",
|
|
162
|
+
)
|
|
163
|
+
def cli() -> None:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
cli.help = f"{app_name} — project generator with plugin-based action system."
|
|
167
|
+
|
|
168
|
+
@click.command()
|
|
169
|
+
@click.argument("project_name")
|
|
170
|
+
@click.option(
|
|
171
|
+
"--interactive",
|
|
172
|
+
is_flag=True,
|
|
173
|
+
help="Enable interactive mode (asks questions to the user).",
|
|
174
|
+
)
|
|
175
|
+
@click.option("--no-install", is_flag=True, help="Skip installation step.")
|
|
176
|
+
def init(project_name: str, interactive: bool, no_install: bool, **kwargs: Any) -> None:
|
|
177
|
+
"""Initialize a new customized project structure."""
|
|
178
|
+
context = dict(kwargs)
|
|
179
|
+
context["project_name"] = project_name
|
|
180
|
+
context["interactive"] = interactive
|
|
181
|
+
context["no_install"] = no_install
|
|
182
|
+
|
|
183
|
+
if not interactive:
|
|
184
|
+
context = fill_default_context(context, action_classes=action_classes, discover=discover)
|
|
185
|
+
|
|
186
|
+
manager = ActionManager(project_name, context, action_classes=action_classes, discover=discover)
|
|
187
|
+
context = manager.run_postfill_hooks(context)
|
|
188
|
+
|
|
189
|
+
if interactive:
|
|
190
|
+
context = manager.ask_interactive_questions(context)
|
|
191
|
+
manager.context = context
|
|
192
|
+
manager.create_project()
|
|
193
|
+
|
|
194
|
+
init = add_dynamic_options(init, action_classes=action_classes, discover=discover)
|
|
195
|
+
cli.add_command(init, "init")
|
|
196
|
+
|
|
197
|
+
return cli
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def make_main(cli_group: click.Group) -> Any:
|
|
201
|
+
"""Create a main() entry point from a CLI group."""
|
|
202
|
+
|
|
203
|
+
def main() -> None:
|
|
204
|
+
try:
|
|
205
|
+
cli_group()
|
|
206
|
+
except Exception as e:
|
|
207
|
+
console = Console()
|
|
208
|
+
console.print(f"[bold red]Error:[/bold red] {e!s}")
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
|
|
211
|
+
return main
|