weightclass 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """weightclass: local, policy-driven routing for Codex and Claude workflows."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Run weightclass as `python -m weightclass`, equivalent to the `wclass` command."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,163 @@
1
+ """Deterministic, local task classification without persistence or logging."""
2
+
3
+ import re
4
+ import sys
5
+ from typing import Final, Literal
6
+
7
+ Tier = Literal["low", "standard", "high"]
8
+
9
+ HIGH_SIGNALS: Final = frozenset(
10
+ {
11
+ "architecture",
12
+ "authentication",
13
+ "authorization",
14
+ "concurrency",
15
+ "credential",
16
+ "database",
17
+ "data loss",
18
+ "deployment",
19
+ "migration",
20
+ "payment",
21
+ "performance",
22
+ "privacy",
23
+ "production",
24
+ "race condition",
25
+ "refactor",
26
+ "rollback",
27
+ "security",
28
+ "개인정보",
29
+ "권한",
30
+ "데이터베이스",
31
+ "동시성",
32
+ "배포",
33
+ "리팩터링",
34
+ "롤백",
35
+ "마이그레이션",
36
+ "보안",
37
+ "성능",
38
+ "아키텍처",
39
+ "인증",
40
+ "결제",
41
+ "리팩토링",
42
+ }
43
+ )
44
+ LOW_SIGNALS: Final = frozenset(
45
+ {
46
+ "format",
47
+ "formats",
48
+ "formatting",
49
+ "punctuation",
50
+ "reformat",
51
+ "reformatting",
52
+ "rename",
53
+ "renames",
54
+ "renaming",
55
+ "spelling",
56
+ "typo",
57
+ "typos",
58
+ "whitespace",
59
+ "whitespaces",
60
+ "오타",
61
+ "이름 변경",
62
+ "이름변경",
63
+ "리네임",
64
+ "문장부호",
65
+ "포맷",
66
+ "포매팅",
67
+ "띄어쓰기",
68
+ }
69
+ )
70
+ MAX_TASK_CHARACTERS: Final = 20_000
71
+ HIGH_TASK_CHARACTERS: Final = 1_200
72
+ LOW_TASK_CHARACTERS: Final = 240
73
+ # UTF-8 한 문자는 최대 4바이트이므로, 이 상한은 문자 상한을 통과할 수 있는 모든
74
+ # 입력을 포함한다. 바이트 상한이 문자 상한보다 먼저 걸려 거부하는 일은 없다.
75
+ MAX_TASK_BYTES: Final = MAX_TASK_CHARACTERS * 4
76
+
77
+
78
+ class InvalidTaskError(ValueError):
79
+ """Raised for task input that cannot be classified safely."""
80
+
81
+
82
+ def read_task_from_standard_input() -> str:
83
+ """Read a bounded task as UTF-8 bytes, independent of the process locale.
84
+
85
+ 표준 입력을 텍스트 모드로 읽으면 로케일 인코딩이 적용되어 LC_ALL=C 환경에서
86
+ 비ASCII 태스크가 surrogate로 깨지고, 그 실패가 진단 메시지로 새어 나간다.
87
+ 바이트로 읽어 UTF-8로 엄격히 디코딩하고, 상한 초과 입력은 전체를 메모리에
88
+ 올리기 전에 거부한다.
89
+ """
90
+ try:
91
+ task_bytes = sys.stdin.buffer.read(MAX_TASK_BYTES + 1)
92
+ if len(task_bytes) > MAX_TASK_BYTES:
93
+ raise InvalidTaskError()
94
+ return task_bytes.decode("utf-8")
95
+ except (OSError, UnicodeDecodeError) as error:
96
+ raise InvalidTaskError() from error
97
+
98
+
99
+ # 선행 경계만으로 "reproduction"이 "production"에 걸리는 오분류를 막을 수 있다.
100
+ # 후행까지 경계로 닫으면 "credentials", "payments", "refactoring" 같은 굴절형이
101
+ # 전부 시그널에서 빠져나가므로, 흔한 어미를 경계 앞에서 함께 허용한다.
102
+ _SIGNAL_SUFFIXES: Final = "(?:s|es|d|ed|ing)?"
103
+ # "data loss"는 "data-loss"로도 쓴다. 여러 단어로 된 시그널이 공백 표기에만
104
+ # 걸리면, 하이픈 표기 작업에서는 상위 시그널이 통째로 사라진다.
105
+ _SIGNAL_WORD_SEPARATOR: Final = r"[\s\-]+"
106
+
107
+
108
+ def _compile_ascii_signals(signals: frozenset[str]) -> re.Pattern[str]:
109
+ """Compile the ASCII signals of one tier into a single word-boundary pattern."""
110
+ ascii_signals = sorted(
111
+ (signal for signal in signals if signal.isascii()), key=len, reverse=True
112
+ )
113
+ alternation = "|".join(
114
+ re.escape(signal).replace(r"\ ", " ").replace(" ", _SIGNAL_WORD_SEPARATOR)
115
+ for signal in ascii_signals
116
+ )
117
+ return re.compile(rf"\b(?:{alternation}){_SIGNAL_SUFFIXES}\b")
118
+
119
+
120
+ def _select_non_ascii_signals(signals: frozenset[str]) -> frozenset[str]:
121
+ """Return the signals that must be matched by containment rather than by word."""
122
+ return frozenset(signal for signal in signals if not signal.isascii())
123
+
124
+
125
+ # 한국어는 조사와 합성어 때문에 단어 경계 개념이 없어 부분 문자열로 검사한다.
126
+ # 그 결과 "저작권한도"처럼 시그널을 품은 합성어가 상위 티어로 오분류될 수 있다.
127
+ # 상위 티어로의 오분류는 보수적인 방향이므로, 형태소 분석 의존성을 들이는 대신
128
+ # 이 한계를 문서화하고 감수한다.
129
+ _HIGH_ASCII_PATTERN: Final = _compile_ascii_signals(HIGH_SIGNALS)
130
+ _HIGH_NON_ASCII_SIGNALS: Final = _select_non_ascii_signals(HIGH_SIGNALS)
131
+ _LOW_ASCII_PATTERN: Final = _compile_ascii_signals(LOW_SIGNALS)
132
+ _LOW_NON_ASCII_SIGNALS: Final = _select_non_ascii_signals(LOW_SIGNALS)
133
+
134
+
135
+ def classify_task(task: str) -> Tier:
136
+ """Classify a transient task conservatively using documented local rules.
137
+
138
+ 두 티어의 시그널이 함께 잡히면 항상 high가 이긴다. 난이도를 낮게 잡는 쪽이
139
+ 비싼 실수이므로 의도적으로 보수적인 우선순위를 둔다.
140
+ """
141
+ normalized_task = task.strip().casefold()
142
+ if not normalized_task or len(normalized_task) > MAX_TASK_CHARACTERS:
143
+ raise InvalidTaskError()
144
+ if len(normalized_task) >= HIGH_TASK_CHARACTERS or _has_signal(
145
+ normalized_task, _HIGH_ASCII_PATTERN, _HIGH_NON_ASCII_SIGNALS
146
+ ):
147
+ return "high"
148
+ if len(normalized_task) <= LOW_TASK_CHARACTERS and _has_signal(
149
+ normalized_task, _LOW_ASCII_PATTERN, _LOW_NON_ASCII_SIGNALS
150
+ ):
151
+ return "low"
152
+ return "standard"
153
+
154
+
155
+ def _has_signal(
156
+ task: str,
157
+ ascii_pattern: re.Pattern[str],
158
+ non_ascii_signals: frozenset[str],
159
+ ) -> bool:
160
+ """Report whether a task carries any signal of one tier."""
161
+ if ascii_pattern.search(task):
162
+ return True
163
+ return any(signal in task for signal in non_ascii_signals)
weightclass/cli.py ADDED
@@ -0,0 +1,516 @@
1
+ """Command-line interface for rendering, never executing, route commands."""
2
+
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import unicodedata
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import Any, Final, NoReturn, cast
11
+
12
+ from . import __version__
13
+ from .classification import (
14
+ InvalidTaskError,
15
+ Tier,
16
+ classify_task,
17
+ read_task_from_standard_input,
18
+ )
19
+ from .router import (
20
+ DEFAULT_ROUTES,
21
+ SUPPORTED_VENDORS,
22
+ Route,
23
+ RouteRequest,
24
+ RouteSelectionError,
25
+ RoutingPolicy,
26
+ native_route_fingerprint,
27
+ select_route,
28
+ select_tier_route,
29
+ )
30
+ from .v2 import (
31
+ V2InvalidInputError,
32
+ load_api_policy,
33
+ render_api_route,
34
+ route_fingerprint,
35
+ select_api_route,
36
+ validate_api_runtime,
37
+ )
38
+
39
+ EXECUTOR_FAILED_EXIT_CODE: Final = 7
40
+
41
+
42
+ class InvalidInputError(ValueError):
43
+ """Raised for invalid policy or descriptor data without exposing it."""
44
+
45
+
46
+ def _report_executor_result(completed_process: subprocess.CompletedProcess[bytes]) -> int:
47
+ """Map a finished child's status to a router exit code without hiding it.
48
+
49
+ 자식의 종료 코드를 그대로 반환하면 라우터 자신의 진단 코드(2~6)와 구분할 수
50
+ 없고, 시그널로 죽은 경우(음수)는 Python 이 241 같은 값으로 뭉갠다. 실패는
51
+ 전용 코드로 보고하고 실제 값은 진단에 담는다.
52
+
53
+ 자식이 종료 코드에 태스크에서 유도한 값을 실을 수 있다는 지적이 리뷰에서
54
+ 나왔으나, 이는 새로운 유출 경로가 아니다. 자식은 이미 태스크 전문을 받고
55
+ stdout/stderr 를 상속받으므로(라우터는 캡처하지 않는다) 훨씬 넓은 대역으로
56
+ 무엇이든 내보낼 수 있다. 라우터가 비유출을 보장하는 대상은 라우터가
57
+ 생성하는 값이며, 여기 실리는 정수는 자식이 스스로 고른 값이다. 이 값을 빼면
58
+ 진단 능력만 잃고 실제 경계는 달라지지 않는다.
59
+ """
60
+ if completed_process.returncode == 0:
61
+ return 0
62
+ diagnostic: dict[str, object] = {"error": "executor_failed"}
63
+ if completed_process.returncode < 0:
64
+ diagnostic["executor_signal"] = -completed_process.returncode
65
+ else:
66
+ diagnostic["executor_exit_code"] = completed_process.returncode
67
+ # 자식은 stderr 를 상속받으므로 이 진단은 자식이 이미 쓴 내용 뒤에 붙는다.
68
+ # 진행 표시처럼 개행 없이 끝나는 출력 뒤에 그대로 이으면 JSON 이 그 줄에
69
+ # 섞여 어떤 파싱으로도 복구되지 않는다. 항상 새 줄에서 시작하게 해서
70
+ # "stderr 의 마지막 줄"이 언제나 진단이 되도록 보장한다.
71
+ print("\n" + json.dumps(diagnostic), file=sys.stderr)
72
+ return EXECUTOR_FAILED_EXIT_CODE
73
+
74
+
75
+ class SafeArgumentParser(argparse.ArgumentParser):
76
+ """Avoid including caller-provided values in diagnostics."""
77
+
78
+ def error(self, message: str) -> NoReturn:
79
+ del message
80
+ raise InvalidInputError()
81
+
82
+
83
+ def _read_json_object(path: Path) -> dict[str, Any]:
84
+ try:
85
+ value = json.loads(path.read_text(encoding="utf-8"))
86
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
87
+ raise InvalidInputError() from error
88
+ if not isinstance(value, dict):
89
+ raise InvalidInputError()
90
+ return value
91
+
92
+
93
+ def _require_exact_keys(value: dict[str, Any], expected_keys: set[str]) -> None:
94
+ if set(value) != expected_keys:
95
+ raise InvalidInputError()
96
+
97
+
98
+ def _require_nonempty_string(value: object) -> str:
99
+ """Require an identifier-like policy value: no whitespace at all."""
100
+ if not isinstance(value, str) or not value or any(character.isspace() for character in value):
101
+ raise InvalidInputError()
102
+ return value
103
+
104
+
105
+ def _require_command_argument(value: object) -> str:
106
+ """Require one reviewable argv token.
107
+
108
+ 명령 인자는 식별자와 규칙이 다르다. 셸을 거치지 않고 argv 로 그대로
109
+ 전달되므로 내부 공백은 위험하지 않고, "/Users/me/My Tools/claude" 같은
110
+ 설치 경로나 여러 단어로 된 플래그 값에는 반드시 필요하다.
111
+
112
+ 거부 기준은 "검토자가 본 대로 실행되는가"이다. ord 범위를 손으로 나열하면
113
+ 매번 빠진 문자가 나온다(NUL, 그다음 lone surrogate). 대신 두 규칙으로
114
+ 정한다.
115
+
116
+ - 유니코드 대분류 C 는 전부 거부한다. 제어문자(Cc, C0/C1), 서식
117
+ 문자(Cf, zero-width space·RTL override·BOM), 서로게이트(Cs),
118
+ 사용자 영역(Co), 미할당(Cn)이 여기 든다. 앞의 둘은 route 출력에
119
+ 드러나지 않아 검토를 무력화하고, 서로게이트는 exec 단계에서
120
+ UnicodeEncodeError 로 터져 진단 없이 트레이스백을 남긴다.
121
+ - 공백은 ASCII 스페이스만 허용한다. NBSP 같은 문자는 스페이스처럼 보이지만
122
+ 다른 인자를 만든다. 앞뒤 공백도 경로를 조용히 다른 값으로 만든다.
123
+ """
124
+ if (
125
+ not isinstance(value, str)
126
+ or not value
127
+ or value != value.strip()
128
+ or any(
129
+ unicodedata.category(character).startswith("C")
130
+ or (character.isspace() and character != " ")
131
+ for character in value
132
+ )
133
+ ):
134
+ raise InvalidInputError()
135
+ return value
136
+
137
+
138
+ def _parse_route(value: object) -> Route:
139
+ if not isinstance(value, dict):
140
+ raise InvalidInputError()
141
+
142
+ keys = set(value)
143
+ has_workflow = keys == {"id", "vendor", "workflow", "command"}
144
+ has_tier = keys == {"id", "vendor", "tier", "command"}
145
+ if not has_workflow and not has_tier:
146
+ raise InvalidInputError()
147
+
148
+ route_id = _require_nonempty_string(value["id"])
149
+ vendor = _require_nonempty_string(value["vendor"])
150
+ command = value["command"]
151
+ if vendor not in SUPPORTED_VENDORS or not isinstance(command, list) or not command:
152
+ raise InvalidInputError()
153
+
154
+ workflow = _require_nonempty_string(value["workflow"]) if has_workflow else ""
155
+ tier: Tier | None = None
156
+ if has_tier:
157
+ parsed_tier = _require_nonempty_string(value["tier"])
158
+ if parsed_tier not in {"low", "standard", "high"}:
159
+ raise InvalidInputError()
160
+ tier = cast(Tier, parsed_tier)
161
+ return Route(
162
+ route_id=route_id,
163
+ vendor=vendor,
164
+ workflow=workflow,
165
+ command=tuple(_require_command_argument(argument) for argument in command),
166
+ tier=tier,
167
+ )
168
+
169
+
170
+ def load_routes(policy_path: Path) -> tuple[Route, ...]:
171
+ """Load a strictly shaped, trusted-local route policy."""
172
+ return load_routing_policy(policy_path).routes
173
+
174
+
175
+ def load_routing_policy(policy_path: Path) -> RoutingPolicy:
176
+ """Load routing options and strictly shaped trusted-local routes."""
177
+ policy = _read_json_object(policy_path)
178
+ if set(policy) not in ({"routes"}, {"routes", "allow_mixed_vendors"}):
179
+ raise InvalidInputError()
180
+ allow_mixed_vendors = policy.get("allow_mixed_vendors", False)
181
+ if not isinstance(allow_mixed_vendors, bool):
182
+ raise InvalidInputError()
183
+ routes = policy["routes"]
184
+ if not isinstance(routes, list) or not routes:
185
+ raise InvalidInputError()
186
+
187
+ parsed_routes = tuple(_parse_route(route) for route in routes)
188
+ if len({route.route_id for route in parsed_routes}) != len(parsed_routes):
189
+ raise InvalidInputError()
190
+ return RoutingPolicy(parsed_routes, allow_mixed_vendors)
191
+
192
+
193
+ def load_request(descriptor_path: Path) -> RouteRequest:
194
+ """Load a redacted descriptor without task content or credentials."""
195
+ descriptor = _read_json_object(descriptor_path)
196
+ _require_exact_keys(descriptor, {"vendor", "workflow"})
197
+ vendor = _require_nonempty_string(descriptor["vendor"])
198
+ if vendor not in SUPPORTED_VENDORS:
199
+ raise InvalidInputError()
200
+ return RouteRequest(vendor=vendor, workflow=_require_nonempty_string(descriptor["workflow"]))
201
+
202
+
203
+ def _add_api_route_arguments(parser: argparse.ArgumentParser) -> None:
204
+ """Declare the arguments shared by both V2 API subcommands."""
205
+ parser.add_argument("--policy", required=True, type=Path)
206
+ parser.add_argument("--source-vendor", required=True, choices=sorted(SUPPORTED_VENDORS))
207
+ parser.add_argument("--api-runtime", required=True, type=Path)
208
+
209
+
210
+ def build_parser() -> argparse.ArgumentParser:
211
+ """Build the whole command surface so `--help` lists every reachable mode.
212
+
213
+ allow_abbrev를 끈 것은 --confirm-api-egress 같은 명시적 승인 플래그가
214
+ --c 같은 축약으로 만족되면 안 되기 때문이다.
215
+ """
216
+ parser = SafeArgumentParser(
217
+ prog="wclass",
218
+ description="Classify a task and select a reviewable vendor command.",
219
+ allow_abbrev=False,
220
+ )
221
+ # argparse 내장 version 액션은 argv의 나머지를 검증하기 전에 종료해 버려서
222
+ # `wclass --version --bogus` 가 0으로 성공한다. 파싱을 끝낸 뒤 직접 처리한다.
223
+ parser.add_argument("--version", action="store_true")
224
+ subcommands = parser.add_subparsers(dest="command")
225
+
226
+ subcommands.add_parser(
227
+ "classify",
228
+ allow_abbrev=False,
229
+ description="Print the tier of a task read from standard input.",
230
+ )
231
+ for name, description in (
232
+ ("route", "Select and print a command for a task read from standard input."),
233
+ ("run", "Select and start a command for a task read from standard input."),
234
+ ):
235
+ native = subcommands.add_parser(name, allow_abbrev=False, description=description)
236
+ native.add_argument("--policy", type=Path)
237
+ native.add_argument("--source-vendor", choices=sorted(SUPPORTED_VENDORS))
238
+ if name == "run":
239
+ native.add_argument("--ack-route-fingerprint")
240
+
241
+ render = subcommands.add_parser(
242
+ "render",
243
+ allow_abbrev=False,
244
+ description="Render the command of a policy route named by a workflow descriptor.",
245
+ )
246
+ render.add_argument("--policy", required=True, type=Path)
247
+ render.add_argument("--descriptor", required=True, type=Path)
248
+
249
+ api = subcommands.add_parser(
250
+ "v2",
251
+ allow_abbrev=False,
252
+ description="Select a declarative API route served by an external runtime.",
253
+ )
254
+ api_subcommands = api.add_subparsers(dest="api_command", required=True)
255
+ _add_api_route_arguments(
256
+ api_subcommands.add_parser(
257
+ "route",
258
+ allow_abbrev=False,
259
+ description="Review a declarative API route.",
260
+ )
261
+ )
262
+ api_run = api_subcommands.add_parser(
263
+ "run",
264
+ allow_abbrev=False,
265
+ description="Run a reviewed declarative API route.",
266
+ )
267
+ _add_api_route_arguments(api_run)
268
+ api_run.add_argument("--confirm-api-egress", action="store_true")
269
+ api_run.add_argument("--ack-route-fingerprint")
270
+ return parser
271
+
272
+
273
+ def v2_route_from_standard_input(
274
+ policy_path: Path,
275
+ source_vendor: str,
276
+ runtime_path: Path,
277
+ ) -> int:
278
+ """Render an API review descriptor without sending data to a provider."""
279
+ try:
280
+ runtime_path = validate_api_runtime(runtime_path)
281
+ task = read_task_from_standard_input()
282
+ policy = load_api_policy(policy_path)
283
+ tier, route = select_api_route(task, policy, source_vendor)
284
+ except InvalidTaskError:
285
+ print(json.dumps({"error": "invalid_task"}), file=sys.stderr)
286
+ return 2
287
+ except (V2InvalidInputError, InvalidInputError):
288
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
289
+ return 2
290
+ except RouteSelectionError:
291
+ print(json.dumps({"error": "unsupported_route"}), file=sys.stderr)
292
+ return 3
293
+ print(json.dumps(render_api_route(route, policy, tier, source_vendor, runtime_path)))
294
+ return 0
295
+
296
+
297
+ def v2_run_from_standard_input(
298
+ policy_path: Path,
299
+ source_vendor: str,
300
+ runtime_path: Path,
301
+ confirm_api_egress: bool,
302
+ acknowledged_fingerprint: str | None,
303
+ ) -> int:
304
+ """Start one acknowledged external API runtime without handling credentials."""
305
+ try:
306
+ runtime_path = validate_api_runtime(runtime_path)
307
+ task = read_task_from_standard_input()
308
+ policy = load_api_policy(policy_path)
309
+ tier, route = select_api_route(task, policy, source_vendor)
310
+ except InvalidTaskError:
311
+ print(json.dumps({"error": "invalid_task"}), file=sys.stderr)
312
+ return 2
313
+ except (V2InvalidInputError, InvalidInputError):
314
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
315
+ return 2
316
+ except RouteSelectionError:
317
+ print(json.dumps({"error": "unsupported_route"}), file=sys.stderr)
318
+ return 3
319
+
320
+ if not confirm_api_egress:
321
+ print(json.dumps({"error": "api_confirmation_required"}), file=sys.stderr)
322
+ return 5
323
+ if acknowledged_fingerprint != route_fingerprint(
324
+ route,
325
+ policy,
326
+ tier,
327
+ source_vendor,
328
+ runtime_path,
329
+ ):
330
+ print(json.dumps({"error": "route_fingerprint_mismatch"}), file=sys.stderr)
331
+ return 6
332
+ try:
333
+ # 로케일 인코딩을 쓰는 text 모드는 비ASCII 태스크에서 UnicodeEncodeError를 내고,
334
+ # 그 예외 메시지가 태스크 문자와 위치를 진단에 노출한다. 항상 UTF-8 바이트로 넘긴다.
335
+ completed_process = subprocess.run(
336
+ (
337
+ str(runtime_path),
338
+ "--provider",
339
+ route.provider,
340
+ "--model",
341
+ route.model,
342
+ "--effort",
343
+ route.effort,
344
+ ),
345
+ check=False,
346
+ input=task.encode("utf-8"),
347
+ )
348
+ except (OSError, ValueError):
349
+ # ValueError 는 argv 를 실제로 인코딩하는 단계에서 나온다(NUL, 서로게이트).
350
+ # 검증기가 이미 막고 있지만, 규칙에 빈틈이 생겨도 트레이스백 대신
351
+ # 진단으로 닫히도록 두 번째 방어선을 둔다.
352
+ print(json.dumps({"error": "executor_unavailable"}), file=sys.stderr)
353
+ return 4
354
+ return _report_executor_result(completed_process)
355
+
356
+
357
+ def classify_from_standard_input() -> int:
358
+ """Classify a task read from stdin without echoing or persisting it."""
359
+ try:
360
+ tier = classify_task(read_task_from_standard_input())
361
+ except InvalidTaskError:
362
+ print(json.dumps({"error": "invalid_task"}), file=sys.stderr)
363
+ return 2
364
+ print(json.dumps({"tier": tier}))
365
+ return 0
366
+
367
+
368
+ def select_task_route(
369
+ task: str,
370
+ policy_path: Path | None,
371
+ source_vendor: str | None = None,
372
+ ) -> tuple[Tier, Route, RoutingPolicy]:
373
+ """Classify a task and select its route without retaining task content."""
374
+ tier = classify_task(task)
375
+ policy = (
376
+ load_routing_policy(policy_path)
377
+ if policy_path is not None
378
+ else RoutingPolicy(DEFAULT_ROUTES)
379
+ )
380
+ route = select_tier_route(
381
+ policy.routes,
382
+ tier,
383
+ source_vendor,
384
+ policy.allow_mixed_vendors,
385
+ )
386
+ return tier, route, policy
387
+
388
+
389
+ def route_from_standard_input(policy_path: Path | None, source_vendor: str | None) -> int:
390
+ """Select and render a command without echoing or persisting the task."""
391
+ try:
392
+ tier, route, policy = select_task_route(
393
+ read_task_from_standard_input(), policy_path, source_vendor
394
+ )
395
+ except InvalidTaskError:
396
+ print(json.dumps({"error": "invalid_task"}), file=sys.stderr)
397
+ return 2
398
+ except InvalidInputError:
399
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
400
+ return 2
401
+ except RouteSelectionError:
402
+ print(json.dumps({"error": "unsupported_route"}), file=sys.stderr)
403
+ return 3
404
+ # vendor는 항상 싣는다. 생략하면 정책이 벤더를 바꿔도 리뷰 출력만 봐서는
405
+ # 어느 벤더로 나가는지 알 수 없다.
406
+ response = {
407
+ "command": list(route.command),
408
+ "route": route.route_id,
409
+ "tier": tier,
410
+ "vendor": route.vendor,
411
+ # 이 지문을 wclass run --ack-route-fingerprint 로 넘기면 검토한 선택이
412
+ # 실행 직전에 다시 확인된다. 넘기지 않으면 구속력은 없다.
413
+ "route_fingerprint": native_route_fingerprint(route, policy.allow_mixed_vendors),
414
+ }
415
+ print(json.dumps(response))
416
+ return 0
417
+
418
+
419
+ def run_from_standard_input(
420
+ policy_path: Path | None,
421
+ source_vendor: str | None,
422
+ acknowledged_fingerprint: str | None = None,
423
+ ) -> int:
424
+ """Run a selected native command without a shell or output capture."""
425
+ try:
426
+ task = read_task_from_standard_input()
427
+ _, route, policy = select_task_route(task, policy_path, source_vendor)
428
+ if acknowledged_fingerprint is not None and acknowledged_fingerprint != (
429
+ native_route_fingerprint(route, policy.allow_mixed_vendors)
430
+ ):
431
+ print(json.dumps({"error": "route_fingerprint_mismatch"}), file=sys.stderr)
432
+ return 6
433
+ # text 모드는 로케일 인코딩을 사용하므로 LC_ALL=C 환경에서 비ASCII 태스크가
434
+ # UnicodeEncodeError로 새어 나간다. 자식 출력을 읽지 않으므로 바이트로 전달한다.
435
+ completed_process = subprocess.run(
436
+ route.command,
437
+ check=False,
438
+ input=task.encode("utf-8"),
439
+ )
440
+ except InvalidTaskError:
441
+ print(json.dumps({"error": "invalid_task"}), file=sys.stderr)
442
+ return 2
443
+ except InvalidInputError:
444
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
445
+ return 2
446
+ except RouteSelectionError:
447
+ print(json.dumps({"error": "unsupported_route"}), file=sys.stderr)
448
+ return 3
449
+ except (OSError, ValueError):
450
+ # ValueError 는 argv 를 실제로 인코딩하는 단계에서 나온다(NUL, 서로게이트).
451
+ # 검증기가 이미 막고 있지만, 규칙에 빈틈이 생겨도 트레이스백 대신
452
+ # 진단으로 닫히도록 두 번째 방어선을 둔다.
453
+ print(json.dumps({"error": "executor_unavailable"}), file=sys.stderr)
454
+ return 4
455
+ return _report_executor_result(completed_process)
456
+
457
+
458
+ def render_workflow_route(policy_path: Path, descriptor_path: Path) -> int:
459
+ """Render the command of the policy route named by a workflow descriptor."""
460
+ try:
461
+ route = select_route(load_routes(policy_path), load_request(descriptor_path))
462
+ except InvalidInputError:
463
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
464
+ return 2
465
+ except RouteSelectionError:
466
+ print(json.dumps({"error": "unsupported_route"}), file=sys.stderr)
467
+ return 3
468
+ print(json.dumps({"command": list(route.command), "route": route.route_id}))
469
+ return 0
470
+
471
+
472
+ def main(argv: Sequence[str] | None = None) -> int:
473
+ """Classify, route, render, or run a native command from explicit input."""
474
+ try:
475
+ arguments = build_parser().parse_args(sys.argv[1:] if argv is None else argv)
476
+ except InvalidInputError:
477
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
478
+ return 2
479
+
480
+ if arguments.version:
481
+ # 버전 조회는 단독 호출일 때만 유효하다. 서브커맨드와 함께 오면 어느 쪽을
482
+ # 요청한 것인지 알 수 없으므로 닫는 방향으로 거부한다.
483
+ if arguments.command is not None:
484
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
485
+ return 2
486
+ print(f"weightclass {__version__}")
487
+ return 0
488
+ if arguments.command is None:
489
+ print(json.dumps({"error": "invalid_input"}), file=sys.stderr)
490
+ return 2
491
+
492
+ if arguments.command == "classify":
493
+ return classify_from_standard_input()
494
+ if arguments.command == "route":
495
+ return route_from_standard_input(arguments.policy, arguments.source_vendor)
496
+ if arguments.command == "run":
497
+ return run_from_standard_input(
498
+ arguments.policy,
499
+ arguments.source_vendor,
500
+ arguments.ack_route_fingerprint,
501
+ )
502
+ if arguments.command == "render":
503
+ return render_workflow_route(arguments.policy, arguments.descriptor)
504
+ if arguments.api_command == "route":
505
+ return v2_route_from_standard_input(
506
+ arguments.policy,
507
+ arguments.source_vendor,
508
+ arguments.api_runtime,
509
+ )
510
+ return v2_run_from_standard_input(
511
+ arguments.policy,
512
+ arguments.source_vendor,
513
+ arguments.api_runtime,
514
+ arguments.confirm_api_egress,
515
+ arguments.ack_route_fingerprint,
516
+ )
weightclass/py.typed ADDED
File without changes