sourcecode 1.36.0__py3-none-any.whl → 1.36.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.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.36.0"
3
+ __version__ = "1.36.2"
sourcecode/cache.py CHANGED
@@ -58,6 +58,8 @@ import json
58
58
  import os
59
59
  import re
60
60
  import subprocess
61
+ import time
62
+ import uuid
61
63
  from datetime import datetime, timezone
62
64
  from pathlib import Path
63
65
  from typing import Any, Optional
@@ -101,6 +103,12 @@ _DEFAULT_KEEP_COMMITS: int = 5
101
103
  _DEFAULT_MAX_CORES: int = 20
102
104
  _DEFAULT_MAX_SIZE_MB: int = 50
103
105
 
106
+ #: Windows hardening for _atomic_write: os.replace can raise PermissionError
107
+ #: (WinError 5/32) when an antivirus scanner, search indexer, or concurrent
108
+ #: reader transiently holds the destination open. Retry briefly to ride it out.
109
+ _REPLACE_RETRIES: int = 5
110
+ _REPLACE_BACKOFF_S: float = 0.05
111
+
104
112
  # Matches "snapshot-<hex_commit>-<hex_flags>.json.gz"
105
113
  _SNAPSHOT_RE = re.compile(r"^snapshot-([0-9a-f]+)-[0-9a-f]+\.json\.gz$")
106
114
 
@@ -808,20 +816,38 @@ def _gc_cas(cache_d: Path, surviving_snapshots: list[Path]) -> None:
808
816
  # ---------------------------------------------------------------------------
809
817
 
810
818
  def _atomic_write(dest: Path, data: bytes) -> None:
