openstat-cli 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.
Files changed (143) hide show
  1. openstat/__init__.py +3 -0
  2. openstat/__main__.py +4 -0
  3. openstat/backends/__init__.py +16 -0
  4. openstat/backends/duckdb_backend.py +70 -0
  5. openstat/backends/polars_backend.py +52 -0
  6. openstat/cli.py +92 -0
  7. openstat/commands/__init__.py +82 -0
  8. openstat/commands/adv_stat_cmds.py +1255 -0
  9. openstat/commands/advanced_ml_cmds.py +576 -0
  10. openstat/commands/advreg_cmds.py +207 -0
  11. openstat/commands/alias_cmds.py +135 -0
  12. openstat/commands/arch_cmds.py +82 -0
  13. openstat/commands/arules_cmds.py +111 -0
  14. openstat/commands/automodel_cmds.py +212 -0
  15. openstat/commands/backend_cmds.py +82 -0
  16. openstat/commands/base.py +170 -0
  17. openstat/commands/bayes_cmds.py +71 -0
  18. openstat/commands/causal_cmds.py +269 -0
  19. openstat/commands/cluster_cmds.py +152 -0
  20. openstat/commands/data_cmds.py +996 -0
  21. openstat/commands/datamanip_cmds.py +672 -0
  22. openstat/commands/dataquality_cmds.py +174 -0
  23. openstat/commands/datetime_cmds.py +176 -0
  24. openstat/commands/dimreduce_cmds.py +184 -0
  25. openstat/commands/discrete_cmds.py +149 -0
  26. openstat/commands/dsl_cmds.py +143 -0
  27. openstat/commands/epi_cmds.py +93 -0
  28. openstat/commands/equiv_tobit_cmds.py +94 -0
  29. openstat/commands/esttab_cmds.py +196 -0
  30. openstat/commands/export_beamer_cmds.py +142 -0
  31. openstat/commands/export_cmds.py +201 -0
  32. openstat/commands/export_extra_cmds.py +240 -0
  33. openstat/commands/factor_cmds.py +180 -0
  34. openstat/commands/groupby_cmds.py +155 -0
  35. openstat/commands/help_cmds.py +237 -0
  36. openstat/commands/i18n_cmds.py +43 -0
  37. openstat/commands/import_extra_cmds.py +561 -0
  38. openstat/commands/influence_cmds.py +134 -0
  39. openstat/commands/iv_cmds.py +106 -0
  40. openstat/commands/manova_cmds.py +105 -0
  41. openstat/commands/mediate_cmds.py +233 -0
  42. openstat/commands/meta_cmds.py +284 -0
  43. openstat/commands/mi_cmds.py +228 -0
  44. openstat/commands/mixed_cmds.py +79 -0
  45. openstat/commands/mixture_changepoint_cmds.py +166 -0
  46. openstat/commands/ml_adv_cmds.py +147 -0
  47. openstat/commands/ml_cmds.py +178 -0
  48. openstat/commands/model_eval_cmds.py +142 -0
  49. openstat/commands/network_cmds.py +288 -0
  50. openstat/commands/nlquery_cmds.py +161 -0
  51. openstat/commands/nonparam_cmds.py +149 -0
  52. openstat/commands/outreg_cmds.py +247 -0
  53. openstat/commands/panel_cmds.py +141 -0
  54. openstat/commands/pdf_cmds.py +226 -0
  55. openstat/commands/pipeline_cmds.py +319 -0
  56. openstat/commands/plot_cmds.py +189 -0
  57. openstat/commands/plugin_cmds.py +79 -0
  58. openstat/commands/posthoc_cmds.py +153 -0
  59. openstat/commands/power_cmds.py +172 -0
  60. openstat/commands/profile_cmds.py +246 -0
  61. openstat/commands/rbridge_cmds.py +81 -0
  62. openstat/commands/regex_cmds.py +104 -0
  63. openstat/commands/report_cmds.py +48 -0
  64. openstat/commands/repro_cmds.py +129 -0
  65. openstat/commands/resampling_cmds.py +109 -0
  66. openstat/commands/reshape_cmds.py +223 -0
  67. openstat/commands/sem_cmds.py +177 -0
  68. openstat/commands/stat_cmds.py +1040 -0
  69. openstat/commands/stata_import_cmds.py +215 -0
  70. openstat/commands/string_cmds.py +124 -0
  71. openstat/commands/surv_cmds.py +145 -0
  72. openstat/commands/survey_cmds.py +153 -0
  73. openstat/commands/textanalysis_cmds.py +192 -0
  74. openstat/commands/ts_adv_cmds.py +136 -0
  75. openstat/commands/ts_cmds.py +195 -0
  76. openstat/commands/tui_cmds.py +111 -0
  77. openstat/commands/ux_cmds.py +191 -0
  78. openstat/commands/validate_cmds.py +270 -0
  79. openstat/commands/viz_adv_cmds.py +312 -0
  80. openstat/commands/viz_extra_cmds.py +251 -0
  81. openstat/commands/watch_cmds.py +69 -0
  82. openstat/config.py +106 -0
  83. openstat/dsl/__init__.py +0 -0
  84. openstat/dsl/parser.py +332 -0
  85. openstat/dsl/tokenizer.py +105 -0
  86. openstat/i18n.py +120 -0
  87. openstat/io/__init__.py +0 -0
  88. openstat/io/loader.py +187 -0
  89. openstat/jupyter/__init__.py +18 -0
  90. openstat/jupyter/display.py +18 -0
  91. openstat/jupyter/magic.py +60 -0
  92. openstat/logging_config.py +59 -0
  93. openstat/plots/__init__.py +0 -0
  94. openstat/plots/plotter.py +437 -0
  95. openstat/plots/surv_plots.py +32 -0
  96. openstat/plots/ts_plots.py +59 -0
  97. openstat/plugins/__init__.py +5 -0
  98. openstat/plugins/manager.py +69 -0
  99. openstat/repl.py +457 -0
  100. openstat/reporting/__init__.py +0 -0
  101. openstat/reporting/eda.py +208 -0
  102. openstat/reporting/report.py +67 -0
  103. openstat/script_runner.py +319 -0
  104. openstat/session.py +133 -0
  105. openstat/stats/__init__.py +0 -0
  106. openstat/stats/advanced_regression.py +269 -0
  107. openstat/stats/arch_garch.py +84 -0
  108. openstat/stats/bayesian.py +103 -0
  109. openstat/stats/causal.py +258 -0
  110. openstat/stats/clustering.py +206 -0
  111. openstat/stats/discrete.py +311 -0
  112. openstat/stats/epidemiology.py +119 -0
  113. openstat/stats/equiv_tobit.py +163 -0
  114. openstat/stats/factor.py +174 -0
  115. openstat/stats/imputation.py +282 -0
  116. openstat/stats/influence.py +78 -0
  117. openstat/stats/iv.py +131 -0
  118. openstat/stats/manova.py +124 -0
  119. openstat/stats/mixed.py +128 -0
  120. openstat/stats/ml.py +275 -0
  121. openstat/stats/ml_advanced.py +117 -0
  122. openstat/stats/model_eval.py +183 -0
  123. openstat/stats/models.py +1342 -0
  124. openstat/stats/nonparametric.py +130 -0
  125. openstat/stats/panel.py +179 -0
  126. openstat/stats/power.py +295 -0
  127. openstat/stats/resampling.py +203 -0
  128. openstat/stats/survey.py +213 -0
  129. openstat/stats/survival.py +196 -0
  130. openstat/stats/timeseries.py +142 -0
  131. openstat/stats/ts_advanced.py +114 -0
  132. openstat/types.py +11 -0
  133. openstat/web/__init__.py +1 -0
  134. openstat/web/app.py +117 -0
  135. openstat/web/session_manager.py +73 -0
  136. openstat/web/static/app.js +117 -0
  137. openstat/web/static/index.html +38 -0
  138. openstat/web/static/style.css +103 -0
  139. openstat_cli-1.0.0.dist-info/METADATA +748 -0
  140. openstat_cli-1.0.0.dist-info/RECORD +143 -0
  141. openstat_cli-1.0.0.dist-info/WHEEL +4 -0
  142. openstat_cli-1.0.0.dist-info/entry_points.txt +2 -0
  143. openstat_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,319 @@
