ltcai 0.1.22 → 0.1.24

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.
package/docs/CHANGELOG.md CHANGED
@@ -1,14 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.24] - 2026-05-24
4
+
5
+ ### 안정화 및 UX 개선
6
+
7
+ - **로컬 파일 인증 강화** — `/local/list` · `/local/read` · `/local/write` · `/local/serve`에서 로그인 세션 필수화 (`_require_local_user` 헬퍼 도입)
8
+ - **`GET /local/list` 라우트 추가** — smoke-test 및 브라우저 직접 호출 호환
9
+ - **VS Code 배지 수정** — shields.io `visual-studio-marketplace` 폐기 → `vsmarketplacebadges.dev`로 전환
10
+ - **README 이미지 URL 안정화** — 로고·스크린샷을 `raw.githubusercontent.com` 절대 URL로 전환해 PyPI / npm / Marketplace 페이지에서도 표시
11
+ - **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
12
+ - **GitHub Actions Node 24** — CI 런타임을 Node 24로 업그레이드
13
+
14
+ ### Release
15
+ - 배포 버전을 `0.1.24`로 상향
16
+ - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
17
+
18
+ ## [0.1.23] - 2026-05-24
19
+
20
+ ### Discord 권한 알림 시스템
21
+
22
+ - **`GET /permissions/pending`** — 대기 중인 파일 접근 권한 요청 목록 (관리자)
23
+ - **`POST /permissions/approve/{token}`** — 권한 승인 (관리자 세션 또는 `LATTICEAI_PERMISSION_SECRET`)
24
+ - **`POST /permissions/deny/{token}`** — 권한 거부/취소
25
+ - **`GET /permissions/status/{token}`** — 승인 상태 폴링 (AI 에이전트용)
26
+ - 권한 토큰 기본값 `approved: False` — 명시적 승인 전까지 파일 접근 불가
27
+ - `~/.ltcai/permission_queue.json` — 서버가 기록, Claude Code Discord 플러그인이 읽어 알림 전송
28
+ - `LATTICEAI_PERMISSION_SECRET` 환경변수 — 모니터 스크립트가 세션 없이 approve/deny 호출 가능
29
+ - `perm_monitor.py` — 권한 목록 조회·승인·거부 CLI 도우미 (`list` / `approve TOKEN` / `deny TOKEN` / `discord-msg`)
30
+ - Discord에서 `승인 <토큰앞8자>` / `거부 <토큰앞8자>` 로 파일 접근 제어 가능
31
+
32
+ ### 리포지터리 UX 개선
33
+
34
+ - **영어 README** 전면 재작성 — 한국어는 접을 수 있는 `<details>` 섹션으로 이동
35
+ - **SVG 로고** 추가 (`docs/images/logo.svg`)
36
+ - **경쟁 제품 비교표** — Lattice AI vs Open WebUI · Continue.dev · GitHub Copilot
37
+ - **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
38
+ - **비교표 기준 명시** — 공개 제품 동작 기준 시점을 README에 표기
39
+ - **패키지 페이지 이미지 안정화** — README 이미지 URL을 GitHub raw URL로 전환해 PyPI / npm / Marketplace에서도 표시되도록 개선
40
+ - **npm 패키지 정리** — 배포 tarball에서 테스트/캐시 파일 제외
41
+ - **실제 UI 스크린샷 3장** — Chat UI · Admin Dashboard · Data Graph (Playwright 2x 캡처)
42
+ - **VS Code 익스텐션 카테고리** `Other` → `AI, Machine Learning, Chat, Other`
43
+ - **VS Code 익스텐션 키워드** 8개 → 16개 (copilot, apple-silicon, groq, graph-rag 등)
44
+ - **VS Code 익스텐션 README** 전면 재작성 (기능표, 비교표, 모델 목록)
45
+ - 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
46
+
47
+ ### CI / 보안 안정화
48
+
49
+ - `/local/list` `GET` smoke-test 호환 라우트 추가
50
+ - `/local/list`, `/local/read`, `/local/write`, `/local/serve`는 로컬 개발 모드에서도 로그인 세션을 요구하도록 강화
51
+ - GitHub Actions integration smoke test 실패 원인 수정
52
+
53
+ ### Release
54
+ - 배포 버전을 `0.1.23`으로 상향
55
+ - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
56
+
3
57
  ## [0.1.22] - 2026-05-24
