5dive 0.1.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.
- 5dive-0.1.0/.gitignore +56 -0
- 5dive-0.1.0/LICENSE +21 -0
- 5dive-0.1.0/PKG-INFO +95 -0
- 5dive-0.1.0/README.md +76 -0
- 5dive-0.1.0/pyproject.toml +29 -0
- 5dive-0.1.0/src/fivedive/__init__.py +14 -0
- 5dive-0.1.0/src/fivedive/client.py +120 -0
- 5dive-0.1.0/tests/test_client.py +78 -0
5dive-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Internal punch lists (kept local, never committed)
|
|
2
|
+
*_TASKS.md
|
|
3
|
+
|
|
4
|
+
# OS cruft
|
|
5
|
+
.DS_Store
|
|
6
|
+
Thumbs.db
|
|
7
|
+
|
|
8
|
+
# Editor / IDE
|
|
9
|
+
.vscode/
|
|
10
|
+
.idea/
|
|
11
|
+
*.swp
|
|
12
|
+
*.swo
|
|
13
|
+
*~
|
|
14
|
+
|
|
15
|
+
# Node / Bun (any local UI or tooling working trees)
|
|
16
|
+
node_modules/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
.cache/
|
|
20
|
+
bun.lockb
|
|
21
|
+
|
|
22
|
+
# Logs
|
|
23
|
+
*.log
|
|
24
|
+
npm-debug.log*
|
|
25
|
+
|
|
26
|
+
# Env / secrets
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
!.env.example
|
|
30
|
+
|
|
31
|
+
# Recovered or stashed working trees (e.g. the removed ui/ Vite SPA
|
|
32
|
+
# from commit 8932961 — strategic removal; if anyone restores it locally,
|
|
33
|
+
# don't let it accidentally get re-added to the OSS repo)
|
|
34
|
+
/ui/
|
|
35
|
+
|
|
36
|
+
# transient mutants written by tests/meta/harness-verdict-probe.sh during a
|
|
37
|
+
# `selfcheck --full` / probe sweep. They are deleted on a clean exit; a killed run
|
|
38
|
+
# leaves them behind, and `git add -A` in the same tree would commit them (it did).
|
|
39
|
+
tests/.probe-*
|
|
40
|
+
|
|
41
|
+
# DIVE-2097: transient mutated build.sh written by
|
|
42
|
+
# tests/build_bundle_self_order_unit.sh. Can't live under tests/.probe-* above —
|
|
43
|
+
# build.sh does `cd "$(dirname "$0")"`, so the mutant must run from a path that
|
|
44
|
+
# still sees src/, i.e. the repo root — but it is the same "deleted on a clean
|
|
45
|
+
# exit, survives a kill, git add -A would commit it" shape as the probe mutants.
|
|
46
|
+
/.dive2097-mutant-build.sh
|
|
47
|
+
|
|
48
|
+
# DIVE-2091: the built bundle is generated at TAG time by release-cut.yml,
|
|
49
|
+
# never committed on main — a 47k-line generated file on main invalidates every
|
|
50
|
+
# open PR on every merge. install.sh fetches it from the tag's tree.
|
|
51
|
+
/5dive
|
|
52
|
+
/5dive.sha256
|
|
53
|
+
|
|
54
|
+
# DIVE-3911: python package build output
|
|
55
|
+
python/dist/
|
|
56
|
+
python/src/*.egg-info/
|
5dive-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 5dive
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
5dive-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: 5dive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the 5dive CLI — read the task queue, agents and org chart of a 5dive box.
|
|
5
|
+
Project-URL: Homepage, https://5dive.ai
|
|
6
|
+
Project-URL: Source, https://github.com/5dive-ai/5dive
|
|
7
|
+
Project-URL: Issues, https://github.com/5dive-ai/5dive/issues
|
|
8
|
+
Author: 5dive
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: 5dive,agents,automation,cli,multi-agent
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# 5dive
|
|
21
|
+
|
|
22
|
+
Python client for the [5dive](https://5dive.ai) CLI — read the task queue, agent
|
|
23
|
+
seats and org chart of a 5dive box from Python.
|
|
24
|
+
|
|
25
|
+
5dive runs a team of AI coding agents on your own machine. State lives on that
|
|
26
|
+
box and the CLI is the interface with a stable contract over it, so this package
|
|
27
|
+
is a thin, dependency-free wrapper over `5dive <verb> --json` rather than an HTTP
|
|
28
|
+
client.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install 5dive
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The distribution is named `5dive`; the import name is `fivedive`, because a
|
|
37
|
+
Python identifier cannot begin with a digit.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from fivedive import FiveDive
|
|
43
|
+
|
|
44
|
+
fd = FiveDive()
|
|
45
|
+
|
|
46
|
+
for t in fd.tasks("--status=todo"):
|
|
47
|
+
print(t["ident"], t["assignee"], t["title"])
|
|
48
|
+
|
|
49
|
+
print(fd.task("DIVE-3903")["status"])
|
|
50
|
+
|
|
51
|
+
for a in fd.agents():
|
|
52
|
+
print(a["name"], a["active"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Anything the CLI can answer in JSON is reachable, whether or not this package
|
|
56
|
+
has a named helper for it:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
fd.raw("org", "tree")
|
|
60
|
+
fd.raw("task", "ls", "--assignee=main")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Errors are raised, not returned
|
|
64
|
+
|
|
65
|
+
Every `--json` verb answers in one envelope:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{"ok": true, "data": {...}}
|
|
69
|
+
{"ok": false, "error": {"code": 4, "class": "not_found", "message": "no such task: NOPE-1"}}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
An unchecked `ok` is the bug this package exists to prevent, so the false branch
|
|
73
|
+
raises instead of handing back a dict you have to remember to test:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from fivedive import FiveDive, FiveDiveError, CliNotFound
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
fd.task("NOPE-1")
|
|
80
|
+
except FiveDiveError as e:
|
|
81
|
+
print(e.err_class, e.code, e) # not_found 4 no such task: NOPE-1
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Branch on `err_class`, not on message text — the class is the stable contract.
|
|
85
|
+
`CliNotFound` is raised when the `5dive` binary is not on `PATH`.
|
|
86
|
+
|
|
87
|
+
## Requirements
|
|
88
|
+
|
|
89
|
+
Python 3.9+, no third-party dependencies, and a 5dive box with the CLI
|
|
90
|
+
installed. Get the CLI at [5dive.ai](https://5dive.ai) or
|
|
91
|
+
[github.com/5dive-ai/5dive](https://github.com/5dive-ai/5dive).
|
|
92
|
+
|
|
93
|
+
## Licence
|
|
94
|
+
|
|
95
|
+
MIT.
|
5dive-0.1.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# 5dive
|
|
2
|
+
|
|
3
|
+
Python client for the [5dive](https://5dive.ai) CLI — read the task queue, agent
|
|
4
|
+
seats and org chart of a 5dive box from Python.
|
|
5
|
+
|
|
6
|
+
5dive runs a team of AI coding agents on your own machine. State lives on that
|
|
7
|
+
box and the CLI is the interface with a stable contract over it, so this package
|
|
8
|
+
is a thin, dependency-free wrapper over `5dive <verb> --json` rather than an HTTP
|
|
9
|
+
client.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install 5dive
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The distribution is named `5dive`; the import name is `fivedive`, because a
|
|
18
|
+
Python identifier cannot begin with a digit.
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from fivedive import FiveDive
|
|
24
|
+
|
|
25
|
+
fd = FiveDive()
|
|
26
|
+
|
|
27
|
+
for t in fd.tasks("--status=todo"):
|
|
28
|
+
print(t["ident"], t["assignee"], t["title"])
|
|
29
|
+
|
|
30
|
+
print(fd.task("DIVE-3903")["status"])
|
|
31
|
+
|
|
32
|
+
for a in fd.agents():
|
|
33
|
+
print(a["name"], a["active"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Anything the CLI can answer in JSON is reachable, whether or not this package
|
|
37
|
+
has a named helper for it:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
fd.raw("org", "tree")
|
|
41
|
+
fd.raw("task", "ls", "--assignee=main")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Errors are raised, not returned
|
|
45
|
+
|
|
46
|
+
Every `--json` verb answers in one envelope:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"ok": true, "data": {...}}
|
|
50
|
+
{"ok": false, "error": {"code": 4, "class": "not_found", "message": "no such task: NOPE-1"}}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
An unchecked `ok` is the bug this package exists to prevent, so the false branch
|
|
54
|
+
raises instead of handing back a dict you have to remember to test:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from fivedive import FiveDive, FiveDiveError, CliNotFound
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
fd.task("NOPE-1")
|
|
61
|
+
except FiveDiveError as e:
|
|
62
|
+
print(e.err_class, e.code, e) # not_found 4 no such task: NOPE-1
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Branch on `err_class`, not on message text — the class is the stable contract.
|
|
66
|
+
`CliNotFound` is raised when the `5dive` binary is not on `PATH`.
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
Python 3.9+, no third-party dependencies, and a 5dive box with the CLI
|
|
71
|
+
installed. Get the CLI at [5dive.ai](https://5dive.ai) or
|
|
72
|
+
[github.com/5dive-ai/5dive](https://github.com/5dive-ai/5dive).
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "5dive"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the 5dive CLI — read the task queue, agents and org chart of a 5dive box."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "5dive" }]
|
|
13
|
+
keywords = ["5dive", "agents", "multi-agent", "cli", "automation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://5dive.ai"
|
|
25
|
+
Source = "https://github.com/5dive-ai/5dive"
|
|
26
|
+
Issues = "https://github.com/5dive-ai/5dive/issues"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/fivedive"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Python client for the 5dive CLI.
|
|
2
|
+
|
|
3
|
+
The distribution is named ``5dive``; the import name is ``fivedive`` because a
|
|
4
|
+
Python identifier cannot begin with a digit.
|
|
5
|
+
|
|
6
|
+
from fivedive import FiveDive
|
|
7
|
+
for t in FiveDive().tasks():
|
|
8
|
+
print(t["ident"], t["status"])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import FiveDive, FiveDiveError, CliNotFound
|
|
12
|
+
|
|
13
|
+
__all__ = ["FiveDive", "FiveDiveError", "CliNotFound"]
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""A thin, dependency-free wrapper over the local ``5dive`` CLI's JSON mode.
|
|
2
|
+
|
|
3
|
+
WHY THIS IS A WRAPPER AND NOT AN HTTP CLIENT: 5dive's state lives on the box the
|
|
4
|
+
agents run on, and the CLI is the only interface with a stable contract over it.
|
|
5
|
+
Every ``--json`` verb answers in one envelope::
|
|
6
|
+
|
|
7
|
+
{"ok": true, "data": {...}}
|
|
8
|
+
{"ok": false, "error": {"code": 4, "class": "not_found", "message": "..."}}
|
|
9
|
+
|
|
10
|
+
so the whole job of this module is to run the binary, parse that envelope, and
|
|
11
|
+
raise on the false branch instead of handing back a dict the caller has to
|
|
12
|
+
remember to check. An unchecked ``ok`` is the bug this exists to prevent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FiveDiveError(RuntimeError):
|
|
24
|
+
"""The CLI answered with ``ok: false``.
|
|
25
|
+
|
|
26
|
+
Carries the machine-readable fields so callers can branch on ``err_class``
|
|
27
|
+
rather than matching on message text, which is not a stable contract.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, code: Optional[int] = None, err_class: Optional[str] = None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
self.err_class = err_class
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CliNotFound(FiveDiveError):
|
|
37
|
+
"""The ``5dive`` binary is not on PATH."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FiveDive:
|
|
41
|
+
"""Run 5dive CLI verbs and return parsed JSON.
|
|
42
|
+
|
|
43
|
+
:param binary: path to the CLI, if it is not simply ``5dive`` on PATH.
|
|
44
|
+
:param timeout: seconds before a call is abandoned. ``None`` waits forever,
|
|
45
|
+
which is rarely what you want from a library.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, binary: str = "5dive", timeout: Optional[float] = 30.0):
|
|
49
|
+
self.binary = binary
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
# -- the one place a subprocess is run -------------------------------
|
|
53
|
+
def raw(self, *args: str) -> Any:
|
|
54
|
+
"""Run ``5dive <args> --json`` and return the ``data`` payload.
|
|
55
|
+
|
|
56
|
+
``--json`` is appended only when the caller has not already passed it,
|
|
57
|
+
so ``raw("task", "ls", "--json")`` and ``raw("task", "ls")`` agree.
|
|
58
|
+
"""
|
|
59
|
+
argv: List[str] = [self.binary, *args]
|
|
60
|
+
if "--json" not in argv:
|
|
61
|
+
argv.append("--json")
|
|
62
|
+
|
|
63
|
+
if shutil.which(self.binary) is None and "/" not in self.binary:
|
|
64
|
+
raise CliNotFound(f"{self.binary!r} is not on PATH — is this a 5dive box?")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout)
|
|
68
|
+
except FileNotFoundError as exc: # binary named by path, but absent
|
|
69
|
+
raise CliNotFound(f"cannot execute {self.binary!r}: {exc}") from exc
|
|
70
|
+
except subprocess.TimeoutExpired as exc:
|
|
71
|
+
raise FiveDiveError(f"{' '.join(argv)} timed out after {self.timeout}s") from exc
|
|
72
|
+
|
|
73
|
+
# A non-zero exit still carries a JSON envelope on the error path, so
|
|
74
|
+
# parse BEFORE judging the status: the envelope's message is better than
|
|
75
|
+
# "exited 4", and falling back to stderr only when there is no envelope
|
|
76
|
+
# keeps a genuine crash legible instead of masking it as a parse error.
|
|
77
|
+
payload = None
|
|
78
|
+
if proc.stdout.strip():
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(proc.stdout)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
payload = None
|
|
83
|
+
|
|
84
|
+
if payload is None:
|
|
85
|
+
detail = (proc.stderr or proc.stdout or "").strip() or f"exited {proc.returncode}"
|
|
86
|
+
raise FiveDiveError(f"{' '.join(argv)}: no JSON envelope — {detail}")
|
|
87
|
+
|
|
88
|
+
if not payload.get("ok", False):
|
|
89
|
+
err = payload.get("error") or {}
|
|
90
|
+
raise FiveDiveError(
|
|
91
|
+
err.get("message", "unknown error"),
|
|
92
|
+
code=err.get("code"),
|
|
93
|
+
err_class=err.get("class"),
|
|
94
|
+
)
|
|
95
|
+
return payload.get("data")
|
|
96
|
+
|
|
97
|
+
# -- convenience readers ---------------------------------------------
|
|
98
|
+
def tasks(self, *flags: str) -> List[Dict[str, Any]]:
|
|
99
|
+
"""The task queue. Extra flags pass straight through, e.g.
|
|
100
|
+
``tasks("--status=todo", "--assignee=main")``."""
|
|
101
|
+
data = self.raw("task", "ls", *flags) or {}
|
|
102
|
+
return data.get("tasks", [])
|
|
103
|
+
|
|
104
|
+
def task(self, ident: str) -> Dict[str, Any]:
|
|
105
|
+
"""One task by ident, e.g. ``task("DIVE-3903")``."""
|
|
106
|
+
return self.raw("task", "show", ident) or {}
|
|
107
|
+
|
|
108
|
+
def agents(self, *flags: str) -> List[Dict[str, Any]]:
|
|
109
|
+
"""The agent seats on this box."""
|
|
110
|
+
data = self.raw("agent", "list", *flags)
|
|
111
|
+
if isinstance(data, list):
|
|
112
|
+
return data
|
|
113
|
+
return (data or {}).get("agents", [])
|
|
114
|
+
|
|
115
|
+
def version(self) -> str:
|
|
116
|
+
"""The CLI's version string (does not use the JSON envelope)."""
|
|
117
|
+
proc = subprocess.run(
|
|
118
|
+
[self.binary, "--version"], capture_output=True, text=True, timeout=self.timeout
|
|
119
|
+
)
|
|
120
|
+
return proc.stdout.strip()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Unit arms for the envelope contract. No 5dive binary is required: every arm
|
|
2
|
+
drives the real FiveDive.raw() and fakes only subprocess.run, so the parsing and
|
|
3
|
+
the raising under test are the shipped ones.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
13
|
+
|
|
14
|
+
from fivedive import FiveDive, FiveDiveError, CliNotFound # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def completed(stdout="", stderr="", rc=0):
|
|
18
|
+
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr=stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnvelopeTests(unittest.TestCase):
|
|
22
|
+
def setUp(self):
|
|
23
|
+
self.fd = FiveDive()
|
|
24
|
+
patcher = mock.patch("shutil.which", return_value="/usr/local/bin/5dive")
|
|
25
|
+
patcher.start()
|
|
26
|
+
self.addCleanup(patcher.stop)
|
|
27
|
+
|
|
28
|
+
def test_ok_true_returns_data(self):
|
|
29
|
+
env = json.dumps({"ok": True, "data": {"tasks": [{"ident": "DIVE-1"}]}})
|
|
30
|
+
with mock.patch("subprocess.run", return_value=completed(env)):
|
|
31
|
+
self.assertEqual(self.fd.tasks(), [{"ident": "DIVE-1"}])
|
|
32
|
+
|
|
33
|
+
def test_ok_false_raises_with_machine_fields(self):
|
|
34
|
+
env = json.dumps(
|
|
35
|
+
{"ok": False, "error": {"code": 4, "class": "not_found", "message": "no such task: NOPE-1"}}
|
|
36
|
+
)
|
|
37
|
+
with mock.patch("subprocess.run", return_value=completed(env, rc=4)):
|
|
38
|
+
with self.assertRaises(FiveDiveError) as ctx:
|
|
39
|
+
self.fd.task("NOPE-1")
|
|
40
|
+
self.assertEqual(ctx.exception.code, 4)
|
|
41
|
+
self.assertEqual(ctx.exception.err_class, "not_found")
|
|
42
|
+
self.assertIn("NOPE-1", str(ctx.exception))
|
|
43
|
+
|
|
44
|
+
def test_error_envelope_is_read_even_though_exit_is_nonzero(self):
|
|
45
|
+
"""The regression this guards: judging returncode first would report
|
|
46
|
+
'exited 4' and throw away the message the CLI actually sent."""
|
|
47
|
+
env = json.dumps({"ok": False, "error": {"code": 4, "class": "not_found", "message": "gone"}})
|
|
48
|
+
with mock.patch("subprocess.run", return_value=completed(env, stderr="error: gone", rc=4)):
|
|
49
|
+
with self.assertRaises(FiveDiveError) as ctx:
|
|
50
|
+
self.fd.raw("task", "show", "X")
|
|
51
|
+
self.assertEqual(str(ctx.exception), "gone")
|
|
52
|
+
|
|
53
|
+
def test_no_envelope_falls_back_to_stderr(self):
|
|
54
|
+
with mock.patch("subprocess.run", return_value=completed("", "segfault", rc=139)):
|
|
55
|
+
with self.assertRaises(FiveDiveError) as ctx:
|
|
56
|
+
self.fd.raw("task", "ls")
|
|
57
|
+
self.assertIn("segfault", str(ctx.exception))
|
|
58
|
+
|
|
59
|
+
def test_json_flag_not_duplicated(self):
|
|
60
|
+
env = json.dumps({"ok": True, "data": {}})
|
|
61
|
+
with mock.patch("subprocess.run", return_value=completed(env)) as run:
|
|
62
|
+
self.fd.raw("task", "ls", "--json")
|
|
63
|
+
self.assertEqual(run.call_args[0][0].count("--json"), 1)
|
|
64
|
+
|
|
65
|
+
def test_missing_binary_raises_cli_not_found(self):
|
|
66
|
+
with mock.patch("shutil.which", return_value=None):
|
|
67
|
+
with self.assertRaises(CliNotFound):
|
|
68
|
+
FiveDive().raw("task", "ls")
|
|
69
|
+
|
|
70
|
+
def test_timeout_is_reported_not_swallowed(self):
|
|
71
|
+
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="5dive", timeout=1)):
|
|
72
|
+
with self.assertRaises(FiveDiveError) as ctx:
|
|
73
|
+
self.fd.raw("task", "ls")
|
|
74
|
+
self.assertIn("timed out", str(ctx.exception))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|