smartcli-toolkit 0.1.0__tar.gz → 0.1.1__tar.gz

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.
@@ -1,8 +1,28 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
+ Author: dwgx
5
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dwgx/SmartCLI
8
+ Project-URL: Repository, https://github.com/dwgx/SmartCLI
9
+ Project-URL: Changelog, https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/dwgx/SmartCLI/issues
11
+ Project-URL: PyPI, https://pypi.org/project/smartcli-toolkit/
12
+ Keywords: terminal,tui,pty,ansi,cli,ascii-art,terminal-effects,claude-skill,pyte,conpty
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Environment :: Console
6
26
  Requires-Python: >=3.9
7
27
  Description-Content-Type: text/markdown
8
28
  License-File: LICENSE
@@ -66,7 +86,7 @@ works today from a clean clone.
66
86
  **Primary — reproduce the full dev environment** (recommended):
67
87
 
68
88
  ```bash
69
- git clone <repo-url> SmartCLI
89
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
70
90
  cd SmartCLI
71
91
  python -m pip install -r requirements.txt
72
92
  ```
@@ -44,7 +44,7 @@ works today from a clean clone.
44
44
  **Primary — reproduce the full dev environment** (recommended):
45
45
 
46
46
  ```bash
47
- git clone <repo-url> SmartCLI
47
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
48
48
  cd SmartCLI
49
49
  python -m pip install -r requirements.txt
50
50
  ```
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
30
30
 
31
31
  [project]
32
32
  name = "smartcli-toolkit"
33
- version = "0.1.0"
33
+ version = "0.1.1"
34
34
  description = "Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core)."
35
35
  readme = "README.md"
36
36
  license = "MIT"
@@ -40,6 +40,41 @@ license-files = ["LICENSE"]
40
40
  # No match/case and no runtime `X | Y` types are used, so nothing forces higher.
41
41
  requires-python = ">=3.9"
42
42
 
43
+ authors = [{name = "dwgx"}]
44
+
45
+ keywords = [
46
+ "terminal",
47
+ "tui",
48
+ "pty",
49
+ "ansi",
50
+ "cli",
51
+ "ascii-art",
52
+ "terminal-effects",
53
+ "claude-skill",
54
+ "pyte",
55
+ "conpty",
56
+ ]
57
+
58
+ # Real PyPI trove classifiers. NOTE: no `License ::` classifier is listed on
59
+ # purpose — with the PEP 639 SPDX `license = "MIT"` field above, setuptools
60
+ # (77+) treats a license trove classifier as redundant/conflicting and errors
61
+ # out. The SPDX field is the single source of truth for licensing.
62
+ classifiers = [
63
+ "Development Status :: 4 - Beta",
64
+ "Intended Audience :: Developers",
65
+ "Operating System :: OS Independent",
66
+ "Programming Language :: Python :: 3",
67
+ "Programming Language :: Python :: 3.9",
68
+ "Programming Language :: Python :: 3.10",
69
+ "Programming Language :: Python :: 3.11",
70
+ "Programming Language :: Python :: 3.12",
71
+ "Programming Language :: Python :: 3.13",
72
+ "Programming Language :: Python :: 3.14",
73
+ "Topic :: Terminals",
74
+ "Topic :: Software Development :: Libraries",
75
+ "Environment :: Console",
76
+ ]
77
+
43
78
  # REQUIRED runtime deps (mirror requirements.txt). pyte is a hard top-level
44
79
  # import; pywinpty is imported only on the Windows PTY backend path, hence the
45
80
  # environment marker. POSIX uses the stdlib `pty` backend (no extra package).
@@ -48,6 +83,13 @@ dependencies = [
48
83
  "pywinpty>=2.0; platform_system == 'Windows'",
49
84
  ]
50
85
 
86
+ [project.urls]
87
+ Homepage = "https://github.com/dwgx/SmartCLI"
88
+ Repository = "https://github.com/dwgx/SmartCLI"
89
+ Changelog = "https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md"
90
+ Issues = "https://github.com/dwgx/SmartCLI/issues"
91
+ PyPI = "https://pypi.org/project/smartcli-toolkit/"
92
+
51
93
  [project.optional-dependencies]