4
58
 
59
+ ### 리포지터리 UX 개선 — 다운로드 유입 최적화
60
+
61
+ #### README 전면 재작성
62
+ - **영어 메인 문서** — 한국어는 접을 수 있는 `<details>` 섹션으로 이동 (국제 유입 대응)
63
+ - **SVG 로고 추가** (`docs/images/logo.svg`) — 인디고→시안 그라디언트 래티스 그리드 아이콘
64
+ - **경쟁 제품 비교표** — Lattice AI vs Open WebUI · Continue.dev · GitHub Copilot 10개 기준 비교
65
+ - **PyPI 월간 다운로드 수 배지** 추가 (신뢰도 지표)
66
+ - 기능 · 보안 · API · 트러블슈팅 섹션을 표(table) 형식으로 정리 (가독성 향상)
67
+
68
+ #### 실제 UI 스크린샷 자동 캡처
69
+ - `docs/images/screenshot-chat.png` — 웹 채팅 UI (사이드바, 모델/파이프라인/VPC 카드)
70
+ - `docs/images/screenshot-admin.png` — 어드민 대시보드 + Audit & Data Governance 섹션
71
+ - `docs/images/screenshot-graph.png` — Data Graph 시각화 (299 노드, 443 엣지)
72
+ - README 상단에 3단 그리드 스크린샷 테이블 추가
73
+ - `scripts/take_screenshots.js` — Playwright Chromium 헤드리스 캡처 스크립트 (2x 레티나)
74
+
75
+ #### VS Code 익스텐션 메타데이터 개선
76
+ - **카테고리** `Other` → `AI, Machine Learning, Chat, Other` (Marketplace 검색 노출 증가)
77
+ - **키워드** 8개 → 16개 추가 (`copilot`, `apple-silicon`, `groq`, `graph-rag` 등)
78
+ - **설명 문구** 구체화 — 핵심 차별점(MLX, MCP, Graph RAG, zero telemetry) 명시
79
+ - **익스텐션 README 전면 재작성** — 기능표 · 빠른 시작 · 단축키 · 지원 모델 · 설정 · 비교표 포함
80
+
81
+ #### 리포지터리 정리
82
+ - 루트 및 `vscode-extension/`의 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
83
+
5
84
  ### Release preparation
6
85
 
7
86
  - 배포 버전을 `0.1.22`로 상향
8
87
  - `package.json`
9
88
  - `pyproject.toml`
10
89
  - `vscode-extension/package.json`
11
- - README 현재 배포 버전과 릴리스 체크 섹션을 `0.1.22` 기준으로 갱신
12
90
  - npm / PyPI / VS Code Marketplace / Open VSX 배포 전 빌드 산출물 생성
13
91
 
14
92
  ### Verification
