minitest-cli 0.8.2__tar.gz → 0.9.0__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.
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/AGENTS.md +1 -1
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/PKG-INFO +1 -1
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/pyproject.toml +1 -1
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/build.py +2 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/build_helpers.py +37 -1
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/skill.py +3 -3
- minitest_cli-0.9.0/src/minitest_cli/commands/test_file.py +163 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/test_file_helpers.py +68 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/test_file_list.py +58 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/test_profile.py +172 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/test_profile_helpers.py +70 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/test_profile_list.py +69 -0
- minitest_cli-0.9.0/src/minitest_cli/commands/user_story_bindings.py +154 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/main.py +6 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/build.py +1 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_build_commands.py +73 -0
- minitest_cli-0.9.0/tests/test_test_file_commands.py +135 -0
- minitest_cli-0.9.0/tests/test_test_profile_commands.py +192 -0
- minitest_cli-0.9.0/tests/test_user_story_bindings_commands.py +140 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/uv.lock +1 -1
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.env.example +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/ci.yml +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/install-scripts.yml +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/release.yml +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.gitignore +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.opencode/skill/release/SKILL.md +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/README.md +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/RELEASE.md +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/install.ps1 +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/install.sh +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/pyrightconfig.json +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/apps_manager_client.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/client.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/assets/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/assets/callback.html +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/app_knowledge.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/app_knowledge_helpers.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/apps.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/apps_helpers.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/auth.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/batch.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/batch_helpers.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/flow_types.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/maintenance_check.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run_display.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run_helpers.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/upgrade.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story_helpers.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story_modify.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/app_context.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/auth.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/config.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/credentials.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/oauth.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/tenants.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/token_exchange.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/app.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/base.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/maintenance_check.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/story_run.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/user_story.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/output.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/skill_refresh.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/update_check.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/__init__.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_app_knowledge_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_apps_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_auth.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_auth_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_batch_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_code_quality.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_flow_types_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_run_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_skill_command.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_upgrade_command.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_user_story_commands.py +0 -0
- {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_version.py +0 -0
|
@@ -49,7 +49,7 @@ uv add <package> # Add new dependency (always
|
|
|
49
49
|
### Output Convention
|
|
50
50
|
- `--json` flag: JSON to stdout, diagnostics to stderr
|
|
51
51
|
- Without `--json`: human-friendly rich tables to stdout, diagnostics to stderr
|
|
52
|
-
- Exit codes: 0=Success, 1=General error, 2=Auth error, 3=Network/API error, 4=Not found
|
|
52
|
+
- Exit codes: 0=Success, 1=General error, 2=Auth error, 3=Network/API error, 4=Not found, 5=Build invalid
|
|
53
53
|
|
|
54
54
|
### No Interactive Prompts
|
|
55
55
|
- All input via flags, env vars, or stdin
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: minitest-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Minitest CLI – command-line interface for the Minitest testing platform
|
|
5
5
|
Project-URL: Homepage, https://minitap.ai/
|
|
6
6
|
Project-URL: Source, https://github.com/minitap-ai/minitest-cli
|
|
@@ -14,6 +14,7 @@ from minitest_cli.commands.build_helpers import (
|
|
|
14
14
|
format_build_row,
|
|
15
15
|
format_pagination_info,
|
|
16
16
|
handle_response_error,
|
|
17
|
+
print_validation_warnings,
|
|
17
18
|
resolve_app,
|
|
18
19
|
run_api_call,
|
|
19
20
|
upload_status_message,
|
|
@@ -79,6 +80,7 @@ def upload(
|
|
|
79
80
|
if json_mode:
|
|
80
81
|
output(result_dict, json_mode=True)
|
|
81
82
|
else:
|
|
83
|
+
print_validation_warnings(result.validation_warnings)
|
|
82
84
|
print_success(f"Build uploaded: {file.name} ({resolved_platform})")
|
|
83
85
|
output(result_dict, json_mode=False)
|
|
84
86
|
|
|
@@ -12,10 +12,13 @@ import typer
|
|
|
12
12
|
from minitest_cli.core.app_context import resolve_app_id
|
|
13
13
|
from minitest_cli.core.config import Settings
|
|
14
14
|
from minitest_cli.models import BuildListResponse, BuildResponse
|
|
15
|
-
from minitest_cli.utils.output import print_error
|
|
15
|
+
from minitest_cli.utils.output import err_console, print_error
|
|
16
16
|
|
|
17
17
|
EXIT_NETWORK_ERROR = 3
|
|
18
18
|
EXIT_NOT_FOUND = 4
|
|
19
|
+
EXIT_BUILD_INVALID = 5
|
|
20
|
+
|
|
21
|
+
BUILD_INVALID_ERROR_CODE = "build_invalid"
|
|
19
22
|
|
|
20
23
|
# ---------------------------------------------------------------------------
|
|
21
24
|
# Context accessors
|
|
@@ -93,11 +96,44 @@ def handle_response_error(resp: httpx.Response, *, resource: str = "Build") -> N
|
|
|
93
96
|
if resp.status_code == 404:
|
|
94
97
|
print_error(f"{resource} not found: {extract_detail(resp)}")
|
|
95
98
|
raise typer.Exit(code=EXIT_NOT_FOUND)
|
|
99
|
+
if resp.status_code == 422 and _try_handle_build_invalid(resp):
|
|
100
|
+
raise typer.Exit(code=EXIT_BUILD_INVALID)
|
|
96
101
|
if resp.status_code >= 400:
|
|
97
102
|
print_error(f"API error ({resp.status_code}): {extract_detail(resp)}")
|
|
98
103
|
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
99
104
|
|
|
100
105
|
|
|
106
|
+
def _try_handle_build_invalid(resp: httpx.Response) -> bool:
|
|
107
|
+
"""If response is a build-invalid error, render issues and return True."""
|
|
108
|
+
try:
|
|
109
|
+
body = resp.json()
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
return False
|
|
112
|
+
if not isinstance(body, dict) or body.get("error_code") != BUILD_INVALID_ERROR_CODE:
|
|
113
|
+
return False
|
|
114
|
+
issues = body.get("issues") or []
|
|
115
|
+
print_error("Build rejected: failed validation for virtual-device execution.")
|
|
116
|
+
for issue in issues:
|
|
117
|
+
if not isinstance(issue, dict):
|
|
118
|
+
continue
|
|
119
|
+
code = issue.get("code", "unknown")
|
|
120
|
+
message = issue.get("message", "")
|
|
121
|
+
err_console.print(f" [red]✖[/red] {code}: {message}")
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def print_validation_warnings(warnings: list[dict] | None) -> None:
|
|
126
|
+
"""Print non-fatal validation warnings to stderr."""
|
|
127
|
+
if not warnings:
|
|
128
|
+
return
|
|
129
|
+
for warning in warnings:
|
|
130
|
+
if not isinstance(warning, dict):
|
|
131
|
+
continue
|
|
132
|
+
code = warning.get("code", "unknown")
|
|
133
|
+
message = warning.get("message", "")
|
|
134
|
+
err_console.print(f" [yellow]⚠[/yellow] {code}: {message}")
|
|
135
|
+
|
|
136
|
+
|
|
101
137
|
def run_api_call[T](coro: Coroutine[object, object, T]) -> T:
|
|
102
138
|
"""Run an async API coroutine, catching network errors → exit 3."""
|
|
103
139
|
try:
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import os
|
|
3
2
|
import sys
|
|
4
3
|
|
|
5
4
|
import httpx
|
|
@@ -9,9 +8,10 @@ from minitest_cli.utils.output import err_console, print_error
|
|
|
9
8
|
|
|
10
9
|
app = typer.Typer(name="skill", help="Retrieve the minitest CLI skill for AI agents.")
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
_DEFAULT_SKILL_URL = (
|
|
13
12
|
"https://raw.githubusercontent.com/minitap-ai/agent-skills/main/skills/minitest-cli/SKILL.md"
|
|
14
13
|
)
|
|
14
|
+
SKILL_URL = os.environ.get("MINITEST_SKILL_URL", _DEFAULT_SKILL_URL)
|
|
15
15
|
|
|
16
16
|
EXIT_NETWORK_ERROR = 3
|
|
17
17
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from minitest_cli.api.client import ApiClient
|
|
8
|
+
from minitest_cli.commands import test_file_list
|
|
9
|
+
from minitest_cli.commands.test_file_helpers import (
|
|
10
|
+
MAX_UPLOAD_BYTES,
|
|
11
|
+
base_path,
|
|
12
|
+
get_app_flag,
|
|
13
|
+
get_settings,
|
|
14
|
+
handle_file_response,
|
|
15
|
+
is_json_mode,
|
|
16
|
+
run_api_call,
|
|
17
|
+
)
|
|
18
|
+
from minitest_cli.core.app_context import resolve_app_id
|
|
19
|
+
from minitest_cli.core.auth import require_auth
|
|
20
|
+
from minitest_cli.utils.output import output, print_error, print_success
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(name="test-file", help="Test-file operations (app-scoped).")
|
|
23
|
+
test_file_list.register(app)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command(name="upload")
|
|
27
|
+
def upload_file(
|
|
28
|
+
path: Annotated[
|
|
29
|
+
Path,
|
|
30
|
+
typer.Argument(
|
|
31
|
+
exists=True,
|
|
32
|
+
file_okay=True,
|
|
33
|
+
dir_okay=False,
|
|
34
|
+
readable=True,
|
|
35
|
+
resolve_path=True,
|
|
36
|
+
help="Path to the local file to upload.",
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
name: Annotated[
|
|
40
|
+
str | None, typer.Option("--name", help="Display name (defaults to the file basename).")
|
|
41
|
+
] = None,
|
|
42
|
+
note: Annotated[
|
|
43
|
+
str | None, typer.Option("--note", help="Optional 'what this file is for' note.")
|
|
44
|
+
] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
settings = get_settings()
|
|
47
|
+
json_mode = is_json_mode()
|
|
48
|
+
require_auth(settings)
|
|
49
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
50
|
+
|
|
51
|
+
size = path.stat().st_size
|
|
52
|
+
if size > MAX_UPLOAD_BYTES:
|
|
53
|
+
print_error(f"File too large: {size} bytes (max {MAX_UPLOAD_BYTES} bytes / 25 MB).")
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
|
|
56
|
+
mime, _ = mimetypes.guess_type(path.name)
|
|
57
|
+
mime = mime or "application/octet-stream"
|
|
58
|
+
|
|
59
|
+
async def _run() -> dict[str, Any]:
|
|
60
|
+
async with ApiClient(settings) as client:
|
|
61
|
+
with path.open("rb") as fh:
|
|
62
|
+
data = {}
|
|
63
|
+
if name is not None:
|
|
64
|
+
data["name"] = name
|
|
65
|
+
if note is not None:
|
|
66
|
+
data["note"] = note
|
|
67
|
+
resp = await client.upload_file(
|
|
68
|
+
base_path(app_id),
|
|
69
|
+
files={"file": (path.name, fh, mime)},
|
|
70
|
+
data=data,
|
|
71
|
+
)
|
|
72
|
+
handle_file_response(resp)
|
|
73
|
+
return resp.json()
|
|
74
|
+
|
|
75
|
+
data = run_api_call(_run())
|
|
76
|
+
if not json_mode:
|
|
77
|
+
print_success(f"Test file uploaded: {data.get('id', '')}")
|
|
78
|
+
output(data, json_mode=json_mode)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command(name="get")
|
|
82
|
+
def get_file(
|
|
83
|
+
file_id: Annotated[str, typer.Argument(help="Test file ID.")],
|
|
84
|
+
) -> None:
|
|
85
|
+
settings = get_settings()
|
|
86
|
+
json_mode = is_json_mode()
|
|
87
|
+
require_auth(settings)
|
|
88
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
89
|
+
|
|
90
|
+
async def _run() -> dict[str, Any]:
|
|
91
|
+
async with ApiClient(settings) as client:
|
|
92
|
+
resp = await client.get(f"{base_path(app_id)}/{file_id}")
|
|
93
|
+
handle_file_response(resp)
|
|
94
|
+
return resp.json()
|
|
95
|
+
|
|
96
|
+
output(run_api_call(_run()), json_mode=json_mode)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command(name="update")
|
|
100
|
+
def update_file(
|
|
101
|
+
file_id: Annotated[str, typer.Argument(help="Test file ID.")],
|
|
102
|
+
name: Annotated[str | None, typer.Option("--name", help="New display name.")] = None,
|
|
103
|
+
note: Annotated[str | None, typer.Option("--note", help="New note.")] = None,
|
|
104
|
+
clear_note: Annotated[
|
|
105
|
+
bool, typer.Option("--clear-note", help="Remove the existing note.")
|
|
106
|
+
] = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
settings = get_settings()
|
|
109
|
+
json_mode = is_json_mode()
|
|
110
|
+
require_auth(settings)
|
|
111
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
112
|
+
|
|
113
|
+
if note is not None and clear_note:
|
|
114
|
+
print_error("Use either --note or --clear-note, not both.")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
body: dict[str, Any] = {}
|
|
118
|
+
if name is not None:
|
|
119
|
+
body["name"] = name
|
|
120
|
+
if note is not None:
|
|
121
|
+
body["note"] = note
|
|
122
|
+
if clear_note:
|
|
123
|
+
body["clearNote"] = True
|
|
124
|
+
|
|
125
|
+
if not body:
|
|
126
|
+
print_error("Provide at least one field to update.")
|
|
127
|
+
raise typer.Exit(code=1)
|
|
128
|
+
|
|
129
|
+
async def _run() -> dict[str, Any]:
|
|
130
|
+
async with ApiClient(settings) as client:
|
|
131
|
+
resp = await client.patch(f"{base_path(app_id)}/{file_id}", json=body)
|
|
132
|
+
handle_file_response(resp)
|
|
133
|
+
return resp.json()
|
|
134
|
+
|
|
135
|
+
data = run_api_call(_run())
|
|
136
|
+
if not json_mode:
|
|
137
|
+
print_success(f"Test file updated: {file_id}")
|
|
138
|
+
output(data, json_mode=json_mode)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command(name="delete")
|
|
142
|
+
def delete_file(
|
|
143
|
+
file_id: Annotated[str, typer.Argument(help="Test file ID.")],
|
|
144
|
+
force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
|
|
145
|
+
) -> None:
|
|
146
|
+
settings = get_settings()
|
|
147
|
+
json_mode = is_json_mode()
|
|
148
|
+
require_auth(settings)
|
|
149
|
+
if not force:
|
|
150
|
+
print_error("Delete requires --force flag.")
|
|
151
|
+
raise typer.Exit(code=1)
|
|
152
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
153
|
+
|
|
154
|
+
async def _run() -> None:
|
|
155
|
+
async with ApiClient(settings) as client:
|
|
156
|
+
resp = await client.delete(f"{base_path(app_id)}/{file_id}")
|
|
157
|
+
handle_file_response(resp)
|
|
158
|
+
|
|
159
|
+
run_api_call(_run())
|
|
160
|
+
if json_mode:
|
|
161
|
+
output({"deleted": True, "id": file_id}, json_mode=True)
|
|
162
|
+
else:
|
|
163
|
+
print_success(f"Test file deleted: {file_id}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from minitest_cli.commands.user_story_helpers import (
|
|
9
|
+
extract_detail,
|
|
10
|
+
get_app_flag,
|
|
11
|
+
get_settings,
|
|
12
|
+
is_json_mode,
|
|
13
|
+
run_api_call,
|
|
14
|
+
)
|
|
15
|
+
from minitest_cli.utils.output import print_error
|
|
16
|
+
|
|
17
|
+
EXIT_GENERAL_ERROR = 1
|
|
18
|
+
EXIT_NETWORK_ERROR = 3
|
|
19
|
+
EXIT_NOT_FOUND = 4
|
|
20
|
+
|
|
21
|
+
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # mirrors the testing-service multipart cap
|
|
22
|
+
|
|
23
|
+
FILE_TABLE_HEADERS = ["ID", "Name", "Original Filename", "Kind", "MIME", "Size", "Updated At"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def base_path(app_id: str) -> str:
|
|
27
|
+
return f"/api/v1/apps/{app_id}/test-files"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_file_response(resp: httpx.Response, *, resource: str = "Test file") -> None:
|
|
31
|
+
if resp.status_code == 404:
|
|
32
|
+
detail = extract_detail(resp)
|
|
33
|
+
print_error(detail or f"{resource} not found.")
|
|
34
|
+
raise typer.Exit(code=EXIT_NOT_FOUND)
|
|
35
|
+
if resp.status_code >= 400:
|
|
36
|
+
detail = extract_detail(resp)
|
|
37
|
+
print_error(detail or f"API error: {resp.status_code}")
|
|
38
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_file_row(entry: dict[str, Any]) -> list[str]:
|
|
42
|
+
size = entry.get("sizeBytes") or entry.get("size_bytes") or 0
|
|
43
|
+
return [
|
|
44
|
+
str(entry.get("id", "")),
|
|
45
|
+
entry.get("name", ""),
|
|
46
|
+
entry.get("originalFilename") or entry.get("original_filename") or "",
|
|
47
|
+
entry.get("kind", "") or "",
|
|
48
|
+
entry.get("mimeType") or entry.get("mime_type") or "",
|
|
49
|
+
str(size),
|
|
50
|
+
entry.get("updatedAt") or entry.get("updated_at") or "",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"EXIT_GENERAL_ERROR",
|
|
56
|
+
"EXIT_NETWORK_ERROR",
|
|
57
|
+
"EXIT_NOT_FOUND",
|
|
58
|
+
"FILE_TABLE_HEADERS",
|
|
59
|
+
"MAX_UPLOAD_BYTES",
|
|
60
|
+
"base_path",
|
|
61
|
+
"extract_detail",
|
|
62
|
+
"format_file_row",
|
|
63
|
+
"get_app_flag",
|
|
64
|
+
"get_settings",
|
|
65
|
+
"handle_file_response",
|
|
66
|
+
"is_json_mode",
|
|
67
|
+
"run_api_call",
|
|
68
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import Annotated, Any
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from minitest_cli.api.client import ApiClient
|
|
6
|
+
from minitest_cli.commands.test_file_helpers import (
|
|
7
|
+
FILE_TABLE_HEADERS,
|
|
8
|
+
base_path,
|
|
9
|
+
format_file_row,
|
|
10
|
+
get_app_flag,
|
|
11
|
+
get_settings,
|
|
12
|
+
handle_file_response,
|
|
13
|
+
is_json_mode,
|
|
14
|
+
run_api_call,
|
|
15
|
+
)
|
|
16
|
+
from minitest_cli.core.app_context import resolve_app_id
|
|
17
|
+
from minitest_cli.core.auth import require_auth
|
|
18
|
+
from minitest_cli.utils.output import output, print_info, print_table
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(app: typer.Typer) -> None:
|
|
22
|
+
|
|
23
|
+
@app.command(name="list")
|
|
24
|
+
def list_files(
|
|
25
|
+
kind: Annotated[
|
|
26
|
+
str | None,
|
|
27
|
+
typer.Option("--kind", help="Filter by kind (image|document|video|audio|other)."),
|
|
28
|
+
] = None,
|
|
29
|
+
page: Annotated[int, typer.Option("--page", min=1, help="Page number.")] = 1,
|
|
30
|
+
page_size: Annotated[
|
|
31
|
+
int, typer.Option("--page-size", min=1, max=100, help="Items per page.")
|
|
32
|
+
] = 20,
|
|
33
|
+
) -> None:
|
|
34
|
+
settings = get_settings()
|
|
35
|
+
json_mode = is_json_mode()
|
|
36
|
+
require_auth(settings)
|
|
37
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
38
|
+
|
|
39
|
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
|
40
|
+
if kind is not None:
|
|
41
|
+
params["kind"] = kind
|
|
42
|
+
|
|
43
|
+
async def _run() -> Any:
|
|
44
|
+
async with ApiClient(settings) as client:
|
|
45
|
+
resp = await client.get(base_path(app_id), params=params)
|
|
46
|
+
handle_file_response(resp)
|
|
47
|
+
return resp.json()
|
|
48
|
+
|
|
49
|
+
data = run_api_call(_run())
|
|
50
|
+
items = data["items"] if isinstance(data, dict) and "items" in data else data
|
|
51
|
+
if json_mode:
|
|
52
|
+
output(items, json_mode=True)
|
|
53
|
+
return
|
|
54
|
+
if not items:
|
|
55
|
+
print_info("No test files found.")
|
|
56
|
+
return
|
|
57
|
+
rows = [format_file_row(f) for f in items]
|
|
58
|
+
print_table(FILE_TABLE_HEADERS, rows, title=f"Test files ({len(items)})")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from minitest_cli.api.client import ApiClient
|
|
7
|
+
from minitest_cli.commands import test_profile_list
|
|
8
|
+
from minitest_cli.commands.test_profile_helpers import (
|
|
9
|
+
app_base_path,
|
|
10
|
+
get_app_flag,
|
|
11
|
+
get_settings,
|
|
12
|
+
handle_profile_response,
|
|
13
|
+
is_json_mode,
|
|
14
|
+
run_api_call,
|
|
15
|
+
)
|
|
16
|
+
from minitest_cli.core.app_context import resolve_app_id
|
|
17
|
+
from minitest_cli.core.auth import require_auth
|
|
18
|
+
from minitest_cli.utils.output import output, print_error, print_success
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="test-profile", help="Test-profile operations (app-scoped).")
|
|
21
|
+
test_profile_list.register(app)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_password(password: str | None, password_stdin: bool) -> str | None:
|
|
25
|
+
if password_stdin:
|
|
26
|
+
if password is not None:
|
|
27
|
+
print_error("Use either --password or --password-stdin, not both.")
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
return sys.stdin.read().rstrip("\r\n")
|
|
30
|
+
return password
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command(name="create")
|
|
34
|
+
def create_profile(
|
|
35
|
+
name: Annotated[str, typer.Option("--name", help="Profile name.")],
|
|
36
|
+
username: Annotated[str | None, typer.Option("--username", help="Account username.")] = None,
|
|
37
|
+
password: Annotated[
|
|
38
|
+
str | None, typer.Option("--password", help="Account password (use stdin for security).")
|
|
39
|
+
] = None,
|
|
40
|
+
password_stdin: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option("--password-stdin", help="Read the password from stdin (no echo)."),
|
|
43
|
+
] = False,
|
|
44
|
+
about: Annotated[
|
|
45
|
+
str | None, typer.Option("--about", help="Free-text notes about the account.")
|
|
46
|
+
] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
settings = get_settings()
|
|
49
|
+
json_mode = is_json_mode()
|
|
50
|
+
require_auth(settings)
|
|
51
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
52
|
+
pwd = _read_password(password, password_stdin)
|
|
53
|
+
|
|
54
|
+
body: dict[str, Any] = {"name": name}
|
|
55
|
+
if username is not None:
|
|
56
|
+
body["username"] = username
|
|
57
|
+
if pwd is not None:
|
|
58
|
+
body["password"] = pwd
|
|
59
|
+
if about is not None:
|
|
60
|
+
body["about"] = about
|
|
61
|
+
|
|
62
|
+
async def _run() -> dict[str, Any]:
|
|
63
|
+
async with ApiClient(settings) as client:
|
|
64
|
+
resp = await client.post(app_base_path(app_id), json=body)
|
|
65
|
+
handle_profile_response(resp)
|
|
66
|
+
return resp.json()
|
|
67
|
+
|
|
68
|
+
data = run_api_call(_run())
|
|
69
|
+
if not json_mode:
|
|
70
|
+
print_success(f"Test profile created: {data.get('id', '')}")
|
|
71
|
+
output(data, json_mode=json_mode)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command(name="get")
|
|
75
|
+
def get_profile(
|
|
76
|
+
profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
|
|
77
|
+
) -> None:
|
|
78
|
+
settings = get_settings()
|
|
79
|
+
json_mode = is_json_mode()
|
|
80
|
+
require_auth(settings)
|
|
81
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
82
|
+
|
|
83
|
+
async def _run() -> dict[str, Any]:
|
|
84
|
+
async with ApiClient(settings) as client:
|
|
85
|
+
resp = await client.get(f"{app_base_path(app_id)}/{profile_id}")
|
|
86
|
+
handle_profile_response(resp)
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
output(run_api_call(_run()), json_mode=json_mode)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command(name="update")
|
|
93
|
+
def update_profile(
|
|
94
|
+
profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
|
|
95
|
+
name: Annotated[str | None, typer.Option("--name", help="New profile name.")] = None,
|
|
96
|
+
username: Annotated[str | None, typer.Option("--username", help="New username.")] = None,
|
|
97
|
+
password: Annotated[
|
|
98
|
+
str | None, typer.Option("--password", help="New password (overrides existing).")
|
|
99
|
+
] = None,
|
|
100
|
+
password_stdin: Annotated[
|
|
101
|
+
bool,
|
|
102
|
+
typer.Option("--password-stdin", help="Read the new password from stdin."),
|
|
103
|
+
] = False,
|
|
104
|
+
clear_password: Annotated[
|
|
105
|
+
bool,
|
|
106
|
+
typer.Option("--clear-password", help="Remove the stored password."),
|
|
107
|
+
] = False,
|
|
108
|
+
about: Annotated[
|
|
109
|
+
str | None, typer.Option("--about", help="New about text (pass '' to clear).")
|
|
110
|
+
] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
settings = get_settings()
|
|
113
|
+
json_mode = is_json_mode()
|
|
114
|
+
require_auth(settings)
|
|
115
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
116
|
+
|
|
117
|
+
pwd = _read_password(password, password_stdin)
|
|
118
|
+
if pwd is not None and clear_password:
|
|
119
|
+
print_error("Use either --password/--password-stdin or --clear-password, not both.")
|
|
120
|
+
raise typer.Exit(code=1)
|
|
121
|
+
|
|
122
|
+
body: dict[str, Any] = {}
|
|
123
|
+
if name is not None:
|
|
124
|
+
body["name"] = name
|
|
125
|
+
if username is not None:
|
|
126
|
+
body["username"] = username
|
|
127
|
+
if pwd is not None:
|
|
128
|
+
body["password"] = pwd
|
|
129
|
+
elif clear_password:
|
|
130
|
+
body["password"] = None
|
|
131
|
+
if about is not None:
|
|
132
|
+
body["about"] = about
|
|
133
|
+
|
|
134
|
+
if not body:
|
|
135
|
+
print_error("Provide at least one field to update.")
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
138
|
+
async def _run() -> dict[str, Any]:
|
|
139
|
+
async with ApiClient(settings) as client:
|
|
140
|
+
resp = await client.patch(f"{app_base_path(app_id)}/{profile_id}", json=body)
|
|
141
|
+
handle_profile_response(resp)
|
|
142
|
+
return resp.json()
|
|
143
|
+
|
|
144
|
+
data = run_api_call(_run())
|
|
145
|
+
if not json_mode:
|
|
146
|
+
print_success(f"Test profile updated: {profile_id}")
|
|
147
|
+
output(data, json_mode=json_mode)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command(name="delete")
|
|
151
|
+
def delete_profile(
|
|
152
|
+
profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
|
|
153
|
+
force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
|
|
154
|
+
) -> None:
|
|
155
|
+
settings = get_settings()
|
|
156
|
+
json_mode = is_json_mode()
|
|
157
|
+
require_auth(settings)
|
|
158
|
+
if not force:
|
|
159
|
+
print_error("Delete requires --force flag.")
|
|
160
|
+
raise typer.Exit(code=1)
|
|
161
|
+
app_id = resolve_app_id(settings, get_app_flag())
|
|
162
|
+
|
|
163
|
+
async def _run() -> None:
|
|
164
|
+
async with ApiClient(settings) as client:
|
|
165
|
+
resp = await client.delete(f"{app_base_path(app_id)}/{profile_id}")
|
|
166
|
+
handle_profile_response(resp)
|
|
167
|
+
|
|
168
|
+
run_api_call(_run())
|
|
169
|
+
if json_mode:
|
|
170
|
+
output({"deleted": True, "id": profile_id}, json_mode=True)
|
|
171
|
+
else:
|
|
172
|
+
print_success(f"Test profile deleted: {profile_id}")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from minitest_cli.commands.user_story_helpers import (
|
|
9
|
+
extract_detail,
|
|
10
|
+
get_app_flag,
|
|
11
|
+
get_settings,
|
|
12
|
+
is_json_mode,
|
|
13
|
+
run_api_call,
|
|
14
|
+
)
|
|
15
|
+
from minitest_cli.utils.output import print_error
|
|
16
|
+
|
|
17
|
+
EXIT_NETWORK_ERROR = 3
|
|
18
|
+
EXIT_NOT_FOUND = 4
|
|
19
|
+
|
|
20
|
+
PROFILE_TABLE_HEADERS = ["ID", "Name", "Username", "Scope", "Updated At"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def app_base_path(app_id: str) -> str:
|
|
24
|
+
return f"/api/v1/apps/{app_id}/test-profiles"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
SHARED_PATH = "/api/v1/test-profiles/shared"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_profile_response(resp: httpx.Response, *, resource: str = "Test profile") -> None:
|
|
31
|
+
if resp.status_code == 404:
|
|
32
|
+
detail = extract_detail(resp)
|
|
33
|
+
print_error(detail or f"{resource} not found.")
|
|
34
|
+
raise typer.Exit(code=EXIT_NOT_FOUND)
|
|
35
|
+
if resp.status_code >= 400:
|
|
36
|
+
detail = extract_detail(resp)
|
|
37
|
+
print_error(detail or f"API error: {resp.status_code}")
|
|
38
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def profile_scope(profile: dict[str, Any]) -> str:
|
|
42
|
+
if profile.get("isShared") or profile.get("is_shared"):
|
|
43
|
+
return "shared"
|
|
44
|
+
return "app"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_profile_row(profile: dict[str, Any]) -> list[str]:
|
|
48
|
+
return [
|
|
49
|
+
str(profile.get("id", "")),
|
|
50
|
+
profile.get("name", ""),
|
|
51
|
+
profile.get("username") or "",
|
|
52
|
+
profile_scope(profile),
|
|
53
|
+
profile.get("updatedAt") or profile.get("updated_at") or "",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"EXIT_NETWORK_ERROR",
|
|
59
|
+
"EXIT_NOT_FOUND",
|
|
60
|
+
"PROFILE_TABLE_HEADERS",
|
|
61
|
+
"SHARED_PATH",
|
|
62
|
+
"app_base_path",
|
|
63
|
+
"extract_detail",
|
|
64
|
+
"format_profile_row",
|
|
65
|
+
"get_app_flag",
|
|
66
|
+
"get_settings",
|
|
67
|
+
"handle_profile_response",
|
|
68
|
+
"is_json_mode",
|
|
69
|
+
"run_api_call",
|
|
70
|
+
]
|