52
94
  # All guarded imports with pure-stdlib fallbacks — none required to run.
53
95
  art = ["pyfiglet>=1.0.0"] # real FIGlet fonts (else built-in block font)
@@ -50,4 +50,4 @@ __all__ = [
50
50
  "KEY_MAP",
51
51
  ]
52
52
 
53
- __version__ = "0.1.0"
53
+ __version__ = "0.1.1"
@@ -1,8 +1,28 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
+ Author: dwgx
5
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dwgx/SmartCLI
8
+ Project-URL: Repository, https://github.com/dwgx/SmartCLI
9
+ Project-URL: Changelog, https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/dwgx/SmartCLI/issues
11
+ Project-URL: PyPI, https://pypi.org/project/smartcli-toolkit/
12
+ Keywords: terminal,tui,pty,ansi,cli,ascii-art,terminal-effects,claude-skill,pyte,conpty
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Environment :: Console
6
26
  Requires-Python: >=3.9
7
27
  Description-Content-Type: text/markdown
8
28
  License-File: LICENSE
@@ -66,7 +86,7 @@ works today from a clean clone.
66
86
  **Primary — reproduce the full dev environment** (recommended):
67
87
 
68
88
  ```bash
69
- git clone <repo-url> SmartCLI
89
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
70
90
  cd SmartCLI
71
91
  python -m pip install -r requirements.txt
72
92
  ```
@@ -11,4 +11,5 @@ smartcli_toolkit.egg-info/PKG-INFO
11
11
  smartcli_toolkit.egg-info/SOURCES.txt
12
12
  smartcli_toolkit.egg-info/dependency_links.txt
13
13
  smartcli_toolkit.egg-info/requires.txt