@@ -0,0 +1,33 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 60" width="240" height="60">
2
+ <defs>
3
+ <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#6366f1"/>
5
+ <stop offset="100%" style="stop-color:#06b6d4"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <!-- Lattice grid icon -->
9
+ <g transform="translate(8,10)">
10
+ <!-- dots -->
11
+ <circle cx="0" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
12
+ <circle cx="16" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
13
+ <circle cx="32" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
14
+ <circle cx="0" cy="16" r="3" fill="url(#g)" opacity="0.9"/>
15
+ <circle cx="16" cy="16" r="4" fill="url(#g)"/>
16
+ <circle cx="32" cy="16" r="3" fill="url(#g)" opacity="0.9"/>
17
+ <circle cx="0" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
18
+ <circle cx="16" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
19
+ <circle cx="32" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
20
+ <!-- lines -->
21
+ <line x1="0" y1="0" x2="32" y2="0" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
22
+ <line x1="0" y1="16" x2="32" y2="16" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
23
+ <line x1="0" y1="32" x2="32" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
24
+ <line x1="0" y1="0" x2="0" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
25
+ <line x1="16" y1="0" x2="16" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
26
+ <line x1="32" y1="0" x2="32" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
27
+ <!-- diagonals -->
28
+ <line x1="0" y1="0" x2="32" y2="32" stroke="url(#g)" stroke-width="1" opacity="0.25"/>
29
+ <line x1="32" y1="0" x2="0" y2="32" stroke="url(#g)" stroke-width="1" opacity="0.25"/>
30
+ </g>
31
+ <!-- Text -->
32
+ <text x="58" y="34" font-family="'SF Pro Display','Segoe UI',system-ui,sans-serif" font-size="26" font-weight="700" fill="url(#g)" letter-spacing="-0.5">Lattice AI</text>
33
+ </svg>
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -50,7 +50,6 @@
50
50
  "tools.py",
51
51
  "codex_telegram_bot.py",
52
52
  "skills/",
53
- "tests/",
54
53
  "static/account.html",
55
54
  "static/chat.html",
56
55
  "static/admin.html",
package/server.py CHANGED
@@ -5631,6 +5631,13 @@ def _local_permission_response(path: str, action: str, user_email: str, content:
5631
5631
  }
5632
5632
 
5633
5633
 