1
+ """Pipeline, batch processing, git integration, progress bars, config profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from openstat.commands.base import command, CommandArgs, friendly_error
9
+ from openstat.session import Session
10
+
11
+
12
+ # ── Pipeline ─────────────────────────────────────────────────────────────────
13
+
14
+ @command("pipeline", usage="pipeline define|run|list|show|rm <name> [commands...]")
15
+ def cmd_pipeline(session: Session, args: str) -> str:
16
+ """Define and run named command pipelines.
17
+
18
+ A pipeline is a named sequence of commands that can be run in one go.
19
+ Pipelines are stored in the session and can be saved via 'session save'.
20
+
21
+ Sub-commands:
22
+ pipeline define <name> <cmd1> | <cmd2> | ... — define a pipeline
23
+ pipeline run <name> — run a defined pipeline
24
+ pipeline list — list all pipelines
25
+ pipeline show <name> — show pipeline steps
26
+ pipeline rm <name> — remove a pipeline
27
+
28
+ Examples:
29
+ pipeline define clean "drop id" | "rename old_name new_name" | "generate x2 = x*x"
30
+ pipeline run clean
31
+ pipeline define model_run "ols y x1 x2" | "plot coef" | "export pdf"
32
+ pipeline run model_run
33
+ """
34
+ _PIPELINES: dict = getattr(session, "_pipelines", {})
35
+ if not hasattr(session, "_pipelines"):
36
+ session._pipelines = _PIPELINES # type: ignore[attr-defined]
37
+
38
+ tokens = args.strip().split(None, 2)
39
+ if not tokens:
40
+ return "Usage: pipeline define|run|list|show|rm <name> [steps]"
41
+ subcmd = tokens[0].lower()
42
+
43
+ if subcmd == "list":
44
+ if not _PIPELINES:
45
+ return "No pipelines defined. Use: pipeline define <name> <cmd> | <cmd>"
46
+ lines = ["Defined pipelines:"]
47
+ for name, steps in _PIPELINES.items():
48
+ lines.append(f" {name} ({len(steps)} steps)")
49
+ return "\n".join(lines)
50
+
51
+ if subcmd == "rm":
52
+ name = tokens[1] if len(tokens) > 1 else ""
53
+ if not name:
54
+ return "Usage: pipeline rm <name>"
55
+ if name in _PIPELINES:
56
+ del _PIPELINES[name]
57
+ return f"Pipeline '{name}' removed."
58
+ return f"Pipeline '{name}' not found."
59
+
60
+ if subcmd == "show":
61
+ name = tokens[1] if len(tokens) > 1 else ""
62
+ if name not in _PIPELINES:
63
+ return f"Pipeline '{name}' not found."
64
+ steps = _PIPELINES[name]
65
+ lines = [f"Pipeline '{name}' ({len(steps)} steps):"]
66
+ for i, step in enumerate(steps, 1):
67
+ lines.append(f" {i}. {step}")
68
+ return "\n".join(lines)
69
+
70
+ if subcmd == "define":
71
+ if len(tokens) < 3:
72
+ return "Usage: pipeline define <name> <cmd1> | <cmd2> | ..."
73
+ name = tokens[1]
74
+ rest = tokens[2]
75
+ steps = [s.strip().strip('"\'') for s in rest.split("|")]
76
+ steps = [s for s in steps if s]
77
+ _PIPELINES[name] = steps
78
+ return f"Pipeline '{name}' defined with {len(steps)} steps."
79
+
80
+ if subcmd == "run":
81
+ name = tokens[1] if len(tokens) > 1 else ""
82
+ if not name:
83
+ return "Usage: pipeline run <name>"
84
+ if name not in _PIPELINES:
85
+ return f"Pipeline '{name}' not found. Use 'pipeline list' to see available."
86
+ steps = _PIPELINES[name]
87
+ from openstat.commands.base import run_command
88
+ results = [f"Running pipeline '{name}' ({len(steps)} steps):", ""]
89
+ for i, step in enumerate(steps, 1):
90
+ results.append(f"[{i}/{len(steps)}] {step}")
91
+ try:
92
+ out = run_command(session, step)
93
+ if out:
94
+ results.append(f" → {out[:200]}")
95
+ except Exception as exc:
96
+ results.append(f" → ERROR: {exc}")
97
+ results.append(f"Pipeline stopped at step {i}.")
98
+ break
99
+ results.append("")
100
+ results.append("Pipeline complete.")
101
+ return "\n".join(results)
102
+
103
+ return f"Unknown pipeline sub-command: {subcmd}"
104
+
105
+
106
+ # ── Batch ────────────────────────────────────────────────────────────────────
107
+
108
+ @command("batch", usage="batch <script1.ost> [script2.ost ...] [--stop-on-error]")
109
+ def cmd_batch(session: Session, args: str) -> str:
110
+ """Run multiple script files in sequence.
111
+
112
+ Options:
113
+ --stop-on-error halt if any script fails (default: continue)
114
+ --fresh start each script with a fresh session
115
+
116
+ Examples:
117
+ batch clean.ost model.ost report.ost
118
+ batch *.ost --stop-on-error
119
+ """
120
+ import glob as _glob
121
+
122
+ ca = CommandArgs(args)
123
+ if not ca.positional:
124
+ return "Usage: batch <script1.ost> [script2.ost ...] [--stop-on-error]"
125
+
126
+ stop_on_error = "--stop-on-error" in args
127
+ fresh = "--fresh" in args
128
+
129
+ # Expand glob patterns
130
+ files = []
131
+ for pat in ca.positional:
132
+ matched = sorted(_glob.glob(pat))
133
+ if matched:
134
+ files.extend(matched)
135
+ else:
136
+ files.append(pat)
137
+
138
+ if not files:
139
+ return "No script files found."
140
+
141
+ from openstat.script_runner import run_script_advanced
142
+ results = [f"Batch: {len(files)} scripts", ""]
143
+ errors = 0
144
+
145
+ for i, fpath in enumerate(files, 1):
146
+ if not Path(fpath).exists():
147
+ msg = f"[{i}/{len(files)}] SKIP {fpath} — file not found"
148
+ results.append(msg)
149
+ errors += 1
150
+ if stop_on_error:
151
+ break
152
+ continue
153
+
154
+ results.append(f"[{i}/{len(files)}] Running: {fpath}")
155
+ try:
156
+ run_sess = Session() if fresh else session
157
+ run_script_advanced(fpath, run_sess)
158
+ results.append(f" → OK")
159
+ except Exception as exc:
160
+ results.append(f" → ERROR: {exc}")
161
+ errors += 1
162
+ if stop_on_error:
163
+ results.append("Stopped due to error (--stop-on-error).")
164
+ break
165
+
166
+ results += ["", f"Batch complete. {len(files) - errors}/{len(files)} scripts succeeded."]
167
+ return "\n".join(results)
168
+
169
+
170
+ # ── Git integration ──────────────────────────────────────────────────────────
171
+
172
+ @command("git", usage="git init|status|add|commit|log|diff [args]")
173
+ def cmd_git(session: Session, args: str) -> str:
174
+ """Git version control for scripts and outputs (requires git).
175
+
176
+ Runs git commands in the current working directory.
177
+ Useful for versioning analysis scripts.
178
+
179
+ Sub-commands:
180
+ git init — initialize a git repository
181
+ git status — show working tree status
182
+ git add <file> [file2 ...] — stage files
183
+ git commit "<message>" — commit staged changes
184
+ git log [--n=10] — show recent commits
185
+ git diff [<file>] — show unstaged changes
186
+ git tag <name> — create a tag for this analysis version
187
+
188
+ Examples:
189
+ git init
190
+ git add analysis.ost outputs/results.pdf
191
+ git commit "Add OLS regression results"
192
+ git log --n=5
193
+ """
194
+ tokens = args.strip().split(None, 1)
195
+ if not tokens:
196
+ return "Usage: git init|status|add|commit|log|diff [args]"
197
+
198
+ subcmd = tokens[0].lower()
199
+ rest = tokens[1].strip() if len(tokens) > 1 else ""
200
+
201
+ try:
202
+ import subprocess
203
+
204
+ def _run(cmd_parts):
205
+ result = subprocess.run(
206
+ cmd_parts, capture_output=True, text=True, cwd=os.getcwd()
207
+ )
208
+ out = (result.stdout + result.stderr).strip()
209
+ return out or "(no output)"
210
+
211
+ if subcmd == "init":
212
+ return _run(["git", "init"])
213
+ elif subcmd == "status":
214
+ return _run(["git", "status", "--short"])
215
+ elif subcmd == "add":
216
+ files = rest.split() if rest else ["."]
217
+ return _run(["git", "add"] + files)
218
+ elif subcmd == "commit":
219
+ msg = rest.strip().strip('"\'') or "OpenStat analysis update"
220
+ return _run(["git", "commit", "-m", msg])
221
+ elif subcmd == "log":
222
+ n = 10
223
+ if "--n=" in rest:
224
+ try:
225
+ n = int(rest.split("--n=")[1].split()[0])
226
+ except Exception:
227
+ pass
228
+ return _run(["git", "log", f"--oneline", f"-{n}"])
229
+ elif subcmd == "diff":
230
+ file_arg = [rest] if rest else []
231
+ return _run(["git", "diff"] + file_arg)
232
+ elif subcmd == "tag":
233
+ tag = rest.strip().strip('"\'')
234
+ if not tag:
235
+ return "Usage: git tag <name>"
236
+ return _run(["git", "tag", tag])
237
+ else:
238
+ # Pass through to git directly
239
+ return _run(["git", subcmd] + (rest.split() if rest else []))
240
+ except FileNotFoundError:
241
+ return "git not found. Install git: https://git-scm.com/"
242
+ except Exception as e:
243
+ return friendly_error(e, "git")
244
+
245
+
246
+ # ── Config profiles ───────────────────────────────────────────────────────────
247
+
248
+ @command("profile config", usage="profile config save|load|list|rm <name>")
249
+ def cmd_config_profile(session: Session, args: str) -> str:
250
+ """Save and load configuration profiles.
251
+
252
+ A profile saves the current config (output dir, plot size, etc.)
253
+ with a name so you can switch between setups.
254
+
255
+ Examples:
256
+ profile config save presentation
257
+ profile config save publication
258
+ profile config load presentation
259
+ profile config list
260
+ profile config rm presentation
261
+ """
262
+ import json
263
+ from openstat.config import get_config
264
+
265
+ PROFILE_DIR = Path.home() / ".openstat" / "profiles"
266
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
267
+
268
+ tokens = args.strip().split()
269
+ if not tokens:
270
+ return "Usage: profile config save|load|list|rm <name>"
271
+
272
+ subcmd = tokens[0].lower()
273
+ name = tokens[1] if len(tokens) > 1 else ""
274
+
275
+ if subcmd == "list":
276
+ profiles = list(PROFILE_DIR.glob("*.json"))
277
+ if not profiles:
278
+ return "No saved profiles. Use: profile config save <name>"
279
+ lines = ["Saved config profiles:"]
280
+ for p in profiles:
281
+ lines.append(f" {p.stem}")
282
+ return "\n".join(lines)
283
+
284
+ if not name:
285
+ return f"Usage: profile config {subcmd} <name>"
286
+
287
+ profile_path = PROFILE_DIR / f"{name}.json"
288
+
289
+ if subcmd == "save":
290
+ cfg = get_config()
291
+ data = {
292
+ "output_dir": str(cfg.output_dir),
293
+ "plot_figsize_w": cfg.plot_figsize_w,
294
+ "plot_figsize_h": cfg.plot_figsize_h,
295
+ "plot_dpi": cfg.plot_dpi,
296
+ "csv_separator": cfg.csv_separator,
297
+ "max_undo_stack": cfg.max_undo_stack,
298
+ }
299
+ profile_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
300
+ return f"Profile '{name}' saved to {profile_path}"
301
+
302
+ elif subcmd == "load":
303
+ if not profile_path.exists():
304
+ return f"Profile '{name}' not found. Use 'profile config list' to see available."
305
+ data = json.loads(profile_path.read_text(encoding="utf-8"))
306
+ cfg = get_config()
307
+ for k, v in data.items():
308
+ if hasattr(cfg, k):
309
+ setattr(cfg, k, v)
310
+ return f"Profile '{name}' loaded: {data}"
311
+
312
+ elif subcmd == "rm":
313
+ if not profile_path.exists():
314
+ return f"Profile '{name}' not found."
315
+ profile_path.unlink()
316
+ return f"Profile '{name}' removed."
317
+
318
+ else:
319
+ return f"Unknown sub-command: {subcmd}. Use save, load, list, or rm."
@@ -0,0 +1,189 @@
1
+ """Plot commands: hist, scatter, line, box, bar, heatmap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openstat.session import Session
6
+ from openstat.plots.plotter import (
7
+ plot_histogram, plot_scatter, plot_line, plot_box, plot_bar, plot_heatmap,
8
+ plot_residuals_vs_fitted, plot_qq, plot_scale_location, plot_coef,
9
+ plot_interaction,
10
+ )
11
+ from openstat.commands.base import command, CommandArgs, friendly_error
12
+
13
+
14
+ @command("plot", usage="plot hist|scatter|line|box|bar|heatmap|coef|interaction ...")
15
+ def cmd_plot(session: Session, args: str) -> str:
16
+ """Create plots: hist, scatter, line, box, bar, heatmap, coef, interaction."""
17
+ df = session.require_data()
18
+ ca = CommandArgs(args)
19
+ if not ca.positional:
20
+ return (
21
+ "Usage:\n"
22
+ " plot hist <col>\n"
23
+ " plot scatter <y> <x>\n"
24
+ " plot line <y> <x>\n"
25
+ " plot box <col> [by <group_col>]\n"
26
+ " plot bar <col> [by <group_col>]\n"
27
+ " plot heatmap [col1 col2 ...]\n"
28
+ " plot coef (coefficient plot after model)\n"
29
+ " plot margins (marginal effects plot after margins)\n"
30
+ " plot interaction <y> <x> <mod> (interaction plot, ±1SD split)\n"
31
+ " plot diagnostics (diagnostic plots after model)"
32
+ )
33
+
34
+ subcmd = ca.positional[0]
35
+ try:
36
+ if subcmd == "hist":
37
+ if len(ca.positional) < 2:
38
+ return "Usage: plot hist <col>"
39
+ col = ca.positional[1]
40
+ path = plot_histogram(df, col, session.output_dir)
41
+ session.plot_paths.append(str(path))
42
+ return f"Histogram saved: {path}"
43
+
44
+ elif subcmd == "scatter":
45
+ if len(ca.positional) < 3:
46
+ return "Usage: plot scatter <y_col> <x_col>"
47
+ y_col, x_col = ca.positional[1], ca.positional[2]
48
+ path = plot_scatter(df, y_col, x_col, session.output_dir)
49
+ session.plot_paths.append(str(path))
50
+ return f"Scatter plot saved: {path}"
51
+
52
+ elif subcmd == "line":
53
+ if len(ca.positional) < 3:
54
+ return "Usage: plot line <y_col> <x_col>"
55
+ y_col, x_col = ca.positional[1], ca.positional[2]
56
+ path = plot_line(df, y_col, x_col, session.output_dir)
57
+ session.plot_paths.append(str(path))
58
+ return f"Line plot saved: {path}"
59
+
60
+ elif subcmd == "box":
61
+ if len(ca.positional) < 2:
62
+ return "Usage: plot box <col> [by <group_col>]"
63
+ col = ca.positional[1]
64
+ by_rest = ca.rest_after("by")
65
+ group_col = by_rest.split()[0] if by_rest else None
66
+ path = plot_box(df, col, session.output_dir, group_col=group_col)
67
+ session.plot_paths.append(str(path))
68
+ return f"Box plot saved: {path}"
69
+
70
+ elif subcmd == "bar":
71
+ if len(ca.positional) < 2:
72
+ return "Usage: plot bar <col> [by <group_col>]"
73
+ col = ca.positional[1]
74
+ by_rest = ca.rest_after("by")
75
+ group_col = by_rest.split()[0] if by_rest else None
76
+ path = plot_bar(df, col, session.output_dir, group_col=group_col)
77
+ session.plot_paths.append(str(path))
78
+ return f"Bar chart saved: {path}"
79
+
80
+ elif subcmd == "heatmap":
81
+ cols = ca.positional[1:] if len(ca.positional) > 1 else None
82
+ path = plot_heatmap(df, cols, session.output_dir)
83
+ session.plot_paths.append(str(path))
84
+ return f"Heatmap saved: {path}"
85
+
86
+ elif subcmd == "acf":
87
+ if len(ca.positional) < 2:
88
+ return "Usage: plot acf <col>"
89
+ col = ca.positional[1]
90
+ from openstat.plots.ts_plots import plot_acf
91
+ series = df[col].drop_nulls().to_numpy()
92
+ path = plot_acf(series, col, session.output_dir)
93
+ session.plot_paths.append(str(path))
94
+ return f"ACF plot saved: {path}"
95
+
96
+ elif subcmd == "pacf":
97
+ if len(ca.positional) < 2:
98
+ return "Usage: plot pacf <col>"
99
+ col = ca.positional[1]
100
+ from openstat.plots.ts_plots import plot_pacf
101
+ series = df[col].drop_nulls().to_numpy()
102
+ path = plot_pacf(series, col, session.output_dir)
103
+ session.plot_paths.append(str(path))
104
+ return f"PACF plot saved: {path}"
105
+
106
+ elif subcmd == "coef":
107
+ # session._last_model is the raw statsmodels result object
108
+ raw = session._last_model
109
+ if raw is None:
110
+ return "No model fitted yet. Run ols/logit/etc. first, then plot coef."
111
+ if not hasattr(raw, "params"):
112
+ return "Current model does not support coefficient plots."
113
+ import numpy as np
114
+ # Get parameter names from model or FitResult
115
+ fit_result = session._last_fit_result
116
+ if fit_result is not None and hasattr(fit_result, "params") and isinstance(fit_result.params, dict):
117
+ names = list(fit_result.params.keys())
118
+ coef_vals = [fit_result.params[n] for n in names]
119
+ se_vals = [fit_result.std_errors.get(n, 0.0) for n in names]
120
+ params = dict(zip(names, coef_vals))
121
+ ci_lower = {n: v - 1.96 * se for n, v, se in zip(names, coef_vals, se_vals)}
122
+ ci_upper = {n: v + 1.96 * se for n, v, se in zip(names, coef_vals, se_vals)}
123
+ else:
124
+ # Fallback: use exog_names from the statsmodels model
125
+ names = list(getattr(getattr(raw, "model", None), "exog_names", None) or [])
126
+ coef_arr = np.atleast_1d(raw.params)
127
+ if not names:
128
+ names = [f"x{i}" for i in range(len(coef_arr))]
129
+ params = dict(zip(names, coef_arr.tolist()))
130
+ try:
131
+ ci_arr = np.atleast_2d(raw.conf_int())
132
+ ci_lower = dict(zip(names, ci_arr[:, 0].tolist()))
133
+ ci_upper = dict(zip(names, ci_arr[:, 1].tolist()))
134
+ except Exception:
135
+ se_arr = np.atleast_1d(raw.bse)
136
+ ci_lower = {n: v - 1.96 * se for n, v, se in zip(names, coef_arr, se_arr)}
137
+ ci_upper = {n: v + 1.96 * se for n, v, se in zip(names, coef_arr, se_arr)}
138
+ model_cls = type(getattr(raw, "model", raw)).__name__
139
+ title = f"Coefficient Plot ({model_cls})"
140
+ path = plot_coef(params, ci_lower, ci_upper, session.output_dir, title=title)
141
+ session.plot_paths.append(str(path))
142
+ return f"Coefficient plot saved: {path}"
143
+
144
+ elif subcmd == "margins":
145
+ mg = session._last_margins
146
+ if mg is None:
147
+ return "No margins result. Run 'margins' after logit/probit first."
148
+ if not hasattr(mg, "effects"):
149
+ return "Stored margins result has no 'effects' attribute."
150
+ params = dict(mg.effects)
151
+ ci_lower = dict(mg.conf_int_low)
152
+ ci_upper = dict(mg.conf_int_high)
153
+ path = plot_coef(
154
+ params, ci_lower, ci_upper, session.output_dir,
155
+ title=f"Marginal Effects ({mg.method})",
156
+ drop_const=False,
157
+ )
158
+ session.plot_paths.append(str(path))
159
+ return f"Marginal effects plot saved: {path}"
160
+
161
+ elif subcmd == "interaction":
162
+ # plot interaction <y> <x> <moderator> [--levels=N]
163
+ if len(ca.positional) < 4:
164
+ return "Usage: plot interaction <y> <x> <moderator> [--levels=3]"
165
+ y_col = ca.positional[1]
166
+ x_col = ca.positional[2]
167
+ mod_col = ca.positional[3]
168
+ n_levels = int(ca.options.get("levels", 3))
169
+ path = plot_interaction(df, y_col, x_col, mod_col, session.output_dir, n_levels=n_levels)
170
+ session.plot_paths.append(str(path))
171
+ return f"Interaction plot saved: {path}"
172
+
173
+ elif subcmd == "diagnostics":
174
+ if session._last_model is None or session._last_model_vars is None:
175
+ return "No model fitted yet. Run ols first, then plot diagnostics."
176
+ from openstat.stats.models import compute_residuals
177
+ dep, indeps = session._last_model_vars
178
+ diag = compute_residuals(session._last_model, df, dep, indeps)
179
+ paths = []
180
+ paths.append(plot_residuals_vs_fitted(diag["fitted"], diag["residuals"], session.output_dir))
181
+ paths.append(plot_qq(diag["std_residuals"], session.output_dir))
182
+ paths.append(plot_scale_location(diag["fitted"], diag["std_residuals"], session.output_dir))
183
+ session.plot_paths.extend(str(p) for p in paths)
184
+ return "Diagnostic plots saved:\n" + "\n".join(f" {p}" for p in paths)
185
+
186
+ else:
187
+ return f"Unknown plot type: {subcmd}. Available: hist, scatter, line, box, bar, heatmap, coef, margins, interaction, diagnostics"
188
+ except Exception as e:
189
+ return friendly_error(e, "Plot error")
@@ -0,0 +1,79 @@
1
+ """Plugin management commands: plugin list, plugin info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from openstat.session import Session
9
+ from openstat.commands.base import command, rich_to_str
10
+
11
+
12
+ # Global plugin manager — initialized at REPL startup
13
+ _manager = None
14
+
15
+
16
+ def get_plugin_manager():
17
+ global _manager
18
+ if _manager is None:
19
+ from openstat.plugins.manager import PluginManager
20
+ _manager = PluginManager()
21
+ return _manager
22
+
23
+
24
+ def init_plugins() -> list[str]:
25
+ """Discover and load all plugins. Called at REPL startup."""
26
+ mgr = get_plugin_manager()
27
+ return mgr.discover()
28
+
29
+
30
+ @command("plugin", usage="plugin list | plugin info <name>")
31
+ def cmd_plugin(session: Session, args: str) -> str:
32
+ """Manage plugins: list installed plugins or show plugin details."""
33
+ mgr = get_plugin_manager()
34
+ parts = args.strip().split(None, 1)
35
+ subcmd = parts[0].lower() if parts else ""
36
+
37
+ if subcmd == "list":
38
+ plugins = mgr.list_plugins()
39
+ if not plugins:
40
+ return "No plugins installed. Install plugins with: pip install <plugin-package>"
41
+
42
+ def render(console: Console) -> None:
43
+ table = Table(title="Installed Plugins")
44
+ table.add_column("Name", style="cyan")
45
+ table.add_column("Version", justify="right")
46
+ table.add_column("Description")
47
+ table.add_column("Commands", style="green")
48
+ for p in plugins:
49
+ table.add_row(
50
+ p.name, p.version, p.description,
51
+ ", ".join(p.commands) if p.commands else "—",
52
+ )
53
+ console.print(table)
54
+
55
+ errors = mgr.errors
56
+ result = rich_to_str(render)
57
+ if errors:
58
+ result += "\n\nFailed to load:"
59
+ for name, err in errors.items():
60
+ result += f"\n {name}: {err}"
61
+ return result
62
+
63
+ elif subcmd == "info":
64
+ name = parts[1].strip() if len(parts) > 1 else ""
65
+ if not name:
66
+ return "Usage: plugin info <name>"
67
+ info = mgr.get_info(name)
68
+ if info is None:
69
+ return f"Plugin '{name}' not found. Use 'plugin list' to see installed plugins."
70
+ lines = [
71
+ f"Plugin: {info.name}",
72
+ f"Version: {info.version}",
73
+ f"Description: {info.description or '(none)'}",
74
+ f"Commands: {', '.join(info.commands) if info.commands else '(none)'}",
75
+ ]
76
+ return "\n".join(lines)
77
+
78
+ else:
79
+ return "Usage: plugin list | plugin info <name>"