Skip to content

Commit 6f06f50

Browse files
committed
merge: v0.11.7 release
2 parents 2d04cc8 + ac6b598 commit 6f06f50

6 files changed

Lines changed: 122 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.11.7] - 2026-05-24
9+
10+
### Added
11+
12+
- **Pipx-only enforcement**`specsmith` now rejects invocations from any
13+
non-pipx Python environment at startup. Running via `pip install`, an editable
14+
dev install, or inside a project venv raises a clear error with install
15+
instructions. Override for CI with `SPECSMITH_ALLOW_NON_PIPX=1`.
16+
- **Persistent 24-hour update check**`_maybe_notify_pypi_update()` now
17+
persists the last-check timestamp to `~/.specsmith/last-update-check` and
18+
contacts PyPI at most once per `SPECSMITH_UPDATE_INTERVAL_HOURS` hours
19+
(default 24). Previously fired a network call on every shell session; now
20+
zero-latency within the window. Disable with `SPECSMITH_NO_UPDATE_CHECK=1`.
21+
- **Hardened `is_pipx_install()`** — correctly detects Windows pipx venvs at
22+
`~/pipx/venvs/` without requiring `PIPX_HOME` env var, plus Linux/macOS
23+
`~/.local/pipx/venvs/` paths.
24+
25+
### Fixed
26+
27+
- Removed competing pip-editable `__editable__.specsmith-0.11.3.pth` and
28+
`__editable__.specsmith-0.11.5.pth` from the system Python site-packages
29+
that caused `import specsmith` to resolve to stale dev copies.
30+
831
## [0.11.6] - 2026-05-21
932

1033
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "specsmith"
7-
version = "0.11.6"
7+
version = "0.11.7"
88
description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
99
readme = "README.md"
1010
license = "MIT"

src/specsmith/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
try:
99
__version__: str = _pkg_version("specsmith")
1010
except PackageNotFoundError: # running from source without install
11-
__version__ = "0.11.6" # fallback: keep in sync with pyproject.toml
11+
__version__ = "0.11.7" # fallback: keep in sync with pyproject.toml

src/specsmith/cli.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,31 @@ class _AutoUpdateGroup(click.Group):
9797

9898
def invoke(self, ctx: click.Context) -> object:
9999
import os
100-
101-
# Skip if explicitly disabled or if this is a meta-command
102-
# ctx.protected_args is deprecated in Click 9.0; suppress the warning
103-
# on access (it still works in 8.x). In 9.0 the subcommand moves to args.
104100
import warnings
105101

102+
# ── Pipx-only enforcement ─────────────────────────────────────────────
103+
# specsmith MUST be installed and invoked through pipx.
104+
# Any invocation from a non-pipx Python environment is rejected unless
105+
# the escape hatch SPECSMITH_ALLOW_NON_PIPX=1 is set (CI / dev only).
106+
if not os.environ.get("SPECSMITH_ALLOW_NON_PIPX"):
107+
from specsmith.updater import is_pipx_install
108+
if not is_pipx_install():
109+
click.echo(
110+
"ERROR: specsmith must be installed and run via pipx only.\n"
111+
" Any pip install, venv install, or editable dev install\n"
112+
" is rejected to prevent version conflicts.\n"
113+
"\n"
114+
" Install: pipx install specsmith\n"
115+
" Upgrade: pipx upgrade specsmith\n"
116+
" Remove other: pip uninstall specsmith\n"
117+
"\n"
118+
" CI/testing override: set SPECSMITH_ALLOW_NON_PIPX=1",
119+
err=True,
120+
)
121+
raise SystemExit(1)
122+
123+
# ── Version checks (skip for meta-commands) ───────────────────────────
124+
# ctx.protected_args is deprecated in Click 9.0; suppress the warning.
106125
with warnings.catch_warnings():
107126
warnings.simplefilter("ignore", DeprecationWarning)
108127
protected = list(ctx.protected_args) # [subcommand] in 8.x, [] in 9.0
@@ -182,17 +201,55 @@ def _maybe_prompt_project_update() -> None:
182201
def _maybe_notify_pypi_update() -> None:
183202
"""Check PyPI for a newer specsmith version. Prints one-liner if outdated.
184203
185-
Runs at most once per shell session (tracked via env var). Uses a 3-second
186-
timeout to avoid blocking the CLI. Only checks stable versions.
204+
Persists the last-check timestamp to ``~/.specsmith/last-update-check``
205+
so the network call is only made when it has been more than
206+
``SPECSMITH_UPDATE_INTERVAL_HOURS`` hours since the previous check
207+
(default: 24h). Within that window the function returns immediately
208+
without any I/O — adding zero latency to every CLI invocation.
209+
210+
Override the interval::
211+
212+
SPECSMITH_UPDATE_INTERVAL_HOURS=4 specsmith audit
213+
214+
Disable entirely::
215+
216+
SPECSMITH_NO_UPDATE_CHECK=1 specsmith audit
217+
218+
Uses a 3-second network timeout so a slow/offline connection never
219+
blocks the user.
187220
"""
188221
import os
222+
import time
223+
from pathlib import Path
189224

