pywire-cli 0.2.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.
- pywire_cli/__init__.py +3 -0
- pywire_cli/check.py +117 -0
- pywire_cli/config.py +144 -0
- pywire_cli/deploy.py +90 -0
- pywire_cli/main.py +894 -0
- pywire_cli/tui.py +574 -0
- pywire_cli-0.2.0.dist-info/METADATA +56 -0
- pywire_cli-0.2.0.dist-info/RECORD +10 -0
- pywire_cli-0.2.0.dist-info/WHEEL +4 -0
- pywire_cli-0.2.0.dist-info/entry_points.txt +2 -0
pywire_cli/__init__.py
ADDED
pywire_cli/check.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""`pywire check` — static analysis for a PyWire project.
|
|
2
|
+
|
|
3
|
+
Thin CLI layer over :mod:`pywire_parser.analysis`. Supports rich output by
|
|
4
|
+
default and a ruff-style ``--plain`` format for CI / greppable logs. Exits
|
|
5
|
+
1 on any ERROR-severity diagnostic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from pywire_parser.analysis import Diagnostic, Severity, analyze_files
|
|
15
|
+
from pywire_parser.analysis.registry import all_rule_codes
|
|
16
|
+
from pywire_parser.parser import PyWireParser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CheckSummary:
|
|
21
|
+
errors: int
|
|
22
|
+
warnings: int
|
|
23
|
+
infos: int
|
|
24
|
+
exit_code: int
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def total(self) -> int:
|
|
28
|
+
return self.errors + self.warnings + self.infos
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_diagnostics(
|
|
32
|
+
pages_dir: Path,
|
|
33
|
+
*,
|
|
34
|
+
rule_codes: Optional[Sequence[str]] = None,
|
|
35
|
+
) -> List[Diagnostic]:
|
|
36
|
+
"""Parse every ``.wire`` under ``pages_dir`` and run the analysis engine."""
|
|
37
|
+
parser = PyWireParser()
|
|
38
|
+
items = []
|
|
39
|
+
if not pages_dir.exists():
|
|
40
|
+
return []
|
|
41
|
+
for wire_file in sorted(pages_dir.rglob("*.wire")):
|
|
42
|
+
try:
|
|
43
|
+
parsed = parser.parse_file(wire_file)
|
|
44
|
+
except Exception as e: # noqa: BLE001 — surface parse errors as diags
|
|
45
|
+
items.append(_parse_error_diag(wire_file, e))
|
|
46
|
+
continue
|
|
47
|
+
items.append((parsed, wire_file))
|
|
48
|
+
# Split: tuples go to engine; parse-error diagnostics are already Diagnostic
|
|
49
|
+
parsed_items = [x for x in items if isinstance(x, tuple)]
|
|
50
|
+
parse_error_diags = [x for x in items if not isinstance(x, tuple)]
|
|
51
|
+
engine_diags = analyze_files(parsed_items, codes=rule_codes)
|
|
52
|
+
return parse_error_diags + engine_diags
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_error_diag(path: Path, err: Exception) -> Diagnostic:
|
|
56
|
+
from pywire_parser.analysis.diagnostics import Span
|
|
57
|
+
|
|
58
|
+
return Diagnostic(
|
|
59
|
+
code="PW000",
|
|
60
|
+
severity=Severity.ERROR,
|
|
61
|
+
message=f"parse error: {err}",
|
|
62
|
+
file_path=str(path),
|
|
63
|
+
span=Span(line=1, column=0),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_plain(diags: Iterable[Diagnostic]) -> str:
|
|
68
|
+
lines = []
|
|
69
|
+
for d in diags:
|
|
70
|
+
lines.append(
|
|
71
|
+
f"{d.file_path}:{d.line}:{d.column}: {d.severity.value} [{d.code}] {d.message}"
|
|
72
|
+
)
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_rich(diags: Iterable[Diagnostic], console) -> None:
|
|
77
|
+
"""Render diagnostics with rich markup. ``console`` is a rich Console."""
|
|
78
|
+
current_file: Optional[str] = None
|
|
79
|
+
sev_style = {
|
|
80
|
+
Severity.ERROR: "bold red",
|
|
81
|
+
Severity.WARNING: "yellow",
|
|
82
|
+
Severity.INFO: "cyan",
|
|
83
|
+
}
|
|
84
|
+
for d in diags:
|
|
85
|
+
if d.file_path != current_file:
|
|
86
|
+
console.print(f"\n[bold]{d.file_path}[/]")
|
|
87
|
+
current_file = d.file_path
|
|
88
|
+
style = sev_style[d.severity]
|
|
89
|
+
console.print(
|
|
90
|
+
f" [dim]{d.line}:{d.column}[/] "
|
|
91
|
+
f"[{style}]{d.severity.value}[/] "
|
|
92
|
+
f"[bold]{d.code}[/] {d.message}"
|
|
93
|
+
)
|
|
94
|
+
if d.hint:
|
|
95
|
+
console.print(f" [dim]→ {d.hint}[/]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def summarize(diags: Iterable[Diagnostic], *, strict: bool = False) -> CheckSummary:
|
|
99
|
+
errors = sum(1 for d in diags if d.severity == Severity.ERROR)
|
|
100
|
+
warnings = sum(1 for d in diags if d.severity == Severity.WARNING)
|
|
101
|
+
infos = sum(1 for d in diags if d.severity == Severity.INFO)
|
|
102
|
+
exit_code = 0
|
|
103
|
+
if errors > 0:
|
|
104
|
+
exit_code = 1
|
|
105
|
+
elif strict and (warnings > 0 or infos > 0):
|
|
106
|
+
exit_code = 1
|
|
107
|
+
return CheckSummary(errors, warnings, infos, exit_code)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"CheckSummary",
|
|
112
|
+
"collect_diagnostics",
|
|
113
|
+
"format_plain",
|
|
114
|
+
"format_rich",
|
|
115
|
+
"summarize",
|
|
116
|
+
"all_rule_codes",
|
|
117
|
+
]
|
pywire_cli/config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Persistent CLI settings stored in .pywire/settings.toml."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import rich_click as click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
except ImportError:
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
print(
|
|
14
|
+
"Error: pywire CLI requires additional dependencies.\n"
|
|
15
|
+
"Install them with: uv add pywire[cli] (or: pip install pywire[cli])",
|
|
16
|
+
file=sys.stderr,
|
|
17
|
+
)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
SETTINGS_DIR = Path(".pywire")
|
|
23
|
+
SETTINGS_FILE = SETTINGS_DIR / "settings.toml"
|
|
24
|
+
|
|
25
|
+
# Valid setting keys and their accepted values
|
|
26
|
+
VALID_SETTINGS: dict[str, dict[str, Any]] = {
|
|
27
|
+
"tui": {
|
|
28
|
+
"type": "bool",
|
|
29
|
+
"on_values": {"on", "true", "1", "yes"},
|
|
30
|
+
"off_values": {"off", "false", "0", "no"},
|
|
31
|
+
"description": "Enable TUI dashboard for dev server",
|
|
32
|
+
},
|
|
33
|
+
"port": {
|
|
34
|
+
"type": "int",
|
|
35
|
+
"description": "Default port for dev server (default: 3000)",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_settings() -> dict[str, Any]:
|
|
41
|
+
"""Read settings from .pywire/settings.toml, returning empty dict if missing."""
|
|
42
|
+
if not SETTINGS_FILE.exists():
|
|
43
|
+
return {}
|
|
44
|
+
with open(SETTINGS_FILE, "rb") as f:
|
|
45
|
+
data = tomllib.load(f)
|
|
46
|
+
return data.get("cli", {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write_settings(settings: dict[str, Any]) -> None:
|
|
50
|
+
"""Write settings to .pywire/settings.toml using simple TOML formatting."""
|
|
51
|
+
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
lines = ["[cli]"]
|
|
53
|
+
for key, value in sorted(settings.items()):
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
lines.append(f"{key} = {'true' if value else 'false'}")
|
|
56
|
+
elif isinstance(value, str):
|
|
57
|
+
lines.append(f'{key} = "{value}"')
|
|
58
|
+
else:
|
|
59
|
+
lines.append(f"{key} = {value}")
|
|
60
|
+
SETTINGS_FILE.write_text("\n".join(lines) + "\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_setting(key: str) -> Optional[Any]:
|
|
64
|
+
"""Get a single setting value, or None if not set."""
|
|
65
|
+
return _read_settings().get(key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_bool_value(key: str, value: str) -> bool:
|
|
69
|
+
"""Parse a string value into a boolean for the given setting key."""
|
|
70
|
+
meta = VALID_SETTINGS[key]
|
|
71
|
+
lower = value.lower()
|
|
72
|
+
if lower in meta["on_values"]:
|
|
73
|
+
return True
|
|
74
|
+
if lower in meta["off_values"]:
|
|
75
|
+
return False
|
|
76
|
+
on = ", ".join(sorted(meta["on_values"]))
|
|
77
|
+
off = ", ".join(sorted(meta["off_values"]))
|
|
78
|
+
raise click.BadParameter(
|
|
79
|
+
f"Invalid value '{value}' for '{key}'. Use one of: {on} (enable) or {off} (disable)."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.command(name="config")
|
|
84
|
+
@click.argument("key", required=False)
|
|
85
|
+
@click.argument("value", required=False)
|
|
86
|
+
def config_command(key: Optional[str], value: Optional[str]) -> None:
|
|
87
|
+
"""View or update persistent CLI settings (.pywire/settings.toml).
|
|
88
|
+
|
|
89
|
+
\b
|
|
90
|
+
Examples:
|
|
91
|
+
pywire config Show all settings
|
|
92
|
+
pywire config tui Show TUI setting
|
|
93
|
+
pywire config tui on Enable TUI by default
|
|
94
|
+
pywire config tui off Disable TUI by default
|
|
95
|
+
"""
|
|
96
|
+
if key is None:
|
|
97
|
+
# Show all settings
|
|
98
|
+
settings = _read_settings()
|
|
99
|
+
if not settings:
|
|
100
|
+
console.print("[dim]No settings configured yet.[/]")
|
|
101
|
+
console.print(
|
|
102
|
+
"[dim]Available settings:[/] "
|
|
103
|
+
+ ", ".join(f"[cyan]{k}[/]" for k in sorted(VALID_SETTINGS))
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
for k, v in sorted(settings.items()):
|
|
107
|
+
console.print(f"[cyan]{k}[/] = [bold]{v}[/]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if key not in VALID_SETTINGS:
|
|
111
|
+
valid = ", ".join(sorted(VALID_SETTINGS))
|
|
112
|
+
raise click.BadParameter(
|
|
113
|
+
f"Unknown setting '{key}'. Valid settings: {valid}",
|
|
114
|
+
param_hint="KEY",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if value is None:
|
|
118
|
+
# Show single setting
|
|
119
|
+
current = get_setting(key)
|
|
120
|
+
if current is None:
|
|
121
|
+
console.print(f"[cyan]{key}[/] = [dim]<not set>[/]")
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[cyan]{key}[/] = [bold]{current}[/]")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Set value
|
|
127
|
+
meta = VALID_SETTINGS[key]
|
|
128
|
+
if meta["type"] == "bool":
|
|
129
|
+
parsed = _parse_bool_value(key, value)
|
|
130
|
+
elif meta["type"] == "int":
|
|
131
|
+
try:
|
|
132
|
+
parsed = int(value)
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise click.BadParameter(
|
|
135
|
+
f"Invalid value '{value}' for '{key}'. Must be an integer.",
|
|
136
|
+
param_hint="VALUE",
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
parsed = value
|
|
140
|
+
|
|
141
|
+
settings = _read_settings()
|
|
142
|
+
settings[key] = parsed
|
|
143
|
+
_write_settings(settings)
|
|
144
|
+
console.print(f"[cyan]{key}[/] = [bold]{parsed}[/]")
|
pywire_cli/deploy.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Deployment configuration generators for PyWire apps.
|
|
2
|
+
|
|
3
|
+
Templates live in the ``pywire-templates`` package so ``create-pywire-app``
|
|
4
|
+
and ``pywire deploy`` stay in sync. This module is a thin rendering layer
|
|
5
|
+
that feeds the right context variables to each template.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pywire_templates import render_deploy_template
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_app_string(app_string: str) -> tuple[str, str]:
|
|
16
|
+
"""Parse ``'src.main:app'`` into ``('src.main', 'app')``."""
|
|
17
|
+
if ":" in app_string:
|
|
18
|
+
app_module, app_attr = app_string.rsplit(":", 1)
|
|
19
|
+
else:
|
|
20
|
+
app_module, app_attr = app_string, "app"
|
|
21
|
+
return app_module, app_attr
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_dockerfile(project_root: Path, workers: int = 1) -> str:
|
|
25
|
+
"""Generate Dockerfile content for a PyWire project."""
|
|
26
|
+
return render_deploy_template("Dockerfile.j2", workers=workers)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def generate_render_yaml(
|
|
30
|
+
project_root: Path, project_name: str, redis: bool = False
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Generate render.yaml content for a PyWire project."""
|
|
33
|
+
return render_deploy_template(
|
|
34
|
+
"render.yaml.j2",
|
|
35
|
+
project_name=project_name,
|
|
36
|
+
redis_enabled=redis,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_fly_toml(project_root: Path, project_name: str) -> str:
|
|
41
|
+
"""Generate fly.toml content for a PyWire project."""
|
|
42
|
+
return render_deploy_template("fly.toml.j2", project_name=project_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_railway_json(project_root: Path) -> str:
|
|
46
|
+
"""Generate railway.json content for a PyWire project."""
|
|
47
|
+
return render_deploy_template("railway.json.j2")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_wrangler_toml(project_root: Path, project_name: str) -> str:
|
|
51
|
+
"""Generate wrangler.toml content for Cloudflare Workers with Durable Objects."""
|
|
52
|
+
return render_deploy_template("wrangler.toml.j2", project_name=project_name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_cf_entry(project_root: Path, app_string: str = "main:app") -> str:
|
|
56
|
+
"""Generate entry.py for Cloudflare Workers with Durable Object routing."""
|
|
57
|
+
app_module, app_attr = _parse_app_string(app_string)
|
|
58
|
+
return render_deploy_template(
|
|
59
|
+
"entry.py.j2", app_module=app_module, app_attr=app_attr
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_cf_durable_object(project_root: Path, app_string: str = "main:app") -> str:
|
|
64
|
+
"""Generate pywire_do.py — the Durable Object class for PyWire sessions."""
|
|
65
|
+
app_module, app_attr = _parse_app_string(app_string)
|
|
66
|
+
return render_deploy_template(
|
|
67
|
+
"pywire_do.py.j2", app_module=app_module, app_attr=app_attr
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_deploy_config(platform: str, project_root: Path) -> list[str]:
|
|
72
|
+
"""Check what's missing for deployment on the given platform.
|
|
73
|
+
|
|
74
|
+
Returns a list of warning/error messages. An empty list means
|
|
75
|
+
everything looks good.
|
|
76
|
+
"""
|
|
77
|
+
issues: list[str] = []
|
|
78
|
+
|
|
79
|
+
if not (project_root / "pyproject.toml").exists():
|
|
80
|
+
issues.append("Missing pyproject.toml — required for dependency installation.")
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
platform in ("docker", "fly", "render", "railway")
|
|
84
|
+
and not (project_root / "uv.lock").exists()
|
|
85
|
+
):
|
|
86
|
+
issues.append(
|
|
87
|
+
"Missing uv.lock — run 'uv lock' to generate a lock file for reproducible builds."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return issues
|