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 ADDED
@@ -0,0 +1,6 @@
1
+ from .core import GenericCLI
2
+ from .config import Config
3
+ from .cli_args import parse_args, build_parser, auto_cli
4
+
5
+ __all__ = ["GenericCLI", "Config", "parse_args", "build_parser", "auto_cli"]
6
+ __version__ = "0.1.0"
clizard/__main__.py ADDED
@@ -0,0 +1,146 @@
1
+ """Default console-script entry point: `clizard`.
2
+
3
+ Run with no args inside a repo to auto-discover everything:
4
+ - the repo's main() (via __main__.py / main.py signature)
5
+ - a Snakemake workflow (Snakefile + config.yaml), if present
6
+ - git remote info (.git/config)
7
+ - pyproject.toml metadata (name, docs url, requirements)
8
+ - .clizard overrides (ascii art, accent color, tips, app name, docs url)
9
+
10
+ Falls back to a bare GenericCLI if nothing is discoverable.
11
+ """
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .cli_args import parse_args
17
+ from .core import GenericCLI
18
+ from .git_info import get_git_info
19
+ from .project_info import get_project_info
20
+ from .clizard_file import ensure_clizard_file
21
+ from .discover import (
22
+ find_main, settings_from_main,
23
+ find_snakemake_config, settings_from_snakemake_config, write_snakemake_config,
24
+ )
25
+ from .scaffold import generate_clizard_main
26
+
27
+
28
+ def build_cli(repo_path="."):
29
+ repo_path = str(Path(repo_path).resolve())
30
+
31
+ git_info = get_git_info(repo_path)
32
+ proj_info = get_project_info(repo_path)
33
+ clz = ensure_clizard_file(repo_path)
34
+
35
+ app_name = clz.get("app_name") or proj_info.get("name") or git_info.get("github_repo") or "clizard"
36
+ docs_url = clz.get("docs_url") or proj_info.get("docs_url")
37
+
38
+ module, main_func, entry_file = find_main(repo_path)
39
+ main_settings, arg_meta, call_style = settings_from_main(main_func) if main_func else ({}, {}, "kwargs")
40
+
41
+ sm_config_path = find_snakemake_config(repo_path)
42
+ sm_settings = settings_from_snakemake_config(sm_config_path) if sm_config_path else {}
43
+
44
+ settings = {
45
+ "path": repo_path,
46
+ "docs_url": docs_url or "",
47
+ **main_settings,
48
+ **sm_settings,
49
+ }
50
+
51
+ has_run_target = main_func is not None or sm_config_path is not None
52
+ default_tips = ["/wizard", "/run", "/settings", "/help"] if has_run_target else ["/settings", "/help"]
53
+
54
+ cli = GenericCLI(
55
+ app_name=app_name,
56
+ ascii_art=clz.get("ascii_art"),
57
+ accent_color=clz.get("accent_color", "#d97757"),
58
+ settings=settings,
59
+ tips=clz.get("tips") if clz.get("tips") else default_tips,
60
+ updates=clz.get("updates"),
61
+ )
62
+ cli.arg_meta = arg_meta
63
+
64
+ # Config persists settings across runs (so a value the user explicitly
65
+ # set via /settings, e.g. username="test", survives between sessions).
66
+ # But that means a key first discovered with no default (None) stays
67
+ # None forever in the persisted store, even after main()'s source gains
68
+ # an explicit default later (e.g. clean=True, verbosity=3). Backfill: if
69
+ # the persisted value is still None, adopt the freshly discovered
70
+ # default instead of leaving it stuck.
71
+ for key, val in {**main_settings, **sm_settings}.items():
72
+ if cli.config.get(key) is None and val is not None:
73
+ cli.config.set(key, val)
74
+
75
+ # Only show /run if there's actually something to run.
76
+ if not has_run_target:
77
+ cli._commands.pop("/run", None)
78
+ if "/run" in cli.tips:
79
+ cli.tips = [t for t in cli.tips if t != "/run"]
80
+
81
+ if has_run_target:
82
+ @cli.command("/run", "Run the project's main()/Snakemake workflow with current settings")
83
+ def _cmd_run(prompt):
84
+ if main_func is not None:
85
+ cli.status("Running main()...")
86
+ if call_style == "argv":
87
+ argv = ["clizard"]
88
+ for name, meta in arg_meta.items():
89
+ flag = meta.get("flag")
90
+ if not flag:
91
+ continue
92
+ val = cli.config.get(name)
93
+ if val is None:
94
+ continue
95
+ if meta.get("is_flag"):
96
+ if val:
97
+ argv.append(flag)
98
+ else:
99
+ argv.extend([flag, str(val)])
100
+ old_argv = sys.argv
101
+ sys.argv = argv
102
+ try:
103
+ result = main_func()
104
+ finally:
105
+ sys.argv = old_argv
106
+ else:
107
+ call_kwargs = {k: cli.config.get(k) for k in main_settings}
108
+ result = main_func(**call_kwargs)
109
+ if result is not None:
110
+ cli.assistant_message(str(result))
111
+
112
+ if sm_config_path is not None:
113
+ current_sm = {k: cli.config.get(k) for k in sm_settings}
114
+ write_snakemake_config(sm_config_path, current_sm)
115
+ cmd = ["snakemake", "--configfile", str(sm_config_path), "--cores", "all"]
116
+ console_cmd = " ".join(cmd)
117
+ cli.status(f"Running: {console_cmd}")
118
+ try:
119
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
120
+ output = (result.stdout + result.stderr).strip()
121
+ cli.assistant_message(f"```\n$ {console_cmd}\n{output[-2000:]}\n```")
122
+ except FileNotFoundError:
123
+ cli.error("snakemake is not installed or not on PATH.")
124
+
125
+ if main_func is not None:
126
+ @cli.command("/scaffold", "Generate clizard_main.py wrapping this project's main()")
127
+ def _cmd_scaffold(prompt):
128
+ try:
129
+ out_path = generate_clizard_main(repo_path)
130
+ cli.assistant_message(f"Wrote `{out_path}`. Run it with:\n\n```\npython {out_path.name}\n```")
131
+ except RuntimeError as e:
132
+ cli.error(str(e))
133
+
134
+ return cli
135
+
136
+
137
+ def main():
138
+ args = parse_args(app_name="clizard")
139
+ cli = build_cli(repo_path=args.path or ".")
140
+ if args.model:
141
+ cli.config.set("model", args.model)
142
+ cli.run()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
clizard/cli_args.py ADDED
@@ -0,0 +1,95 @@
1
+ """Argument parsing for GenericCLI-based tools."""
2
+ import argparse
3
+
4
+
5
+ def build_parser(app_name="Generic CLI", extra_args=None):
6
+ """Create an argparse.ArgumentParser with common options.
7
+
8
+ extra_args: list of dicts like {"flags": ["--foo"], "kwargs": {...}}
9
+ to let downstream scripts add their own arguments.
10
+ """
11
+ parser = argparse.ArgumentParser(prog=app_name, description=f"{app_name} - interactive CLI")
12
+ parser.add_argument("--model", default=None, help="Model name/identifier to use")
13
+ parser.add_argument("--path", default=None, help="Working/project path")
14
+ parser.add_argument("--config", default=None, help="Path to a JSON config file")
15
+ parser.add_argument("--name", default=None, help="Override the displayed app name")
16
+
17
+ if extra_args:
18
+ for arg in extra_args:
19
+ parser.add_argument(*arg["flags"], **arg.get("kwargs", {}))
20
+
21
+ return parser
22
+
23
+
24
+ def parse_args(app_name="Generic CLI", extra_args=None, argv=None):
25
+ parser = build_parser(app_name, extra_args)
26
+ return parser.parse_args(argv)
27
+
28
+
29
+ def auto_cli(parser, args=None, app_name=None, handler=None, run_callback=None, config_path=None):
30
+ """Build a GenericCLI automatically from an existing argparse.ArgumentParser.
31
+
32
+ Every `--flag` already defined on `parser` becomes an editable setting:
33
+ its current value (parsed from argv, or its default) seeds the CLI's
34
+ `/settings` table, and its `choices`/`type`/`help` are kept so `/settings`
35
+ can validate and cast edits correctly.
36
+
37
+ Parameters
38
+ ----------
39
+ parser : argparse.ArgumentParser
40
+ An already-configured parser (e.g. the one built in your existing
41
+ `main()`), or just `parser` before calling `parse_args()` on it.
42
+ args : argparse.Namespace, optional
43
+ Pre-parsed args. If omitted, `parser.parse_args()` is called.
44
+ app_name : str, optional
45
+ Defaults to `parser.prog`.
46
+ handler : callable(prompt, cli) -> str, optional
47
+ Called for free-text input. If omitted, free-text input does nothing
48
+ special; use `run_callback` + a `/run` command instead (see below).
49
+ run_callback : callable(cli) -> str, optional
50
+ If given, a `/run` command is registered that calls
51
+ `run_callback(cli)` using the current settings, mirroring how you'd
52
+ call your script's `run(...)` function with `args.*`.
53
+ config_path : str, optional
54
+ Where to persist settings between sessions.
55
+
56
+ Returns
57
+ -------
58
+ GenericCLI
59
+ """
60
+ from .core import GenericCLI # local import avoids a circular import
61
+
62
+ if args is None:
63
+ args = parser.parse_args()
64
+
65
+ settings = {}
66
+ arg_meta = {}
67
+ for action in parser._actions:
68
+ dest = action.dest
69
+ if dest in ("help",) or isinstance(action, argparse._HelpAction):
70
+ continue
71
+ settings[dest] = getattr(args, dest, action.default)
72
+ arg_meta[dest] = {
73
+ "choices": list(action.choices) if action.choices else None,
74
+ "type": action.type,
75
+ "help": action.help,
76
+ }
77
+
78
+ cli = GenericCLI(
79
+ app_name=app_name or parser.prog or "CLI",
80
+ settings=settings,
81
+ config_path=config_path,
82
+ handler=handler,
83
+ tips=["/run", "/settings", "/docs", "/help"] if run_callback else ["/settings", "/help"],
84
+ )
85
+ cli.arg_meta = arg_meta # exposed for validation / display in /settings
86
+
87
+ if run_callback is not None:
88
+ @cli.command("/run", "Run the script with current settings")
89
+ def _cmd_run(prompt):
90
+ cli.status("Running...")
91
+ result = run_callback(cli)
92
+ if result is not None:
93
+ cli.assistant_message(str(result))
94
+
95
+ return cli
@@ -0,0 +1,55 @@
1
+ """Read/write the project-local .clizard metadata file.
2
+
3
+ This stores anything that can't be auto-discovered from git/pyproject:
4
+ ascii_art, app_name override, docs_url override, accent_color, tips, etc.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+
9
+ DEFAULT_ASCII = r"""
10
+ .-.
11
+ |o o|
12
+ | = |
13
+ /|___|\
14
+ """
15
+
16
+ CLIZARD_FILENAME = ".clizard"
17
+
18
+
19
+ def load_clizard_file(repo_path="."):
20
+ """Return the parsed .clizard JSON dict, or {} if absent/invalid."""
21
+ path = Path(repo_path) / CLIZARD_FILENAME
22
+ if not path.exists():
23
+ return {}
24
+ try:
25
+ with open(path, "r") as f:
26
+ return json.load(f)
27
+ except (json.JSONDecodeError, OSError):
28
+ return {}
29
+
30
+
31
+ def save_clizard_file(data: dict, repo_path="."):
32
+
33
+ path = Path(repo_path) / CLIZARD_FILENAME
34
+ with open(path, "w") as f:
35
+ json.dump(data, f, indent=2)
36
+ return path
37
+
38
+
39
+ def ensure_clizard_file(repo_path=".", **overrides):
40
+ """Create a .clizard file with sane defaults if one doesn't exist yet."""
41
+ path = Path(repo_path) / CLIZARD_FILENAME
42
+ if path.exists():
43
+ return load_clizard_file(repo_path)
44
+
45
+ data = {
46
+ "app_name": None, # None -> auto from pyproject/git
47
+ "ascii_art": DEFAULT_ASCII,
48
+ "docs_url": None, # None -> auto from pyproject, else docs/index.html
49
+ "accent_color": "#d97757",
50
+ "tips": ["/wizard", "/run", "/settings", "/docs", "/help"],
51
+ "updates": [],
52
+ }
53
+ data.update(overrides)
54
+ save_clizard_file(data, repo_path)
55
+ return data
clizard/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """Settings persistence for GenericCLI."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "clizard"
6
+
7
+
8
+ class Config:
9
+ """Simple JSON-backed settings store.
10
+
11
+ settings dict holds arbitrary key/values (model, path, theme, etc).
12
+ """
13
+
14
+ def __init__(self, app_name: str, defaults: dict = None, config_path: str = None):
15
+ self.app_name = app_name
16
+ self.defaults = defaults or {}
17
+ if config_path:
18
+ self.path = Path(config_path)
19
+ else:
20
+ self.path = DEFAULT_CONFIG_DIR / f"{app_name}.json"
21
+ self.settings = dict(self.defaults)
22
+ self.load()
23
+
24
+ def load(self):
25
+ if self.path.exists():
26
+ try:
27
+ with open(self.path, "r") as f:
28
+ data = json.load(f)
29
+ self.settings.update(data)
30
+ except (json.JSONDecodeError, OSError):
31
+ pass
32
+ return self.settings
33
+
34
+ def save(self):
35
+ self.path.parent.mkdir(parents=True, exist_ok=True)
36
+ with open(self.path, "w") as f:
37
+ json.dump(self.settings, f, indent=2)
38
+
39
+ def get(self, key, fallback=None):
40
+ return self.settings.get(key, fallback)
41
+
42
+ def set(self, key, value):
43
+ self.settings[key] = value
44
+ self.save()
45
+
46
+ def update_from_args(self, args_dict: dict):
47
+ """Override settings with non-None values from parsed CLI args."""
48
+ for k, v in args_dict.items():
49
+ if v is not None:
50
+ self.settings[k] = v