811
- """Write *data* to *dest* atomically via a sibling .tmp file + rename.
812
-
813
- On POSIX, ``Path.replace()`` is a single ``rename(2)`` syscall the
814
- destination either has the old content or the new content, never a partial
815
- write. The .tmp suffix keeps the partial file out of glob patterns used
816
- by the cache reader and GC.
819
+ """Write *data* to *dest* atomically via a unique temp file + rename.
820
+
821
+ ``os.replace`` is an atomic overwrite on both POSIX and Windows: the
822
+ destination ends up with either the old or the new content, never a partial
823
+ write. The trailing ``.tmp`` suffix keeps partial files out of the
824
+ ``*.json.gz`` / ``*.gz`` glob patterns used by the cache reader and GC.
825
+
826
+ Windows hardening (no-ops on POSIX):
827
+ * The temp name is made *unique* (pid + random token) so concurrent
828
+ writers of the same destination never collide on a shared temp file —
829
+ on Windows that collision raises ``PermissionError`` (WinError 32,
830
+ sharing violation), whereas POSIX tolerates it.
831
+ * ``os.replace`` is retried with a short backoff: on Windows it raises
832
+ ``PermissionError`` (WinError 5/32) when an antivirus scanner, the
833
+ search indexer, or a concurrent reader holds a transient handle on the
834
+ destination. POSIX ``rename(2)`` has no such restriction.
817
835
  """
818
- tmp = dest.with_suffix(".tmp")
836
+ tmp = dest.parent / f"{dest.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
819
837
  try:
820
838
  tmp.write_bytes(data)
821
- tmp.replace(dest)
822
- except Exception:
823
- _safe_unlink(tmp)
824
- raise
839
+ last_exc: Optional[BaseException] = None
840
+ for attempt in range(_REPLACE_RETRIES):
841
+ try:
842
+ os.replace(tmp, dest)
843
+ return
844
+ except PermissionError as exc: # transient lock on Windows — retry
845
+ last_exc = exc
846
+ time.sleep(_REPLACE_BACKOFF_S * (attempt + 1))
847
+ if last_exc is not None:
848
+ raise last_exc
849
+ finally:
850
+ _safe_unlink(tmp) # remove temp on failure; no-op after a successful replace
825
851
 
826
852
 
827
853
  def _safe_unlink(path: Path) -> None:
sourcecode/cli.py CHANGED
@@ -168,7 +168,6 @@ Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
168
168
  sourcecode --agent full structured JSON for AI agents
169
169
 
170
170
  [bold]Auth commands:[/bold]
171
- auth login [dim]# authenticate via browser (device code)[/dim]
172
171
  auth status [dim]# show current plan and auth state[/dim]
173
172
  auth logout [dim]# remove local credentials[/dim]
174
173
 
@@ -570,7 +569,7 @@ app.add_typer(mcp_app, name="mcp")
570
569
  cache_app = typer.Typer(help="Cache inspection and management.", rich_markup_mode="rich")
571
570
  app.add_typer(cache_app, name="cache")
572
571
 
573
- auth_app = typer.Typer(help="Authentication: login, status, logout.", rich_markup_mode="rich")
572
+ auth_app = typer.Typer(help="Authentication: status, logout.", rich_markup_mode="rich")
574
573
  app.add_typer(auth_app, name="auth")
575
574
 
576
575
 
@@ -653,6 +652,46 @@ def version_callback(value: bool) -> None:
653
652
  raise typer.Exit()
654
653
 
655
654
 
655
+ # ANSI Shadow block wordmark, stacked "source" / "code" so it fits an 80-col
656
+ # terminal (the single-line "sourcecode" is 83 wide and would wrap).
657
+ _WELCOME_SOURCE = (
658
+ "███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗",
659
+ "██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝",
660
+ "███████╗██║ ██║██║ ██║██████╔╝██║ █████╗ ",
661
+ "╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝ ",
662
+ "███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗",
663
+ "╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝",
664
+ )
665
+ _WELCOME_CODE = (
666
+ " ██████╗ ██████╗ ██████╗ ███████╗",
667
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝",
668
+ "██║ ██║ ██║██║ ██║█████╗ ",
669
+ "██║ ██║ ██║██║ ██║██╔══╝ ",
670
+ "╚██████╗╚██████╔╝██████╔╝███████╗",
671
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
672
+ )
673
+
674
+ _WELCOME_CMDS = (
675
+ ("sourcecode --compact", "repo summary"),
676
+ ("sourcecode prepare-context onboard", "onboarding"),
677
+ ("sourcecode mcp init", "connect IDE"),
678
+ )
679
+
680
+
681
+ def _print_welcome_plain(tier: str) -> None:
682
+ """Plain-text welcome — fallback when rich is unavailable."""
683
+ lines = ["", f" sourcecode {__version__} · {tier}", "",
684
+ " AI coding-agent context, instant.", "", " Get started:"]
685
+ for cmd, desc in _WELCOME_CMDS:
686
+ lines.append(f" {cmd.ljust(34)}{desc}")
687
+ lines.append("")
688
+ lines.append(" sourcecode --help all commands")
689
+ if tier != "Pro":
690
+ lines.append(" sourcecode activate <key> unlock Pro")
691
+ lines.append("")
692
+ typer.echo("\n".join(lines))
693
+
694
+
656
695
  def _print_welcome() -> None:
657
696
  """Branded quickstart shown only on a bare invocation at a human terminal.
658
697
 
@@ -665,24 +704,55 @@ def _print_welcome() -> None:
665
704
  except Exception:
666
705
  tier = "Free"
667
706
 
