abax 0.1.2__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 (223) hide show
  1. abax/__init__.py +4 -0
  2. abax/__main__.py +6 -0
  3. abax/_runtime.py +119 -0
  4. abax/app.py +239 -0
  5. abax/autodeps.py +221 -0
  6. abax/console_worker.py +117 -0
  7. abax/core/__init__.py +33 -0
  8. abax/core/archive.py +133 -0
  9. abax/core/arrayfuncs.py +197 -0
  10. abax/core/ast_nodes.py +69 -0
  11. abax/core/budget.py +171 -0
  12. abax/core/calc/__init__.py +1 -0
  13. abax/core/calc/algebraic.py +530 -0
  14. abax/core/calc/rpn.py +350 -0
  15. abax/core/calc/rpn12.py +476 -0
  16. abax/core/calc/rpn16.py +516 -0
  17. abax/core/calc/ti_engine.py +265 -0
  18. abax/core/calc/voyager.py +140 -0
  19. abax/core/calc/voyager_layout.py +123 -0
  20. abax/core/cells.py +51 -0
  21. abax/core/clipboard.py +221 -0
  22. abax/core/completion.py +301 -0
  23. abax/core/console_ns.py +211 -0
  24. abax/core/criteria.py +92 -0
  25. abax/core/engineering_fns.py +648 -0
  26. abax/core/errors.py +52 -0
  27. abax/core/evaluator.py +197 -0
  28. abax/core/faceplate_assets.py +175 -0
  29. abax/core/fileops.py +221 -0
  30. abax/core/filesearch.py +101 -0
  31. abax/core/fill.py +196 -0
  32. abax/core/finance_fns.py +784 -0
  33. abax/core/fmbuttons.py +133 -0
  34. abax/core/fonts.py +77 -0
  35. abax/core/format/__init__.py +1 -0
  36. abax/core/format/ansipalette.py +60 -0
  37. abax/core/format/cellformat.py +64 -0
  38. abax/core/format/cellstyle.py +100 -0
  39. abax/core/format/colormap.py +44 -0
  40. abax/core/format/condformat.py +233 -0
  41. abax/core/functions/__init__.py +216 -0
  42. abax/core/functions/builtins.py +1520 -0
  43. abax/core/functions/helpers.py +167 -0
  44. abax/core/functions/rf.py +184 -0
  45. abax/core/goalseek.py +177 -0
  46. abax/core/graphing.py +271 -0
  47. abax/core/io/__init__.py +1 -0
  48. abax/core/io/adif_io.py +151 -0
  49. abax/core/io/csv_io.py +76 -0
  50. abax/core/io/csv_stream.py +301 -0
  51. abax/core/io/exchange_io.py +169 -0
  52. abax/core/io/flatfile_io.py +223 -0
  53. abax/core/io/html_report.py +104 -0
  54. abax/core/io/markdown_io.py +142 -0
  55. abax/core/io/notebook_io.py +165 -0
  56. abax/core/io/r_io.py +184 -0
  57. abax/core/io/sqlite_io.py +180 -0
  58. abax/core/io/urlfetch.py +177 -0
  59. abax/core/io/xml_io.py +142 -0
  60. abax/core/latexmath.py +282 -0
  61. abax/core/math_fns.py +693 -0
  62. abax/core/names.py +166 -0
  63. abax/core/navigation.py +120 -0
  64. abax/core/pandoc.py +68 -0
  65. abax/core/parser.py +162 -0
  66. abax/core/pivot.py +288 -0
  67. abax/core/precedents.py +127 -0
  68. abax/core/profile.py +175 -0
  69. abax/core/ptyterm.py +325 -0
  70. abax/core/r1c1.py +116 -0
  71. abax/core/recode.py +398 -0
  72. abax/core/reference.py +87 -0
  73. abax/core/reffuncs.py +165 -0
  74. abax/core/richdisplay.py +79 -0
  75. abax/core/science/__init__.py +1 -0
  76. abax/core/science/antenna.py +172 -0
  77. abax/core/science/antenna_impedance.py +139 -0
  78. abax/core/science/bayes.py +317 -0
  79. abax/core/science/chartsvg.py +298 -0
  80. abax/core/science/cluster.py +307 -0
  81. abax/core/science/complexnum.py +234 -0
  82. abax/core/science/dxcc.py +486 -0
  83. abax/core/science/eigen.py +404 -0
  84. abax/core/science/fft.py +154 -0
  85. abax/core/science/filters.py +314 -0
  86. abax/core/science/financial.py +597 -0
  87. abax/core/science/gmm.py +326 -0
  88. abax/core/science/interp.py +196 -0
  89. abax/core/science/iq.py +109 -0
  90. abax/core/science/matrix.py +229 -0
  91. abax/core/science/metrics.py +305 -0
  92. abax/core/science/ml.py +396 -0
  93. abax/core/science/mom.py +193 -0
  94. abax/core/science/nec.py +209 -0
  95. abax/core/science/numeric.py +192 -0
  96. abax/core/science/ode.py +267 -0
  97. abax/core/science/ode_implicit.py +361 -0
  98. abax/core/science/regression.py +189 -0
  99. abax/core/science/resynth.py +243 -0
  100. abax/core/science/rf.py +359 -0
  101. abax/core/science/rf_bands.py +81 -0
  102. abax/core/science/rf_math.py +175 -0
  103. abax/core/science/signal.py +243 -0
  104. abax/core/science/spectral.py +217 -0
  105. abax/core/science/stats.py +632 -0
  106. abax/core/science/trees.py +378 -0
  107. abax/core/science/units.py +252 -0
  108. abax/core/science/wire_mom.py +277 -0
  109. abax/core/search.py +161 -0
  110. abax/core/series.py +133 -0
  111. abax/core/sheet.py +404 -0
  112. abax/core/shell.py +118 -0
  113. abax/core/sortfilter.py +181 -0
  114. abax/core/sqlsheets.py +202 -0
  115. abax/core/stats_dist.py +1081 -0
  116. abax/core/structure.py +233 -0
  117. abax/core/text_datetime_fns.py +536 -0
  118. abax/core/tokenizer.py +101 -0
  119. abax/core/translate.py +87 -0
  120. abax/core/typeinfer.py +156 -0
  121. abax/core/undo.py +89 -0
  122. abax/core/validation.py +188 -0
  123. abax/core/values.py +53 -0
  124. abax/core/wbdiff.py +107 -0
  125. abax/core/workbook.py +180 -0
  126. abax/diagnostics.py +139 -0
  127. abax/engine/__init__.py +16 -0
  128. abax/engine/analysis.py +699 -0
  129. abax/engine/document.py +168 -0
  130. abax/engine/excel_io.py +97 -0
  131. abax/engine/nbvalidate.py +81 -0
  132. abax/engine/necpy.py +148 -0
  133. abax/engine/npkernel.py +63 -0
  134. abax/engine/ods_io.py +222 -0
  135. abax/engine/parquet_io.py +134 -0
  136. abax/gui/__init__.py +5 -0
  137. abax/gui/_qtcompat.py +232 -0
  138. abax/gui/calc/__init__.py +1 -0
  139. abax/gui/calc/algebraic_faceplate.py +114 -0
  140. abax/gui/calc/calculator_panel.py +220 -0
  141. abax/gui/calc/faceplate.py +488 -0
  142. abax/gui/calc/image_faceplate.py +245 -0
  143. abax/gui/calc/ti_faceplate.py +731 -0
  144. abax/gui/command_palette.py +154 -0
  145. abax/gui/completion.py +53 -0
  146. abax/gui/console/__init__.py +1 -0
  147. abax/gui/console/console_bridge.py +102 -0
  148. abax/gui/console/ptyterminal.py +185 -0
  149. abax/gui/console/pyconsole.py +244 -0
  150. abax/gui/console/terminal.py +83 -0
  151. abax/gui/dialogs/__init__.py +1 -0
  152. abax/gui/dialogs/antenna_dialog.py +220 -0
  153. abax/gui/dialogs/budget_dialog.py +170 -0
  154. abax/gui/dialogs/clipboard_dialog.py +102 -0
  155. abax/gui/dialogs/condformat_dialog.py +93 -0
  156. abax/gui/dialogs/dataframe_dialog.py +117 -0
  157. abax/gui/dialogs/deps_dialog.py +151 -0
  158. abax/gui/dialogs/equation_dialog.py +106 -0
  159. abax/gui/dialogs/filemanager_dialog.py +541 -0
  160. abax/gui/dialogs/filter_dialog.py +90 -0
  161. abax/gui/dialogs/find_dialog.py +146 -0
  162. abax/gui/dialogs/formula_browser.py +67 -0
  163. abax/gui/dialogs/goalseek_dialog.py +87 -0
  164. abax/gui/dialogs/graph_dialog.py +522 -0
  165. abax/gui/dialogs/matrix_dialog.py +108 -0
  166. abax/gui/dialogs/ml_dialog.py +166 -0
  167. abax/gui/dialogs/name_manager_dialog.py +63 -0
  168. abax/gui/dialogs/ode_dialog.py +104 -0
  169. abax/gui/dialogs/pivot_dialog.py +126 -0
  170. abax/gui/dialogs/recode_dialog.py +130 -0
  171. abax/gui/dialogs/rf_dialog.py +160 -0
  172. abax/gui/dialogs/rf_reference_dialog.py +113 -0
  173. abax/gui/dialogs/signal_dialog.py +209 -0
  174. abax/gui/dialogs/smith_dialog.py +169 -0
  175. abax/gui/dialogs/solver_dialog.py +88 -0
  176. abax/gui/dialogs/sort_dialog.py +77 -0
  177. abax/gui/dialogs/sql_dialog.py +105 -0
  178. abax/gui/dialogs/stats_dialog.py +175 -0
  179. abax/gui/dialogs/theme_dialog.py +74 -0
  180. abax/gui/dialogs/undo_history_dialog.py +55 -0
  181. abax/gui/dialogs/validation_dialog.py +90 -0
  182. abax/gui/grid/__init__.py +1 -0
  183. abax/gui/grid/frozen_panes.py +127 -0
  184. abax/gui/grid/grid_model.py +211 -0
  185. abax/gui/grid/grid_view.py +312 -0
  186. abax/gui/icons.py +359 -0
  187. abax/gui/main_window.py +823 -0
  188. abax/gui/mixin_calc.py +120 -0
  189. abax/gui/mixin_console.py +92 -0
  190. abax/gui/mixin_document.py +698 -0
  191. abax/gui/mixin_io.py +265 -0
  192. abax/gui/mixin_macros.py +124 -0
  193. abax/gui/mixin_navigation.py +60 -0
  194. abax/gui/mixin_palette.py +156 -0
  195. abax/gui/mixin_settings.py +20 -0
  196. abax/gui/mixin_tools.py +433 -0
  197. abax/gui/mixin_view.py +163 -0
  198. abax/gui/runner.py +73 -0
  199. abax/gui/themes/high_contrast.qss +57 -0
  200. abax/gui/themes/light.qss +53 -0
  201. abax/gui/themes/obsidian.qss +53 -0
  202. abax/gui/theming.py +145 -0
  203. abax/kernel.py +154 -0
  204. abax/macros.py +183 -0
  205. abax/recorder.py +237 -0
  206. abax/settings.py +103 -0
  207. abax/state.py +58 -0
  208. abax/tui/__init__.py +34 -0
  209. abax/tui/app.py +53 -0
  210. abax/tui/capabilities.py +27 -0
  211. abax/tui/commands.py +21 -0
  212. abax/tui/editor.py +617 -0
  213. abax/tui/keys.py +86 -0
  214. abax/tui/render.py +205 -0
  215. abax/tui/themes.py +112 -0
  216. abax/widget.py +131 -0
  217. abax/workers.py +82 -0
  218. abax-0.1.2.dist-info/METADATA +366 -0
  219. abax-0.1.2.dist-info/RECORD +223 -0
  220. abax-0.1.2.dist-info/WHEEL +5 -0
  221. abax-0.1.2.dist-info/entry_points.txt +3 -0
  222. abax-0.1.2.dist-info/licenses/LICENSE +674 -0
  223. abax-0.1.2.dist-info/top_level.txt +1 -0
