witbitz-code 1.3.5__tar.gz → 1.3.7__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.
- {witbitz_code-1.3.5/src/witbitz_code.egg-info → witbitz_code-1.3.7}/PKG-INFO +2 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/pyproject.toml +2 -2
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/cli.py +6 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +19 -10
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/ports.py +53 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/tools_probe.py +26 -4
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/uninstall.py +37 -2
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/winagent.py +6 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/wizard.py +7 -3
- {witbitz_code-1.3.5 → witbitz_code-1.3.7/src/witbitz_code.egg-info}/PKG-INFO +2 -1
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/SOURCES.txt +1 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/requires.txt +1 -0
- witbitz_code-1.3.7/tests/test_ports_win.py +83 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_tools_probe.py +28 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_toolsetup.py +58 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_winagent.py +11 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/LICENSE +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/README.md +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/setup.cfg +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_ports.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_setup.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.3.5 → witbitz_code-1.3.7}/tests/test_windows_files.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.7
|
|
4
4
|
Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
|
|
5
5
|
Author: Witbitz
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,6 +21,7 @@ Requires-Dist: segno>=1.5
|
|
|
21
21
|
Requires-Dist: tinfoil<0.15,>=0.14
|
|
22
22
|
Provides-Extra: test
|
|
23
23
|
Requires-Dist: pytest>=8; extra == "test"
|
|
24
|
+
Requires-Dist: pyflakes>=3; extra == "test"
|
|
24
25
|
Dynamic: license-file
|
|
25
26
|
|
|
26
27
|
# witbitz-code
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.7"
|
|
8
8
|
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -29,7 +29,7 @@ dependencies = [
|
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
test = ["pytest>=8"]
|
|
32
|
+
test = ["pytest>=8", "pyflakes>=3"]
|
|
33
33
|
|
|
34
34
|
[project.urls]
|
|
35
35
|
Documentation = "https://witbitz.chat/docs/opencode.md"
|
|
@@ -26,7 +26,7 @@ import httpx
|
|
|
26
26
|
from . import __version__
|
|
27
27
|
from . import pair as pair_cmd
|
|
28
28
|
from . import pairings as pp
|
|
29
|
-
from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, winagent, winconsole, wizard
|
|
29
|
+
from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winconsole, wizard
|
|
30
30
|
from ._js import to_number, truthy
|
|
31
31
|
from .connector import pairings_for_port, start_connector
|
|
32
32
|
from .payers import SessionPayers
|
|
@@ -161,6 +161,11 @@ async def serve(args: list[str]) -> int:
|
|
|
161
161
|
if not pairings:
|
|
162
162
|
_err(f"witbitz-code: no pairing uses OpenCode on port {port} — pair with: witbitz-code pair --port {port}")
|
|
163
163
|
return 1
|
|
164
|
+
# Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
|
|
165
|
+
# this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
|
|
166
|
+
reg = toolsetup.registry_path()
|
|
167
|
+
if reg:
|
|
168
|
+
os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
|
|
164
169
|
# Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
|
|
165
170
|
# written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
|
|
166
171
|
# running picks them up at its next restart.
|
{witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
@@ -719,26 +719,35 @@ export function parseSearchAnswer (data) { // == agent/tinfoilWebSearch.mjs pars
|
|
|
719
719
|
}
|
|
720
720
|
return { text, citations }
|
|
721
721
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (!apiKey) fail('web_search needs a Tinfoil key: TINFOIL_API_KEY is not set for this OpenCode (on a laptop it comes from ~/.opencode-server.env; a cloud computer gets it from its Sentinel)')
|
|
727
|
-
const url = env.WITBITZ_SEARCH_URL || `https://${SEARCH_HOST}${SEARCH_PATH}`
|
|
728
|
-
const body = {
|
|
722
|
+
/** The one Tinfoil web-search request, for a laptop (sent with the key) and for a cloud computer's Sentinel (which builds it
|
|
723
|
+
* from the query alone, so the key it holds cannot be spent on any other call — infra/cloud-computer/sentinel). */
|
|
724
|
+
export function searchRequest (query, { piiCheck = true } = {}) {
|
|
725
|
+
return {
|
|
729
726
|
model: SEARCH_MODEL,
|
|
730
727
|
messages: [
|
|
731
728
|
{ role: 'system', content: 'You are a web research assistant. Search the web and report what you find, briefly and factually, with dates. State plainly when sources disagree or when something has not happened yet. Do not speculate beyond what the sources say.' },
|
|
732
|
-
{ role: 'user', content:
|
|
729
|
+
{ role: 'user', content: query },
|
|
733
730
|
],
|
|
734
731
|
max_tokens: SEARCH_MAX_TOKENS,
|
|
735
732
|
reasoning_effort: SEARCH_REASONING_EFFORT,
|
|
736
733
|
web_search_options: {},
|
|
737
|
-
...(
|
|
734
|
+
...(piiCheck === false ? {} : { pii_check_options: {} }), // a query carrying personal data is refused before it leaves the enclave for the index
|
|
738
735
|
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function webSearch (a, { fetchImpl = fetch, env = process.env } = {}) {
|
|
739
|
+
const q = String((a && a.query) || '').replace(/\s+/g, ' ').trim().slice(0, 2000)
|
|
740
|
+
if (!q) fail('query is required')
|
|
741
|
+
const piiCheck = !(a && a.pii_check === false)
|
|
742
|
+
// A cloud computer holds no key: the query goes to its Credential Sentinel, which builds this same request with the key.
|
|
743
|
+
const viaSentinel = env.WITBITZ_SEARCH_SENTINEL
|
|
744
|
+
const apiKey = env.TINFOIL_API_KEY
|
|
745
|
+
if (!viaSentinel && !apiKey) fail('web_search needs a Tinfoil key: TINFOIL_API_KEY is not set for this OpenCode (on a laptop it comes from ~/.opencode-server.env; a cloud computer gets it from its Sentinel)')
|
|
746
|
+
const url = viaSentinel || env.WITBITZ_SEARCH_URL || `https://${SEARCH_HOST}${SEARCH_PATH}`
|
|
747
|
+
const body = viaSentinel ? { query: q, pii_check: piiCheck } : searchRequest(q, { piiCheck })
|
|
739
748
|
let data
|
|
740
749
|
try {
|
|
741
|
-
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) })
|
|
750
|
+
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(viaSentinel ? {} : { authorization: `Bearer ${apiKey}` }) }, body: JSON.stringify(body), signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) })
|
|
742
751
|
if (!res.ok) fail(`the search endpoint answered ${res.status}`)
|
|
743
752
|
data = await res.json()
|
|
744
753
|
} catch (e) { fail(`could not search the web (${String((e && e.message) || e).slice(0, 160)})`) }
|
|
@@ -3,19 +3,25 @@
|
|
|
3
3
|
setup asks it before starting: an earlier witbitz-code still running (`pipx uninstall` does not stop it) holds OpenCode's
|
|
4
4
|
port and the model proxy's (port + 100), and keeps answering the phone with the keys it started with (the owner,
|
|
5
5
|
2026-10-08: "could not start the model proxy … address already in use", then "Invalid API key"). Linux reads /proc; macOS
|
|
6
|
-
asks lsof;
|
|
6
|
+
asks lsof; Windows asks netstat and tasklist (_win_owners, = tools/code-ports-win.mjs — before 1.3.7 Windows knew nobody and
|
|
7
|
+
setup called the person's own `witbitz-code serve` "a program that is not yours"); elsewhere nothing is known.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
import os
|
|
12
13
|
import shutil
|
|
14
|
+
import re
|
|
13
15
|
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable
|
|
14
18
|
from pathlib import Path
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def port_owners(port: int) -> list[dict]:
|
|
18
22
|
"""[{pid, cmd}] of this user's processes listening on 127.0.0.1/::1 `port` (never this process)."""
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
return win_owners(port)
|
|
19
25
|
if Path("/proc/net/tcp").exists():
|
|
20
26
|
return _proc_owners(port)
|
|
21
27
|
if shutil.which("lsof"):
|
|
@@ -85,3 +91,49 @@ def on_wsl() -> bool:
|
|
|
85
91
|
return "microsoft" in Path("/proc/version").read_text().lower()
|
|
86
92
|
except OSError:
|
|
87
93
|
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_netstat_listeners(text: str, port: int) -> list[int]:
|
|
97
|
+
"""The process ids listening on TCP `port`, from `netstat -ano` output. A listening socket's foreign address is 0.0.0.0:0
|
|
98
|
+
or [::]:0 — the State column is translated on some Windows languages, the addresses are not."""
|
|
99
|
+
pids: set[int] = set()
|
|
100
|
+
for line in (text or "").splitlines():
|
|
101
|
+
c = line.split()
|
|
102
|
+
if len(c) < 5 or c[0].upper() != "TCP" or not c[1].endswith(f":{port}") or c[2] not in ("0.0.0.0:0", "[::]:0"):
|
|
103
|
+
continue
|
|
104
|
+
if c[-1].isdigit() and int(c[-1]) > 0:
|
|
105
|
+
pids.add(int(c[-1]))
|
|
106
|
+
return sorted(pids)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_tasklist_row(text: str) -> dict | None:
|
|
110
|
+
"""One CSV row of `tasklist /V /FO CSV /NH` → {image, pid, user}, or None."""
|
|
111
|
+
line = next((ln for ln in (text or "").splitlines() if ln.strip().startswith('"')), None)
|
|
112
|
+
if line is None:
|
|
113
|
+
return None
|
|
114
|
+
cells = [m.replace('""', '"') for m in re.findall(r'"((?:[^"]|"")*)"', line)]
|
|
115
|
+
if len(cells) < 7 or not cells[1].isdigit():
|
|
116
|
+
return None
|
|
117
|
+
return {"image": cells[0], "pid": int(cells[1]), "user": cells[6]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_text(argv: list[str]) -> str:
|
|
121
|
+
try:
|
|
122
|
+
return subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=15).stdout or ""
|
|
123
|
+
except (OSError, subprocess.SubprocessError):
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def win_owners(port: int, *, run: Callable[[list[str]], str] = _run_text, user: str | None = None, self_pid: int | None = None) -> list[dict]:
|
|
128
|
+
"""[{pid, cmd}] of this user's processes listening on `port` on Windows: netstat for the ids, tasklist for each one's
|
|
129
|
+
program and user (another user's process says N/A and is left out)."""
|
|
130
|
+
user = os.environ.get("USERNAME", "") if user is None else user
|
|
131
|
+
self_pid = os.getpid() if self_pid is None else self_pid
|
|
132
|
+
hits = []
|
|
133
|
+
for pid in parse_netstat_listeners(run(["netstat", "-ano"]), port):
|
|
134
|
+
if pid == self_pid:
|
|
135
|
+
continue
|
|
136
|
+
row = parse_tasklist_row(run(["tasklist", "/V", "/FO", "CSV", "/NH", "/FI", f"PID eq {pid}"]))
|
|
137
|
+
if row and row["pid"] == pid and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
|
|
138
|
+
hits.append({"pid": pid, "cmd": row["image"]})
|
|
139
|
+
return hits
|
|
@@ -30,6 +30,28 @@ _EXTRA_HOME = [".local/bin", ".cargo/bin", "bin"]
|
|
|
30
30
|
# Windows: under %LOCALAPPDATA%\Witbitz — what the Windows installer (code.ps1) fills and witbitz-code serve gives the agent:
|
|
31
31
|
# its Git (Git Bash, the agent's shell), its own Python, and what setup's Tools step puts there (Poppler). = code-tools-probe.mjs
|
|
32
32
|
_WIN_WITBITZ = ["bin", "git\\cmd", "git\\bin", "agent-python\\Scripts", "poppler\\bin"]
|
|
33
|
+
# Windows: tools whose installers do not add themselves to PATH (measured 2026-10-09 on the owner's laptop: winget installed
|
|
34
|
+
# Tesseract (UB-Mannheim) and qpdf, setup's re-check still found neither) — their usual folders. = code-tools-probe.mjs
|
|
35
|
+
_WIN_PROGRAM_ROOTS = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _listdir(d: str) -> list[str]:
|
|
39
|
+
try:
|
|
40
|
+
return os.listdir(d)
|
|
41
|
+
except OSError:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def win_program_dirs(env: Mapping[str, str], listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
|
|
46
|
+
"""Tesseract-OCR and qpdf <version>\\bin under Program Files (all three) and %LOCALAPPDATA%\\Programs."""
|
|
47
|
+
roots = [(env.get(k) or "").rstrip("\\/") for k in _WIN_PROGRAM_ROOTS]
|
|
48
|
+
local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
|
|
49
|
+
roots = list(dict.fromkeys([r for r in roots + ([f"{local}\\Programs"] if local else []) if r]))
|
|
50
|
+
out: list[str] = []
|
|
51
|
+
for r in roots:
|
|
52
|
+
out.append(f"{r}\\Tesseract-OCR")
|
|
53
|
+
out += [f"{r}\\{n}\\bin" for n in sorted(listdir(r)) if n.lower().startswith("qpdf")]
|
|
54
|
+
return out
|
|
33
55
|
|
|
34
56
|
|
|
35
57
|
def _is_executable(path: str) -> bool:
|
|
@@ -40,26 +62,26 @@ def _is_executable(path: str) -> bool:
|
|
|
40
62
|
return stat.S_ISREG(st.st_mode) and os.access(path, os.X_OK)
|
|
41
63
|
|
|
42
64
|
|
|
43
|
-
def search_dirs(*, env: Mapping[str, str], platform: str, home: str) -> list[str]:
|
|
65
|
+
def search_dirs(*, env: Mapping[str, str], platform: str, home: str, listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
|
|
44
66
|
"""The folders searched, in order, without repeats."""
|
|
45
67
|
win = platform == "win32"
|
|
46
68
|
raw = env.get("PATH") or env.get("Path") or ""
|
|
47
69
|
from_path = [d for d in raw.split(";" if win else ":") if d]
|
|
48
70
|
if win:
|
|
49
71
|
local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
|
|
50
|
-
extra = [f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []
|
|
72
|
+
extra = ([f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []) + win_program_dirs(env, listdir)
|
|
51
73
|
else:
|
|
52
74
|
extra = _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
|
|
53
75
|
return list(dict.fromkeys(from_path + extra))
|
|
54
76
|
|
|
55
77
|
|
|
56
78
|
def probe_tools(*, env: Mapping[str, str] | None = None, platform: str | None = None, home: str | None = None,
|
|
57
|
-
is_executable: Callable[[str], bool] = _is_executable) -> dict:
|
|
79
|
+
is_executable: Callable[[str], bool] = _is_executable, listdir: Callable[[str], list[str]] = _listdir) -> dict:
|
|
58
80
|
env = os.environ if env is None else env
|
|
59
81
|
platform = sys.platform if platform is None else platform
|
|
60
82
|
home = str(Path.home()) if home is None else home
|
|
61
83
|
win = platform == "win32"
|
|
62
|
-
dirs = search_dirs(env=env, platform=platform, home=home)
|
|
84
|
+
dirs = search_dirs(env=env, platform=platform, home=home, listdir=listdir)
|
|
63
85
|
exts = [""] + [e.lower() for e in (env.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split(";") if e] if win else [""]
|
|
64
86
|
|
|
65
87
|
def join(d: str, b: str) -> str:
|
|
@@ -150,8 +150,43 @@ def opencode_holders(data_dir: str) -> list[dict]:
|
|
|
150
150
|
return hits
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
def _win_alive(pid: int) -> bool:
|
|
154
|
+
"""Windows: is `pid` still running? (OpenProcess + GetExitCodeProcess; access denied = another user's, still running.)"""
|
|
155
|
+
import ctypes # noqa: PLC0415 — Windows only
|
|
156
|
+
|
|
157
|
+
k = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
158
|
+
h = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
159
|
+
if not h:
|
|
160
|
+
return k.GetLastError() == 5 # ERROR_ACCESS_DENIED
|
|
161
|
+
try:
|
|
162
|
+
code = ctypes.c_ulong()
|
|
163
|
+
return bool(k.GetExitCodeProcess(h, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
164
|
+
finally:
|
|
165
|
+
k.CloseHandle(h)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def win_stop_process(pid: int, *, run: Callable[[list[str]], Any] | None = None, alive: Callable[[int], bool] = _win_alive,
|
|
169
|
+
sleep: Callable[[float], None] = time.sleep) -> bool:
|
|
170
|
+
"""Windows: taskkill /T /F (the process and what it started — a witbitz-code serve's OpenCode goes with it), then wait up
|
|
171
|
+
to 5 s for it to be gone. Never os.kill(pid, 0): on Windows signal 0 is CTRL_C_EVENT, a Ctrl-C to the console's
|
|
172
|
+
processes — setup's own window included — not a probe."""
|
|
173
|
+
run = run or (lambda argv: subprocess.run(argv, capture_output=True, check=False, timeout=30))
|
|
174
|
+
try:
|
|
175
|
+
run(["taskkill", "/PID", str(pid), "/T", "/F"])
|
|
176
|
+
except (OSError, subprocess.SubprocessError):
|
|
177
|
+
pass
|
|
178
|
+
for _ in range(50):
|
|
179
|
+
if not alive(pid):
|
|
180
|
+
return True
|
|
181
|
+
sleep(0.1)
|
|
182
|
+
return not alive(pid)
|
|
183
|
+
|
|
184
|
+
|
|
153
185
|
def stop_process(pid: int) -> bool:
|
|
154
|
-
"""SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped)."""
|
|
186
|
+
"""SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped). Windows: win_stop_process."""
|
|
187
|
+
if sys.platform == "win32":
|
|
188
|
+
return win_stop_process(pid)
|
|
189
|
+
|
|
155
190
|
def gone() -> bool:
|
|
156
191
|
try:
|
|
157
192
|
os.kill(pid, 0)
|
|
@@ -170,7 +205,7 @@ def stop_process(pid: int) -> bool:
|
|
|
170
205
|
if gone():
|
|
171
206
|
return True
|
|
172
207
|
try:
|
|
173
|
-
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
208
|
+
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
174
209
|
except OSError:
|
|
175
210
|
pass
|
|
176
211
|
time.sleep(0.3)
|
|
@@ -19,6 +19,8 @@ import sys
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Callable
|
|
21
21
|
|
|
22
|
+
from . import tools_probe
|
|
23
|
+
|
|
22
24
|
INSTRUCTIONS_NAME = "witbitz-windows.md"
|
|
23
25
|
|
|
24
26
|
GIT_BASH = """# This computer
|
|
@@ -70,7 +72,7 @@ def _write_once(f: Path, text: str) -> None:
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def windows_agent(config_dir: Path, env: dict | None = None, platform: str | None = None,
|
|
73
|
-
exists: Callable[[str], bool] = os.path.exists) -> tuple[dict, str | None, str]:
|
|
75
|
+
exists: Callable[[str], bool] = os.path.exists, listdir: Callable[[str], list] | None = None) -> tuple[dict, str | None, str]:
|
|
74
76
|
"""→ (env additions — PATH_PREPEND is a list for apply(), the instructions file or None, a line for the log or "")."""
|
|
75
77
|
platform = sys.platform if platform is None else platform
|
|
76
78
|
if platform != "win32":
|
|
@@ -84,6 +86,9 @@ def windows_agent(config_dir: Path, env: dict | None = None, platform: str | Non
|
|
|
84
86
|
# Poppler from setup's Tools step (toolsetup.py: winget has no package, so a pinned release into Witbitz\poppler\bin)
|
|
85
87
|
poppler = ntpath.join(_witbitz_dir(env), "poppler", "bin")
|
|
86
88
|
prepend = [d for d in (py, poppler if exists(ntpath.join(poppler, "pdftotext.exe")) else None) if d]
|
|
89
|
+
# Tesseract and qpdf: their installers leave PATH alone (tools_probe.win_program_dirs — setup's probe looks there too)
|
|
90
|
+
programs = tools_probe.win_program_dirs(env, listdir) if listdir else tools_probe.win_program_dirs(env)
|
|
91
|
+
prepend += [d for d in programs if exists(ntpath.join(d, "tesseract.exe")) or exists(ntpath.join(d, "qpdf.exe"))]
|
|
87
92
|
if prepend:
|
|
88
93
|
add["PATH_PREPEND"] = prepend
|
|
89
94
|
f = Path(config_dir) / INSTRUCTIONS_NAME
|
|
@@ -241,7 +241,7 @@ def _describe(step: dict) -> str:
|
|
|
241
241
|
|
|
242
242
|
|
|
243
243
|
async def _tools_step(d: Deps) -> str:
|
|
244
|
-
"""→ 'here' | 'installed' | 'partly' | 'skipped' | 'unavailable'."""
|
|
244
|
+
"""→ 'here' | 'installed' | 'partly' | 'skipped' | 'unavailable' | 'failed' (an install that breaks never ends setup)."""
|
|
245
245
|
try:
|
|
246
246
|
report = d.probe_tools()
|
|
247
247
|
except Exception: # noqa: BLE001 — a probe that fails never stops setup
|
|
@@ -262,8 +262,12 @@ async def _tools_step(d: Deps) -> str:
|
|
|
262
262
|
if not _yes(d.ask(" Install them now? [Y/n] ")):
|
|
263
263
|
d.say(" Skipped — run witbitz-code setup again to install them, or use the card in Code.")
|
|
264
264
|
return "skipped"
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
try:
|
|
266
|
+
d.run_tools(plan)
|
|
267
|
+
d.refresh_path()
|
|
268
|
+
except Exception as e: # noqa: BLE001 — the tools are a convenience: setup goes on to serve
|
|
269
|
+
d.say(f" Could not install them ({str(e)[:160]}) — setup goes on; \"Install with the agent\" in Code can try.")
|
|
270
|
+
return "failed"
|
|
267
271
|
try:
|
|
268
272
|
after = {t["id"] for t in toolsetup.missing_tools(d.probe_tools())}
|
|
269
273
|
except Exception: # noqa: BLE001
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.7
|
|
4
4
|
Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
|
|
5
5
|
Author: Witbitz
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,6 +21,7 @@ Requires-Dist: segno>=1.5
|
|
|
21
21
|
Requires-Dist: tinfoil<0.15,>=0.14
|
|
22
22
|
Provides-Extra: test
|
|
23
23
|
Requires-Dist: pytest>=8; extra == "test"
|
|
24
|
+
Requires-Dist: pyflakes>=3; extra == "test"
|
|
24
25
|
Dynamic: license-file
|
|
25
26
|
|
|
26
27
|
# witbitz-code
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Windows: who holds a port (netstat + tasklist) and stopping it without os.kill(pid, 0) — measured on the owner's laptop,
|
|
2
|
+
2026-10-09: setup called their own `witbitz-code serve` "a program that is not yours" (Windows knew nobody)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from conftest import requires_node, run_node
|
|
10
|
+
|
|
11
|
+
from witbitz_code import ports, uninstall
|
|
12
|
+
|
|
13
|
+
NETSTAT = """
|
|
14
|
+
Active Connections
|
|
15
|
+
|
|
16
|
+
Proto Local Address Foreign Address State PID
|
|
17
|
+
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1100
|
|
18
|
+
TCP 127.0.0.1:4096 0.0.0.0:0 ABHÖREN 7001
|
|
19
|
+
TCP 127.0.0.1:4096 127.0.0.1:52011 ESTABLISHED 7001
|
|
20
|
+
TCP 127.0.0.1:52011 127.0.0.1:4096 ESTABLISHED 9999
|
|
21
|
+
TCP 127.0.0.1:4196 0.0.0.0:0 LISTENING 7002
|
|
22
|
+
TCP [::1]:4096 [::]:0 LISTENING 7003
|
|
23
|
+
TCP 127.0.0.1:40960 0.0.0.0:0 LISTENING 7004
|
|
24
|
+
UDP 0.0.0.0:4096 *:* 7005
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
TASKLIST = {
|
|
28
|
+
7001: '"opencode.exe","7001","Console","1","120,404 K","Running","LAPTOP\\shter","0:00:03","N/A"',
|
|
29
|
+
7002: '"python.exe","7002","Console","1","80,000 K","Running","LAPTOP\\Shter","0:00:01","witbitz-code serve"',
|
|
30
|
+
7003: '"other.exe","7003","Services","0","9,000 K","Unknown","N/A","0:00:00","N/A"',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fake_run(argv: list[str]) -> str:
|
|
35
|
+
if argv[0] == "netstat":
|
|
36
|
+
return NETSTAT
|
|
37
|
+
pid = int(argv[-1].split()[-1])
|
|
38
|
+
return TASKLIST.get(pid, "INFO: No tasks are running which match the specified criteria.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_the_listening_processes_are_read_by_address_not_by_the_translated_state():
|
|
42
|
+
assert ports.parse_netstat_listeners(NETSTAT, 4096) == [7001, 7003]
|
|
43
|
+
assert ports.parse_netstat_listeners(NETSTAT, 4196) == [7002]
|
|
44
|
+
assert ports.parse_netstat_listeners(NETSTAT, 52011) == [], "a connection's port is not a listener"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_only_this_users_programs_are_named_and_never_this_process():
|
|
48
|
+
assert ports.win_owners(4096, run=fake_run, user="shter", self_pid=1) == [{"pid": 7001, "cmd": "opencode.exe"}]
|
|
49
|
+
assert ports.win_owners(4196, run=fake_run, user="shter", self_pid=1) == [{"pid": 7002, "cmd": "python.exe"}]
|
|
50
|
+
assert ports.win_owners(4196, run=fake_run, user="shter", self_pid=7002) == []
|
|
51
|
+
assert ports.parse_tasklist_row("INFO: No tasks are running which match the specified criteria.") is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@requires_node
|
|
55
|
+
def test_the_javascript_reads_the_same():
|
|
56
|
+
js = run_node("""
|
|
57
|
+
import { parseNetstatListeners, winPortOwners } from '@TOOLS@/code-ports-win.mjs'
|
|
58
|
+
const run = (argv) => argv[0] === 'netstat' ? INPUT.netstat : (INPUT.tasklist[argv[argv.length - 1].split(' ').pop()] || 'INFO: none')
|
|
59
|
+
OUT({ l: [4096, 4196, 52011].map((p) => parseNetstatListeners(INPUT.netstat, p)),
|
|
60
|
+
o: [[4096, 1], [4196, 1], [4196, 7002]].map(([p, self]) => winPortOwners(p, { run, user: 'shter', self })) })
|
|
61
|
+
""", {"netstat": NETSTAT, "tasklist": {str(k): v for k, v in TASKLIST.items()}})
|
|
62
|
+
assert js["l"] == [ports.parse_netstat_listeners(NETSTAT, p) for p in (4096, 4196, 52011)]
|
|
63
|
+
assert js["o"] == [ports.win_owners(p, run=fake_run, user="shter", self_pid=s) for p, s in ((4096, 1), (4196, 1), (4196, 7002))]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_stopping_on_windows_is_taskkill_of_the_tree_and_never_os_kill(monkeypatch):
|
|
67
|
+
def no_kill(*a):
|
|
68
|
+
raise AssertionError("os.kill on Windows: signal 0 is CTRL_C_EVENT — a Ctrl-C to setup's own window")
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(os, "kill", no_kill)
|
|
71
|
+
ran, left = [], [3]
|
|
72
|
+
|
|
73
|
+
def alive(pid: int) -> bool:
|
|
74
|
+
left[0] -= 1
|
|
75
|
+
return left[0] > 0
|
|
76
|
+
|
|
77
|
+
assert uninstall.win_stop_process(4242, run=ran.append, alive=alive, sleep=lambda s: None) is True
|
|
78
|
+
assert ran == [["taskkill", "/PID", "4242", "/T", "/F"]]
|
|
79
|
+
assert uninstall.win_stop_process(4243, run=lambda a: None, alive=lambda p: True, sleep=lambda s: None) is False
|
|
80
|
+
stopped = []
|
|
81
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
82
|
+
monkeypatch.setattr(uninstall, "win_stop_process", lambda pid: stopped.append(pid) or True)
|
|
83
|
+
assert uninstall.stop_process(5150) is True and stopped == [5150]
|
|
@@ -100,3 +100,31 @@ def test_the_connector_answers_tools_with_the_current_nonce_only(make, tmp_path)
|
|
|
100
100
|
await connector.stop()
|
|
101
101
|
|
|
102
102
|
run(go())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
WIN_DIRS_JS = """
|
|
106
|
+
import { probeTools } from '@TOOLS@/code-tools-probe.mjs'
|
|
107
|
+
const files = new Set(INPUT.files)
|
|
108
|
+
OUT(probeTools({ env: INPUT.env, platform: 'win32', home: '', isExecutable: (p) => files.has(p), listdir: (d) => INPUT.dirs[d] || [] }))
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_tesseract_and_qpdf_are_found_where_their_installers_put_them_off_path():
|
|
113
|
+
# the owner's laptop, 2026-10-09: winget installed both, PATH has neither, setup said "Still missing: qpdf, Tesseract"
|
|
114
|
+
env = {"PATH": "C:\\Windows\\System32", "ProgramFiles": "C:\\Program Files", "ProgramFiles(x86)": "C:\\Program Files (x86)",
|
|
115
|
+
"LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"}
|
|
116
|
+
dirs = {"C:\\Program Files": ["Git", "qpdf 12.4.2", "Tesseract-OCR"], "C:\\Users\\u\\AppData\\Local\\Programs": ["qpdf"]}
|
|
117
|
+
files = ["C:\\Program Files\\Tesseract-OCR\\tesseract.exe", "C:\\Program Files\\qpdf 12.4.2\\bin\\qpdf.exe"]
|
|
118
|
+
got = probe_tools(env=env, platform="win32", home="", is_executable=set(files).__contains__, listdir=lambda d: dirs.get(d, []))
|
|
119
|
+
assert got["tools"]["tesseract"] and got["tools"]["qpdf"]
|
|
120
|
+
none = probe_tools(env=env, platform="win32", home="", is_executable=lambda p: False, listdir=lambda d: dirs.get(d, []))
|
|
121
|
+
assert not none["tools"]["tesseract"] and not none["tools"]["qpdf"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@requires_node
|
|
125
|
+
def test_the_javascript_probe_looks_in_the_same_program_folders():
|
|
126
|
+
env = {"PATH": "", "ProgramFiles": "C:\\Program Files\\", "ProgramW6432": "C:\\Program Files", "LOCALAPPDATA": "C:\\L"}
|
|
127
|
+
dirs = {"C:\\Program Files": ["QPDF 11", "qpdf 12.4.2"], "C:\\L\\Programs": ["Tesseract-OCR", "qpdf-x"]}
|
|
128
|
+
for files in (["C:\\Program Files\\QPDF 11\\bin\\qpdf.exe"], ["C:\\L\\Programs\\Tesseract-OCR\\tesseract.EXE"], ["C:\\L\\Programs\\qpdf-x\\bin\\qpdf.exe"], []):
|
|
129
|
+
js = run_node(WIN_DIRS_JS, {"env": env, "dirs": dirs, "files": files})
|
|
130
|
+
assert probe_tools(env=env, platform="win32", home="", is_executable=set(files).__contains__, listdir=lambda d: dirs.get(d, [])) == js, files
|
|
@@ -145,3 +145,61 @@ def test_no_package_manager_says_so_without_asking():
|
|
|
145
145
|
t = Tools(["jq"], {"os": "darwin", "pm": ""})
|
|
146
146
|
r, said = step(t, [])
|
|
147
147
|
assert r == "unavailable" and t.plans == [] and any("no package manager found" in s for s in said)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_an_install_that_breaks_never_ends_setup():
|
|
151
|
+
# 1.3.5 on Windows: cli.py never imported toolsetup, so the first "y" died "FATAL — name 'toolsetup' is not defined"
|
|
152
|
+
t = Tools(["jq"], {"os": "win32", "pm": "winget"})
|
|
153
|
+
|
|
154
|
+
def broken(plan: list) -> dict:
|
|
155
|
+
raise NameError("name 'toolsetup' is not defined")
|
|
156
|
+
|
|
157
|
+
t.run = broken # type: ignore[method-assign]
|
|
158
|
+
r, said = step(t, [""])
|
|
159
|
+
assert r == "failed"
|
|
160
|
+
assert any("Could not install them (name 'toolsetup' is not defined) — setup goes on" in s for s in said)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_setup_hands_the_wizard_the_real_installer_and_path_refresh(monkeypatch):
|
|
164
|
+
# the wizard's own tests fake run_tools/refresh_path, so only this one runs the lambdas cli.py builds
|
|
165
|
+
from witbitz_code import cli
|
|
166
|
+
|
|
167
|
+
seen: dict = {}
|
|
168
|
+
|
|
169
|
+
async def capture(d):
|
|
170
|
+
seen["d"] = d
|
|
171
|
+
return {"served": 0}
|
|
172
|
+
|
|
173
|
+
monkeypatch.setattr(wizard, "run_setup", capture)
|
|
174
|
+
asyncio.run(cli.setup([]))
|
|
175
|
+
seen["d"].refresh_path()
|
|
176
|
+
assert seen["d"].run_tools([]) == {"done": [], "failed": [], "auto": [], "none": []}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_no_module_uses_a_name_it_never_defined():
|
|
180
|
+
# pyflakes: the two missing imports that reached a release (ports in 1.3.3's serve, toolsetup in 1.3.5's setup)
|
|
181
|
+
from pathlib import Path
|
|
182
|
+
|
|
183
|
+
import pyflakes.api
|
|
184
|
+
import pyflakes.messages
|
|
185
|
+
|
|
186
|
+
import witbitz_code
|
|
187
|
+
|
|
188
|
+
class Undefined:
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
self.found: list[str] = []
|
|
191
|
+
|
|
192
|
+
def unexpectedError(self, filename, msg): # noqa: N802 — pyflakes' reporter interface
|
|
193
|
+
self.found.append(f"{filename}: {msg}")
|
|
194
|
+
|
|
195
|
+
def syntaxError(self, filename, msg, lineno, offset, text): # noqa: N802
|
|
196
|
+
self.found.append(f"{filename}:{lineno}: {msg}")
|
|
197
|
+
|
|
198
|
+
def flake(self, message):
|
|
199
|
+
if isinstance(message, pyflakes.messages.UndefinedName):
|
|
200
|
+
self.found.append(str(message))
|
|
201
|
+
|
|
202
|
+
rep = Undefined()
|
|
203
|
+
for f in sorted(Path(witbitz_code.__file__).parent.glob("*.py")):
|
|
204
|
+
pyflakes.api.check(f.read_text(encoding="utf-8"), str(f), rep)
|
|
205
|
+
assert rep.found == []
|
|
@@ -77,3 +77,14 @@ def test_setups_poppler_joins_the_agents_path_when_it_is_there(tmp_path):
|
|
|
77
77
|
assert add["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts", r"C:\Users\t\AppData\Local\Witbitz\poppler\bin"]
|
|
78
78
|
add2, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(py))
|
|
79
79
|
assert add2["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_tesseract_and_qpdf_join_the_agents_path_where_their_installers_put_them(tmp_path):
|
|
83
|
+
# the owner's laptop, 2026-10-09: winget installed both and neither installer touches PATH
|
|
84
|
+
env = {**ENV, "ProgramFiles": r"C:\Program Files"}
|
|
85
|
+
tess, qpdf = r"C:\Program Files\Tesseract-OCR\tesseract.exe", r"C:\Program Files\qpdf 12.4.2\bin\qpdf.exe"
|
|
86
|
+
listdir = {r"C:\Program Files": ["qpdf 12.4.2", "Tesseract-OCR"]}.get
|
|
87
|
+
add, _, _ = winagent.windows_agent(tmp_path, env=env, platform="win32", exists=exists_of(tess, qpdf), listdir=lambda d: listdir(d, []))
|
|
88
|
+
assert add["PATH_PREPEND"] == [r"C:\Program Files\Tesseract-OCR", r"C:\Program Files\qpdf 12.4.2\bin"]
|
|
89
|
+
none, _, _ = winagent.windows_agent(tmp_path, env=env, platform="win32", exists=exists_of(), listdir=lambda d: listdir(d, []))
|
|
90
|
+
assert "PATH_PREPEND" not in none
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.3.5 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|