fx-tool 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.
fx/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ FX: project structuring and management tooling built on the
3
+ registers CLI + DB framework.
4
+ """
5
+
6
+ from fx.commands import FX_VERSION, get_registry, main, run
7
+
8
+ __version__ = FX_VERSION
9
+
10
+ __all__ = ["run", "main", "get_registry", "__version__"]
fx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from fx.commands import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
fx/commands.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Public FX command surface.
3
+
4
+ This module owns the FX command registry and runtime entrypoints. Command
5
+ implementations are split into plugin modules under ``fx.plugins``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from importlib.metadata import PackageNotFoundError, version as resolve_distribution_version
12
+ from threading import Lock
13
+ from typing import Any
14
+
15
+ from registers.cli.plugins import load_plugins
16
+ from registers.cli.registry import CommandRegistry
17
+
18
+ try:
19
+ from registers.cli.registry import MISSING # type: ignore[attr-defined]
20
+ except ImportError:
21
+ # Older/incompatible runtime builds may not export MISSING.
22
+ MISSING = object()
23
+
24
+ _registry = CommandRegistry()
25
+ _FX_DISTRIBUTION_NAME = "fx-tool"
26
+ _PLUGINS_PACKAGE = "fx.plugins"
27
+ _REQUIRED_COMMANDS = frozenset(
28
+ {
29
+ "init",
30
+ "status",
31
+ "module",
32
+ "plugin",
33
+ "version",
34
+ "cron",
35
+ "run",
36
+ "install",
37
+ "update",
38
+ "pull",
39
+ "health",
40
+ "history",
41
+ }
42
+ )
43
+ _plugins_lock = Lock()
44
+ _plugins_loaded = False
45
+ _plugin_load_error: Exception | None = None
46
+
47
+
48
+ def _resolve_fx_version() -> str:
49
+ try:
50
+ return resolve_distribution_version(_FX_DISTRIBUTION_NAME)
51
+ except PackageNotFoundError:
52
+ return "dev"
53
+
54
+
55
+ FX_VERSION = _resolve_fx_version()
56
+
57
+
58
+ def argument(
59
+ name: str,
60
+ *,
61
+ type: Any = str,
62
+ help: str = "",
63
+ default: Any = MISSING,
64
+ ):
65
+ def decorator(fn):
66
+ if not hasattr(_registry, "stage_argument"):
67
+ raise RuntimeError(
68
+ "Incompatible 'registers' runtime: CommandRegistry.stage_argument is required."
69
+ )
70
+ _registry.stage_argument(fn, name, arg_type=type, help_text=help, default=default)
71
+ return fn
72
+
73
+ return decorator
74
+
75
+
76
+ def option(flag: str, *, help: str = ""):
77
+ def decorator(fn):
78
+ if not hasattr(_registry, "stage_option"):
79
+ raise RuntimeError(
80
+ "Incompatible 'registers' runtime: CommandRegistry.stage_option is required."
81
+ )
82
+ _registry.stage_option(fn, flag, help_text=help)
83
+ return fn
84
+
85
+ return decorator
86
+
87
+
88
+ def register(name: str | None = None, *, description: str = "", help: str = ""):
89
+ def decorator(fn):
90
+ if not hasattr(_registry, "finalize_command"):
91
+ raise RuntimeError(
92
+ "Incompatible 'registers' runtime: CommandRegistry.finalize_command is required."
93
+ )
94
+ _registry.finalize_command(fn, name=name, description=description, help_text=help)
95
+ return fn
96
+
97
+ return decorator
98
+
99
+
100
+ def ensure_plugins_loaded() -> None:
101
+ global _plugins_loaded, _plugin_load_error
102
+
103
+ if _plugins_loaded:
104
+ return
105
+ if _plugin_load_error is not None:
106
+ raise RuntimeError("FX command plugins failed to load.") from _plugin_load_error
107
+
108
+ with _plugins_lock:
109
+ if _plugins_loaded:
110
+ return
111
+ if _plugin_load_error is not None:
112
+ raise RuntimeError("FX command plugins failed to load.") from _plugin_load_error
113
+
114
+ try:
115
+ load_plugins(_PLUGINS_PACKAGE, _registry)
116
+ missing = sorted(name for name in _REQUIRED_COMMANDS if not _registry.has(name))
117
+ if missing:
118
+ raise RuntimeError(
119
+ "FX command plugins loaded incompletely. Missing commands: "
120
+ + ", ".join(missing)
121
+ )
122
+ _plugins_loaded = True
123
+ except Exception as exc:
124
+ _plugin_load_error = exc
125
+ raise
126
+
127
+
128
+ def run(
129
+ argv: Sequence[str] | None = None,
130
+ *,
131
+ print_result: bool = True,
132
+ shell_prompt: str = "fx > ",
133
+ shell_input_fn=None,
134
+ shell_banner: bool = True,
135
+ shell_banner_text: str | None = None,
136
+ shell_title: str = "Functionals FX",
137
+ shell_description: str = "Manage Functionals projects, modules, and plugin structures.",
138
+ shell_colors: bool | None = None,
139
+ shell_usage: bool = True,
140
+ ) -> Any:
141
+ ensure_plugins_loaded()
142
+ return _registry.run(
143
+ argv,
144
+ print_result=print_result,
145
+ shell_prompt=shell_prompt,
146
+ shell_input_fn=shell_input_fn,
147
+ shell_banner=shell_banner,
148
+ shell_banner_text=shell_banner_text,
149
+ shell_title=shell_title,
150
+ shell_description=shell_description,
151
+ shell_version=f"Version: {FX_VERSION}",
152
+ shell_colors=shell_colors,
153
+ shell_usage=shell_usage,
154
+ )
155
+
156
+
157
+ def get_registry() -> CommandRegistry:
158
+ ensure_plugins_loaded()
159
+ return _registry
160
+
161
+
162
+ def main(argv: Sequence[str] | None = None) -> int:
163
+ run(argv)
164
+ return 0
165
+
166
+
167
+ __all__ = [
168
+ "FX_VERSION",
169
+ "argument",
170
+ "option",
171
+ "register",
172
+ "ensure_plugins_loaded",
173
+ "run",
174
+ "get_registry",
175
+ "main",
176
+ ]
177
+
fx/plugin_sync.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ Plugin pull/sync helpers for ``fx``.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ import shutil
10
+
11
+ from fx.structure import normalize_identifier
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SyncReport:
16
+ created: tuple[str, ...] = ()
17
+ updated: tuple[str, ...] = ()
18
+ skipped: tuple[str, ...] = ()
19
+
20
+ @property
21
+ def synced_aliases(self) -> tuple[str, ...]:
22
+ return tuple(dict.fromkeys([*self.created, *self.updated]))
23
+
24
+
25
+ def sync_plugins_from_checkout(
26
+ *,
27
+ checkout_root: Path,
28
+ subdir: str,
29
+ target_plugins_dir: Path,
30
+ force: bool = False,
31
+ ) -> SyncReport:
32
+ source_dir = (checkout_root / subdir).resolve()
33
+ if not source_dir.exists() or not source_dir.is_dir():
34
+ raise FileNotFoundError(
35
+ f"Plugin source directory '{subdir}' not found in checkout."
36
+ )
37
+
38
+ created: list[str] = []
39
+ updated: list[str] = []
40
+ skipped: list[str] = []
41
+
42
+ target_plugins_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ for candidate in sorted(source_dir.iterdir()):
45
+ if not candidate.is_dir():
46
+ continue
47
+ if not (candidate / "__init__.py").exists():
48
+ continue
49
+
50
+ alias = normalize_identifier(candidate.name)
51
+ target = target_plugins_dir / alias
52
+ existed = target.exists()
53
+
54
+ if existed and not force:
55
+ skipped.append(alias)
56
+ continue
57
+
58
+ if existed and force:
59
+ shutil.rmtree(target)
60
+ updated.append(alias)
61
+ else:
62
+ created.append(alias)
63
+
64
+ shutil.copytree(candidate, target)
65
+
66
+ return SyncReport(
67
+ created=tuple(created),
68
+ updated=tuple(updated),
69
+ skipped=tuple(skipped),
70
+ )
71
+
72
+
fx/plugins/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ FX command plugins.
3
+
4
+ Modules in this package are auto-imported by ``fx.commands`` to register
5
+ commands against the shared FX command registry.
6
+ """
7
+
fx/plugins/core.py ADDED
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from fx.commands import FX_VERSION, argument, option, register
6
+ from fx.state import (
7
+ module_registry,
8
+ plugin_registry,
9
+ project_registry,
10
+ record_operation,
11
+ resolve_root,
12
+ utc_now,
13
+ )
14
+ from fx.structure import (
15
+ create_module_layout,
16
+ create_plugin_link,
17
+ discover_local_plugins,
18
+ discover_project_package,
19
+ init_project_layout,
20
+ normalize_identifier,
21
+ resolve_plugin_import_base,
22
+ resolve_plugin_layout,
23
+ )
24
+ from fx.support import render_structure_result
25
+
26
+
27
+ @register(name="init", description="Initialize a cli or db project structure and fx control database")
28
+ @option("--init")
29
+ @argument("project_type", type=str, default="cli", help="Project type: cli or db")
30
+ @argument("project_name", type=str, default="", help="Project display name; defaults to root folder name")
31
+ @argument("root", type=str, default="", help="Project root path; defaults to <project_name> when provided")
32
+ @argument("force", type=bool, default=False, help="Overwrite structure files if they already exist")
33
+ def init(
34
+ project_type: str = "cli",
35
+ project_name: str = "",
36
+ root: str = "",
37
+ force: bool = False,
38
+ ) -> str:
39
+ normalized_type = project_type.strip().lower() or "cli"
40
+ if normalized_type not in {"cli", "db"}:
41
+ if root.strip():
42
+ raise ValueError("project_type must be either 'cli' or 'db'.")
43
+ # Backward-compatible shapes:
44
+ # fx init <project_name>
45
+ # fx init <project_name> <root>
46
+ legacy_project_name = project_type
47
+ legacy_root = project_name
48
+ project_name = legacy_project_name
49
+ root = legacy_root
50
+ normalized_type = "cli"
51
+
52
+ name = project_name.strip()
53
+ root_input = root.strip()
54
+ if not root_input:
55
+ if name in {".", "./"}:
56
+ root_input = "."
57
+ name = ""
58
+ else:
59
+ root_input = name or "."
60
+ root_path = resolve_root(root_input)
61
+ root_path.mkdir(parents=True, exist_ok=True)
62
+ if not name or name in {".", "./"}:
63
+ name = root_path.name
64
+
65
+ structure = init_project_layout(
66
+ root=root_path,
67
+ project_name=name,
68
+ project_type=normalized_type,
69
+ force=force,
70
+ )
71
+
72
+ projects = project_registry(root_path)
73
+ existing = projects.get(root_path=str(root_path))
74
+ created_at = existing.created_at if existing is not None else utc_now()
75
+ projects.upsert(
76
+ name=name,
77
+ root_path=str(root_path),
78
+ project_type=normalized_type,
79
+ created_at=created_at,
80
+ updated_at=utc_now(),
81
+ )
82
+ record_operation(
83
+ root=root_path,
84
+ command="init",
85
+ arguments={
86
+ "project_type": normalized_type,
87
+ "project_name": name,
88
+ "root": str(root_path),
89
+ "force": force,
90
+ },
91
+ status="success",
92
+ message=f"Initialized {normalized_type} project '{name}'.",
93
+ )
94
+ return render_structure_result(
95
+ title=f"Initialized {normalized_type} project '{name}' at {root_path}",
96
+ root=root_path,
97
+ result=structure,
98
+ )
99
+
100
+
101
+ @register(name="status", description="Show current project structure and registry status")
102
+ @option("--status")
103
+ @argument("root", type=str, default=".", help="Project root path")
104
+ def status(root: str = ".") -> str:
105
+ root_path = resolve_root(root)
106
+ project = project_registry(root_path).get(root_path=str(root_path))
107
+ modules = module_registry(root_path).filter(project_root=str(root_path), order_by="module_name")
108
+ plugins = plugin_registry(root_path).filter(project_root=str(root_path), order_by="alias")
109
+ local_plugins = discover_local_plugins(root_path)
110
+ package_name = discover_project_package(root_path)
111
+ plugin_layout = resolve_plugin_layout(root_path)
112
+ src_root = root_path / "src"
113
+ todo_file = src_root / package_name / "todo.py" if package_name else None
114
+ api_file = src_root / package_name / "api.py" if package_name else None
115
+ models_file = src_root / package_name / "models.py" if package_name else None
116
+
117
+ registered_aliases = [plugin.alias for plugin in plugins]
118
+ missing_on_disk = sorted(set(registered_aliases) - set(local_plugins))
119
+ untracked_on_disk = sorted(set(local_plugins) - set(registered_aliases))
120
+
121
+ lines = [
122
+ f"Root: {root_path}",
123
+ f"Project record: {'present' if project else 'missing'}",
124
+ f"Project type: {getattr(project, 'project_type', 'unknown') if project else 'unknown'}",
125
+ f"pyproject.toml: {'present' if (root_path / 'pyproject.toml').exists() else 'missing'}",
126
+ f"src package: {package_name or 'missing'}",
127
+ f"legacy app.py: {'present' if (root_path / 'app.py').exists() else 'missing'}",
128
+ f"todo.py: {'present' if (todo_file and todo_file.exists()) else 'missing'}",
129
+ f"api.py: {'present' if (api_file and api_file.exists()) else 'missing'}",
130
+ f"legacy models.py: {'present' if (root_path / 'models.py').exists() else 'missing'}",
131
+ f"package models.py: {'present' if (models_file and models_file.exists()) else 'missing'}",
132
+ f"plugins package: {'present' if (plugin_layout.directory / '__init__.py').exists() else 'missing'}",
133
+ f"plugins import base: {plugin_layout.import_base}",
134
+ f"Registered modules: {len(modules)}",
135
+ f"Registered plugin links: {len(plugins)}",
136
+ f"Local plugin packages: {len(local_plugins)}",
137
+ ]
138
+
139
+ if missing_on_disk:
140
+ lines.append(f"Missing on disk: {', '.join(missing_on_disk)}")
141
+ if untracked_on_disk:
142
+ lines.append(f"Untracked on disk: {', '.join(untracked_on_disk)}")
143
+ if not missing_on_disk and not untracked_on_disk:
144
+ lines.append("Registry and filesystem plugin lists are aligned.")
145
+
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _module_add(
150
+ module_type: Literal["cli", "db"],
151
+ module_name: str,
152
+ root: str = ".",
153
+ force: bool = False,
154
+ ) -> str:
155
+ root_path = resolve_root(root)
156
+ normalized = normalize_identifier(module_name)
157
+ import_base = resolve_plugin_import_base(root_path)
158
+
159
+ structure = create_module_layout(
160
+ root=root_path,
161
+ module_type=module_type,
162
+ module_name=normalized,
163
+ force=force,
164
+ )
165
+
166
+ modules = module_registry(root_path)
167
+ package_path = f"{import_base}.{normalized}"
168
+ existing = modules.get(package_path=package_path)
169
+ created_at = existing.created_at if existing is not None else utc_now()
170
+ modules.upsert(
171
+ project_root=str(root_path),
172
+ module_type=module_type,
173
+ module_name=normalized,
174
+ package_path=package_path,
175
+ entry_file=str(structure.entry_file or ""),
176
+ created_at=created_at,
177
+ updated_at=utc_now(),
178
+ )
179
+
180
+ plugins = plugin_registry(root_path)
181
+ existing_plugin = plugins.get(alias=normalized)
182
+ plugin_created_at = existing_plugin.created_at if existing_plugin is not None else utc_now()
183
+ link_file = str(structure.entry_file.parent / "__init__.py") if structure.entry_file is not None else ""
184
+ plugins.upsert(
185
+ project_root=str(root_path),
186
+ alias=normalized,
187
+ package_path=package_path,
188
+ enabled=True,
189
+ link_file=link_file,
190
+ created_at=plugin_created_at,
191
+ updated_at=utc_now(),
192
+ )
193
+
194
+ record_operation(
195
+ root=root_path,
196
+ command="module-add",
197
+ arguments={
198
+ "module_type": module_type,
199
+ "module_name": normalized,
200
+ "root": str(root_path),
201
+ "force": force,
202
+ },
203
+ status="success",
204
+ message=f"Structured {module_type} module '{normalized}'.",
205
+ )
206
+ return render_structure_result(
207
+ title=f"Structured {module_type} module '{normalized}'",
208
+ root=root_path,
209
+ result=structure,
210
+ )
211
+
212
+
213
+ def _module_list(root: str = ".") -> str:
214
+ root_path = resolve_root(root)
215
+ modules = module_registry(root_path).filter(project_root=str(root_path), order_by="module_name")
216
+ if not modules:
217
+ return "No modules registered for this project."
218
+
219
+ lines = ["Registered modules:"]
220
+ for entry in modules:
221
+ lines.append(f" {entry.module_name} ({entry.module_type}) {entry.package_path}")
222
+ return "\n".join(lines)
223
+
224
+
225
+ def _plugin_make(
226
+ package_path: str,
227
+ alias: str = "",
228
+ root: str = ".",
229
+ force: bool = False,
230
+ ) -> str:
231
+ root_path = resolve_root(root)
232
+ resolved_alias = normalize_identifier(alias or package_path.split(".")[-1])
233
+
234
+ structure = create_plugin_link(
235
+ root=root_path,
236
+ package_path=package_path,
237
+ alias=resolved_alias,
238
+ force=force,
239
+ )
240
+
241
+ plugins = plugin_registry(root_path)
242
+ existing = plugins.get(alias=resolved_alias)
243
+ created_at = existing.created_at if existing is not None else utc_now()
244
+ plugins.upsert(
245
+ project_root=str(root_path),
246
+ alias=resolved_alias,
247
+ package_path=package_path,
248
+ enabled=True,
249
+ link_file=str(structure.entry_file or ""),
250
+ created_at=created_at,
251
+ updated_at=utc_now(),
252
+ )
253
+
254
+ record_operation(
255
+ root=root_path,
256
+ command="plugin-link",
257
+ arguments={
258
+ "package_path": package_path,
259
+ "alias": resolved_alias,
260
+ "root": str(root_path),
261
+ "force": force,
262
+ },
263
+ status="success",
264
+ message=f"Linked plugin '{resolved_alias}' to {package_path}.",
265
+ )
266
+ return render_structure_result(
267
+ title=f"Linked plugin '{resolved_alias}' -> {package_path}",
268
+ root=root_path,
269
+ result=structure,
270
+ )
271
+
272
+
273
+ def _plugin_list(root: str = ".") -> str:
274
+ root_path = resolve_root(root)
275
+ plugins = plugin_registry(root_path).filter(project_root=str(root_path), order_by="alias")
276
+ if not plugins:
277
+ return "No plugins linked for this project."
278
+
279
+ lines = ["Linked plugins:"]
280
+ for entry in plugins:
281
+ marker = "enabled" if entry.enabled else "disabled"
282
+ lines.append(f" {entry.alias} -> {entry.package_path} ({marker})")
283
+ return "\n".join(lines)
284
+
285
+
286
+ @register(name="module", description="Manage project modules (add, list)")
287
+ @option("--module")
288
+ @argument("action", type=str, help="Action: add or list")
289
+ @argument("module_type", type=str, default="", help="For add: module type (cli or db); for list: optional root path")
290
+ @argument("module_name", type=str, default="", help="For add: module identifier")
291
+ @argument("root", type=str, default=".", help="Project root path")
292
+ @argument("force", type=bool, default=False, help="For add: overwrite files if they already exist")
293
+ def module_manage(
294
+ action: str,
295
+ module_type: str = "",
296
+ module_name: str = "",
297
+ root: str = ".",
298
+ force: bool = False,
299
+ ) -> str:
300
+ normalized_action = action.strip().lower()
301
+ if normalized_action == "add":
302
+ normalized_type = module_type.strip().lower()
303
+ if normalized_type not in {"cli", "db"}:
304
+ raise ValueError("module add requires module_type to be 'cli' or 'db'.")
305
+ if not module_name.strip():
306
+ raise ValueError("module add requires module_name.")
307
+ module_type_value: Literal["cli", "db"] = "cli" if normalized_type == "cli" else "db"
308
+ return _module_add(
309
+ module_type=module_type_value,
310
+ module_name=module_name,
311
+ root=root,
312
+ force=force,
313
+ )
314
+
315
+ if normalized_action == "list":
316
+ root_arg = root
317
+ module_type_arg = module_type.strip()
318
+ if root == "." and not module_name.strip() and module_type_arg and module_type_arg not in {"cli", "db"}:
319
+ root_arg = module_type_arg
320
+ return _module_list(root=root_arg)
321
+
322
+ raise ValueError("module action must be one of: add, list.")
323
+
324
+
325
+ @register(name="plugin", description="Manage plugin links (make, list)")
326
+ @option("--plugin")
327
+ @argument("action", type=str, help="Action: make or list")
328
+ @argument("package_path", type=str, default="", help="For make: importable package path; for list: optional root path")
329
+ @argument("alias", type=str, default="", help="For make: local alias under plugins/")
330
+ @argument("root", type=str, default=".", help="Project root path")
331
+ @argument("force", type=bool, default=False, help="For make: overwrite alias shim files if they already exist")
332
+ def plugin_manage(
333
+ action: str,
334
+ package_path: str = "",
335
+ alias: str = "",
336
+ root: str = ".",
337
+ force: bool = False,
338
+ ) -> str:
339
+ normalized_action = action.strip().lower()
340
+ if normalized_action in {"make", "link"}:
341
+ if not package_path.strip():
342
+ raise ValueError("plugin make requires package_path.")
343
+ return _plugin_make(
344
+ package_path=package_path,
345
+ alias=alias,
346
+ root=root,
347
+ force=force,
348
+ )
349
+
350
+ if normalized_action == "list":
351
+ root_arg = root
352
+ package_arg = package_path.strip()
353
+ if root == "." and not alias.strip() and package_arg:
354
+ root_arg = package_arg
355
+ return _plugin_list(root=root_arg)
356
+
357
+ raise ValueError("plugin action must be one of: make, list.")
358
+
359
+
360
+ @register(name="version", description="Show fx version")
361
+ @option("--version")
362
+ @option("-V")
363
+ def show_version() -> str:
364
+ return f"fx {FX_VERSION}"