abax/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """abax — a keyboard-first statistics and data-science workstation."""
2
+
3
+ __version__ = "0.1.2"
4
+ __all__ = ["__version__"]
abax/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m abax`` entry point."""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
abax/_runtime.py ADDED
@@ -0,0 +1,119 @@
1
+ """Runtime detection: platform paths, optional-dependency booleans, version.
2
+
3
+ Imported widely, so it stays cheap: no Qt, no curses, no heavy work at import.
4
+ Mirrors the spec's _runtime.py exactly, parameterized for project "abax".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ APP_NAME = "abax"
14
+
15
+ # --- optional dependency flags --------------------------------------------
16
+
17
+ try:
18
+ import msgspec as _ms # noqa: F401
19
+
20
+ _HAS_MSGSPEC = True
21
+ except ImportError:
22
+ _ms = None
23
+ _HAS_MSGSPEC = False
24
+
25
+ try:
26
+ import platformdirs as _pd_mod # noqa: F401
27
+
28
+ _HAS_PLATFORMDIRS = True
29
+ except ImportError:
30
+ _pd_mod = None
31
+ _HAS_PLATFORMDIRS = False
32
+
33
+ try:
34
+ import openpyxl as _openpyxl # noqa: F401
35
+
36
+ _HAS_OPENPYXL = True
37
+ except ImportError:
38
+ _openpyxl = None
39
+ _HAS_OPENPYXL = False
40
+
41
+ try:
42
+ import importlib.util as _ilu
43
+
44
+ # GUI works on either Qt binding; PySide6 (LGPL) is preferred. Detect via
45
+ # find_spec so the fast paths never import a heavy Qt stack just to check.
46
+ _HAS_QT = (_ilu.find_spec("PySide6") is not None
47
+ or _ilu.find_spec("PyQt6") is not None)
48
+ except Exception:
49
+ _HAS_QT = False
50
+
51
+ try:
52
+ import textual # noqa: F401
53
+
54
+ _HAS_TEXTUAL = True
55
+ except ImportError:
56
+ _HAS_TEXTUAL = False
57
+
58
+ # --- version flags ---------------------------------------------------------
59
+
60
+ PY_VERSION = sys.version_info
61
+ HAS_LAZY_IMPORTS = PY_VERSION >= (3, 15) # PEP 810
62
+
63
+ # --- platform paths --------------------------------------------------------
64
+
65
+ if _HAS_PLATFORMDIRS:
66
+ from platformdirs import PlatformDirs as _PD
67
+
68
+ _dirs = _PD(APP_NAME, appauthor=False)
69
+ CONFIG_DIR = Path(_dirs.user_config_dir)
70
+ DATA_DIR = Path(_dirs.user_data_dir)
71
+ CACHE_DIR = Path(_dirs.user_cache_dir)
72
+ LOG_DIR = Path(_dirs.user_log_dir)
73
+ else:
74
+ # stdlib fallback — mirrors platformdirs logic.
75
+ _home = Path.home()
76
+ if sys.platform == "win32":
77
+ _base = Path(os.environ.get("APPDATA", _home / "AppData/Roaming"))
78
+ _local = Path(os.environ.get("LOCALAPPDATA", _home / "AppData/Local"))
79
+ CONFIG_DIR = _base / APP_NAME
80
+ DATA_DIR = _local / APP_NAME
81
+ CACHE_DIR = _local / APP_NAME / "Cache"
82
+ LOG_DIR = _local / APP_NAME / "Logs"
83
+ elif sys.platform == "darwin":
84
+ _sup = _home / "Library" / "Application Support" / APP_NAME
85
+ CONFIG_DIR = _sup
86
+ DATA_DIR = _sup
87
+ CACHE_DIR = _home / "Library" / "Caches" / APP_NAME
88
+ LOG_DIR = _home / "Library" / "Logs" / APP_NAME
89
+ else:
90
+ _cfg = Path(os.environ.get("XDG_CONFIG_HOME", _home / ".config"))
91
+ _data = Path(os.environ.get("XDG_DATA_HOME", _home / ".local/share"))
92
+ _cache = Path(os.environ.get("XDG_CACHE_HOME", _home / ".cache"))
93
+ CONFIG_DIR = _cfg / APP_NAME
94
+ DATA_DIR = _data / APP_NAME
95
+ CACHE_DIR = _cache / APP_NAME
96
+ LOG_DIR = _data / APP_NAME / "logs"
97
+
98
+ EXCHANGE_DIR = DATA_DIR / "exchange"
99
+
100
+ for _d in (CONFIG_DIR, DATA_DIR, CACHE_DIR, LOG_DIR, EXCHANGE_DIR):
101
+ _d.mkdir(parents=True, exist_ok=True)
102
+
103
+ # --- optional aggregate accelerator ---------------------------------------
104
+ # The stdlib core reduces ranges itself; the engine layer may inject a faster
105
+ # numpy-backed reducer here for large all-numeric ranges. core reads the slot
106
+ # through this seam (abax._runtime is the one sanctioned cross-layer import),
107
+ # so it never imports numpy directly.
108
+ _aggregate_accelerator = None
109
+
110
+
111
+ def set_aggregate_accelerator(fn) -> None:
112
+ """Register (or clear, with ``None``) the optional range-aggregate accelerator."""
113
+ global _aggregate_accelerator
114
+ _aggregate_accelerator = fn
115
+
116
+
117
+ def aggregate_accelerator():
118
+ """The currently registered aggregate accelerator, or ``None``."""
119
+ return _aggregate_accelerator
abax/app.py ADDED
@@ -0,0 +1,239 @@
1
+ """CLI entry point.
2
+
3
+ Lazy imports only — never import Qt, Textual, or curses at module top level.
4
+ The ``--help``/``--version``/``--deps`` fast paths respond instantly without
5
+ touching the GUI/TUI stacks (and never create a venv).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from . import __version__
14
+
15
+
16
+ def _build_parser() -> argparse.ArgumentParser:
17
+ p = argparse.ArgumentParser(
18
+ prog="abax",
19
+ description="abax — a keyboard-first statistics and data-science workstation.",
20
+ )
21
+ p.add_argument("--version", action="store_true", help="print version and exit")
22
+ p.add_argument("--deps", action="store_true", help="print optional-dependency status and exit")
23
+ p.add_argument(
24
+ "--macros",
25
+ action="append",
26
+ default=[],
27
+ metavar="PATH",
28
+ help="macro file or directory to load (repeatable); adds UDFs and macros",
29
+ )
30
+ sub = p.add_subparsers(dest="command")
31
+
32
+ pg = sub.add_parser("gui", help="launch the Qt GUI")
33
+ pg.add_argument("file", nargs="?", help="spreadsheet to open")
34
+
35
+ pt = sub.add_parser("tui", help="launch the curses TUI")
36
+ pt.add_argument("file", nargs="?", help="spreadsheet to open")
37
+
38
+ pv = sub.add_parser("view", help="print a spreadsheet as a table")
39
+ pv.add_argument("file", help="spreadsheet to open (.csv/.xlsx/.json)")
40
+ pv.add_argument("--sheet", help="sheet name (default: active)")
41
+
42
+ pc = sub.add_parser("convert", help="convert between formats by extension")
43
+ pc.add_argument("src")
44
+ pc.add_argument("dst")
45
+ pc.add_argument("--values", action="store_true", help="write computed values, not formulas")
46
+
47
+ pe = sub.add_parser("get", help="print one cell's computed value")
48
+ pe.add_argument("file")
49
+ pe.add_argument("ref", help="A1 reference, e.g. B7")
50
+
51
+ sub.add_parser("deps", help="install the optional dependencies (full-fat)")
52
+
53
+ pm = sub.add_parser("macro", help="list or run macros")
54
+ msub = pm.add_subparsers(dest="macro_cmd")
55
+ msub.add_parser("list", help="list discovered macros and user functions")
56
+ mr = msub.add_parser("run", help="run a macro against a file")
57
+ mr.add_argument("name")
58
+ mr.add_argument("file")
59
+ mr.add_argument("-o", "--output", help="save path (default: overwrite the input file)")
60
+ mr.add_argument("--at", metavar="A1", help="anchor cell for relative macros (e.g. C5)")
61
+
62
+ return p
63
+
64
+
65
+ _SUBCOMMANDS = frozenset({"gui", "tui", "view", "convert", "get", "macro", "deps"})
66
+
67
+
68
+ def _normalize_argv(argv: list[str]) -> list[str]:
69
+ """A bare file path (no subcommand) opens it in the GUI, so `abax data.csv`
70
+ behaves like `abax gui data.csv`. Flags and real subcommands pass through."""
71
+ if argv and not argv[0].startswith("-") and argv[0] not in _SUBCOMMANDS:
72
+ return ["gui", *argv]
73
+ return argv
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ argv = _normalize_argv(sys.argv[1:] if argv is None else argv)
78
+ parser = _build_parser()
79
+ args = parser.parse_args(argv)
80
+
81
+ # --- fast paths (no heavy imports) ---
82
+ if args.version:
83
+ print(f"abax {__version__}")
84
+ return 0
85
+ if args.deps:
86
+ from .diagnostics import format_deps
87
+
88
+ print(format_deps())
89
+ return 0
90
+
91
+ # Load macros (and their UDFs) so every command — view/get/gui/tui — can use
92
+ # them. Cheap when no macro files exist; runs after the fast paths above.
93
+ from .macros import default_macro_dirs, discover_macros, install_functions
94
+
95
+ registry = discover_macros([*default_macro_dirs(), *args.macros])
96
+ udfs = install_functions(registry)
97
+
98
+ cmd = args.command
99
+ if cmd == "gui":
100
+ from .gui.runner import run_gui
101
+
102
+ return run_gui(args.file, registry)
103
+ if cmd == "tui":
104
+ from .tui import run_tui
105
+
106
+ return run_tui(args.file, registry)
107
+ if cmd == "view":
108
+ return _cmd_view(args.file, args.sheet)
109
+ if cmd == "convert":
110
+ return _cmd_convert(args.src, args.dst, args.values)
111
+ if cmd == "get":
112
+ return _cmd_get(args.file, args.ref)
113
+ if cmd == "deps":
114
+ return _cmd_deps()
115
+ if cmd == "macro":
116
+ return _cmd_macro(args, registry, udfs)
117
+
118
+ # No subcommand: prefer GUI, fall back to TUI, then help.
119
+ from . import _runtime as rt
120
+
121
+ if rt._HAS_QT:
122
+ from .gui.runner import run_gui
123
+
124
+ return run_gui(None, registry)
125
+ if sys.stdout.isatty():
126
+ from .tui import run_tui
127
+
128
+ return run_tui(None, registry)
129
+ parser.print_help()
130
+ return 0
131
+
132
+
133
+ def _cmd_deps() -> int:
134
+ """Install every optional dependency (full-fat), blocking with progress."""
135
+ from . import autodeps
136
+
137
+ autodeps.set_enabled(True)
138
+ todo = autodeps.prefetch_all(background=False, force=True)
139
+ if todo:
140
+ print(f"Attempted {len(todo)} package(s): {', '.join(todo)}")
141
+ have = sum(1 for _pip, mod in autodeps.ALL if autodeps.installed(mod))
142
+ print(f"Optional dependencies present: {have}/{len(autodeps.ALL)}")
143
+ return 0
144
+
145
+
146
+ def _cmd_view(path: str, sheet_name: str | None) -> int:
147
+ from .engine.document import Document
148
+
149
+ doc = Document.open(path)
150
+ sheet = doc.workbook.get_sheet(sheet_name) if sheet_name else doc.workbook.sheet
151
+ if sheet is None:
152
+ print(f"no such sheet: {sheet_name}", file=sys.stderr)
153
+ return 2
154
+ print(_render_table(sheet))
155
+ return 0
156
+
157
+
158
+ def _cmd_convert(src: str, dst: str, values: bool) -> int:
159
+ from .engine.document import Document
160
+
161
+ doc = Document.open(src)
162
+ try:
163
+ doc.save(dst)
164
+ except RuntimeError as exc: # e.g. openpyxl missing
165
+ print(str(exc), file=sys.stderr)
166
+ return 3
167
+ print(f"converted {src} -> {dst}")
168
+ return 0
169
+
170
+
171
+ def _cmd_get(path: str, ref: str) -> int:
172
+ from .engine.document import Document
173
+
174
+ doc = Document.open(path)
175
+ print(doc.workbook.sheet.format_value(doc.workbook.sheet.get(ref)))
176
+ return 0
177
+
178
+
179
+ def _cmd_macro(args, registry, udfs) -> int:
180
+ if args.macro_cmd == "run":
181
+ from .engine.document import Document
182
+ from .macros import MacroError, run_macro
183
+
184
+ cursor = None
185
+ if args.at:
186
+ from .core.reference import parse_a1
187
+
188
+ cursor = parse_a1(args.at)
189
+ doc = Document.open(args.file)
190
+ try:
191
+ ctx = run_macro(registry, args.name, doc.workbook, cursor=cursor)
192
+ except MacroError as exc:
193
+ print(str(exc), file=sys.stderr)
194
+ return 4
195
+ for msg in ctx.messages:
196
+ print(msg)
197
+ out = args.output or args.file
198
+ doc.save(out)
199
+ print(f"ran macro {args.name!r}; saved {out}")
200
+ return 0
201
+
202
+ # default / "list"
203
+ if registry.macros:
204
+ print("macros:")
205
+ for name in sorted(registry.macros):
206
+ print(f" {name}")
207
+ else:
208
+ print("no macros found (drop .py files in CONFIG_DIR/macros or pass --macros PATH)")
209
+ if udfs:
210
+ print("user functions:")
211
+ for name in udfs:
212
+ print(f" {name}()")
213
+ return 0
214
+
215
+
216
+ def _render_table(sheet) -> str:
217
+ from .core.reference import index_to_col
218
+
219
+ n_rows, n_cols = sheet.used_bounds()
220
+ if n_rows == 0:
221
+ return "(empty)"
222
+ cells = [[sheet.display(r, c) for c in range(n_cols)] for r in range(n_rows)]
223
+ headers = [index_to_col(c) for c in range(n_cols)]
224
+ row_label_w = len(str(n_rows))
225
+ widths = [
226
+ max(len(headers[c]), max((len(cells[r][c]) for r in range(n_rows)), default=0))
227
+ for c in range(n_cols)
228
+ ]
229
+ out = [" " * row_label_w + " | " + " | ".join(h.ljust(widths[c]) for c, h in enumerate(headers))]
230
+ out.append("-" * len(out[0]))
231
+ for r in range(n_rows):
232
+ line = str(r + 1).rjust(row_label_w) + " | "
233
+ line += " | ".join(cells[r][c].ljust(widths[c]) for c in range(n_cols))
234
+ out.append(line)
235
+ return "\n".join(out)
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
abax/autodeps.py ADDED
@@ -0,0 +1,221 @@
1
+ """On-demand auto-installation of abax's optional dependencies.
2
+
3
+ abax's core is stdlib-only and every heavier capability is an *optional* package
4
+ with a graceful fallback. By default abax fetches those packages **automatically**
5
+ in the background, so a plain install grows into a "full-fat" one on its own — the
6
+ data-science stack, Excel/Parquet I/O, the PTY terminal, and Jupyter integration
7
+ all appear without the user running a single `pip install … [extra]`.
8
+
9
+ Design points:
10
+ - **Best-effort & non-blocking.** Installs run in a daemon thread; startup and the
11
+ UI never wait on pip. If pip is missing, the machine is offline, or a build fails,
12
+ abax silently keeps using its pure-Python fallbacks.
13
+ - **Attempted once per machine.** A marker file per package (under the cache dir)
14
+ means a slow or failing install isn't retried on every launch. A *forced* install
15
+ (the explicit "install optional features now" action) ignores the markers.
16
+ - **Opt-out.** ``settings.auto_install = False`` or the ``ABAX_NO_AUTOINSTALL``
17
+ environment variable disables it entirely.
18
+
19
+ The GUI binding itself (PySide6/PyQt6) is **not** auto-installed — you need a Qt
20
+ binding to launch the GUI in the first place, and it's the one heavyweight a user
21
+ deliberately chooses (`pip install abax[gui]`).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import importlib.util
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import threading
31
+
32
+ # (pip distribution name, import module name) for every optional dependency.
33
+ # Ordered light -> heavy so the quick wins land first and the slowest (pymc) last.
34
+ _SCIENCE = [
35
+ ("numpy", "numpy"), ("pandas", "pandas"), ("scipy", "scipy"),
36
+ ("scikit-learn", "sklearn"), ("statsmodels", "statsmodels"),
37
+ ("pingouin", "pingouin"), ("lifelines", "lifelines"),
38
+ ("scikit-survival", "sksurv"),
39
+ ]
40
+ # Bayesian stack, split out (pymc pulls pytensor + arviz + numba/llvmlite ~150 MB).
41
+ # Still part of the default full-fat `ALL` set below.
42
+ _BAYES = [("pymc", "pymc")]
43
+ _TERMINAL = [("pyte", "pyte")]
44
+ if sys.platform == "win32":
45
+ _TERMINAL.append(("pywinpty", "winpty")) # ConPTY backend on Windows
46
+
47
+ FEATURES: dict[str, list[tuple[str, str]]] = {
48
+ "fast-io": [("platformdirs", "platformdirs"), ("msgspec", "msgspec")],
49
+ "excel": [("openpyxl", "openpyxl")],
50
+ "parquet": [("pyarrow", "pyarrow")],
51
+ "terminal": _TERMINAL,
52
+ "tui": [("rich", "rich"), ("textual", "textual")],
53
+ "jupyter": [("nbformat", "nbformat"), ("ipykernel", "ipykernel"),
54
+ ("anywidget", "anywidget")],
55
+ "science": _SCIENCE,
56
+ "bayes": _BAYES,
57
+ }
58
+
59
+ # The full-fat set (the `all` extra), ordered light -> heavy so the quick wins
60
+ # land first and the heaviest (pymc) last; markers dedupe across features.
61
+ ALL: list[tuple[str, str]] = [
62
+ ("platformdirs", "platformdirs"), ("msgspec", "msgspec"),
63
+ ("openpyxl", "openpyxl"),
64
+ *_TERMINAL,
65
+ ("rich", "rich"), ("textual", "textual"),
66
+ ("nbformat", "nbformat"), ("anywidget", "anywidget"),
67
+ ("pyarrow", "pyarrow"),
68
+ *_SCIENCE,
69
+ ("ipykernel", "ipykernel"),
70
+ *_BAYES,
71
+ ]
72
+
73
+ # Human-facing descriptions for the first-run chooser: feature -> (label,
74
+ # what it installs, approximate MB). Feature closures share dependencies, so the
75
+ # totals for a preset are smaller than the sum of the parts.
76
+ FEATURE_INFO: dict[str, tuple[str, str, int]] = {
77
+ "fast-io": ("Faster settings & correct folders",
78
+ "msgspec + platformdirs", 8),
79
+ "excel": ("Excel spreadsheets (.xlsx)", "openpyxl", 4),
80
+ "terminal": ("A true terminal panel",
81
+ "pyte" + (" + pywinpty" if sys.platform == "win32" else ""), 3),
82
+ "tui": ("A richer terminal UI", "textual + rich", 12),
83
+ "jupyter": ("Jupyter integration",
84
+ "nbformat + ipykernel + anywidget — notebook validation, the abax "
85
+ "kernel, and the editable-sheet widget", 80),
86
+ "parquet": ("Parquet / Feather data files", "pyarrow", 90),
87
+ "science": ("Data science: statistics, ML, DataFrames, graphing",
88
+ "numpy, pandas, scipy, scikit-learn, statsmodels, lifelines, "
89
+ "pingouin, scikit-survival", 450),
90
+ "bayes": ("Bayesian / probabilistic modeling (large)",
91
+ "pymc + pytensor + arviz + numba/llvmlite", 150),
92
+ }
93
+
94
+ # The two common presets offered by the chooser. "thin" = the lean conveniences
95
+ # (matching the pip `thin` extra minus the Qt binding); "all" = everything.
96
+ PRESETS: dict[str, list[str]] = {
97
+ "thin": ["fast-io", "excel", "terminal", "tui"],
98
+ "all": list(FEATURES),
99
+ }
100
+
101
+
102
+ def preset(name: str) -> list[str]:
103
+ """Feature keys for a named preset (``"thin"`` / ``"all"``)."""
104
+ return list(PRESETS.get(name, []))
105
+
106
+
107
+ # --- configuration / hooks (the install fn + marker dir are injectable for tests)
108
+ _INSTALL_FN = None # set below; tests may replace it
109
+ _MARKER_DIR = None # None -> CACHE_DIR/autodeps
110
+ _enabled_override: bool | None = None
111
+ _lock = threading.Lock()
112
+ _attempted_session: set[str] = set()
113
+
114
+
115
+ def set_enabled(flag: bool | None) -> None:
116
+ """Force auto-install on/off (``None`` restores the default)."""
117
+ global _enabled_override
118
+ _enabled_override = flag
119
+
120
+
121
+ def enabled() -> bool:
122
+ if os.environ.get("ABAX_NO_AUTOINSTALL"):
123
+ return False
124
+ if _enabled_override is not None:
125
+ return _enabled_override
126
+ return True
127
+
128
+
129
+ def installed(import_name: str) -> bool:
130
+ try:
131
+ return importlib.util.find_spec(import_name) is not None
132
+ except Exception:
133
+ return False
134
+
135
+
136
+ def _marker_dir():
137
+ from pathlib import Path
138
+ if _MARKER_DIR is not None:
139
+ return Path(_MARKER_DIR)
140
+ from ._runtime import CACHE_DIR
141
+ return CACHE_DIR / "autodeps"
142
+
143
+
144
+ def _attempted(pip_name: str) -> bool:
145
+ if pip_name in _attempted_session:
146
+ return True
147
+ try:
148
+ return (_marker_dir() / f"{pip_name}.attempted").exists()
149
+ except Exception:
150
+ return False
151
+
152
+
153
+ def _mark(pip_name: str) -> None:
154
+ _attempted_session.add(pip_name)
155
+ try:
156
+ d = _marker_dir()
157
+ d.mkdir(parents=True, exist_ok=True)
158
+ (d / f"{pip_name}.attempted").write_text("1", encoding="utf-8")
159
+ except Exception:
160
+ pass
161
+
162
+
163
+ def _pip_install(pip_name: str, timeout: float = 1800) -> bool:
164
+ try:
165
+ proc = subprocess.run(
166
+ [sys.executable, "-m", "pip", "install", "--quiet", pip_name],
167
+ capture_output=True, timeout=timeout)
168
+ return proc.returncode == 0
169
+ except Exception:
170
+ return False
171
+
172
+
173
+ _INSTALL_FN = _pip_install
174
+
175
+
176
+ def missing(packages) -> list[tuple[str, str]]:
177
+ """The subset of ``(pip, module)`` pairs whose module isn't importable."""
178
+ return [(pip, mod) for pip, mod in packages if not installed(mod)]
179
+
180
+
181
+ def ensure(packages, *, background: bool = True, force: bool = False) -> list[str]:
182
+ """Install any missing packages, best-effort and once.
183
+
184
+ ``packages`` is a list of ``(pip_name, import_name)`` pairs. Returns the pip
185
+ names it will attempt (empty if disabled, all present, or already attempted).
186
+ With ``force`` it ignores the once-per-machine markers (still skips packages
187
+ already importable).
188
+ """
189
+ if not enabled():
190
+ return []
191
+ todo: list[str] = []
192
+ with _lock:
193
+ for pip, mod in packages:
194
+ if installed(mod):
195
+ continue
196
+ if not force and _attempted(pip):
197
+ continue
198
+ _mark(pip) # claim now so concurrent calls don't race
199
+ todo.append(pip)
200
+ if not todo:
201
+ return []
202
+
203
+ def work() -> None:
204
+ for pip in todo:
205
+ _INSTALL_FN(pip)
206
+
207
+ if background:
208
+ threading.Thread(target=work, name="abax-autodeps", daemon=True).start()
209
+ else:
210
+ work()
211
+ return todo
212
+
213
+
214
+ def ensure_feature(key: str, *, background: bool = True, force: bool = False) -> list[str]:
215
+ """Ensure the packages backing one feature (e.g. ``"science"``/``"excel"``)."""
216
+ return ensure(FEATURES.get(key, []), background=background, force=force)
217
+
218
+
219
+ def prefetch_all(*, background: bool = True, force: bool = False) -> list[str]:
220
+ """Fetch the entire full-fat optional stack (called once on GUI startup)."""
221
+ return ensure(ALL, background=background, force=force)