obvyr-cli 1.0.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.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: obvyr-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line wrapper to allow Obvyr to capture evidence from any machine.
5
+ License: Proprietary
6
+ Author: Wyrd Technology Ltd
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: boto3 (>=1.35.68,<2.0.0)
14
+ Requires-Dist: click (>=8.1.7,<9.0.0)
15
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
16
+ Requires-Dist: orjson (>=3.10.15,<4.0.0)
17
+ Requires-Dist: pydantic (>=2.10.1,<3.0.0)
18
+ Requires-Dist: pydantic-settings (>=2.6.1,<3.0.0)
19
+ Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
20
+ Requires-Dist: tenacity (>=9.0.0,<10.0.0)
21
+ Requires-Dist: toml (>=0.10.2,<0.11.0)
22
+ Requires-Dist: zstandard (>=0.25.0,<0.26.0)
23
+ Project-URL: Homepage, https://wyrd-technology.com
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Obvyr CLI
27
+
28
+ Command-line wrapper to run Obvyr agents locally or in CI.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install obvyr-cli
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ obvyr-cli --help
40
+ ```
41
+
42
+ ## Python Support
43
+
44
+ Python 3.12+
45
+
46
+ ## Licence
47
+
48
+ Proprietary © Wyrd Technology Ltd
49
+
@@ -0,0 +1,23 @@
1
+ # Obvyr CLI
2
+
3
+ Command-line wrapper to run Obvyr agents locally or in CI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install obvyr-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ obvyr-cli --help
15
+ ```
16
+
17
+ ## Python Support
18
+
19
+ Python 3.12+
20
+
21
+ ## Licence
22
+
23
+ Proprietary © Wyrd Technology Ltd
@@ -0,0 +1,139 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.9.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [tool.poetry]
6
+ name = "obvyr-cli"
7
+ version = "1.0.0"
8
+ description = "Command-line wrapper to allow Obvyr to capture evidence from any machine."
9
+ authors = ["Wyrd Technology Ltd"]
10
+ license = "Proprietary"
11
+ readme = "README_PYPI.md"
12
+ homepage = "https://wyrd-technology.com"
13
+
14
+ exclude = [
15
+ "README.md",
16
+ "docs/**",
17
+ "tests/**",
18
+ "bin/**",
19
+ ".mypy_cache/**",
20
+ ".ruff_cache/**",
21
+ ".pytest_cache/**",
22
+ "dist/**",
23
+ ]
24
+
25
+ [[tool.poetry.packages]]
26
+ from = "src"
27
+ include = "obvyr_cli"
28
+
29
+ [tool.poetry.dependencies]
30
+ python = "^3.12"
31
+ python-dotenv = "^1.0.1"
32
+ click = "^8.1.7"
33
+ boto3 = "^1.35.68"
34
+ pydantic = "^2.10.1"
35
+ pydantic-settings = "^2.6.1"
36
+ httpx = "^0.28.1"
37
+ tenacity = "^9.0.0"
38
+ toml = "^0.10.2"
39
+ orjson = "^3.10.15"
40
+ zstandard = "^0.25.0"
41
+
42
+ [tool.poetry.group.dev.dependencies]
43
+ ruff = "^0.13.0"
44
+ mypy = "^1.13.0"
45
+ pytest = "^8.3.3"
46
+ pytest-cov = "^7.0.0"
47
+ black = "^25.0.0"
48
+ pyproject-fmt = "^2.5.0"
49
+ pytest-subprocess = "^1.5.2"
50
+ types-toml = "^0.10.8.20240310"
51
+ poethepoet = "^0.37.0"
52
+
53
+ [tool.poetry.scripts]
54
+ obvyr = "obvyr_cli.cli:cli_run_process"
55
+
56
+ [tool.black]
57
+ line-length = 80
58
+
59
+ [tool.ruff]
60
+ line-length = 80
61
+ indent-width = 4
62
+
63
+ lint.select = ["B", "B9", "E", "F", "I", "S", "W", "PLC"]
64
+ lint.ignore = ["E501", "S101"]
65
+ lint.exclude = [".venv"]
66
+
67
+ [tool.pytest.ini_options]
68
+ addopts = "-lra --no-header --color=yes --durations=5 --cov -vv -m 'not e2e' --junitxml=tests/junit.xml"
69
+ testpaths = ["tests/"]
70
+ python_classes = ["Should*", "Test*"]
71
+ python_functions = ["should_*", "test_*"]
72
+ markers = ["e2e: Large end-to-end tests that require external services"]
73
+ junit_family = "xunit2"
74
+ junit_suite_name = "CLI Tests"
75
+ junit_duration_report = "call"
76
+
77
+ [tool.coverage.paths]
78
+ source = ["src", "*/site-packages"]
79
+
80
+ [tool.coverage.run]
81
+ branch = true
82
+ source = ["src"]
83
+
84
+ [tool.coverage.report]
85
+ show_missing = true
86
+
87
+ [tool.mypy]
88
+ plugins = ["pydantic.mypy"]
89
+ files = ["src", "tests"]
90
+ ignore_missing_imports = true
91
+ disallow_untyped_defs = true
92
+
93
+ [tool.poe.tasks]
94
+ # Run the tests with coverage
95
+ test = "pytest"
96
+
97
+ # Run mypy for static type checking
98
+ typecheck = "mypy"
99
+
100
+ # Format code with Black
101
+ srcformat = "black ."
102
+
103
+ # Format the pyproject.toml file
104
+ pyprojfmt = "pyproject-fmt pyproject.toml"
105
+
106
+ format = ["srcformat"]
107
+
108
+ # Lint with Ruff
109
+ lint = "ruff check ."
110
+
111
+ # Lint and fix
112
+ lintfix = "ruff check --fix ."
113
+
114
+ # All: Run all checks and tests together
115
+ all = ["lintfix", "format", "typecheck", "test"]
116
+
117
+ [tool.poe.tasks.publish_test]
118
+ cmd = """
119
+ bash -c '
120
+ set -euo pipefail
121
+ rm -rf dist
122
+ poetry build
123
+ python -m pip install --upgrade twine
124
+ twine check dist/*
125
+ poetry publish -r testpypi --skip-existing
126
+ '
127
+ """
128
+
129
+ [tool.poe.tasks.publish_pypi]
130
+ cmd = """
131
+ bash -c '
132
+ set -euo pipefail
133
+ rm -rf dist
134
+ poetry build
135
+ python -m pip install --upgrade twine
136
+ twine check dist/*
137
+ poetry publish --skip-existing
138
+ '
139
+ """
File without changes
@@ -0,0 +1,134 @@
1
+ import atexit
2
+ import pathlib
3
+ from typing import Any, BinaryIO, Dict, Optional, Tuple
4
+
5
+ import httpx
6
+ from tenacity import (
7
+ retry,
8
+ retry_if_exception_type,
9
+ stop_after_attempt,
10
+ wait_fixed,
11
+ )
12
+
13
+ from obvyr_cli import utils
14
+ from obvyr_cli.error_handling import handle_api_error, handle_network_error
15
+ from obvyr_cli.logging_config import get_logger
16
+
17
+ logger = get_logger("api_client")
18
+
19
+ project_config = utils.get_project_config()
20
+
21
+
22
+ class ObvyrAPIClient:
23
+ """Client for sending execution data to the Obvyr API."""
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str,
28
+ base_url: str,
29
+ timeout: float = 5.0,
30
+ verify_ssl: bool = True,
31
+ http_client: Optional[httpx.Client] = None,
32
+ ) -> None:
33
+ """Initialize API client with optional settings injection."""
34
+ self.api_key = api_key
35
+ self.verify_ssl = verify_ssl
36
+ self.client: httpx.Client = http_client or httpx.Client(
37
+ base_url=base_url, timeout=timeout, verify=self.verify_ssl
38
+ )
39
+ self._closed = False
40
+ atexit.register(self.close)
41
+
42
+ def _get_headers(self) -> Dict[str, str]:
43
+ """Generate headers for authentication."""
44
+ return {
45
+ "Authorization": (f"Bearer {self.api_key}"),
46
+ "User-Agent": f"obvyr-cli/{project_config['version']}",
47
+ }
48
+
49
+ @retry(
50
+ stop=stop_after_attempt(3),
51
+ wait=wait_fixed(2),
52
+ retry=retry_if_exception_type(
53
+ (httpx.RequestError, httpx.HTTPStatusError)
54
+ ),
55
+ reraise=True,
56
+ )
57
+ def send_data(
58
+ self,
59
+ endpoint: str,
60
+ data: Dict[str, Any],
61
+ file: Optional[Tuple[str, BinaryIO]] = None,
62
+ ) -> Optional[Dict[str, Any]]:
63
+ """Send execution data to the API with retries on transient failures."""
64
+ headers = self._get_headers()
65
+
66
+ try:
67
+ optional_parameters: Dict[str, Any] = {}
68
+
69
+ if file:
70
+ optional_parameters = {
71
+ **optional_parameters,
72
+ "files": {"attachment": (file[0], file[1])},
73
+ }
74
+
75
+ response = self.client.post(
76
+ endpoint, headers=headers, data=data, **optional_parameters
77
+ )
78
+
79
+ response.raise_for_status()
80
+
81
+ return response.json()
82
+ except httpx.HTTPStatusError as e:
83
+ return handle_api_error(e)
84
+ except (httpx.RequestError, httpx.TimeoutException) as e:
85
+ handle_network_error(e)
86
+ return None
87
+
88
+ @retry(
89
+ stop=stop_after_attempt(3),
90
+ wait=wait_fixed(2),
91
+ retry=retry_if_exception_type(
92
+ (httpx.RequestError, httpx.HTTPStatusError)
93
+ ),
94
+ reraise=True,
95
+ )
96
+ def send_archive(
97
+ self, endpoint: str, archive_path: pathlib.Path
98
+ ) -> Optional[Dict[str, Any]]:
99
+ """Send archive file to the API with retries on transient failures."""
100
+ if not archive_path.exists():
101
+ raise FileNotFoundError(f"Archive file not found: {archive_path}")
102
+
103
+ headers = self._get_headers()
104
+
105
+ try:
106
+ with open(archive_path, "rb") as archive_file:
107
+ files = {"archive": ("artifacts.tar.zst", archive_file)}
108
+ response = self.client.post(
109
+ endpoint, headers=headers, files=files
110
+ )
111
+
112
+ response.raise_for_status()
113
+
114
+ return response.json()
115
+ except httpx.HTTPStatusError as e:
116
+ return handle_api_error(e)
117
+ except (httpx.RequestError, httpx.TimeoutException) as e:
118
+ handle_network_error(e)
119
+ return None
120
+
121
+ def close(self) -> None:
122
+ """Close the HTTP connection pool."""
123
+ if not self._closed:
124
+ self.client.close()
125
+ self._closed = True
126
+ logger.debug("Closed API client connection.")
127
+
128
+ def __enter__(self) -> "ObvyrAPIClient":
129
+ """Enable use of `with ObvyrAPIClient() as client`."""
130
+ return self
131
+
132
+ def __exit__(self, *args: object, **kwargs: object) -> None:
133
+ """Ensure the HTTP client is closed when exiting context."""
134
+ self.close()
@@ -0,0 +1,147 @@
1
+ """
2
+ Archive builder for creating artifacts.tar.zst files.
3
+
4
+ This module creates compressed tar archives containing command execution data
5
+ and optional attachments in the format required by the /collect API endpoint.
6
+ """
7
+
8
+ import io
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import tarfile
13
+ import tempfile
14
+ from datetime import UTC, datetime
15
+ from typing import Dict, Optional
16
+
17
+ import zstandard as zstd
18
+ from pydantic import BaseModel
19
+
20
+ from obvyr_cli.schemas import RunCommandResponse
21
+
22
+
23
+ class ArchiveSummary(BaseModel):
24
+ """Summary of archive contents and sizes."""
25
+
26
+ archive_bytes: int
27
+ members: Dict[str, Dict[str, int]]
28
+
29
+
30
+ def build_artifacts_tar_zst(
31
+ run_command_response: RunCommandResponse,
32
+ attachment_paths: Optional[list[pathlib.Path]] = None,
33
+ tmp_dir: Optional[pathlib.Path] = None,
34
+ tags: Optional[list[str]] = None,
35
+ ) -> pathlib.Path:
36
+ """
37
+ Build artifacts.tar.zst archive from command execution data.
38
+
39
+ Creates a compressed tar archive containing:
40
+ - /command.json (required)
41
+ - /output.txt (optional; UTF-8 mixed stdout/stderr)
42
+ - /attachment/<filename> (optional)
43
+
44
+ Args:
45
+ run_command_response: Command execution response containing metadata and output
46
+ attachment_paths: Optional list of attachment files to include
47
+ tmp_dir: Optional temporary directory for output file
48
+ tags: Optional list of tags to include in command.json
49
+
50
+ Returns:
51
+ Path to the created artifacts.tar.zst file
52
+ """
53
+ if tmp_dir is None:
54
+ tmp_dir = pathlib.Path(tempfile.gettempdir())
55
+
56
+ # Create output file path
57
+ output_path = tmp_dir / "artifacts.tar.zst"
58
+
59
+ # Prepare command.json data exactly as specified in the doc
60
+ command_data = {
61
+ "command": run_command_response.command,
62
+ "user": run_command_response.user,
63
+ "return_code": run_command_response.returncode,
64
+ "execution_time_ms": round(run_command_response.execution_time * 1000),
65
+ "executed": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
66
+ "env": dict(os.environ),
67
+ "tags": tags or [],
68
+ }
69
+
70
+ # Create tar archive in memory first
71
+ tar_buffer = io.BytesIO()
72
+
73
+ with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
74
+ # Add command.json (required)
75
+ command_json_bytes = json.dumps(
76
+ command_data, separators=(",", ":")
77
+ ).encode("utf-8")
78
+ command_info = tarfile.TarInfo("command.json")
79
+ command_info.size = len(command_json_bytes)
80
+ tar.addfile(command_info, io.BytesIO(command_json_bytes))
81
+
82
+ # Add output.txt if present (optional; mixed stdout/stderr)
83
+ if run_command_response.output:
84
+ output_bytes = run_command_response.output.encode("utf-8")
85
+ output_info = tarfile.TarInfo("output.txt")
86
+ output_info.size = len(output_bytes)
87
+ tar.addfile(output_info, io.BytesIO(output_bytes))
88
+
89
+ # Add attachments if provided (optional)
90
+ if attachment_paths:
91
+ for attachment_path in attachment_paths:
92
+ if not attachment_path.exists():
93
+ continue
94
+
95
+ # Use attachment/<filename> structure as specified
96
+ arcname = f"attachment/{attachment_path.name}"
97
+
98
+ # Stream file without loading into memory
99
+ with open(attachment_path, "rb") as f:
100
+ attachment_info = tarfile.TarInfo(arcname)
101
+ attachment_info.size = attachment_path.stat().st_size
102
+ tar.addfile(attachment_info, f)
103
+
104
+ # Compress tar with zstd
105
+ tar_buffer.seek(0)
106
+ tar_data = tar_buffer.read()
107
+
108
+ compressor = zstd.ZstdCompressor(write_content_size=True)
109
+ compressed_data = compressor.compress(tar_data)
110
+
111
+ with open(output_path, "wb") as output_file:
112
+ output_file.write(compressed_data)
113
+
114
+ return output_path
115
+
116
+
117
+ def summarize_archive(archive_path: pathlib.Path) -> ArchiveSummary:
118
+ """
119
+ Summarise contents of an artifacts.tar.zst archive.
120
+
121
+ Args:
122
+ archive_path: Path to the artifacts.tar.zst file
123
+
124
+ Returns:
125
+ ArchiveSummary containing archive size and member information
126
+
127
+ Raises:
128
+ FileNotFoundError: If archive file doesn't exist
129
+ """
130
+ if not archive_path.exists():
131
+ raise FileNotFoundError(f"Archive not found: {archive_path}")
132
+
133
+ # Get archive size
134
+ archive_bytes = archive_path.stat().st_size
135
+
136
+ # Extract and examine tar contents
137
+ decompressor = zstd.ZstdDecompressor()
138
+ members: Dict[str, Dict[str, int]] = {}
139
+
140
+ with open(archive_path, "rb") as archive_file:
141
+ with decompressor.stream_reader(archive_file) as reader:
142
+ with tarfile.open(fileobj=reader, mode="r|") as tar:
143
+ for member in tar:
144
+ if member.isfile():
145
+ members[member.name] = {"bytes": member.size}
146
+
147
+ return ArchiveSummary(archive_bytes=archive_bytes, members=members)