14
- smartcli_toolkit.egg-info/top_level.txt
14
+ smartcli_toolkit.egg-info/top_level.txt
15
+ tests/test_readiness.py
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env python
2
+ """test_readiness.py — deterministic unit tests for smartcli_core.readiness.
3
+
4
+ These cover the failure/edge paths that historically broke and are otherwise only
5
+ reachable through flaky real-PTY timing:
6
+ * TIMEOUT — a screen that never settles / a marker that never matches.
7
+ * STABLE — the content hash repeats long enough to declare stability.
8
+ * MARKER — an expected regex is found (beats stability).
9
+ * late-flush — data arrives after the grace sleep → the wait resumes instead of
10
+ declaring done on a half-drawn screen (the ConPTY quiet-gap bug).
11
+ * min_wait — stability/marker is NOT declared before the minimum elapsed time.
12
+
13
+ Determinism: we monkeypatch ``readiness.time.monotonic`` / ``readiness.time.sleep``
14
+ with a virtual clock so ``sleep(x)`` advances virtual time by ``x`` and timeouts
15
+ fire instantly with no real waiting. This patches only the clock BOUNDARY — the
16
+ real decision logic in readiness.py runs unchanged (HARD-LESSONS rule 5: a test
17
+ double for the clock/IO edge, never a patch that hides a bug).
18
+
19
+ Run: python tests/test_readiness.py (exit 0 = pass, 1 = fail)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # Portable: make the repo root importable regardless of cwd.
28
+ _ROOT = Path(__file__).resolve().parents[1]
29
+ if str(_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(_ROOT))
31
+
32
+ from smartcli_core import readiness # noqa: E402
33
+
34
+
35
+ class VirtualClock:
36
+ """A fake monotonic clock: sleep() advances virtual time; no real waiting."""
37
+
38
+ def __init__(self) -> None:
39
+ self.t = 0.0
40
+
41
+ def monotonic(self) -> float:
42
+ return self.t
43
+
44
+ def sleep(self, seconds: float) -> None:
45
+ # Advance virtual time; also nudge forward on zero-sleep to guarantee
46
+ # progress so no loop can spin forever in a test.
47
+ self.t += seconds if seconds > 0 else 0.001
48
+
49
+
50
+ def _install_clock():
51
+ clk = VirtualClock()
52
+ readiness.time.monotonic = clk.monotonic # type: ignore[assignment]
53
+ readiness.time.sleep = clk.sleep # type: ignore[assignment]
54
+ return clk
55
+
56
+
57
+ # scripted-callable helpers ------------------------------------------------
58
+
59
+ def seq_reader(batches):
60
+ """read_fn that returns each batch in turn, then b'' forever."""
61
+ it = iter(batches)
62
+
63
+ def _read():
64
+ return next(it, b"")
65
+
66
+ return _read
67
+
68
+
69
+ def seq_hashes(values):
70
+ """hash_fn that returns each value in turn, then repeats the last forever."""
71
+ state = {"i": 0, "vals": list(values)}
72
+
73
+ def _hash():
74
+ i = state["i"]
75
+ vals = state["vals"]
76
+ v = vals[i] if i < len(vals) else vals[-1]
77
+ if i < len(vals) - 1:
78
+ state["i"] = i + 1
79
+ return v
80
+
81
+ return _hash
82
+
83
+
84
+ def seq_text(values):
85
+ """text_fn that returns each value in turn, then repeats the last forever."""
86
+ state = {"i": 0, "vals": list(values)}
87
+
88
+ def _text():
89
+ i = state["i"]
90
+ vals = state["vals"]
91
+ v = vals[i] if i < len(vals) else vals[-1]
92
+ if i < len(vals) - 1:
93
+ state["i"] = i + 1
94
+ return v
95
+
96
+ return _text
97
+
98
+
99
+ _fails = []
100
+
101
+
102
+ def check(cond, name, detail=""):
103
+ tag = "[PASS]" if cond else "[FAIL]"
104
+ print(f"{tag} {name} {detail}")
105
+ if not cond:
106
+ _fails.append(name)
107
+
108
+
109
+ def test_stable_reached():
110
+ """Hash unchanged + no new bytes for quiet_ms → wait_until_stable True, fast."""
111
+ _install_clock()
112
+ ok = readiness.wait_until_stable(
113
+ read_fn=seq_reader([]), # no bytes ever
114
+ get_screen_hash_fn=seq_hashes([42]), # constant hash
115
+ quiet_ms=200, poll_ms=30, max_wait_ms=8000, grace_ms=0, min_wait_ms=0,
116
+ )
117
+ check(ok is True, "wait_until_stable: constant screen settles", f"ret={ok}")
118
+
119
+
120
+ def test_stable_timeout():
121
+ """Hash never stops changing → returns False (TIMEOUT) within budget, no hang."""
122
+ clk = _install_clock()
123
+ # hash flips forever: 0,1,0,1,... → never quiet
124
+ flip = {"n": 0}
125
+
126
+ def churn_hash():
127
+ flip["n"] ^= 1
128
+ return flip["n"]
129
+
130
+ ok = readiness.wait_until_stable(
131
+ read_fn=seq_reader([]),
132
+ get_screen_hash_fn=churn_hash,
133
+ quiet_ms=200, poll_ms=30, max_wait_ms=1000, grace_ms=0, min_wait_ms=0,
134
+ )
135
+ check(ok is False, "wait_until_stable: churning screen hits TIMEOUT", f"ret={ok}")
136
+ check(clk.t >= 1.0, "wait_until_stable: respected max_wait ceiling", f"virt_t={clk.t:.2f}s")
137
+
138
+
139
+ def test_late_flush_resume():
140
+ """A late flush after grace resumes the wait instead of declaring done early.
141
+
142
+ We inject exactly one late batch on the post-grace `tail = read_fn()` drain.
143
+ readiness must consume it, reset stability, and only return True once the
144
+ screen re-settles — never return on the half-drawn (pre-flush) screen.
145
+ """
146
+ _install_clock()
147
+ reads = {"n": 0}
148
+ flush_at = {"call": None} # records which read_fn call delivered the flush
149
+
150
+ def read_fn():
151
+ reads["n"] += 1
152
+ # Deliver the late flush the first time we've gone quiet long enough that
153
+ # a grace-drain read happens (n>=4 in this cadence), exactly once.
154
+ if flush_at["call"] is None and reads["n"] >= 4:
155
+ flush_at["call"] = reads["n"]
156
+ return b"late output"
157
+ return b""
158
+
159
+ def hash_fn():
160
+ # screen changes once the flush has been delivered, then settles at 2
161
+ return 2 if flush_at["call"] is not None else 1
162
+
163
+ ok = readiness.wait_until_stable(
164
+ read_fn=read_fn,
165
+ get_screen_hash_fn=hash_fn,
166
+ quiet_ms=60, poll_ms=30, max_wait_ms=5000, grace_ms=40, min_wait_ms=0,
167
+ )
168
+ # Main contract: it re-settled to True AFTER a late flush was consumed.
169
+ check(ok is True, "wait_until_stable: resumes after late flush then settles", f"ret={ok}")
170
+ # A late flush was actually delivered and consumed, and reads continued
171
+ # past that point (so it did NOT return on the stale pre-flush screen).
172
+ check(flush_at["call"] is not None, "wait_until_stable: late flush was delivered", f"flush_at={flush_at['call']}")
173
+ check(reads["n"] > flush_at["call"], "wait_until_stable: kept reading past the late flush",
174
+ f"reads={reads['n']} flush_at={flush_at['call']}")
175
+
176
+
177
+ def test_min_wait_guard():
178
+ """Stability must NOT be declared before min_wait_ms even if screen is quiet."""
179
+ clk = _install_clock()
180
+ ok = readiness.wait_until_stable(
181
+ read_fn=seq_reader([]),
182
+ get_screen_hash_fn=seq_hashes([7]), # instantly "quiet"
183
+ quiet_ms=50, poll_ms=30, max_wait_ms=8000, grace_ms=0, min_wait_ms=500,
184
+ )
185
+ check(ok is True, "wait_until_stable: settles after min_wait", f"ret={ok}")
186
+ check(clk.t >= 0.5, "wait_until_stable: honored min_wait_ms floor", f"virt_t={clk.t:.3f}s (>=0.5)")
187
+
188
+
189
+ def test_regex_match():
190
+ """wait_for_regex finds the marker and returns (True, snapshot)."""
191
+ _install_clock()
192
+ matched, snap = readiness.wait_for_regex(
193
+ read_fn=seq_reader([b">>> "]),
194
+ get_text_fn=seq_text(["loading...", "loading...", ">>> ready"]),
195
+ get_snapshot_fn=lambda: "SNAP",
196
+ pattern=r">>> ", timeout_ms=5000, poll_ms=30, min_wait_ms=0,
197
+ )
198
+ check(matched is True, "wait_for_regex: finds marker", f"matched={matched}")
199
+ check(snap == "SNAP", "wait_for_regex: returns snapshot on match", f"snap={snap}")
200
+
201
+
202
+ def test_regex_timeout_returns_snapshot():
203
+ """wait_for_regex on a never-matching pattern returns (False, snapshot)."""
204
+ clk = _install_clock()
205
+ matched, snap = readiness.wait_for_regex(
206
+ read_fn=seq_reader([]),
207
+ get_text_fn=seq_text(["nothing here"]),
208
+ get_snapshot_fn=lambda: "LAST",
209
+ pattern=r"WILL_NEVER_APPEAR", timeout_ms=800, poll_ms=30, min_wait_ms=0,
210
+ )
211
+ check(matched is False, "wait_for_regex: TIMEOUT when no match", f"matched={matched}")
212
+ check(snap == "LAST", "wait_for_regex: still returns last snapshot on timeout", f"snap={snap}")
213
+ check(clk.t >= 0.8, "wait_for_regex: respected timeout ceiling", f"virt_t={clk.t:.2f}s")
214
+
215
+
216
+ def test_regex_min_wait():
217
+ """A match present from t=0 must be ignored until min_wait_ms elapses."""
218
+ clk = _install_clock()
219
+ matched, _ = readiness.wait_for_regex(
220
+ read_fn=seq_reader([]),
221
+ get_text_fn=seq_text([">>> "]), # matches immediately
222
+ get_snapshot_fn=lambda: None,
223
+ pattern=r">>> ", timeout_ms=5000, poll_ms=30, min_wait_ms=300,
224
+ )
225
+ check(matched is True, "wait_for_regex: eventually matches past min_wait", f"matched={matched}")
226
+ check(clk.t >= 0.3, "wait_for_regex: did not match before min_wait", f"virt_t={clk.t:.3f}s (>=0.3)")
227
+
228
+
229
+ def test_wait_ready_marker_beats_stability():
230
+ """wait_ready returns MARKER when the regex hits (even amid churn)."""
231
+ _install_clock()
232
+ # screen keeps changing (never stable) but marker appears on 3rd text sample
233
+ reason, snap = readiness.wait_ready(
234
+ read_fn=seq_reader([b"a", b"b", b"c"]),
235
+ get_screen_hash_fn=seq_hashes([1, 2, 3, 4, 5]), # churning
236
+ get_text_fn=seq_text(["...", "...", "DONE!"]),
237
+ get_snapshot_fn=lambda: "S",
238
+ marker=r"DONE!", quiet_ms=200, poll_ms=30, max_wait_ms=5000, min_wait_ms=0,
239
+ )
240
+ check(reason == "MARKER", "wait_ready: marker beats a churning screen", f"reason={reason}")
241
+
242
+
243
+ def test_wait_ready_stable():
244
+ """wait_ready returns STABLE when no marker and screen settles."""
245
+ _install_clock()
246
+ reason, _ = readiness.wait_ready(
247
+ read_fn=seq_reader([]),
248
+ get_screen_hash_fn=seq_hashes([9]),
249
+ get_text_fn=seq_text(["idle"]),
250
+ get_snapshot_fn=lambda: "S",
251
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000, min_wait_ms=0, grace_ms=0,
252
+ )
253
+ check(reason == "STABLE", "wait_ready: settles to STABLE with no marker", f"reason={reason}")
254
+
255
+
256
+ def test_wait_ready_timeout():
257
+ """wait_ready returns TIMEOUT when marker never matches and screen churns."""
258
+ clk = _install_clock()
259
+ flip = {"n": 0}
260
+
261
+ def churn_hash():
262
+ flip["n"] ^= 1
263
+ return flip["n"]
264
+
265
+ reason, snap = readiness.wait_ready(
266
+ read_fn=seq_reader([b"x", b"y", b"z"] * 100), # bytes keep coming
267
+ get_screen_hash_fn=churn_hash,
268
+ get_text_fn=seq_text(["busy"]),
269
+ get_snapshot_fn=lambda: "LASTSNAP",
270
+ marker=r"NOPE", quiet_ms=200, poll_ms=30, max_wait_ms=1000, min_wait_ms=0,
271
+ )
272
+ check(reason == "TIMEOUT", "wait_ready: TIMEOUT when nothing satisfies", f"reason={reason}")
273
+ check(snap == "LASTSNAP", "wait_ready: returns last snapshot on timeout", f"snap={snap}")
274
+ check(clk.t >= 1.0, "wait_ready: respected max_wait ceiling", f"virt_t={clk.t:.2f}s")
275
+
276
+
277
+ def main() -> int:
278
+ import time as _real_time
279
+ t0 = _real_time.perf_counter()
280
+ print("=" * 60)
281
+ print("readiness deterministic unit tests (virtual clock, no real waits)")
282
+ print("=" * 60)
283
+ for fn in (
284
+ test_stable_reached, test_stable_timeout, test_late_flush_resume,
285
+ test_min_wait_guard, test_regex_match, test_regex_timeout_returns_snapshot,
286
+ test_regex_min_wait, test_wait_ready_marker_beats_stability,
287
+ test_wait_ready_stable, test_wait_ready_timeout,
288
+ ):
289
+ fn()
290
+ wall = _real_time.perf_counter() - t0
291
+ print("-" * 60)
292
+ if _fails:
293
+ print(f"FAIL: {len(_fails)} check(s) failed: {_fails}")
294
+ return 1
295
+ print(f"PASS: all readiness edge paths covered in {wall*1000:.0f} ms wall (deterministic)")
296
+ return 0
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.exit(main())
301
+