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.
- obvyr_cli-1.0.0/PKG-INFO +49 -0
- obvyr_cli-1.0.0/README_PYPI.md +23 -0
- obvyr_cli-1.0.0/pyproject.toml +139 -0
- obvyr_cli-1.0.0/src/obvyr_cli/__init__.py +0 -0
- obvyr_cli-1.0.0/src/obvyr_cli/api_client.py +134 -0
- obvyr_cli-1.0.0/src/obvyr_cli/archive_builder.py +147 -0
- obvyr_cli-1.0.0/src/obvyr_cli/cli.py +352 -0
- obvyr_cli-1.0.0/src/obvyr_cli/command_wrapper.py +302 -0
- obvyr_cli-1.0.0/src/obvyr_cli/config.py +88 -0
- obvyr_cli-1.0.0/src/obvyr_cli/error_handling.py +164 -0
- obvyr_cli-1.0.0/src/obvyr_cli/logging_config.py +162 -0
- obvyr_cli-1.0.0/src/obvyr_cli/schemas.py +94 -0
- obvyr_cli-1.0.0/src/obvyr_cli/utils.py +13 -0
obvyr_cli-1.0.0/PKG-INFO
ADDED
|
@@ -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)
|