5634
+ def _require_local_user(request: Request) -> str:
5635
+ email = get_current_user(request)
5636
+ if not email:
5637
+ raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
5638
+ return email
5639
+
5640
+
5634
5641
  def _require_local_approval(
5635
5642
  *,
5636
5643
  token: Optional[str],
@@ -5773,16 +5780,22 @@ async def permissions_status(token: str, request: Request):
5773
5780
 
5774
5781
  @app.post("/local/list")
5775
5782
  async def local_list_endpoint(req: LocalAccessRequest, request: Request):
5776
- current_user = require_user(request)
5783
+ current_user = _require_local_user(request)
5777
5784
  if not req.approved:
5778
5785
  return _local_permission_response(req.path, "list", current_user)
5779
5786
  _require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
5780
5787
  return _tool_response(local_list, req.path)
5781
5788
 
5782
5789
 
5790
+ @app.get("/local/list")
5791
+ async def local_list_get_endpoint(path: str, request: Request):
5792
+ current_user = _require_local_user(request)
5793
+ return _local_permission_response(path, "list", current_user)
5794
+
5795
+
5783
5796
  @app.post("/local/read")
5784
5797
  async def local_read_endpoint(req: LocalAccessRequest, request: Request):
5785
- current_user = require_user(request)
5798
+ current_user = _require_local_user(request)
5786
5799
  if not req.approved:
5787
5800
  return _local_permission_response(req.path, "read", current_user)
5788
5801
  _require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
@@ -5792,7 +5805,7 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
5792
5805
  @app.get("/local/serve")
5793
5806
  async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
5794
5807
  """Serve a local file (images etc.) directly for browser preview."""
5795
- current_user = require_user(request)
5808
+ current_user = _require_local_user(request)
5796
5809
  _require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
5797
5810
  target = Path(path).expanduser().resolve()
5798
5811
  if not target.exists() or not target.is_file():
@@ -5802,7 +5815,7 @@ async def local_serve_file(path: str, request: Request, approval_token: Optional
5802
5815
 
5803
5816
  @app.post("/local/write")
5804
5817
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
5805
- current_user = require_user(request)
5818
+ current_user = _require_local_user(request)
5806
5819
  if not req.approved:
5807
5820
  return _local_permission_response(req.path, "write", current_user, req.content)
5808
5821
  _require_local_approval(
package/tests/__init__.py DELETED
File without changes
File without changes
@@ -1,94 +0,0 @@
1
- """Integration tests for FastAPI endpoints.
2
-
3
- Run against a live server: pytest tests/integration/ --base-url http://localhost:8899
4
- Default base URL falls back to http://localhost:8899 if flag not provided.
5
- """
6
- import os
7
- import pytest
8
- import httpx
9
-
10
- BASE_URL = os.environ.get("LTCAI_TEST_BASE_URL", "http://localhost:8899")
11
-
12
-
13
- def _session_cookie() -> dict:
14
- """Return session cookie if LTCAI_TEST_SESSION env var is set, else empty dict."""
15
- sid = os.environ.get("LTCAI_TEST_SESSION", "")
16
- return {"session_id": sid} if sid else {}
17
-
18
-
19
- @pytest.fixture(scope="session")
20
- def client():
21
- with httpx.Client(base_url=BASE_URL, cookies=_session_cookie(), timeout=15) as c:
22
- yield c
23
-
24
-
25
- # ---------------------------------------------------------------------------
26
- # Health / Info
27
- # ---------------------------------------------------------------------------
28
-
29
- def test_health(client):
30
- r = client.get("/health")
31
- assert r.status_code == 200
32
- data = r.json()
33
- assert "status" in data
34
-
35
-
36
- def test_mode_endpoint(client):
37
- r = client.get("/mode")
38
- assert r.status_code == 200
39
- data = r.json()
40
- assert "model" in data or "mode" in data
41
-
42
-
43
- def test_runtime_features(client):
44
- r = client.get("/runtime_features")
45
- assert r.status_code == 200
46
-
47
-
48
- # ---------------------------------------------------------------------------
49
- # Local filesystem endpoints
50
- # ---------------------------------------------------------------------------
51
-
52
- def test_local_list_home(client):
53
- r = client.get("/local/list", params={"path": "~"})
54
- # 200 if authenticated, 401/403 if no session
55
- assert r.status_code in (200, 401, 403)
56
-
57
-
58
- def test_local_list_requires_auth(client):
59
- """Without a session cookie the endpoint must reject the request."""
60
- r = httpx.get(f"{BASE_URL}/local/list", params={"path": "~"}, timeout=10)
61
- assert r.status_code in (401, 403)
62
-
63
-
64
- def test_local_serve_missing_file(client):
65
- r = client.get("/local/serve", params={"path": "/nonexistent_lattice_ai_test_xyz.txt"})
66
- assert r.status_code in (400, 401, 403, 404)
67
-
68
-
69
- # ---------------------------------------------------------------------------
70
- # /tools/read_document
71
- # ---------------------------------------------------------------------------
72
-
73
- def test_read_document_missing(client):
74
- r = client.post("/tools/read_document", json={"path": "/nonexistent_lattice_ai_doc.txt"})
75
- assert r.status_code in (400, 401, 403, 404, 422)
76
-
77
-
78
- # ---------------------------------------------------------------------------
79
- # /chat — smoke test (no model required)
80
- # ---------------------------------------------------------------------------
81
-
82
- def test_chat_requires_message(client):
83
- r = client.post("/chat", json={})
84
- # Missing message → 422 validation error or 401 unauth
85
- assert r.status_code in (401, 403, 422)
86
-
87
-
88
- # ---------------------------------------------------------------------------
89
- # /agent — smoke test
90
- # ---------------------------------------------------------------------------
91
-
92
- def test_agent_requires_task(client):
93
- r = client.post("/agent", json={})
94
- assert r.status_code in (401, 403, 422)
File without changes
@@ -1,193 +0,0 @@
1
- """Unit tests for security-sensitive helpers in server.py."""
2
- import sys
3
- import time
4
- import pytest
5
- from pathlib import Path
6
-
7
- sys.path.insert(0, str(Path(__file__).parent.parent.parent))
8
-
9
- from server import (
10
- _bytes_match_extension,
11
- _rate_buckets,
12
- enforce_rate_limit,
13
- hash_password,
14
- verify_password,
15
- _agent_risk,
16
- _host_is_loopback,
17
- _local_permission_response,
18
- _require_local_approval,
19
- _LOCAL_WRITE_BLOCKED_PREFIXES,
20
- )
21
- from fastapi import HTTPException
22
-
23
-
24
- # ---------------------------------------------------------------------------
25
- # Password hashing
26
- # ---------------------------------------------------------------------------
27
-
28
- def test_password_hash_roundtrip():
29
- h = hash_password("hunter2")
30
- assert verify_password("hunter2", h)
31
- assert not verify_password("wrong", h)
32
-
33
-
34
- def test_password_hash_not_plaintext():
35
- h = hash_password("hunter2")
36
- assert "hunter2" not in h
37
- assert ":" in h # salt:hash format
38
-
39
-
40
- def test_password_hash_unique_per_call():
41
- """Same input must yield different hashes (salted)."""
42
- h1 = hash_password("same")
43
- h2 = hash_password("same")
44
- assert h1 != h2
45
- assert verify_password("same", h1)
46
- assert verify_password("same", h2)
47
-
48
-
49
- # ---------------------------------------------------------------------------
50
- # MIME / magic-number sniffing
51
- # ---------------------------------------------------------------------------
52
-
53
- def test_bytes_match_pdf():
54
- assert _bytes_match_extension(b"%PDF-1.7\n...", ".pdf")
55
-
56
-
57
- def test_bytes_match_pdf_rejects_zip_bytes():
58
- assert not _bytes_match_extension(b"PK\x03\x04...", ".pdf")
59
-
60
-
61
- def test_bytes_match_docx_is_zip():
62
- assert _bytes_match_extension(b"PK\x03\x04...", ".docx")
63
-
64
-
65
- def test_bytes_match_png():
66
- assert _bytes_match_extension(b"\x89PNG\r\n\x1a\nrest", ".png")
67
-
68
-
69
- def test_bytes_match_txt_skips_check():
70
- """Text-like formats have no magic — always accepted."""
71
- assert _bytes_match_extension(b"anything goes", ".txt")
72
- assert _bytes_match_extension(b"anything goes", ".md")
73
- assert _bytes_match_extension(b"anything goes", ".csv")
74
-
75
-
76
- # ---------------------------------------------------------------------------
77
- # Rate limiting
78
- # ---------------------------------------------------------------------------
79
-
80
- def test_rate_limit_allows_within_capacity():
81
- _rate_buckets.clear()
82
- for _ in range(10):
83
- enforce_rate_limit("test_user@example.com", "agent") # capacity 10
84
-
85
-
86
- def test_rate_limit_blocks_over_capacity():
87
- _rate_buckets.clear()
88
- for _ in range(10):
89
- enforce_rate_limit("burst_user@example.com", "agent")
90
- with pytest.raises(HTTPException) as exc:
91
- enforce_rate_limit("burst_user@example.com", "agent")
92
- assert exc.value.status_code == 429
93
- assert "Retry-After" in exc.value.headers
94
-
95
-
96
- def test_rate_limit_skips_unauth():
97
- """Empty email = no rate-limit (anon health-check style)."""
98
- _rate_buckets.clear()
99
- for _ in range(200):
100
- enforce_rate_limit("", "agent") # never raises
101
-
102
-
103
- # ---------------------------------------------------------------------------
104
- # Harness risk classification
105
- # ---------------------------------------------------------------------------
106
-
107
- def test_agent_risk_read_only_is_low():
108
- assert _agent_risk("local_read", {"path": "/tmp/x"}) == "low"
109
- assert _agent_risk("list_dir", {}) == "low"
110
-
111
-
112
- def test_agent_risk_write_is_medium():
113
- assert _agent_risk("write_file", {"path": "out.txt"}) == "medium"
114
- assert _agent_risk("local_write", {"path": "/tmp/safe.txt"}) == "medium"
115
-
116
-
117
- def test_agent_risk_run_command_is_high():
118
- assert _agent_risk("run_command", {"command": "ls"}) == "high"
119
-
120
-
121
- def test_agent_risk_system_path_write_upgraded_to_high():
122
- for prefix in _LOCAL_WRITE_BLOCKED_PREFIXES:
123
- risk = _agent_risk("local_write", {"path": prefix + "evil.txt"})
124
- assert risk == "high", f"prefix {prefix} should upgrade local_write to high"
125
-
126
-
127
- def test_agent_risk_unknown_action_defaults_medium():
128
- assert _agent_risk("nonexistent_tool_xyz", {}) == "medium"
129
-
130
-
131
- # ---------------------------------------------------------------------------
132
- # Network exposure / local file approvals
133
- # ---------------------------------------------------------------------------
134
-
135
- def test_host_loopback_detection():
136
- assert _host_is_loopback("127.0.0.1")
137
- assert _host_is_loopback("localhost")
138
- assert not _host_is_loopback("0.0.0.0")
139
- assert not _host_is_loopback("192.168.0.2")
140
-
141
-
142
- def test_local_approval_token_allows_exact_scope(tmp_path):
143
- target = tmp_path / "note.txt"
144
- target.write_text("hello")
145
- user = "alice@example.com"
146
- approval = _local_permission_response(str(target), "read", user)
147
- from server import _local_approvals
148
- _local_approvals[approval["approval_token"]]["approved"] = True
149
-
150
- _require_local_approval(
151
- token=approval["approval_token"],
152
- path=str(target),
153
- action="read",
154
- user_email=user,
155
- )
156
-
157
-
158
- def test_local_approval_token_rejects_wrong_path(tmp_path):
159
- allowed = tmp_path / "allowed.txt"
160
- denied = tmp_path / "denied.txt"
161
- allowed.write_text("allowed")
162
- denied.write_text("denied")
163
- user = "alice@example.com"
164
- approval = _local_permission_response(str(allowed), "read", user)
165
- from server import _local_approvals
166
- _local_approvals[approval["approval_token"]]["approved"] = True
167
-
168
- with pytest.raises(HTTPException) as exc:
169
- _require_local_approval(
170
- token=approval["approval_token"],
171
- path=str(denied),
172
- action="read",
173
- user_email=user,
174
- )
175
- assert exc.value.status_code == 403
176
-
177
-
178
- def test_local_write_approval_binds_content(tmp_path):
179
- target = tmp_path / "out.txt"
180
- user = "alice@example.com"
181
- approval = _local_permission_response(str(target), "write", user, "first")
182
- from server import _local_approvals
183
- _local_approvals[approval["approval_token"]]["approved"] = True
184
-
185
- with pytest.raises(HTTPException) as exc:
186
- _require_local_approval(
187
- token=approval["approval_token"],
188
- path=str(target),
189
- action="write",
190
- user_email=user,
191
- content="changed",
192
- )
193
- assert exc.value.status_code == 403
@@ -1,35 +0,0 @@
1
- import os
2
- import shutil
3
-
4
- import setup
5
-
6
-
7
- def test_scan_environment_includes_components_and_paths():
8
- env = setup.scan_environment()
9
-
10
- assert "components" in env
11
- assert "python" in env["components"]
12
- assert "official_url" in env["components"]["python"]
13
- assert "path" in env
14
- assert "active" in env["path"]
15
-
16
-
17
- def test_recommendations_include_components():
18
- env = setup.scan_environment()
19
- recs = setup.get_recommendations(env)
20
-
21
- assert "components" in recs
22
- assert any(item["id"].startswith("component_") for item in recs["components"])
23
-
24
-
25
- def test_repair_path_can_find_binary_from_common_dir(monkeypatch):
26
- python_path = shutil.which("python3") or shutil.which("python")
27
- assert python_path
28
- python_dir = os.path.dirname(python_path)
29
-
30
- monkeypatch.setattr(setup, "COMMON_PATH_DIRS", [python_dir])
31
- monkeypatch.setenv("PATH", "")
32
-
33
- setup.repair_path_for("python3" if python_path.endswith("python3") else "python")
34
-
35
- assert python_dir in os.environ["PATH"].split(os.pathsep)