668
- lines = [
669
- "",
670
- f" sourcecode {__version__} · {tier}",
671
- "",
672
- " Structural context for AI coding agents — analyze a repo, get",
673
- " LLM-ready JSON in milliseconds.",
674
- "",
675
- " Get started:",
676
- " sourcecode --compact high-signal summary of this repo",
677
- " sourcecode prepare-context onboard full onboarding context",
678
- " sourcecode mcp init connect Claude / Cursor",
679
- "",
680
- " sourcecode --help all commands",
681
- ]
707
+ try:
708
+ from rich import box
709
+ from rich.console import Console
710
+ from rich.panel import Panel
711
+ from rich.text import Text
712
+ except Exception:
713
+ _print_welcome_plain(tier)
714
+ return
715
+
716
+ pad = max(len(c) for c, _ in _WELCOME_CMDS) + 2
717
+ tier_style = "green" if tier != "Pro" else "magenta"
718
+ code_w = max(len(s) for s in _WELCOME_CODE)
719
+
720
+ t = Text()
721
+ for ln in _WELCOME_SOURCE:
722
+ t.append(ln + "\n", style="bold cyan")
723
+ # Append version + tier to the right of the "code" block's middle rows.
724
+ for i, ln in enumerate(_WELCOME_CODE):
725
+ t.append(ln.ljust(code_w), style="bold cyan")
726
+ if i == 2:
727
+ t.append(f" {__version__}", style="dim")
728
+ elif i == 3:
729
+ t.append(" ")
730
+ t.append(tier, style=tier_style)
731
+ t.append("\n")
732
+
733
+ t.append("\nAI coding-agent context, instant.\n\n", style="white")
734
+
735
+ for cmd, desc in _WELCOME_CMDS:
736
+ t.append("▸ ", style="cyan")
737
+ t.append(cmd.ljust(pad), style="bold")
738
+ t.append(desc + "\n", style="dim")
739
+
740
+ t.append("\n--help", style="bold")
741
+ t.append(" · ", style="dim")
682
742
  if tier != "Pro":
683
- lines.append(" sourcecode activate <key> unlock Pro (large repos)")
684
- lines.append("")
685
- typer.echo("\n".join(lines))
743
+ t.append("activate <key>", style="bold")
744
+ t.append(" for Pro", style="dim")
745
+ else:
746
+ t.append("you're on Pro ✓", style="green")
747
+
748
+ panel = Panel(
749
+ t,
750
+ box=box.ROUNDED,
751
+ border_style="cyan",
752
+ padding=(1, 3),
753
+ expand=False,
754
+ )
755
+ Console().print(panel)
686
756
 
687
757
 
688
758
  @app.callback(invoke_without_command=True)
