oy-cli 0.3.3__tar.gz → 0.3.5__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.
- {oy_cli-0.3.3 → oy_cli-0.3.5}/PKG-INFO +40 -10
- {oy_cli-0.3.3 → oy_cli-0.3.5}/README.md +39 -9
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/PKG-INFO +40 -10
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.py +102 -22
- {oy_cli-0.3.3 → oy_cli-0.3.5}/pyproject.toml +1 -1
- {oy_cli-0.3.3 → oy_cli-0.3.5}/shim.py +170 -6
- {oy_cli-0.3.3 → oy_cli-0.3.5}/tests/test_oy_cli.py +44 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/LICENSE +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/SOURCES.txt +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/dependency_links.txt +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/entry_points.txt +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/requires.txt +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/oy_cli.egg-info/top_level.txt +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/setup.cfg +0 -0
- {oy_cli-0.3.3 → oy_cli-0.3.5}/tests/test_shim.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oy-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Tiny local coding CLI with a small tool surface
|
|
5
5
|
Author: oy-cli contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -32,6 +32,8 @@ uv tool install oy-cli
|
|
|
32
32
|
oy "add docstrings to public functions"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
|
|
36
|
+
|
|
35
37
|
## Examples
|
|
36
38
|
|
|
37
39
|
```bash
|
|
@@ -63,7 +65,9 @@ oy --help # Show all commands
|
|
|
63
65
|
|
|
64
66
|
Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
|
|
65
67
|
|
|
66
|
-
**Design goals:** small auditable codebase, minimal tool surface,
|
|
68
|
+
**Design goals:** small auditable codebase, minimal tool surface,
|
|
69
|
+
OpenAI-completions-focused CLI loop, multiple backends behind shims,
|
|
70
|
+
fresh session each run, and explicit checkpoints when needed.
|
|
67
71
|
|
|
68
72
|
## System Prompts
|
|
69
73
|
|
|
@@ -114,7 +118,19 @@ Each tool description is passed directly to the model. These are the exact descr
|
|
|
114
118
|
### Audit Prompt
|
|
115
119
|
|
|
116
120
|
```markdown
|
|
117
|
-
Audit the repo for security, unnecessary complexity, and major
|
|
121
|
+
Audit the repo for security, unnecessary complexity, and major
|
|
122
|
+
obvious performance issues, preserving project and human context.
|
|
123
|
+
First read key markdown docs, then refresh or generate an audit
|
|
124
|
+
header at the top of ISSUES.md that includes the current date,
|
|
125
|
+
the latest Git commit reference, and a concise codebase summary
|
|
126
|
+
using tools like `scc` or `tokei`. Next, fetch the current OWASP
|
|
127
|
+
ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
|
|
128
|
+
using httpx, inspect the codebase against these, and write or
|
|
129
|
+
merge prioritised findings (max 10-15) into the ISSUES.md file.
|
|
130
|
+
Ensure each finding is formatted to include its location, category
|
|
131
|
+
(security, complexity, or performance), standard reference, a clear
|
|
132
|
+
recommendation, and has a Status, if existing findings have been
|
|
133
|
+
resolved, summarise and note them in a short log at the end.
|
|
118
134
|
```
|
|
119
135
|
|
|
120
136
|
```bash
|
|
@@ -141,18 +157,27 @@ OY_ROOT=./src oy audit # Audit specific directory
|
|
|
141
157
|
{"shim": "openai", "model": "glm-5"}
|
|
142
158
|
```
|
|
143
159
|
|
|
144
|
-
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
160
|
+
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
161
|
+
Use `oy model <filter>` to pick interactively; it merges models from available
|
|
162
|
+
signed-in shims into a single list using `shim:model` prefixes.
|
|
145
163
|
|
|
146
|
-
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
164
|
+
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
165
|
+
the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
|
|
166
|
+
`oy model` to pin behavior.
|
|
147
167
|
|
|
148
|
-
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
168
|
+
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
169
|
+
intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
|
|
170
|
+
The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
|
|
171
|
+
is a good reference.
|
|
149
172
|
|
|
150
173
|
## Requirements
|
|
151
174
|
|
|
152
175
|
- Python 3.14+
|
|
153
176
|
- `bash`
|
|
154
177
|
- (Optional) `rg` (ripgrep) for faster search
|
|
155
|
-
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
178
|
+
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
179
|
+
(`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
|
|
180
|
+
AWS CLI configured for Bedrock
|
|
156
181
|
|
|
157
182
|
## Installation
|
|
158
183
|
|
|
@@ -174,7 +199,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
|
|
|
174
199
|
export OPENAI_API_KEY=...
|
|
175
200
|
```
|
|
176
201
|
|
|
177
|
-
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
202
|
+
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
203
|
+
and used, if creds are available `oy model` will show them in the model list.
|
|
178
204
|
|
|
179
205
|
**AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
|
|
180
206
|
```bash
|
|
@@ -184,7 +210,9 @@ export AWS_REGION=us-west-2
|
|
|
184
210
|
|
|
185
211
|
## Troubleshooting
|
|
186
212
|
|
|
187
|
-
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
213
|
+
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
214
|
+
`gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
|
|
215
|
+
ensure your profile has `bedrock:InvokeModel` permission.
|
|
188
216
|
|
|
189
217
|
**"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
|
|
190
218
|
|
|
@@ -200,7 +228,9 @@ Recommended:
|
|
|
200
228
|
- avoid exposing long-lived secrets in the environment
|
|
201
229
|
- review generated changes before shipping
|
|
202
230
|
|
|
203
|
-
**Automatic protections:** workspace-bound file access, structured edits
|
|
231
|
+
**Automatic protections:** workspace-bound file access, structured edits
|
|
232
|
+
through `apply`, sensitive header redaction in `httpx`, and native boto3
|
|
233
|
+
credential resolution for Bedrock.
|
|
204
234
|
|
|
205
235
|
## Links
|
|
206
236
|
|
|
@@ -9,6 +9,8 @@ uv tool install oy-cli
|
|
|
9
9
|
oy "add docstrings to public functions"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
|
|
13
|
+
|
|
12
14
|
## Examples
|
|
13
15
|
|
|
14
16
|
```bash
|
|
@@ -40,7 +42,9 @@ oy --help # Show all commands
|
|
|
40
42
|
|
|
41
43
|
Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
|
|
42
44
|
|
|
43
|
-
**Design goals:** small auditable codebase, minimal tool surface,
|
|
45
|
+
**Design goals:** small auditable codebase, minimal tool surface,
|
|
46
|
+
OpenAI-completions-focused CLI loop, multiple backends behind shims,
|
|
47
|
+
fresh session each run, and explicit checkpoints when needed.
|
|
44
48
|
|
|
45
49
|
## System Prompts
|
|
46
50
|
|
|
@@ -91,7 +95,19 @@ Each tool description is passed directly to the model. These are the exact descr
|
|
|
91
95
|
### Audit Prompt
|
|
92
96
|
|
|
93
97
|
```markdown
|
|
94
|
-
Audit the repo for security, unnecessary complexity, and major
|
|
98
|
+
Audit the repo for security, unnecessary complexity, and major
|
|
99
|
+
obvious performance issues, preserving project and human context.
|
|
100
|
+
First read key markdown docs, then refresh or generate an audit
|
|
101
|
+
header at the top of ISSUES.md that includes the current date,
|
|
102
|
+
the latest Git commit reference, and a concise codebase summary
|
|
103
|
+
using tools like `scc` or `tokei`. Next, fetch the current OWASP
|
|
104
|
+
ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
|
|
105
|
+
using httpx, inspect the codebase against these, and write or
|
|
106
|
+
merge prioritised findings (max 10-15) into the ISSUES.md file.
|
|
107
|
+
Ensure each finding is formatted to include its location, category
|
|
108
|
+
(security, complexity, or performance), standard reference, a clear
|
|
109
|
+
recommendation, and has a Status, if existing findings have been
|
|
110
|
+
resolved, summarise and note them in a short log at the end.
|
|
95
111
|
```
|
|
96
112
|
|
|
97
113
|
```bash
|
|
@@ -118,18 +134,27 @@ OY_ROOT=./src oy audit # Audit specific directory
|
|
|
118
134
|
{"shim": "openai", "model": "glm-5"}
|
|
119
135
|
```
|
|
120
136
|
|
|
121
|
-
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
137
|
+
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
138
|
+
Use `oy model <filter>` to pick interactively; it merges models from available
|
|
139
|
+
signed-in shims into a single list using `shim:model` prefixes.
|
|
122
140
|
|
|
123
|
-
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
141
|
+
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
142
|
+
the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
|
|
143
|
+
`oy model` to pin behavior.
|
|
124
144
|
|
|
125
|
-
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
145
|
+
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
146
|
+
intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
|
|
147
|
+
The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
|
|
148
|
+
is a good reference.
|
|
126
149
|
|
|
127
150
|
## Requirements
|
|
128
151
|
|
|
129
152
|
- Python 3.14+
|
|
130
153
|
- `bash`
|
|
131
154
|
- (Optional) `rg` (ripgrep) for faster search
|
|
132
|
-
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
155
|
+
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
156
|
+
(`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
|
|
157
|
+
AWS CLI configured for Bedrock
|
|
133
158
|
|
|
134
159
|
## Installation
|
|
135
160
|
|
|
@@ -151,7 +176,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
|
|
|
151
176
|
export OPENAI_API_KEY=...
|
|
152
177
|
```
|
|
153
178
|
|
|
154
|
-
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
179
|
+
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
180
|
+
and used, if creds are available `oy model` will show them in the model list.
|
|
155
181
|
|
|
156
182
|
**AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
|
|
157
183
|
```bash
|
|
@@ -161,7 +187,9 @@ export AWS_REGION=us-west-2
|
|
|
161
187
|
|
|
162
188
|
## Troubleshooting
|
|
163
189
|
|
|
164
|
-
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
190
|
+
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
191
|
+
`gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
|
|
192
|
+
ensure your profile has `bedrock:InvokeModel` permission.
|
|
165
193
|
|
|
166
194
|
**"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
|
|
167
195
|
|
|
@@ -177,7 +205,9 @@ Recommended:
|
|
|
177
205
|
- avoid exposing long-lived secrets in the environment
|
|
178
206
|
- review generated changes before shipping
|
|
179
207
|
|
|
180
|
-
**Automatic protections:** workspace-bound file access, structured edits
|
|
208
|
+
**Automatic protections:** workspace-bound file access, structured edits
|
|
209
|
+
through `apply`, sensitive header redaction in `httpx`, and native boto3
|
|
210
|
+
credential resolution for Bedrock.
|
|
181
211
|
|
|
182
212
|
## Links
|
|
183
213
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oy-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Tiny local coding CLI with a small tool surface
|
|
5
5
|
Author: oy-cli contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -32,6 +32,8 @@ uv tool install oy-cli
|
|
|
32
32
|
oy "add docstrings to public functions"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
|
|
36
|
+
|
|
35
37
|
## Examples
|
|
36
38
|
|
|
37
39
|
```bash
|
|
@@ -63,7 +65,9 @@ oy --help # Show all commands
|
|
|
63
65
|
|
|
64
66
|
Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
|
|
65
67
|
|
|
66
|
-
**Design goals:** small auditable codebase, minimal tool surface,
|
|
68
|
+
**Design goals:** small auditable codebase, minimal tool surface,
|
|
69
|
+
OpenAI-completions-focused CLI loop, multiple backends behind shims,
|
|
70
|
+
fresh session each run, and explicit checkpoints when needed.
|
|
67
71
|
|
|
68
72
|
## System Prompts
|
|
69
73
|
|
|
@@ -114,7 +118,19 @@ Each tool description is passed directly to the model. These are the exact descr
|
|
|
114
118
|
### Audit Prompt
|
|
115
119
|
|
|
116
120
|
```markdown
|
|
117
|
-
Audit the repo for security, unnecessary complexity, and major
|
|
121
|
+
Audit the repo for security, unnecessary complexity, and major
|
|
122
|
+
obvious performance issues, preserving project and human context.
|
|
123
|
+
First read key markdown docs, then refresh or generate an audit
|
|
124
|
+
header at the top of ISSUES.md that includes the current date,
|
|
125
|
+
the latest Git commit reference, and a concise codebase summary
|
|
126
|
+
using tools like `scc` or `tokei`. Next, fetch the current OWASP
|
|
127
|
+
ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
|
|
128
|
+
using httpx, inspect the codebase against these, and write or
|
|
129
|
+
merge prioritised findings (max 10-15) into the ISSUES.md file.
|
|
130
|
+
Ensure each finding is formatted to include its location, category
|
|
131
|
+
(security, complexity, or performance), standard reference, a clear
|
|
132
|
+
recommendation, and has a Status, if existing findings have been
|
|
133
|
+
resolved, summarise and note them in a short log at the end.
|
|
118
134
|
```
|
|
119
135
|
|
|
120
136
|
```bash
|
|
@@ -141,18 +157,27 @@ OY_ROOT=./src oy audit # Audit specific directory
|
|
|
141
157
|
{"shim": "openai", "model": "glm-5"}
|
|
142
158
|
```
|
|
143
159
|
|
|
144
|
-
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
160
|
+
The `shim` field pins which backend to use regardless of what else is signed in.
|
|
161
|
+
Use `oy model <filter>` to pick interactively; it merges models from available
|
|
162
|
+
signed-in shims into a single list using `shim:model` prefixes.
|
|
145
163
|
|
|
146
|
-
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
164
|
+
On first run, if no model is configured, `oy` prompts you to pick one from
|
|
165
|
+
the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
|
|
166
|
+
`oy model` to pin behavior.
|
|
147
167
|
|
|
148
|
-
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
168
|
+
**Recommended model:** From testing, `glm-5` offers the best balance of
|
|
169
|
+
intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
|
|
170
|
+
The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
|
|
171
|
+
is a good reference.
|
|
149
172
|
|
|
150
173
|
## Requirements
|
|
151
174
|
|
|
152
175
|
- Python 3.14+
|
|
153
176
|
- `bash`
|
|
154
177
|
- (Optional) `rg` (ripgrep) for faster search
|
|
155
|
-
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
178
|
+
- OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
|
|
179
|
+
(`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
|
|
180
|
+
AWS CLI configured for Bedrock
|
|
156
181
|
|
|
157
182
|
## Installation
|
|
158
183
|
|
|
@@ -174,7 +199,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
|
|
|
174
199
|
export OPENAI_API_KEY=...
|
|
175
200
|
```
|
|
176
201
|
|
|
177
|
-
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
202
|
+
Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
|
|
203
|
+
and used, if creds are available `oy model` will show them in the model list.
|
|
178
204
|
|
|
179
205
|
**AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
|
|
180
206
|
```bash
|
|
@@ -184,7 +210,9 @@ export AWS_REGION=us-west-2
|
|
|
184
210
|
|
|
185
211
|
## Troubleshooting
|
|
186
212
|
|
|
187
|
-
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
213
|
+
**"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
|
|
214
|
+
`gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
|
|
215
|
+
ensure your profile has `bedrock:InvokeModel` permission.
|
|
188
216
|
|
|
189
217
|
**"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
|
|
190
218
|
|
|
@@ -200,7 +228,9 @@ Recommended:
|
|
|
200
228
|
- avoid exposing long-lived secrets in the environment
|
|
201
229
|
- review generated changes before shipping
|
|
202
230
|
|
|
203
|
-
**Automatic protections:** workspace-bound file access, structured edits
|
|
231
|
+
**Automatic protections:** workspace-bound file access, structured edits
|
|
232
|
+
through `apply`, sensitive header redaction in `httpx`, and native boto3
|
|
233
|
+
credential resolution for Bedrock.
|
|
204
234
|
|
|
205
235
|
## Links
|
|
206
236
|
|
|
@@ -4,7 +4,6 @@ from dataclasses import dataclass
|
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
-
import readline
|
|
8
7
|
import re
|
|
9
8
|
import sys
|
|
10
9
|
import tempfile
|
|
@@ -89,8 +88,9 @@ def _init_debug_log() -> tuple[logging.Logger | None, str | None]:
|
|
|
89
88
|
raw = os.environ.get("OY_DEBUG", "").strip().lower()
|
|
90
89
|
if raw not in {"1", "true", "yes", "on"}:
|
|
91
90
|
return None, None
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
debug_dir = CONFIG_PATH.parent
|
|
92
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path = str(debug_dir / "debug.jsonl")
|
|
94
94
|
logger = logging.getLogger("oy.debug")
|
|
95
95
|
logger.setLevel(logging.DEBUG)
|
|
96
96
|
logger.propagate = False
|
|
@@ -256,7 +256,7 @@ def abort(m, c=1):
|
|
|
256
256
|
def clip_tokens(text, limit=MAX_TOOL_OUTPUT_TOKENS, tail=0):
|
|
257
257
|
"""Truncate *text* to *limit* tokens, optionally keeping *tail* tokens from the end."""
|
|
258
258
|
e = get_tokenizer()
|
|
259
|
-
ids = e.encode(text)
|
|
259
|
+
ids = e.encode(text, disallowed_special=())
|
|
260
260
|
n = len(ids)
|
|
261
261
|
if n <= limit:
|
|
262
262
|
return text
|
|
@@ -312,15 +312,23 @@ def _http_body(text, ct):
|
|
|
312
312
|
)
|
|
313
313
|
|
|
314
314
|
|
|
315
|
+
_JSON_PATH_MAX_DEPTH = 20
|
|
316
|
+
|
|
317
|
+
|
|
315
318
|
def _json_path(v, p):
|
|
316
319
|
"""Walk into *v* using dot-separated *p* (supports dict keys and list indices)."""
|
|
317
|
-
for part in (p or "").split("."):
|
|
320
|
+
for i, part in enumerate((p or "").split(".")):
|
|
318
321
|
if not part:
|
|
319
322
|
continue
|
|
323
|
+
if i >= _JSON_PATH_MAX_DEPTH:
|
|
324
|
+
raise ValueError(f"json_path exceeded max depth of {_JSON_PATH_MAX_DEPTH}")
|
|
320
325
|
if isinstance(v, list):
|
|
321
326
|
if not part.isdigit():
|
|
322
327
|
raise ValueError(f"json_path expected index, got {part}")
|
|
323
|
-
|
|
328
|
+
try:
|
|
329
|
+
v = v[int(part)]
|
|
330
|
+
except IndexError:
|
|
331
|
+
raise ValueError(f"json_path index {part} out of range (length {len(v)})")
|
|
324
332
|
elif isinstance(v, dict):
|
|
325
333
|
if part not in v:
|
|
326
334
|
raise ValueError(f"json_path key not found: {part}")
|
|
@@ -1419,21 +1427,36 @@ def tool_httpx(
|
|
|
1419
1427
|
if parsed.scheme not in {"http", "https"}:
|
|
1420
1428
|
raise ValueError("httpx only supports http and https")
|
|
1421
1429
|
_print("status", "Fetching HTTP content.", err=True)
|
|
1430
|
+
max_bytes = max_tokens * 16 # generous: ~16 bytes per token
|
|
1422
1431
|
try:
|
|
1423
1432
|
with httpx.Client(
|
|
1424
|
-
follow_redirects=True, timeout=float(timeout_seconds)
|
|
1433
|
+
follow_redirects=True, timeout=float(timeout_seconds), max_redirects=10,
|
|
1425
1434
|
) as http:
|
|
1426
|
-
|
|
1435
|
+
with http.stream(
|
|
1427
1436
|
method,
|
|
1428
1437
|
parsed.geturl(),
|
|
1429
1438
|
headers=_norm_map(headers, "headers"),
|
|
1430
1439
|
params=_norm_map(params, "params"),
|
|
1431
1440
|
content=body,
|
|
1432
1441
|
json=json_body,
|
|
1433
|
-
)
|
|
1442
|
+
) as response:
|
|
1443
|
+
chunks: list[bytes] = []
|
|
1444
|
+
total = 0
|
|
1445
|
+
for chunk in response.iter_bytes():
|
|
1446
|
+
chunks.append(chunk)
|
|
1447
|
+
total += len(chunk)
|
|
1448
|
+
if total > max_bytes:
|
|
1449
|
+
break
|
|
1434
1450
|
except httpx.HTTPError as exc:
|
|
1435
1451
|
raise ValueError(_httpx_err(exc, timeout_seconds)) from exc
|
|
1436
|
-
|
|
1452
|
+
# Reconstruct a response with bounded content for render_httpx_output
|
|
1453
|
+
bounded = httpx.Response(
|
|
1454
|
+
status_code=response.status_code,
|
|
1455
|
+
headers=response.headers,
|
|
1456
|
+
content=b"".join(chunks)[:max_bytes],
|
|
1457
|
+
request=response.request,
|
|
1458
|
+
)
|
|
1459
|
+
out = render_httpx_output(bounded, response_mode, json_path=json_path)
|
|
1437
1460
|
show(out, 1)
|
|
1438
1461
|
return clip_tokens(out, max_tokens)
|
|
1439
1462
|
|
|
@@ -1513,7 +1536,7 @@ def truncate_str_to_tokens(text: str, max_tokens: int = MAX_MESSAGE_TOKENS) -> s
|
|
|
1513
1536
|
characters were removed so the model knows the content was cut.
|
|
1514
1537
|
"""
|
|
1515
1538
|
enc = get_tokenizer()
|
|
1516
|
-
ids = enc.encode(text)
|
|
1539
|
+
ids = enc.encode(text, disallowed_special=())
|
|
1517
1540
|
if len(ids) <= max_tokens:
|
|
1518
1541
|
return text
|
|
1519
1542
|
kept = enc.decode(ids[:max_tokens])
|
|
@@ -1757,6 +1780,10 @@ def audit(prompt: str = ""):
|
|
|
1757
1780
|
|
|
1758
1781
|
def _setup_readline():
|
|
1759
1782
|
"""Configure readline with persistent history for shell-like UX."""
|
|
1783
|
+
try:
|
|
1784
|
+
import readline
|
|
1785
|
+
except ImportError:
|
|
1786
|
+
return # no readline on minimal builds (Alpine, WASM)
|
|
1760
1787
|
history_path = CONFIG_PATH.parent / "history"
|
|
1761
1788
|
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1762
1789
|
try:
|
|
@@ -1764,24 +1791,76 @@ def _setup_readline():
|
|
|
1764
1791
|
except FileNotFoundError:
|
|
1765
1792
|
pass
|
|
1766
1793
|
readline.set_history_length(1000)
|
|
1794
|
+
# Ensure history file has restrictive permissions (M2: OWASP ASVS V8.3.4)
|
|
1795
|
+
history_path.touch(mode=0o600, exist_ok=True)
|
|
1767
1796
|
import atexit
|
|
1768
1797
|
|
|
1769
1798
|
atexit.register(readline.write_history_file, str(history_path))
|
|
1770
1799
|
|
|
1771
1800
|
|
|
1801
|
+
def _drain_stdin(timeout: float = 0.05) -> str:
|
|
1802
|
+
"""Read any data already buffered on stdin (e.g. the tail of a paste).
|
|
1803
|
+
|
|
1804
|
+
Uses select() with a short timeout. Returns the extra text, or "".
|
|
1805
|
+
Only works on real ttys; returns "" for piped stdin.
|
|
1806
|
+
"""
|
|
1807
|
+
import select
|
|
1808
|
+
if not sys.stdin.isatty():
|
|
1809
|
+
return ""
|
|
1810
|
+
chunks: list[str] = []
|
|
1811
|
+
while True:
|
|
1812
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
1813
|
+
if not ready:
|
|
1814
|
+
break
|
|
1815
|
+
chunk = os.read(sys.stdin.fileno(), 4096)
|
|
1816
|
+
if not chunk:
|
|
1817
|
+
break
|
|
1818
|
+
chunks.append(chunk.decode("utf-8", errors="replace"))
|
|
1819
|
+
# After first chunk, use a tighter timeout for the rest.
|
|
1820
|
+
timeout = 0.01
|
|
1821
|
+
return "".join(chunks)
|
|
1822
|
+
|
|
1823
|
+
|
|
1772
1824
|
def _read_input():
|
|
1773
|
-
|
|
1825
|
+
'''Read user input, with automatic paste detection.
|
|
1826
|
+
|
|
1827
|
+
Input modes:
|
|
1828
|
+
1. Single line -- type and press Enter.
|
|
1829
|
+
2. Paste -- paste multiline text; lines that arrive within a
|
|
1830
|
+
few milliseconds of Enter are collected automatically.
|
|
1831
|
+
3. Block mode -- start with ``"""`` to open a fenced block;
|
|
1832
|
+
close it with ``"""`` on its own line.
|
|
1833
|
+
|
|
1834
|
+
Paste detection works by draining stdin right after readline returns.
|
|
1835
|
+
During normal typing there is nothing buffered, so it is a no-op.
|
|
1836
|
+
During a paste, the remaining lines are already queued up.
|
|
1837
|
+
'''
|
|
1774
1838
|
line = input("oy > ")
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1839
|
+
|
|
1840
|
+
# --- block mode: triple-quote fence (still supported) ------------------
|
|
1841
|
+
stripped = line.strip()
|
|
1842
|
+
if stripped == '"""' or stripped.startswith('"""'):
|
|
1843
|
+
if stripped == '"""':
|
|
1844
|
+
parts: list[str] = []
|
|
1845
|
+
else:
|
|
1846
|
+
parts = [stripped[3:]]
|
|
1847
|
+
while True:
|
|
1848
|
+
try:
|
|
1849
|
+
cont = input('... ')
|
|
1850
|
+
except EOFError:
|
|
1851
|
+
break
|
|
1852
|
+
if cont.strip() == '"""':
|
|
1853
|
+
break
|
|
1781
1854
|
parts.append(cont)
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1855
|
+
return "\n".join(parts)
|
|
1856
|
+
|
|
1857
|
+
# --- paste detection: drain any remaining buffered input ---------------
|
|
1858
|
+
extra = _drain_stdin()
|
|
1859
|
+
if extra:
|
|
1860
|
+
# Strip trailing newline that the terminal added from the final Enter.
|
|
1861
|
+
return line + "\n" + extra.rstrip("\n")
|
|
1862
|
+
|
|
1863
|
+
return line
|
|
1785
1864
|
|
|
1786
1865
|
|
|
1787
1866
|
|
|
@@ -1850,7 +1929,8 @@ def _chat_command(cmd, transcript, system_prompt, model_spec):
|
|
|
1850
1929
|
"- `/clear` -- reset conversation (keeps system prompt)",
|
|
1851
1930
|
"- `/quit` or `/exit` -- end session",
|
|
1852
1931
|
"",
|
|
1853
|
-
"Tip:
|
|
1932
|
+
"Tip: paste multiline text — extra lines are detected automatically.",
|
|
1933
|
+
'Tip: type `"""` to start a multiline block, `"""` to end it.',
|
|
1854
1934
|
]), err=True)
|
|
1855
1935
|
return True
|
|
1856
1936
|
if cmd == "/tokens":
|
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from email.utils import parsedate_to_datetime
|
|
15
15
|
from functools import lru_cache
|
|
16
|
+
from types import MappingProxyType
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any, Awaitable, Callable, TypeAlias
|
|
18
19
|
from urllib.parse import quote
|
|
@@ -35,6 +36,7 @@ SHIM_GEMINI = "gemini"
|
|
|
35
36
|
SHIM_BEDROCK = "bedrock"
|
|
36
37
|
SHIM_MANTLE = "bedrock-mantle"
|
|
37
38
|
SHIM_CLAUDE = "claude"
|
|
39
|
+
SHIM_COPILOT = "copilot"
|
|
38
40
|
SHIM_ORDER = (
|
|
39
41
|
SHIM_OPENAI,
|
|
40
42
|
SHIM_CODEX,
|
|
@@ -42,6 +44,7 @@ SHIM_ORDER = (
|
|
|
42
44
|
SHIM_BEDROCK,
|
|
43
45
|
SHIM_MANTLE,
|
|
44
46
|
SHIM_CLAUDE,
|
|
47
|
+
SHIM_COPILOT,
|
|
45
48
|
)
|
|
46
49
|
KNOWN_SHIMS = set(SHIM_ORDER)
|
|
47
50
|
|
|
@@ -567,7 +570,11 @@ def run_cmd(cmd, cwd=None, env=None, timeout=120, stdin_text=None):
|
|
|
567
570
|
# If env vars change mid-process (e.g. in tests), the cache will be stale.
|
|
568
571
|
@lru_cache(maxsize=8)
|
|
569
572
|
def command_env(cwd=None):
|
|
570
|
-
"""Build a shell environment dict, merging Homebrew and mise paths.
|
|
573
|
+
"""Build a shell environment dict, merging Homebrew and mise paths.
|
|
574
|
+
|
|
575
|
+
Returns a read-only ``MappingProxyType`` so the cached value cannot be
|
|
576
|
+
accidentally mutated by callers.
|
|
577
|
+
"""
|
|
571
578
|
env = os.environ.copy()
|
|
572
579
|
if brew := which("brew", env.get("PATH")):
|
|
573
580
|
prefix = Path(brew).parent.parent
|
|
@@ -576,7 +583,7 @@ def command_env(cwd=None):
|
|
|
576
583
|
[str(prefix / "bin"), str(prefix / "sbin")], split_path(env.get("PATH"))
|
|
577
584
|
)
|
|
578
585
|
if not (mise := which("mise", env.get("PATH"))):
|
|
579
|
-
return env
|
|
586
|
+
return MappingProxyType(env)
|
|
580
587
|
try:
|
|
581
588
|
result = run_cmd(
|
|
582
589
|
[mise, "env", "--json"],
|
|
@@ -598,7 +605,7 @@ def command_env(cwd=None):
|
|
|
598
605
|
if key == "PATH"
|
|
599
606
|
else value
|
|
600
607
|
)
|
|
601
|
-
return merged
|
|
608
|
+
return MappingProxyType(merged)
|
|
602
609
|
|
|
603
610
|
|
|
604
611
|
def http_client(**kw):
|
|
@@ -1640,9 +1647,6 @@ def _decode_responses_output(response: Any) -> AssistantMessage:
|
|
|
1640
1647
|
)
|
|
1641
1648
|
|
|
1642
1649
|
|
|
1643
|
-
parse_tool_call_arguments = _decode_tool_call_arguments
|
|
1644
|
-
_responses_output_to_message = _decode_responses_output
|
|
1645
|
-
|
|
1646
1650
|
|
|
1647
1651
|
def _http_error_message(prefix: str, response: httpx.Response) -> str:
|
|
1648
1652
|
try:
|
|
@@ -2484,6 +2488,160 @@ def _list_mantle_models(
|
|
|
2484
2488
|
return _build_mantle_client(region, cwd).list_models()
|
|
2485
2489
|
|
|
2486
2490
|
|
|
2491
|
+
# ---------------------------------------------------------------------------
|
|
2492
|
+
# Copilot shim – uses the GitHub Copilot API (api.githubcopilot.com)
|
|
2493
|
+
# with a GitHub PAT obtained from COPILOT_GITHUB_TOKEN / GH_TOKEN /
|
|
2494
|
+
# GITHUB_TOKEN / `gh auth token`. Set COPILOT_BASE_URL to override
|
|
2495
|
+
# (e.g. https://api.business.githubcopilot.com for enterprise).
|
|
2496
|
+
# Models that support /responses use that API; others fall back to
|
|
2497
|
+
# /chat/completions automatically.
|
|
2498
|
+
# ---------------------------------------------------------------------------
|
|
2499
|
+
|
|
2500
|
+
_COPILOT_BASE_URL = os.environ.get(
|
|
2501
|
+
"COPILOT_BASE_URL", "https://api.githubcopilot.com"
|
|
2502
|
+
)
|
|
2503
|
+
_COPILOT_INTEGRATION_ID = "copilot-developer-cli"
|
|
2504
|
+
_COPILOT_EDITOR_VERSION = "copilot-developer-cli/1.0.6"
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _get_github_token() -> str | None:
|
|
2508
|
+
"""Return a GitHub PAT from env vars or the ``gh`` CLI, or *None*.
|
|
2509
|
+
|
|
2510
|
+
Checks (in order): ``COPILOT_GITHUB_TOKEN``, ``GH_TOKEN``,
|
|
2511
|
+
``GITHUB_TOKEN``, then ``gh auth token``.
|
|
2512
|
+
"""
|
|
2513
|
+
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
|
|
2514
|
+
val = os.environ.get(var)
|
|
2515
|
+
if isinstance(val, str) and val:
|
|
2516
|
+
return val
|
|
2517
|
+
gh = which("gh")
|
|
2518
|
+
if not gh:
|
|
2519
|
+
return None
|
|
2520
|
+
try:
|
|
2521
|
+
proc = subprocess.run(
|
|
2522
|
+
[gh, "auth", "token"],
|
|
2523
|
+
capture_output=True,
|
|
2524
|
+
text=True,
|
|
2525
|
+
timeout=10,
|
|
2526
|
+
)
|
|
2527
|
+
token = proc.stdout.strip()
|
|
2528
|
+
return token if proc.returncode == 0 and token else None
|
|
2529
|
+
except Exception:
|
|
2530
|
+
return None
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _copilot_default_headers() -> dict[str, str]:
|
|
2534
|
+
return {
|
|
2535
|
+
"Copilot-Integration-Id": _COPILOT_INTEGRATION_ID,
|
|
2536
|
+
"Editor-Version": _COPILOT_EDITOR_VERSION,
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
def _copilot_openai_pair(token: str) -> tuple[AsyncOpenAI, OpenAI]:
|
|
2541
|
+
kwargs: dict[str, Any] = {
|
|
2542
|
+
"api_key": token,
|
|
2543
|
+
"base_url": _COPILOT_BASE_URL,
|
|
2544
|
+
"max_retries": 0,
|
|
2545
|
+
"default_headers": _copilot_default_headers(),
|
|
2546
|
+
}
|
|
2547
|
+
return AsyncOpenAI(**kwargs), OpenAI(**kwargs)
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
def _require_copilot_env(_: Path | None = None) -> None:
|
|
2551
|
+
_require_string(
|
|
2552
|
+
_get_github_token(),
|
|
2553
|
+
"No GitHub token found (set GH_TOKEN, GITHUB_TOKEN, or run `gh auth login`)",
|
|
2554
|
+
)
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def _fetch_copilot_models_raw(token: str) -> list[JSONDict]:
|
|
2558
|
+
"""Fetch the full model metadata list from the Copilot API."""
|
|
2559
|
+
resp = httpx.get(
|
|
2560
|
+
f"{_COPILOT_BASE_URL}/models",
|
|
2561
|
+
headers={
|
|
2562
|
+
"Authorization": f"Bearer {token}",
|
|
2563
|
+
**_copilot_default_headers(),
|
|
2564
|
+
},
|
|
2565
|
+
timeout=15,
|
|
2566
|
+
)
|
|
2567
|
+
resp.raise_for_status()
|
|
2568
|
+
data = resp.json()
|
|
2569
|
+
return data.get("data", []) if isinstance(data, dict) else []
|
|
2570
|
+
|
|
2571
|
+
|
|
2572
|
+
def _copilot_chat_model_ids(token: str) -> list[str]:
|
|
2573
|
+
"""Return sorted model IDs that support chat (not embeddings)."""
|
|
2574
|
+
raw = _fetch_copilot_models_raw(token)
|
|
2575
|
+
return sorted(
|
|
2576
|
+
m["id"]
|
|
2577
|
+
for m in raw
|
|
2578
|
+
if isinstance(m.get("id"), str)
|
|
2579
|
+
and m.get("capabilities", {}).get("type") == "chat"
|
|
2580
|
+
)
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
def _copilot_responses_model_ids(token: str) -> set[str]:
|
|
2584
|
+
"""Return model IDs that support the /responses endpoint."""
|
|
2585
|
+
raw = _fetch_copilot_models_raw(token)
|
|
2586
|
+
return {
|
|
2587
|
+
m["id"]
|
|
2588
|
+
for m in raw
|
|
2589
|
+
if isinstance(m.get("id"), str)
|
|
2590
|
+
and "/responses" in (m.get("supported_endpoints") or [])
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
def _build_copilot_client(
|
|
2595
|
+
region: str | None = None, cwd: Path | None = None
|
|
2596
|
+
) -> CompletionClient:
|
|
2597
|
+
"""Build a Copilot client that routes to /responses or /chat/completions."""
|
|
2598
|
+
_ = region, cwd
|
|
2599
|
+
token = _require_string(_get_github_token(), "No GitHub token found")
|
|
2600
|
+
async_client, sync_client = _copilot_openai_pair(token)
|
|
2601
|
+
|
|
2602
|
+
# Probe which models support /responses vs /chat/completions
|
|
2603
|
+
try:
|
|
2604
|
+
responses_models = _copilot_responses_model_ids(token)
|
|
2605
|
+
except Exception:
|
|
2606
|
+
responses_models = set()
|
|
2607
|
+
|
|
2608
|
+
responses_inner = _openai_responses_client(
|
|
2609
|
+
async_client, sync_client, fallback_models=None, default_models=None
|
|
2610
|
+
)
|
|
2611
|
+
chat_inner = _openai_chat_completions_client(
|
|
2612
|
+
async_client, sync_client, tools_map=_tool_specs_to_openai
|
|
2613
|
+
)
|
|
2614
|
+
|
|
2615
|
+
async def chat_completion(
|
|
2616
|
+
model: str,
|
|
2617
|
+
messages: list[ChatMessage],
|
|
2618
|
+
tools: list[ToolSpec] | None = None,
|
|
2619
|
+
tool_choice: str = "auto",
|
|
2620
|
+
on_retry=None,
|
|
2621
|
+
) -> AssistantMessage:
|
|
2622
|
+
inner = responses_inner if model in responses_models else chat_inner
|
|
2623
|
+
return await inner.chat_completion(model, messages, tools, tool_choice, on_retry)
|
|
2624
|
+
|
|
2625
|
+
def list_models() -> list[str]:
|
|
2626
|
+
try:
|
|
2627
|
+
return _copilot_chat_model_ids(token)
|
|
2628
|
+
except Exception:
|
|
2629
|
+
return sorted(
|
|
2630
|
+
m.id
|
|
2631
|
+
for m in sync_client.models.list()
|
|
2632
|
+
if not m.id.startswith("text-embedding")
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
return CompletionClient(chat_completion=chat_completion, list_models=list_models)
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _list_copilot_models(
|
|
2639
|
+
region: str | None = None, cwd: Path | None = None
|
|
2640
|
+
) -> list[str]:
|
|
2641
|
+
_ = region, cwd
|
|
2642
|
+
return _build_copilot_client(region, cwd).list_models()
|
|
2643
|
+
|
|
2644
|
+
|
|
2487
2645
|
SHIM_SPECS: dict[str, ShimSpec] = {
|
|
2488
2646
|
SHIM_OPENAI: ShimSpec(
|
|
2489
2647
|
name=SHIM_OPENAI,
|
|
@@ -2521,6 +2679,12 @@ SHIM_SPECS: dict[str, ShimSpec] = {
|
|
|
2521
2679
|
build_client=_build_claude_shim,
|
|
2522
2680
|
list_models=_list_claude_models,
|
|
2523
2681
|
),
|
|
2682
|
+
SHIM_COPILOT: ShimSpec(
|
|
2683
|
+
name=SHIM_COPILOT,
|
|
2684
|
+
ensure_env=_require_copilot_env,
|
|
2685
|
+
build_client=_build_copilot_client,
|
|
2686
|
+
list_models=_list_copilot_models,
|
|
2687
|
+
),
|
|
2524
2688
|
}
|
|
2525
2689
|
|
|
2526
2690
|
|
|
@@ -220,5 +220,49 @@ class ClipTokensTests(unittest.TestCase):
|
|
|
220
220
|
self.assertIn("tokens omitted", result)
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
class JsonPathTests(unittest.TestCase):
|
|
224
|
+
"""Tests for _json_path including the depth cap (M3)."""
|
|
225
|
+
|
|
226
|
+
def test_simple_dict_traversal(self):
|
|
227
|
+
self.assertEqual(oy_cli._json_path({"a": {"b": 1}}, "a.b"), 1)
|
|
228
|
+
|
|
229
|
+
def test_list_index(self):
|
|
230
|
+
self.assertEqual(oy_cli._json_path([10, 20, 30], "1"), 20)
|
|
231
|
+
|
|
232
|
+
def test_missing_key_raises(self):
|
|
233
|
+
with self.assertRaisesRegex(ValueError, "key not found"):
|
|
234
|
+
oy_cli._json_path({"a": 1}, "b")
|
|
235
|
+
|
|
236
|
+
def test_non_integer_index_raises(self):
|
|
237
|
+
with self.assertRaisesRegex(ValueError, "expected index"):
|
|
238
|
+
oy_cli._json_path([1, 2], "foo")
|
|
239
|
+
|
|
240
|
+
def test_depth_cap_raises(self):
|
|
241
|
+
# Build a deeply nested structure and a path exceeding 20 levels
|
|
242
|
+
obj = 0
|
|
243
|
+
for _ in range(25):
|
|
244
|
+
obj = {"a": obj}
|
|
245
|
+
deep = ".".join(["a"] * 21)
|
|
246
|
+
with self.assertRaisesRegex(ValueError, "json_path exceeded max depth"):
|
|
247
|
+
oy_cli._json_path(obj, deep)
|
|
248
|
+
|
|
249
|
+
def test_index_error_caught(self):
|
|
250
|
+
with self.assertRaisesRegex(ValueError, "out of range"):
|
|
251
|
+
oy_cli._json_path([1, 2], "5")
|
|
252
|
+
|
|
253
|
+
def test_empty_path_returns_value(self):
|
|
254
|
+
self.assertEqual(oy_cli._json_path(42, ""), 42)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class CommandEnvImmutabilityTests(unittest.TestCase):
|
|
258
|
+
"""Test that command_env returns an immutable mapping (L3)."""
|
|
259
|
+
|
|
260
|
+
def test_command_env_is_immutable(self):
|
|
261
|
+
from shim import command_env
|
|
262
|
+
env = command_env()
|
|
263
|
+
with self.assertRaises(TypeError):
|
|
264
|
+
env["SHOULD_NOT_WORK"] = "value"
|
|
265
|
+
|
|
266
|
+
|
|
223
267
|
if __name__ == "__main__":
|
|
224
268
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|