clizard 0.1.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.
- clizard/__init__.py +6 -0
- clizard/__main__.py +146 -0
- clizard/cli_args.py +95 -0
- clizard/clizard_file.py +55 -0
- clizard/config.py +50 -0
- clizard/core.py +359 -0
- clizard/discover.py +349 -0
- clizard/examples/examples_auto_cli_release_tool.py +31 -0
- clizard/examples/examples_llmlight_app.py +42 -0
- clizard/examples/examples_summarizer.py +16 -0
- clizard/examples/examples_wrap_summarizer.py +63 -0
- clizard/git_info.py +64 -0
- clizard/project_info.py +41 -0
- clizard/scaffold.py +187 -0
- clizard-0.1.0.dist-info/METADATA +174 -0
- clizard-0.1.0.dist-info/RECORD +20 -0
- clizard-0.1.0.dist-info/WHEEL +5 -0
- clizard-0.1.0.dist-info/entry_points.txt +3 -0
- clizard-0.1.0.dist-info/licenses/LICENSE +22 -0
- clizard-0.1.0.dist-info/top_level.txt +1 -0
clizard/core.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Generic Claude-style rich CLI, reusable across projects."""
|
|
2
|
+
import time
|
|
3
|
+
import sys
|
|
4
|
+
from rich import box
|
|
5
|
+
from rich.align import Align
|
|
6
|
+
from rich.console import Console, Group
|
|
7
|
+
from rich.live import Live
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
from rich.spinner import Spinner
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .project_info import get_project_info
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
DEFAULT_ASCII = r"""
|
|
22
|
+
.-.
|
|
23
|
+
|o o|
|
|
24
|
+
| = |
|
|
25
|
+
/|___|\
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GenericCLI:
|
|
30
|
+
"""Reusable rich-based chat CLI.
|
|
31
|
+
|
|
32
|
+
Pass `handler(prompt, cli) -> str` to process user input, or subclass
|
|
33
|
+
and override `handle(prompt)`. Register extra slash-commands with
|
|
34
|
+
`@cli.command("/foo")`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
app_name="Generic CLI",
|
|
40
|
+
ascii_art=None,
|
|
41
|
+
accent_color="#d97757",
|
|
42
|
+
dim_color="#9ca3af",
|
|
43
|
+
user_color="#3b82f6",
|
|
44
|
+
settings: dict = None,
|
|
45
|
+
config_path: str = None,
|
|
46
|
+
handler=None,
|
|
47
|
+
tips: list = None,
|
|
48
|
+
updates: list = None,
|
|
49
|
+
hidden_settings=None,
|
|
50
|
+
):
|
|
51
|
+
self.app_name = app_name
|
|
52
|
+
self.ascii_art = ascii_art or DEFAULT_ASCII
|
|
53
|
+
self.ACCENT = accent_color
|
|
54
|
+
self.DIM = dim_color
|
|
55
|
+
self.USER = user_color
|
|
56
|
+
self.handler = handler
|
|
57
|
+
self.tips = tips or ["/help", "/settings"]
|
|
58
|
+
self.updates = updates or ["Initial release"]
|
|
59
|
+
# Keys that exist in config (e.g. internal/derived values like git
|
|
60
|
+
# remote info or the model name) but shouldn't clutter /settings or
|
|
61
|
+
# the /wizard walkthrough. Still readable/writable via
|
|
62
|
+
# `/settings set <key> <value>` and `self.config.get/set`.
|
|
63
|
+
self.hidden_settings = set(hidden_settings) if hidden_settings else {
|
|
64
|
+
"github_user", "github_repo", "remote_url", "default_branch", "model",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
defaults = {}
|
|
68
|
+
if settings:
|
|
69
|
+
defaults.update(settings)
|
|
70
|
+
self.config = Config(app_name, defaults=defaults, config_path=config_path)
|
|
71
|
+
|
|
72
|
+
self._commands = {}
|
|
73
|
+
self._register_builtins()
|
|
74
|
+
|
|
75
|
+
# ---------- command registry ----------
|
|
76
|
+
def command(self, name, help_text=""):
|
|
77
|
+
"""Decorator: register a slash-command. fn(self, prompt) -> None."""
|
|
78
|
+
def deco(fn):
|
|
79
|
+
self._commands[name] = (fn, help_text)
|
|
80
|
+
return fn
|
|
81
|
+
return deco
|
|
82
|
+
|
|
83
|
+
def register_command(self, name, fn, help_text=""):
|
|
84
|
+
self._commands[name] = (fn, help_text)
|
|
85
|
+
|
|
86
|
+
def _register_builtins(self):
|
|
87
|
+
self.register_command("/help", self._cmd_help, "Show available commands")
|
|
88
|
+
self.register_command("/clear", self._cmd_clear, "Clear the terminal")
|
|
89
|
+
self.register_command("/settings", self._cmd_settings, "View or edit settings")
|
|
90
|
+
self.register_command("/exit", self._cmd_exit, "Exit the session")
|
|
91
|
+
self.register_command("/quit", self._cmd_exit, "Exit the session")
|
|
92
|
+
self.register_command("/install", self._cmd_install, "Install project requirements")
|
|
93
|
+
self.register_command("/docs", self._cmd_docs, "Open the project documentation")
|
|
94
|
+
self.register_command("/wizard", self._cmd_wizard, "Step through all settings, then optionally /run")
|
|
95
|
+
|
|
96
|
+
def _cmd_help(self, prompt):
|
|
97
|
+
lines = "\n".join(f"* `{name}` - {desc}" for name, (_, desc) in sorted(self._commands.items()))
|
|
98
|
+
self.assistant_message(f"### Available Commands\n{lines}")
|
|
99
|
+
|
|
100
|
+
def _cmd_clear(self, prompt):
|
|
101
|
+
console.clear()
|
|
102
|
+
|
|
103
|
+
def _cmd_exit(self, prompt):
|
|
104
|
+
console.print(f"[dim]Goodbye! Thanks for using {self.app_name}.[/dim]")
|
|
105
|
+
raise SystemExit(0)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _cast_value(raw: str, current):
|
|
109
|
+
"""Cast a string CLI value to match the type of the existing setting."""
|
|
110
|
+
if isinstance(current, bool):
|
|
111
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
112
|
+
if isinstance(current, int) and not isinstance(current, bool):
|
|
113
|
+
try:
|
|
114
|
+
return int(raw)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return raw
|
|
117
|
+
if isinstance(current, float):
|
|
118
|
+
try:
|
|
119
|
+
return float(raw)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return raw
|
|
122
|
+
if raw.lower() == "none":
|
|
123
|
+
return None
|
|
124
|
+
return raw
|
|
125
|
+
|
|
126
|
+
def _cmd_settings(self, prompt):
|
|
127
|
+
# /settings -> interactive view + optional edit
|
|
128
|
+
# /settings set k v... -> direct set (v can contain spaces)
|
|
129
|
+
parts = prompt.split(maxsplit=3)
|
|
130
|
+
if len(parts) >= 3 and parts[1] == "set":
|
|
131
|
+
key = parts[2]
|
|
132
|
+
raw_value = parts[3] if len(parts) > 3 else ""
|
|
133
|
+
current = self.config.get(key)
|
|
134
|
+
value = self._cast_value(raw_value, current)
|
|
135
|
+
self.config.set(key, value)
|
|
136
|
+
console.print(f"[dim]Set {key} = {value!r}[/dim]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
self._show_settings_table()
|
|
140
|
+
try:
|
|
141
|
+
answer = Prompt.ask(" ✏️ Edit a setting now?", choices=["y", "n"], default="n")
|
|
142
|
+
except (KeyboardInterrupt, EOFError):
|
|
143
|
+
return
|
|
144
|
+
if answer != "y":
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
keys = [k for k in self.config.settings.keys() if k not in self.hidden_settings]
|
|
148
|
+
key = Prompt.ask(f" Which key? [{'/'.join(keys)}]")
|
|
149
|
+
if key not in self.config.settings:
|
|
150
|
+
self.error(f"Unknown key: {key}")
|
|
151
|
+
return
|
|
152
|
+
current = self.config.get(key)
|
|
153
|
+
raw_value = Prompt.ask(f" New value for [bold]{key}[/bold]", default=str(current))
|
|
154
|
+
value = self._cast_value(raw_value, current)
|
|
155
|
+
self.config.set(key, value)
|
|
156
|
+
console.print(f"[dim]Set {key} = {value!r}[/dim]")
|
|
157
|
+
self._show_settings_table()
|
|
158
|
+
|
|
159
|
+
def _cmd_wizard(self, prompt):
|
|
160
|
+
console.print(f"[bold {self.ACCENT}]Setup Wizard[/bold {self.ACCENT}] — press Enter to keep the current value.\n")
|
|
161
|
+
meta = getattr(self, "arg_meta", {}) or {}
|
|
162
|
+
|
|
163
|
+
for key in list(self.config.settings.keys()):
|
|
164
|
+
if key in self.hidden_settings:
|
|
165
|
+
continue
|
|
166
|
+
current = self.config.get(key)
|
|
167
|
+
info = meta.get(key, {})
|
|
168
|
+
help_text = info.get("help")
|
|
169
|
+
choices = info.get("choices")
|
|
170
|
+
|
|
171
|
+
label = f" [bold]{key}[/bold]"
|
|
172
|
+
if help_text:
|
|
173
|
+
label += f" [dim]({help_text})[/dim]"
|
|
174
|
+
if choices:
|
|
175
|
+
label += f" [dim]choices: {choices}[/dim]"
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
raw_value = Prompt.ask(label, default=str(current))
|
|
179
|
+
caster = info.get("type")
|
|
180
|
+
if caster and current is None:
|
|
181
|
+
try:
|
|
182
|
+
value = caster(raw_value)
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
value = self._cast_value(raw_value, current)
|
|
185
|
+
else:
|
|
186
|
+
value = self._cast_value(raw_value, current)
|
|
187
|
+
if choices and value not in choices:
|
|
188
|
+
self.error(f"Invalid value {value!r}. Choose from {choices}.")
|
|
189
|
+
continue
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
self.config.set(key, value)
|
|
193
|
+
|
|
194
|
+
console.print()
|
|
195
|
+
self._show_settings_table()
|
|
196
|
+
|
|
197
|
+
if "/run" in self._commands:
|
|
198
|
+
try:
|
|
199
|
+
answer = Prompt.ask(" ▶️ Start /run now?", choices=["y", "n"], default="y")
|
|
200
|
+
except (KeyboardInterrupt, EOFError):
|
|
201
|
+
return
|
|
202
|
+
if answer == "y":
|
|
203
|
+
self._commands["/run"][0]("/run")
|
|
204
|
+
else:
|
|
205
|
+
console.print("[dim]No /run command registered.[/dim]")
|
|
206
|
+
|
|
207
|
+
def _cmd_install(self, prompt):
|
|
208
|
+
import subprocess
|
|
209
|
+
from pathlib import Path
|
|
210
|
+
|
|
211
|
+
repo_path = Path(self.config.get("path", "."))
|
|
212
|
+
pyproject = repo_path / "pyproject.toml"
|
|
213
|
+
requirements = repo_path / "requirements.txt"
|
|
214
|
+
|
|
215
|
+
if pyproject.exists():
|
|
216
|
+
cmd = [sys.executable, "-m", "pip", "install", "-e", str(repo_path)]
|
|
217
|
+
elif requirements.exists():
|
|
218
|
+
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements)]
|
|
219
|
+
else:
|
|
220
|
+
self.error("No pyproject.toml or requirements.txt found.")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
224
|
+
self.status("Installing...")
|
|
225
|
+
try:
|
|
226
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
self.error(str(e))
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
if result.returncode == 0:
|
|
232
|
+
self.assistant_message("Install completed successfully.")
|
|
233
|
+
else:
|
|
234
|
+
self.error(result.stderr.strip()[-1000:] or "Install failed.")
|
|
235
|
+
|
|
236
|
+
def _cmd_docs(self, prompt):
|
|
237
|
+
import webbrowser
|
|
238
|
+
from pathlib import Path
|
|
239
|
+
|
|
240
|
+
docs_url = self.config.get("docs_url")
|
|
241
|
+
repo_path = Path(self.config.get("path", "."))
|
|
242
|
+
local_docs = repo_path / "docs" / "index.html"
|
|
243
|
+
|
|
244
|
+
if local_docs.exists():
|
|
245
|
+
target = local_docs.resolve().as_uri()
|
|
246
|
+
elif docs_url:
|
|
247
|
+
target = docs_url
|
|
248
|
+
else:
|
|
249
|
+
self.error("No docs/index.html or docs_url configured.")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
console.print(f"[dim]Opening {target}[/dim]")
|
|
253
|
+
webbrowser.open(target)
|
|
254
|
+
|
|
255
|
+
def _show_settings_table(self):
|
|
256
|
+
table = Table(
|
|
257
|
+
title="Current Configuration Settings",
|
|
258
|
+
box=box.ROUNDED,
|
|
259
|
+
border_style=self.ACCENT,
|
|
260
|
+
show_lines=False,
|
|
261
|
+
)
|
|
262
|
+
table.add_column("Parameter", style=self.ACCENT)
|
|
263
|
+
table.add_column("Current Value")
|
|
264
|
+
for k, v in self.config.settings.items():
|
|
265
|
+
if k in self.hidden_settings:
|
|
266
|
+
continue
|
|
267
|
+
table.add_row(str(k), str(v))
|
|
268
|
+
console.print(table)
|
|
269
|
+
|
|
270
|
+
# ---------- rendering ----------
|
|
271
|
+
def welcome(self):
|
|
272
|
+
repo_info = get_project_info()
|
|
273
|
+
|
|
274
|
+
console.print()
|
|
275
|
+
# Build a centered Text block for the header so all fragments are centered.
|
|
276
|
+
header_text = Text(justify="center")
|
|
277
|
+
header_text.append(f"{self.app_name}\n", style=f"bold {self.ACCENT} reverse")
|
|
278
|
+
# Ensure ascii art is on its own centered block
|
|
279
|
+
header_text.append(f"\n{self.ascii_art}\n", style=self.ACCENT)
|
|
280
|
+
if repo_info.get('description'):
|
|
281
|
+
header_text.append(f"\n{repo_info['description']}")
|
|
282
|
+
|
|
283
|
+
centered_header = Align.center(header_text, vertical="middle")
|
|
284
|
+
|
|
285
|
+
grid = Table.grid(expand=True, padding=(0, 4))
|
|
286
|
+
grid.add_column(ratio=1)
|
|
287
|
+
grid.add_column(ratio=1)
|
|
288
|
+
|
|
289
|
+
tips_md = "\n".join(f" • [cyan]{t}[/cyan]" for t in self.tips)
|
|
290
|
+
updates_md = "\n".join(f"• {u}" for u in self.updates)
|
|
291
|
+
|
|
292
|
+
tips_text = Text.from_markup(f"[bold {self.ACCENT}]Getting started[/bold {self.ACCENT}]\n\nType commands like:\n{tips_md}")
|
|
293
|
+
updates_text = Text.from_markup(f"[bold {self.ACCENT}]What's new[/bold {self.ACCENT}]\n\n{updates_md}")
|
|
294
|
+
|
|
295
|
+
grid.add_row(tips_text, updates_text)
|
|
296
|
+
|
|
297
|
+
layout_group = Group(centered_header, "─" * 64, "", grid)
|
|
298
|
+
|
|
299
|
+
console.print(
|
|
300
|
+
Align.center(
|
|
301
|
+
Panel(layout_group, box=box.ROUNDED, border_style=self.ACCENT, padding=(2, 4), width=76)
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
console.print()
|
|
305
|
+
|
|
306
|
+
def user_message(self, text):
|
|
307
|
+
console.print(f"\n[bold {self.USER}]👤 You[/bold {self.USER}] › {text}\n")
|
|
308
|
+
|
|
309
|
+
def assistant_message(self, text):
|
|
310
|
+
console.print(f"[bold {self.ACCENT}]🤖 Assistant[/bold {self.ACCENT}]")
|
|
311
|
+
console.print("═" * 40, style=self.ACCENT)
|
|
312
|
+
console.print(Markdown(text))
|
|
313
|
+
console.print("═" * 40, style="dim")
|
|
314
|
+
console.print()
|
|
315
|
+
|
|
316
|
+
def status(self, text, duration=1.2):
|
|
317
|
+
with Live(Spinner("dots", text=f"[dim]{text}[/dim]", style=self.ACCENT), refresh_per_second=12, transient=True):
|
|
318
|
+
time.sleep(duration)
|
|
319
|
+
|
|
320
|
+
def tool_call(self, tool_name):
|
|
321
|
+
console.print(f" [dim]🛠️ Running tool:[/dim] [italic cyan]{tool_name}[/italic cyan]...")
|
|
322
|
+
|
|
323
|
+
def error(self, text):
|
|
324
|
+
console.print(f"[bold red]✕ Error:[/bold red] {text}")
|
|
325
|
+
|
|
326
|
+
# ---------- main loop ----------
|
|
327
|
+
def handle(self, prompt):
|
|
328
|
+
"""Default handler if none supplied; override or pass `handler=`."""
|
|
329
|
+
if self.handler:
|
|
330
|
+
return self.handler(prompt, self)
|
|
331
|
+
return f"Successfully captured your request: **'{prompt}'**."
|
|
332
|
+
|
|
333
|
+
def run(self):
|
|
334
|
+
self.welcome()
|
|
335
|
+
while True:
|
|
336
|
+
try:
|
|
337
|
+
prompt = Prompt.ask(f"[bold {self.ACCENT}]❯[/bold {self.ACCENT}]").strip()
|
|
338
|
+
if not prompt:
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
cmd = prompt.split(maxsplit=1)[0]
|
|
342
|
+
if cmd in self._commands:
|
|
343
|
+
fn, _ = self._commands[cmd]
|
|
344
|
+
fn(prompt)
|
|
345
|
+
continue
|
|
346
|
+
if cmd.startswith("/"):
|
|
347
|
+
self.error(f"Unknown command: {cmd} (type /help for a list)")
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
self.user_message(prompt)
|
|
351
|
+
self.status("Thinking...")
|
|
352
|
+
reply = self.handle(prompt)
|
|
353
|
+
self.assistant_message(reply)
|
|
354
|
+
|
|
355
|
+
except SystemExit:
|
|
356
|
+
break
|
|
357
|
+
except (KeyboardInterrupt, EOFError):
|
|
358
|
+
console.print("\n[dim]Session terminated gracefully.[/dim]")
|
|
359
|
+
break
|
clizard/discover.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Find a repo's entry-point main() function and turn its signature into
|
|
2
|
+
editable CLI settings, without needing argparse to be structured any
|
|
3
|
+
particular way.
|
|
4
|
+
"""
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .git_info import get_git_info
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
"""Locate the most likely entry-point .py file in a repo."""
|
|
14
|
+
def _find_entry_file(repo_path="."):
|
|
15
|
+
repo_path = Path(repo_path)
|
|
16
|
+
repo_name = repo_path.name
|
|
17
|
+
|
|
18
|
+
CANDIDATE_FILENAMES = [
|
|
19
|
+
"__main__.py",
|
|
20
|
+
"main.py",
|
|
21
|
+
f"{repo_name}.py",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Add GitHub repo name if available
|
|
25
|
+
gitinfo = get_git_info()
|
|
26
|
+
if gitinfo.get("github_repo"):
|
|
27
|
+
CANDIDATE_FILENAMES.append(f"{gitinfo['github_repo']}.py")
|
|
28
|
+
|
|
29
|
+
CANDIDATE_FILENAMES = list(dict.fromkeys(CANDIDATE_FILENAMES))
|
|
30
|
+
|
|
31
|
+
# --- 1. Direct top-level candidates ---
|
|
32
|
+
for name in CANDIDATE_FILENAMES:
|
|
33
|
+
# subfolder match
|
|
34
|
+
for candidate in repo_path.glob(f"*/{name}"):
|
|
35
|
+
return candidate
|
|
36
|
+
|
|
37
|
+
# direct match
|
|
38
|
+
direct = repo_path / name
|
|
39
|
+
if direct.exists():
|
|
40
|
+
return direct
|
|
41
|
+
|
|
42
|
+
# --- 2. Find file that actually handles arguments ---
|
|
43
|
+
ARG_PATTERNS = [
|
|
44
|
+
r"sys\.argv",
|
|
45
|
+
r"argparse",
|
|
46
|
+
r"ArgumentParser",
|
|
47
|
+
r"click\.command",
|
|
48
|
+
r"typer\.run",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
MAIN_PATTERN = r"def\s+main\s*\("
|
|
52
|
+
|
|
53
|
+
for py_file in repo_path.rglob("*.py"):
|
|
54
|
+
if ".git" in py_file.parts or "site-packages" in py_file.parts:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
text = py_file.read_text(errors="ignore")
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# must define main()
|
|
63
|
+
if not re.search(MAIN_PATTERN, text):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# must reference arguments
|
|
67
|
+
if any(re.search(p, text) for p in ARG_PATTERNS):
|
|
68
|
+
return py_file
|
|
69
|
+
|
|
70
|
+
# --- 3. Fallback: any file with main() and __name__ check ---
|
|
71
|
+
for py_file in repo_path.rglob("*.py"):
|
|
72
|
+
if ".git" in py_file.parts or "site-packages" in py_file.parts:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
text = py_file.read_text(errors="ignore")
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if "__name__" in text and "def main(" in text:
|
|
81
|
+
return py_file
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _load_module(py_file: Path):
|
|
86
|
+
"""Load py_file as a module, preserving package context so that any
|
|
87
|
+
relative imports (`from . import foo`, `from .bar import baz`) inside
|
|
88
|
+
it actually resolve, instead of raising ImportError.
|
|
89
|
+
|
|
90
|
+
If py_file lives inside a package (its directory has an __init__.py),
|
|
91
|
+
we import it as a real submodule of that package via importlib, with
|
|
92
|
+
the package's *parent* directory on sys.path. Otherwise we fall back
|
|
93
|
+
to loading it as a standalone module.
|
|
94
|
+
"""
|
|
95
|
+
parent = py_file.parent
|
|
96
|
+
|
|
97
|
+
if (parent / "__init__.py").exists():
|
|
98
|
+
package_name = parent.name
|
|
99
|
+
package_parent_dir = str(parent.parent)
|
|
100
|
+
sys.path.insert(0, package_parent_dir)
|
|
101
|
+
try:
|
|
102
|
+
module_name = f"{package_name}.{py_file.stem}"
|
|
103
|
+
# Drop any stale cached import from a previous discovery run.
|
|
104
|
+
sys.modules.pop(module_name, None)
|
|
105
|
+
sys.modules.pop(package_name, None)
|
|
106
|
+
module = importlib.import_module(module_name)
|
|
107
|
+
finally:
|
|
108
|
+
sys.path.remove(package_parent_dir)
|
|
109
|
+
return module
|
|
110
|
+
|
|
111
|
+
module_name = f"_clizard_discovered_{py_file.stem}"
|
|
112
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
113
|
+
module = importlib.util.module_from_spec(spec)
|
|
114
|
+
sys.path.insert(0, str(py_file.parent))
|
|
115
|
+
try:
|
|
116
|
+
spec.loader.exec_module(module)
|
|
117
|
+
finally:
|
|
118
|
+
sys.path.pop(0)
|
|
119
|
+
return module
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_main(repo_path="."):
|
|
123
|
+
"""Return (module, main_func, file_path) or (None, None, None)."""
|
|
124
|
+
py_file = _find_entry_file(repo_path)
|
|
125
|
+
if py_file is None:
|
|
126
|
+
return None, None, None
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
module = _load_module(py_file)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
print(f"clizard: failed to import {py_file} to discover main(): {e!r}", file=sys.stderr)
|
|
132
|
+
return None, None, py_file
|
|
133
|
+
|
|
134
|
+
main_func = getattr(module, "main", None)
|
|
135
|
+
if main_func is None or not callable(main_func):
|
|
136
|
+
return module, None, py_file
|
|
137
|
+
|
|
138
|
+
return module, main_func, py_file
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_argparse_settings(main_func):
|
|
142
|
+
"""Many main()s take *no* parameters and instead build their own
|
|
143
|
+
argparse.ArgumentParser() internally, reading from sys.argv. In that
|
|
144
|
+
case there's nothing in the signature to inspect, but the settings
|
|
145
|
+
are sitting right there in the source as `parser.add_argument(...)`
|
|
146
|
+
calls. Parse the function's source with `ast` and recover them.
|
|
147
|
+
|
|
148
|
+
Returns (settings, arg_meta). arg_meta entries carry a "flag" (e.g.
|
|
149
|
+
"--username") and "is_flag" (True for store_true/store_false), which
|
|
150
|
+
the generated /run command uses to rebuild sys.argv before calling
|
|
151
|
+
main() with no arguments, since main() will parse it itself.
|
|
152
|
+
"""
|
|
153
|
+
import ast
|
|
154
|
+
|
|
155
|
+
settings = {}
|
|
156
|
+
arg_meta = {}
|
|
157
|
+
try:
|
|
158
|
+
source = inspect.getsource(main_func)
|
|
159
|
+
except (OSError, TypeError):
|
|
160
|
+
return settings, arg_meta
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
tree = ast.parse(source)
|
|
164
|
+
except SyntaxError:
|
|
165
|
+
return settings, arg_meta
|
|
166
|
+
|
|
167
|
+
parser_names = set()
|
|
168
|
+
for node in ast.walk(tree):
|
|
169
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
170
|
+
func = node.value.func
|
|
171
|
+
func_name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None)
|
|
172
|
+
if func_name == "ArgumentParser":
|
|
173
|
+
for target in node.targets:
|
|
174
|
+
if isinstance(target, ast.Name):
|
|
175
|
+
parser_names.add(target.id)
|
|
176
|
+
|
|
177
|
+
if not parser_names:
|
|
178
|
+
return settings, arg_meta
|
|
179
|
+
|
|
180
|
+
for node in ast.walk(tree):
|
|
181
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
182
|
+
and node.func.attr == "add_argument"):
|
|
183
|
+
continue
|
|
184
|
+
obj = node.func.value
|
|
185
|
+
if not (isinstance(obj, ast.Name) and obj.id in parser_names):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
flags = [a.value for a in node.args if isinstance(a, ast.Constant) and isinstance(a.value, str)]
|
|
189
|
+
if not flags:
|
|
190
|
+
continue
|
|
191
|
+
long_flags = [f for f in flags if f.startswith("--")]
|
|
192
|
+
flag = long_flags[0] if long_flags else flags[-1]
|
|
193
|
+
dest_name = flag.lstrip("-").replace("-", "_")
|
|
194
|
+
|
|
195
|
+
kw = {}
|
|
196
|
+
type_name = None
|
|
197
|
+
for keyword in node.keywords:
|
|
198
|
+
if keyword.arg is None:
|
|
199
|
+
continue
|
|
200
|
+
if keyword.arg == "type" and isinstance(keyword.value, ast.Name):
|
|
201
|
+
type_name = keyword.value.id # e.g. "int", "str", "float"
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
kw[keyword.arg] = ast.literal_eval(keyword.value)
|
|
205
|
+
except (ValueError, SyntaxError):
|
|
206
|
+
kw[keyword.arg] = None
|
|
207
|
+
|
|
208
|
+
dest_name = kw.get("dest", dest_name)
|
|
209
|
+
action = kw.get("action")
|
|
210
|
+
is_flag = action in ("store_true", "store_false")
|
|
211
|
+
default = kw.get("default")
|
|
212
|
+
if is_flag and default is None:
|
|
213
|
+
default = (action == "store_false")
|
|
214
|
+
|
|
215
|
+
settings[dest_name] = default
|
|
216
|
+
arg_meta[dest_name] = {
|
|
217
|
+
"choices": kw.get("choices"),
|
|
218
|
+
"type": type_name,
|
|
219
|
+
"help": kw.get("help"),
|
|
220
|
+
"flag": flag,
|
|
221
|
+
"is_flag": is_flag,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return settings, arg_meta
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def settings_from_main(main_func):
|
|
228
|
+
"""Inspect main()'s signature -> (settings dict, arg_meta dict, call_style).
|
|
229
|
+
|
|
230
|
+
call_style is "kwargs" when main() takes plain keyword parameters
|
|
231
|
+
(settings become those params, and /run calls main(**settings)), or
|
|
232
|
+
"argv" when main() takes no usable parameters but builds its own
|
|
233
|
+
argparse.ArgumentParser() internally (settings are recovered from its
|
|
234
|
+
add_argument() calls via AST, and /run rebuilds sys.argv before
|
|
235
|
+
calling main() with no arguments).
|
|
236
|
+
"""
|
|
237
|
+
settings = {}
|
|
238
|
+
arg_meta = {}
|
|
239
|
+
try:
|
|
240
|
+
sig = inspect.signature(main_func)
|
|
241
|
+
except (TypeError, ValueError):
|
|
242
|
+
sig = None
|
|
243
|
+
|
|
244
|
+
if sig is not None:
|
|
245
|
+
for name, param in sig.parameters.items():
|
|
246
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
247
|
+
continue
|
|
248
|
+
default = None if param.default is inspect.Parameter.empty else param.default
|
|
249
|
+
annotation = None if param.annotation is inspect.Parameter.empty else param.annotation
|
|
250
|
+
settings[name] = default
|
|
251
|
+
arg_meta[name] = {
|
|
252
|
+
"choices": None,
|
|
253
|
+
"type": annotation if callable(annotation) else None,
|
|
254
|
+
"help": None,
|
|
255
|
+
"flag": None,
|
|
256
|
+
"is_flag": False,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if settings:
|
|
260
|
+
return settings, arg_meta, "kwargs"
|
|
261
|
+
|
|
262
|
+
# main() took no usable params -- see if it builds an argparse parser
|
|
263
|
+
# internally instead, and recover settings from that.
|
|
264
|
+
argv_settings, argv_meta = _extract_argparse_settings(main_func)
|
|
265
|
+
if argv_settings:
|
|
266
|
+
return argv_settings, argv_meta, "argv"
|
|
267
|
+
|
|
268
|
+
return settings, arg_meta, "kwargs"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
SNAKEMAKE_CONFIG_CANDIDATES = [
|
|
272
|
+
"config.yaml", "config.yml",
|
|
273
|
+
"config/config.yaml", "config/config.yml",
|
|
274
|
+
"workflow/config.yaml", "workflow/config.yml",
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def find_snakefile(repo_path="."):
|
|
279
|
+
repo_path = Path(repo_path)
|
|
280
|
+
for name in ("Snakefile", "workflow/Snakefile"):
|
|
281
|
+
candidate = repo_path / name
|
|
282
|
+
if candidate.exists():
|
|
283
|
+
return candidate
|
|
284
|
+
matches = list(repo_path.rglob("Snakefile"))
|
|
285
|
+
return matches[0] if matches else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def find_snakemake_config(repo_path="."):
|
|
289
|
+
"""Locate a Snakemake config YAML: explicit `configfile:` in the
|
|
290
|
+
Snakefile takes priority, else common default paths are tried.
|
|
291
|
+
"""
|
|
292
|
+
repo_path = Path(repo_path)
|
|
293
|
+
snakefile = find_snakefile(repo_path)
|
|
294
|
+
|
|
295
|
+
if snakefile and snakefile.exists():
|
|
296
|
+
try:
|
|
297
|
+
text = snakefile.read_text(errors="ignore")
|
|
298
|
+
except OSError:
|
|
299
|
+
text = ""
|
|
300
|
+
for line in text.splitlines():
|
|
301
|
+
line = line.strip()
|
|
302
|
+
if line.startswith("configfile:"):
|
|
303
|
+
rel = line.split(":", 1)[1].strip().strip("'\"")
|
|
304
|
+
candidate = (snakefile.parent / rel).resolve()
|
|
305
|
+
if candidate.exists():
|
|
306
|
+
return candidate
|
|
307
|
+
|
|
308
|
+
for rel in SNAKEMAKE_CONFIG_CANDIDATES:
|
|
309
|
+
candidate = repo_path / rel
|
|
310
|
+
if candidate.exists():
|
|
311
|
+
return candidate
|
|
312
|
+
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def settings_from_snakemake_config(config_path):
|
|
317
|
+
"""Load a Snakemake config YAML -> flat settings dict (keys prefixed
|
|
318
|
+
'sm_' to avoid clashing with main()'s own argument names).
|
|
319
|
+
"""
|
|
320
|
+
settings = {}
|
|
321
|
+
if config_path is None:
|
|
322
|
+
return settings
|
|
323
|
+
try:
|
|
324
|
+
import yaml
|
|
325
|
+
except ImportError:
|
|
326
|
+
return settings
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
with open(config_path, "r") as f:
|
|
330
|
+
data = yaml.safe_load(f) or {}
|
|
331
|
+
except (OSError, yaml.YAMLError):
|
|
332
|
+
return settings
|
|
333
|
+
|
|
334
|
+
if not isinstance(data, dict):
|
|
335
|
+
return settings
|
|
336
|
+
|
|
337
|
+
for key, value in data.items():
|
|
338
|
+
settings[f"sm_{key}"] = value
|
|
339
|
+
|
|
340
|
+
return settings
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def write_snakemake_config(config_path, settings: dict):
|
|
344
|
+
"""Write back sm_-prefixed settings into the Snakemake config YAML."""
|
|
345
|
+
import yaml
|
|
346
|
+
|
|
347
|
+
data = {k[3:]: v for k, v in settings.items() if k.startswith("sm_")}
|
|
348
|
+
with open(config_path, "w") as f:
|
|
349
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|