@@ -5344,26 +5414,9 @@ def activate_cmd(
5344
5414
 
5345
5415
 
5346
5416
  # ---------------------------------------------------------------------------
5347
- # Auth commands (device-flow login / status / logout)
5417
+ # Auth commands (status / logout)
5348
5418
  # ---------------------------------------------------------------------------
5349
5419
 
5350
- @auth_app.command("login")
5351
- def auth_login_cmd() -> None:
5352
- """Authenticate via browser (device code flow).
5353
-
5354
- \b
5355
- The CLI shows a URL. Open it in your browser, log in with your account,
5356
- and the CLI completes authentication automatically.
5357
- Credentials are stored in ~/.sourcecode/license.json (30-min cache; Supabase is source of truth).
5358
-
5359
- \b
5360
- Examples:
5361
- sourcecode auth login
5362
- """
5363
- from sourcecode.license import auth_login as _auth_login
5364
- _auth_login()
5365
-
5366
-
5367
5420
  @auth_app.command("status")
5368
5421
  def auth_status_cmd() -> None:
5369
5422
  """Show current authentication and plan status."""
@@ -6159,4 +6212,13 @@ def main_entry() -> None:
6159
6212
  except Exception:
6160
6213
  pass
6161
6214
  _preprocess_argv()
6162
- app()
6215
+ try:
6216
+ app()
6217
+ finally:
6218
+ # Best-effort "new version available" nudge. Only speaks on an
6219
+ # interactive terminal; never blocks, raises, or affects exit status.
6220
+ try:
6221
+ from sourcecode.version_check import maybe_notify_update
6222
+ maybe_notify_update(__version__)
6223
+ except Exception:
6224
+ pass
sourcecode/license.py CHANGED
@@ -71,9 +71,6 @@ _DELTA_FREE_LIMIT: int = 30
71
71
  # Hybrid model size limit: repos at/under this many Java source files are fully
72
72
  # free (every command, no caps). Above it = enterprise-scale monolith = Pro.
73
73
  _FREE_REPO_JAVA_FILE_LIMIT: int = 500
74
- _DEVICE_POLL_INTERVAL_S: float = 2.5
75
- _DEVICE_POLL_TIMEOUT_S: float = 300.0 # 5-minute window for user to complete browser auth
76
- _AUTH_BASE_URL: str = "https://sourcecode.dev"
77
74
  _LICENSE_KEY_RE = re.compile(r"^[A-Za-z0-9_\-]{1,200}$")
78
75
 
79
76
  # ---------------------------------------------------------------------------
@@ -243,78 +240,6 @@ def _call_get_license(license_key: str) -> Optional[dict]:
243
240
  return None # Network error — caller decides what to do
244
241
 
245
242
 
246
- def _generate_device_code() -> str:
247
- """Generate a human-readable device code: XXXX-XXXX-XXXX."""
248
- import uuid
249
- raw = uuid.uuid4().hex.upper()
250
- return f"{raw[:4]}-{raw[4:8]}-{raw[8:12]}"
251
-
252
-
253
- def _call_device_check(device_code: str) -> Optional[dict]:
254
- """Poll /device-check edge function. Returns dict or None on network error.
255
-
256
- Expected responses:
257
- {"status": "pending"}
258
- {"status": "complete", "device_token": "...", "email": "...", "plan": "pro", ...}
259
- {"status": "error", "message": "..."}
260
- """
261
- import urllib.error
262
- import urllib.request
263
-
264
- if not _SUPABASE_ANON_KEY:
265
- return None
266
-
267
- url = f"{_SUPABASE_URL}/functions/v1/device-check"
268
- body = json.dumps({"device_code": device_code}).encode("utf-8")
269
- req = urllib.request.Request(url, data=body, method="POST")
270
- req.add_header("apikey", _SUPABASE_ANON_KEY)
271
- req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
272
- req.add_header("Content-Type", "application/json")
273
- req.add_header("Accept", "application/json")
274
- try:
275
- with urllib.request.urlopen(req, timeout=8) as resp:
276
- return json.loads(resp.read().decode("utf-8"))
277
- except urllib.error.HTTPError as exc:
278
- try:
279
- return json.loads(exc.read().decode("utf-8", errors="replace"))
280
- except Exception:
281
- return {"status": "error", "message": f"HTTP {exc.code}"}
282
- except Exception:
283
- return None
284
-
285
-
286
- def _call_get_user_plan(device_token: str) -> Optional[dict]:
287
- """Fetch current plan/status for an authenticated device token.
288
-
289
- Expected response:
290
- {"valid": true, "plan": "pro", "status": "active", "features": [...], "email": "..."}
291
- {"valid": false, "error": "token_revoked"}
292
- """
293
- import urllib.error
294
- import urllib.request
295
-
296
- if not _SUPABASE_ANON_KEY:
297
- return None
298
-
299
- url = f"{_SUPABASE_URL}/functions/v1/get-user-plan"
300
- body = json.dumps({"device_token": device_token}).encode("utf-8")
301
- req = urllib.request.Request(url, data=body, method="POST")
302
- req.add_header("apikey", _SUPABASE_ANON_KEY)
303
- req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
304
- req.add_header("Content-Type", "application/json")
305
- req.add_header("Accept", "application/json")
306
- try:
307
- with urllib.request.urlopen(req, timeout=8) as resp:
308
- return json.loads(resp.read().decode("utf-8"))
309
- except urllib.error.HTTPError as exc:
310
- try:
311
- return json.loads(exc.read().decode("utf-8", errors="replace"))
312
- except Exception:
313
- return {"valid": False, "error": f"HTTP {exc.code}"}
314
- except Exception:
315
- return None
316
-
317
-
318
243
  def _maybe_revalidate() -> None:
319
244
  """Re-validate cached license if stale. Mutates globals; never raises."""
320
245
  global _license_data, is_pro
@@ -338,39 +263,7 @@ def _maybe_revalidate() -> None:
338
263
  except Exception:
339
264
  pass
340
265
 
341
- auth_method = _license_data.get("auth_method")
342
-
343
- if auth_method == "device_flow":
344
- device_token = _license_data.get("device_token")
345
- if not device_token:
346
- return
347
- result = _call_get_user_plan(device_token)
348
- if result is None:
349
- return # Network error — keep cached (offline-first)
350
- if not result.get("valid", True):
351
- _license_data = None
352
- is_pro = False
353
- try:
354
- if _LICENSE_FILE.exists():
355
- _LICENSE_FILE.unlink()
356
- except Exception:
357
- pass
358
- return
359
- _license_data["plan"] = result.get("plan", "free")
360
- _license_data["status"] = result.get("status", "active")
361
- _license_data["features"] = result.get("features", [])
362
- _license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
363
- is_pro = (
364
- _license_data.get("plan") == "pro"
365
- and _license_data.get("status", "active") != "inactive"
366
- )
367
- try:
368
- _write_license_file(_license_data)
369
- except Exception:
370
- pass
371
- return
372
-
373
- # Key-based auth (existing flow / legacy)
266
+ # Key-based auth
374
267
  key = _license_data.get("license_key")
375
268
  if not key:
376
269
  return
@@ -585,106 +478,7 @@ def require_pro(feature_name: str) -> None:
585
478
 
586
479
 
587
480
  # ---------------------------------------------------------------------------
588
- # Device-flow authentication
589
- # ---------------------------------------------------------------------------
590
-
591
- def _finish_device_auth(result: dict) -> None:
592
- """Persist device-flow credentials and emit success JSON. Exits on error."""
593
- global _license_data, is_pro
594
-
595
- device_token = result.get("device_token") or result.get("access_token") or ""
596
- email = result.get("email", "")
597
- plan = result.get("plan", "free")
598
- plan_status = (
599
- result.get("status_detail")
600
- or result.get("user_status")
601
- or result.get("status", "active")
602
- )
603
- features = result.get("features") or []
604
-
605
- if not device_token:
606
- sys.stderr.write("\n")
607
- _fail("auth_error", "Authentication completed but no session token received. Contact support.")
608
-
609
- _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
610
- now = datetime.now(timezone.utc).isoformat()
611
- data: dict = {
612
- "auth_method": "device_flow",
613
- "device_token": device_token,
614
- "email": email,
615
- "plan": plan,
616
- "status": plan_status,
617
- "features": features,
618
- "authenticated_at": now,
619
- "validated_at": now,
620
- }
621
- _write_license_file(data)
622
- _license_data = data
623
- is_pro = plan == "pro" and plan_status != "inactive"
624
- _emit_telemetry("activation", feature="device_flow", success=is_pro)
625
-
626
- sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
627
- sys.stderr.flush()
628
-
629
- output: dict = {"status": "authenticated", "email": email, "plan": plan, "pro": is_pro}
630
- if not is_pro:
631
- output["upgrade_hint"] = "https://sourcecode.dev/pricing"
632
- else:
633
- output["features"] = features
634
- sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
635
- sys.stdout.flush()
636
-
637
-
638
- def auth_login() -> None:
639
- """Device code authentication flow.
640
-
641
- Shows a browser URL; polls the backend every 2.5 s until the user
642
- completes authentication or the 5-minute window expires.
643
- Writes credentials to ~/.sourcecode/license.json on success.
644
- Exits 0 on success, 1 on any failure.
645
- """
646
- import time
647
-
648
- device_code = _generate_device_code()
649
- activate_url = f"{_AUTH_BASE_URL}/activate?code={device_code}"
650
-
651
- sys.stderr.write(f"\n Open this URL to authenticate:\n {activate_url}\n\n Waiting")
652
- sys.stderr.flush()
653
-
654
- deadline = time.monotonic() + _DEVICE_POLL_TIMEOUT_S
655
- _tick = 0
656
-
657
- while time.monotonic() < deadline:
658
- time.sleep(_DEVICE_POLL_INTERVAL_S)
659
- _tick += 1
660
- if _tick % 4 == 0:
661
- sys.stderr.write(".")
662
- sys.stderr.flush()
663
-
664
- result = _call_device_check(device_code)
665
- if result is None:
666
- continue # network blip — keep polling
667
-
668
- status = result.get("status")
669
- if status == "pending":
670
- continue
671
-
672
- if status == "complete":
673
- _finish_device_auth(result)
674
- return
675
-
676
- if status == "error" or result.get("error"):
677
- sys.stderr.write("\n")
678
- _fail("auth_error", result.get("message") or result.get("error") or "Authentication failed.")
679
-
680
- # Unknown status — keep polling
681
-
682
- sys.stderr.write("\n")
683
- _fail("auth_timeout", "Authentication timed out after 5 minutes. Please try again.")
684
-
685
-
686
- # ---------------------------------------------------------------------------
687
- # Activation (key-based — legacy / direct key entry)
481
+ # Activation (key-based — direct key entry)
688
482
  # ---------------------------------------------------------------------------
689
483
 
690
484
  def activate_license(license_key: str) -> None:
@@ -75,7 +75,7 @@ _SAFE_FEATURES: frozenset[str] = frozenset({
75
75
  # prepare-context task names not already above
76
76
  "explain", "onboard", "refactor",
77
77
  # activation outcomes
78
- "key", "device_flow",
78
+ "key",
79
79
  })
80
80
  _SAFE_SIZES: frozenset[str] = frozenset({"tiny", "small", "medium", "large", "huge", "unknown"})
81
81
  _SAFE_DURATIONS: frozenset[str] = frozenset({"<1s", "<5s", "<15s", "<60s", "60s+", "unknown"})
@@ -0,0 +1,149 @@
1
+ """Best-effort "new version available" nudge.
2
+
3
+ Prints a single stderr line when a newer release exists on PyPI. Designed to be
4
+ invisible unless it has something useful to say:
5
+
6
+ * Only runs in an interactive terminal (stderr.isatty()) — never pollutes
7
+ piped output, MCP stdio, CI logs, or test runners.
8
+ * Hits the network at most once per 24h (cached in
9
+ ~/.sourcecode/version_check.json); warm runs read the cache and are instant.
10
+ * Re-shows the same nudge at most ~once per 20h so it informs without nagging.
11
+ * Swallows every error and never blocks meaningfully (1.5s network timeout).
12
+
13
+ Disable entirely with SOURCECODE_NO_UPDATE_CHECK=1 (also off under SOURCECODE_CI).
14
+ The check reads PyPI only; it never touches the license in ~/.sourcecode/license.json.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ _CACHE_DIR = Path.home() / ".sourcecode"
26
+ _CACHE_FILE = _CACHE_DIR / "version_check.json"
27
+ _PYPI_URL = "https://pypi.org/pypi/sourcecode/json"
28
+ _CHECK_TTL_SECONDS = 86_400 # refresh the PyPI lookup at most once per 24h
29
+ _NOTIFY_TTL_SECONDS = 72_000 # re-show the nudge at most every ~20h
30
+ _FETCH_TIMEOUT = 1.5
31
+
32
+
33
+ def _disabled() -> bool:
34
+ """True when the nudge must stay silent (opt-out, CI, or non-interactive)."""
35
+ if os.environ.get("SOURCECODE_NO_UPDATE_CHECK"):
36
+ return True
37
+ if os.environ.get("SOURCECODE_CI"):
38
+ return True
39
+ try:
40
+ return not sys.stderr.isatty()
41
+ except Exception:
42
+ return True # no usable stderr -> stay silent
43
+
44
+
45
+ def _read_cache() -> dict:
46
+ try:
47
+ if _CACHE_FILE.exists():
48
+ return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
49
+ except Exception:
50
+ pass
51
+ return {}
52
+
53
+
54
+ def _write_cache(data: dict) -> None:
55
+ try:
56
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
57
+ tmp = _CACHE_FILE.with_suffix(".tmp")
58
+ tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
59
+ tmp.replace(_CACHE_FILE)
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def _age_seconds(iso: Optional[str]) -> float:
65
+ if not iso:
66
+ return float("inf")
67
+ try:
68
+ ts = datetime.fromisoformat(iso)
69
+ if ts.tzinfo is None:
70
+ ts = ts.replace(tzinfo=timezone.utc)
71
+ return (datetime.now(timezone.utc) - ts).total_seconds()
72
+ except Exception:
73
+ return float("inf")
74
+
75
+
76
+ def _fetch_latest() -> Optional[str]:
77
+ import urllib.request
78
+ try:
79
+ req = urllib.request.Request(_PYPI_URL, headers={"Accept": "application/json"})
80
+ with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
81
+ data = json.loads(resp.read().decode("utf-8"))
82
+ return ((data.get("info") or {}).get("version")) or None
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def _parse(v: str) -> tuple:
88
+ """Lenient dotted-numeric parse for the fallback path (no packaging dep)."""
89
+ parts = []
90
+ for chunk in str(v).split("."):
91
+ num = ""
92
+ for ch in chunk:
93
+ if ch.isdigit():
94
+ num += ch
95
+ else:
96
+ break
97
+ parts.append(int(num) if num else 0)
98
+ return tuple(parts)
99
+
100
+
101
+ def _is_newer(latest: str, current: str) -> bool:
102
+ try:
103
+ from packaging.version import parse as _vparse # type: ignore
104
+ return _vparse(latest) > _vparse(current)
105
+ except Exception:
106
+ return _parse(latest) > _parse(current)
107
+
108
+
109
+ def maybe_notify_update(current_version: str) -> None:
110
+ """Print an upgrade nudge to stderr if PyPI has a newer release.
111
+
112
+ Best-effort and fully guarded: any failure is silently ignored. Safe to call
113
+ unconditionally from the CLI entry point.
114
+ """
115
+ if _disabled():
116
+ return
117
+ try:
118
+ cache = _read_cache()
119
+
120
+ # Refresh the cached "latest" at most once per TTL (the only network hit).
121
+ if _age_seconds(cache.get("checked_at")) >= _CHECK_TTL_SECONDS:
122
+ latest = _fetch_latest()
123
+ if latest:
124
+ cache["latest"] = latest
125
+ cache["checked_at"] = datetime.now(timezone.utc).isoformat()
126
+ _write_cache(cache)
127
+
128
+ latest = cache.get("latest")
129
+ if not latest or not _is_newer(latest, current_version):
130
+ return
131
+
132
+ # Throttle: don't nag for the same version more than once per ~20h.
133
+ if (
134
+ cache.get("notified_for") == latest
135
+ and _age_seconds(cache.get("notified_at")) < _NOTIFY_TTL_SECONDS
136
+ ):
137
+ return
138
+
139
+ sys.stderr.write(
140
+ f"\n[sourcecode] v{latest} is available (you have {current_version}). "
141
+ "Upgrade: pipx upgrade sourcecode (pip: pip install -U sourcecode)\n"
142
+ )
143
+ sys.stderr.flush()
144
+
145
+ cache["notified_for"] = latest
146
+ cache["notified_at"] = datetime.now(timezone.utc).isoformat()
147
+ _write_cache(cache)
148
+ except Exception:
149
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.36.0
3
+ Version: 1.36.2
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.36.0-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.36.1-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.36.0
117
+ # sourcecode 1.36.1
118
118
  ```
119
119
 
120
120
  ---
@@ -311,8 +311,7 @@ when the work gets bigger or automated.
311
311
  files only, by design. sourcecode monetises enterprise Java monoliths.
312
312
 
313
313
  ```bash
314
- sourcecode auth login # browser device-code auth
315
- sourcecode activate <key> # or activate a license key directly
314
+ sourcecode activate <key> # activate a license key
316
315
  ```
317
316
 
318
317
  Full breakdown: [docs/PRODUCT_TIERS.md](docs/PRODUCT_TIERS.md).
@@ -1,13 +1,13 @@
1
- sourcecode/__init__.py,sha256=j2Rx292C93M1gnbvMuKrqWMZXlDm3K85SxUiFzO4zac,103
1
+ sourcecode/__init__.py,sha256=OwsI0itjMvrYqTYz2jWyNvxs5j-e5JKVwJ4h7_kCDEQ,103
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
6
- sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
6
+ sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
7
7
  sourcecode/canonical_ir.py,sha256=c_lYTVoegg-1W2dZ34_2s3tN8L0GVx7eiDRh9ghdSD8,24178
8
8
  sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
9
9
  sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
10
- sourcecode/cli.py,sha256=c8t9Jh68JjwcNyTZx9_hmoBLz1D0HOWu4snDFKtvang,249785
10
+ sourcecode/cli.py,sha256=oNcQu-l2maZ9Pb2fZiY4JTCojFi0C156TLGTrJBKT34,252665
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
13
13
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -27,7 +27,7 @@ sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,2
27
27
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
28
28
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
29
29
  sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
30
- sourcecode/license.py,sha256=gHNXu4_yLiHCJLhIryBCCmtzF6wBoWplHMS8rmu7m24,28074
30
+ sourcecode/license.py,sha256=i_X1bYdobL_z9OVuLiycnWEFSaaNhcKKuTd6G55U3_k,20747
31
31
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
32
32
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
33
33
  sourcecode/migrate_check.py,sha256=vowVIAxVaHU8vhZUEt-HrWrWM38m6a5INHJQGjEg5E0,55390
@@ -58,6 +58,7 @@ sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM
58
58
  sourcecode/spring_tx_analyzer.py,sha256=FdFcyqPp3aT9oJ-PKrnXcTA6s69wdvzG-NBm0GMGPTU,30717
59
59
  sourcecode/summarizer.py,sha256=zgdps7yS2IktAbWe7IWz0oUcr3QIuNPRGrsScbZ4R1g,21797
60
60
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
61
+ sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
61
62
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
62
63
  sourcecode/detectors/__init__.py,sha256=A0AACJFF6HWf_RgatNtWu3PUzstcKtIGM9f1PoFcJug,1987
63
64
  sourcecode/detectors/base.py,sha256=C2EqfZudQ1ITK4DID4M70nPxqoh9bl1zn_ta6XRaGWs,1168
@@ -94,10 +95,10 @@ sourcecode/telemetry/__init__.py,sha256=rth1GuU9Tqt6BvbOe6q6sro1yCygiDW4dN3r1Ovm
94
95
  sourcecode/telemetry/config.py,sha256=_MfMevIic1NTc8IRmCzQs96D8KPBLOWZ5cdhWrnHuwI,2639
95
96
  sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWnII,2237
96
97
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
97
- sourcecode/telemetry/filters.py,sha256=zFJfvmE7TT5ZYsXr3mh6kTe0adzzYqFZSx61wUJ8Rew,5849
98
+ sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
98
99
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
99
- sourcecode-1.36.0.dist-info/METADATA,sha256=_SkuM5cooAYdgrd0BAhzvvKwy3f8i92t8XOMT2iwRtE,30383
100
- sourcecode-1.36.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
- sourcecode-1.36.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
- sourcecode-1.36.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
- sourcecode-1.36.0.dist-info/RECORD,,
100
+ sourcecode-1.36.2.dist-info/METADATA,sha256=JWwQcBBUaBi8XxPm9ztTXwbj-lqk1CebR-LnQPQ731A,30313
101
+ sourcecode-1.36.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
102
+ sourcecode-1.36.2.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
103
+ sourcecode-1.36.2.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
104
+ sourcecode-1.36.2.dist-info/RECORD,,