225+
if os.environ.get("SPECSMITH_NO_UPDATE_CHECK"):
226+
return
227+
228+
# One check per shell session — never fire twice in the same process tree.
190229
session_key = "SPECSMITH_PYPI_CHECKED"
191230
if os.environ.get(session_key):
192231
return
193232
os.environ[session_key] = "1"
194233

195234
try:
235+
interval_h = float(os.environ.get("SPECSMITH_UPDATE_INTERVAL_HOURS", "24"))
236+
interval_s = interval_h * 3600
237+
238+
stamp_file = Path.home() / ".specsmith" / "last-update-check"
239+
now = time.time()
240+
241+
# Read persisted last-check time (best-effort).
242+
last_check = 0.0
243+
if stamp_file.is_file():
244+
try:
245+
last_check = float(stamp_file.read_text(encoding="utf-8").strip())
246+
except (ValueError, OSError):
247+
pass
248+
249+
# Not due yet — skip entirely (no network call).
250+
if now - last_check < interval_s:
251+
return
252+
196253
import json as _json # noqa: PLC0415
197254
from urllib.request import urlopen # noqa: PLC0415
198255

@@ -202,9 +259,16 @@ def _maybe_notify_pypi_update() -> None:
202259
if not latest:
203260
return
204261

205-
# Simple version comparison: split into tuples of ints
262+
# Persist the timestamp now that we have a successful response.
263+
try:
264+
stamp_file.parent.mkdir(parents=True, exist_ok=True)
265+
stamp_file.write_text(str(now), encoding="utf-8")
266+
except OSError:
267+
pass # Never fail the CLI over a timestamp write error
268+
269+
# Simple version comparison — no packaging dep needed.
206270
def _ver(v: str) -> tuple[int, ...]:
207-
import re
271+
import re # noqa: PLC0415
208272

209273
clean = re.match(r"(\d+\.\d+\.\d+)", v)
210274
return tuple(int(x) for x in clean.group(1).split(".")) if clean else (0,)

src/specsmith/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ class ProjectConfig(BaseModel):
115115
),
116116
)
117117
language: str = Field(default="python", description="Primary language/runtime")
118-
spec_version: str = Field(default="0.11.6", description="Spec version to scaffold from")
118+
spec_version: str = Field(default="0.11.7", description="Spec version to scaffold from")
119119
description: str = Field(default="", description="Short project description")
120120

121121
# Options

src/specsmith/updater.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,35 @@ def is_outdated() -> bool:
6666
def is_pipx_install() -> bool:
6767
"""Return True if specsmith was installed via pipx.
6868
69-
Checks sys.executable path and PIPX_HOME / PIPX_LOCAL_VENVS env vars.
69+
Checks sys.executable path against known pipx venv locations and the
70+
PIPX_HOME / PIPX_LOCAL_VENVS env vars. Robust on Windows where pipx
71+
stores venvs in ``~/pipx/venvs/`` without setting PIPX_HOME.
7072
"""
7173
import os
7274
import sys
75+
from pathlib import Path
7376

7477
exe = sys.executable.replace("\\", "/").lower()
75-
return (
76-
"pipx" in exe
77-
or bool(os.environ.get("PIPX_HOME"))
78-
or bool(os.environ.get("PIPX_LOCAL_VENVS"))
79-
)
78+
79+
# 1. Executable path contains 'pipx' (catches most OS defaults)
80+
if "pipx" in exe:
81+
return True
82+
83+
# 2. Explicit pipx env vars (set by some CI / pipx versions)
84+
if os.environ.get("PIPX_HOME") or os.environ.get("PIPX_LOCAL_VENVS"):
85+
return True
86+
87+
# 3. Windows default: ~/pipx/venvs/<pkg>/Scripts/python.exe
88+
win_pipx = (Path.home() / "pipx" / "venvs").as_posix().lower()
89+
if exe.startswith(win_pipx):
90+
return True
91+
92+
# 4. Linux/macOS default: ~/.local/pipx/venvs/<pkg>/bin/python
93+
unix_pipx = (Path.home() / ".local" / "pipx" / "venvs").as_posix().lower()
94+
if exe.startswith(unix_pipx):
95+
return True
96+
97+
return False
8098

8199

82100
def run_self_update(

0 commit comments

Comments
 (0)