skybrush-log-analyzer 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.
- skybrush_log_analyzer-0.1.0/PKG-INFO +88 -0
- skybrush_log_analyzer-0.1.0/README.md +76 -0
- skybrush_log_analyzer-0.1.0/pyproject.toml +85 -0
- skybrush_log_analyzer-0.1.0/pyproject.toml.orig +74 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/__init__.py +0 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/analysis.py +70 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/api_client.py +149 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/auth_client.py +105 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/cli.py +276 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/config.py +144 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/errors.py +80 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/main.py +8 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/paths.py +36 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/resources/skills/skybrush-log-analysis/SKILL.md +78 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/typing.py +5 -0
- skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/utils.py +13 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: skybrush-log-analyzer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command line application for ArduPilot log analysis using https://logs.skybrush.io.
|
|
5
|
+
Author: Peter Volf, Tamas Nepusz, Gabor Vasarhelyi
|
|
6
|
+
Author-email: Peter Volf <do.volfp@gmail.com>, Tamas Nepusz <tamas@collmot.com>, Gabor Vasarhelyi <vasarhelyi@collmot.com>
|
|
7
|
+
Requires-Dist: httpx2>=2.9.1
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.0
|
|
9
|
+
Requires-Dist: typer>=0.27.1
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Skybrush Log Analyzer
|
|
14
|
+
|
|
15
|
+
Command line application for analyzing ArduPilot logs using [Skybrush Log Analyzer](https://logs.skybrush.io).
|
|
16
|
+
|
|
17
|
+
Agent skill included for AI assistance.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
**Option A (recommended): install as a tool**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv tool install skybrush-log-analyzer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If `uv` warns that its tool binary directory (e.g. `~/.local/bin`) is not on your `PATH`, follow its suggestion so `skybrush-log-analyzer` can be invoked directly.
|
|
28
|
+
|
|
29
|
+
**Option B: run without installing**
|
|
30
|
+
|
|
31
|
+
Skip installation and prefix every CLI command with `uvx skybrush-log-analyzer`.
|
|
32
|
+
|
|
33
|
+
## How to use
|
|
34
|
+
|
|
35
|
+
The commands below assume Option A.
|
|
36
|
+
|
|
37
|
+
1. Create a Skybrush API key at [https://account.skybrush.io/developer](https://account.skybrush.io/developer)
|
|
38
|
+
2. Log in with your API key: `skybrush-log-analyzer login`
|
|
39
|
+
3. Navigate to the directory containing your log files and analyze a log file: `skybrush-log-analyzer analyze <path-to-log-file>`
|
|
40
|
+
|
|
41
|
+
Run `skybrush-log-analyzer --help` to see all commands.
|
|
42
|
+
|
|
43
|
+
## Agent skill
|
|
44
|
+
|
|
45
|
+
The project comes with an agent skill that helps agents use the included CLI to help you with log analysis.
|
|
46
|
+
|
|
47
|
+
You can install the skill with the following command:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
skybrush-log-analyzer add-skill
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or, without installing the CLI:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uvx skybrush-log-analyzer add-skill
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Good to know:
|
|
60
|
+
|
|
61
|
+
- By default, the skill will be installed globally to `.agents/skills` in your home directory.
|
|
62
|
+
- If you use Claude Code, add the `--claude` option to the command, which changes the install path to `.claude/skills`.
|
|
63
|
+
- To install the skill in the current working directory, use the `--local` option.
|
|
64
|
+
|
|
65
|
+
Depending on your agent harness, you may need to restart or reload the harness so it recognizes the newly installed skill.
|
|
66
|
+
|
|
67
|
+
Assuming you logged in and navigated to your log files as described above, all you need to do is start a new agent session, check whether the `skybrush-log-analysis` skill appears in the list of loaded skills, and prompt the agent to analyze a log file or list available log analysis tests.
|
|
68
|
+
|
|
69
|
+
## Upgrade
|
|
70
|
+
|
|
71
|
+
When using `uv tool`, upgrade the CLI and re-install the bundled agent skill so that it matches the new version:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv tool upgrade skybrush-log-analyzer
|
|
75
|
+
skybrush-log-analyzer add-skill --force
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
When using `uvx`, the CLI always runs the latest revision, but the installed skill does not follow automatically. Refresh it periodically to avoid inconsistency between the skill and the CLI:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uvx skybrush-log-analyzer add-skill --force
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Troubleshooting
|
|
85
|
+
|
|
86
|
+
### [Windows] Character encoding issues
|
|
87
|
+
|
|
88
|
+
Set `PYTHONIOENCODING=utf-8` before commands to avoid Windows codepage errors (e.g. `UnicodeEncodeError` on `µ`), or set the `PYTHONUTF8=1` environment variable permanently.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Skybrush Log Analyzer
|
|
2
|
+
|
|
3
|
+
Command line application for analyzing ArduPilot logs using [Skybrush Log Analyzer](https://logs.skybrush.io).
|
|
4
|
+
|
|
5
|
+
Agent skill included for AI assistance.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
**Option A (recommended): install as a tool**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install skybrush-log-analyzer
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
If `uv` warns that its tool binary directory (e.g. `~/.local/bin`) is not on your `PATH`, follow its suggestion so `skybrush-log-analyzer` can be invoked directly.
|
|
16
|
+
|
|
17
|
+
**Option B: run without installing**
|
|
18
|
+
|
|
19
|
+
Skip installation and prefix every CLI command with `uvx skybrush-log-analyzer`.
|
|
20
|
+
|
|
21
|
+
## How to use
|
|
22
|
+
|
|
23
|
+
The commands below assume Option A.
|
|
24
|
+
|
|
25
|
+
1. Create a Skybrush API key at [https://account.skybrush.io/developer](https://account.skybrush.io/developer)
|
|
26
|
+
2. Log in with your API key: `skybrush-log-analyzer login`
|
|
27
|
+
3. Navigate to the directory containing your log files and analyze a log file: `skybrush-log-analyzer analyze <path-to-log-file>`
|
|
28
|
+
|
|
29
|
+
Run `skybrush-log-analyzer --help` to see all commands.
|
|
30
|
+
|
|
31
|
+
## Agent skill
|
|
32
|
+
|
|
33
|
+
The project comes with an agent skill that helps agents use the included CLI to help you with log analysis.
|
|
34
|
+
|
|
35
|
+
You can install the skill with the following command:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
skybrush-log-analyzer add-skill
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or, without installing the CLI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvx skybrush-log-analyzer add-skill
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Good to know:
|
|
48
|
+
|
|
49
|
+
- By default, the skill will be installed globally to `.agents/skills` in your home directory.
|
|
50
|
+
- If you use Claude Code, add the `--claude` option to the command, which changes the install path to `.claude/skills`.
|
|
51
|
+
- To install the skill in the current working directory, use the `--local` option.
|
|
52
|
+
|
|
53
|
+
Depending on your agent harness, you may need to restart or reload the harness so it recognizes the newly installed skill.
|
|
54
|
+
|
|
55
|
+
Assuming you logged in and navigated to your log files as described above, all you need to do is start a new agent session, check whether the `skybrush-log-analysis` skill appears in the list of loaded skills, and prompt the agent to analyze a log file or list available log analysis tests.
|
|
56
|
+
|
|
57
|
+
## Upgrade
|
|
58
|
+
|
|
59
|
+
When using `uv tool`, upgrade the CLI and re-install the bundled agent skill so that it matches the new version:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv tool upgrade skybrush-log-analyzer
|
|
63
|
+
skybrush-log-analyzer add-skill --force
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
When using `uvx`, the CLI always runs the latest revision, but the installed skill does not follow automatically. Refresh it periodically to avoid inconsistency between the skill and the CLI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uvx skybrush-log-analyzer add-skill --force
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Troubleshooting
|
|
73
|
+
|
|
74
|
+
### [Windows] Character encoding issues
|
|
75
|
+
|
|
76
|
+
Set `PYTHONIOENCODING=utf-8` before commands to avoid Windows codepage errors (e.g. `UnicodeEncodeError` on `µ`), or set the `PYTHONUTF8=1` environment variable permanently.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skybrush-log-analyzer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command line application for ArduPilot log analysis using https://logs.skybrush.io."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx2>=2.9.1",
|
|
9
|
+
"platformdirs>=4.11.0",
|
|
10
|
+
"typer>=0.27.1",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Peter Volf"
|
|
15
|
+
email = "do.volfp@gmail.com"
|
|
16
|
+
|
|
17
|
+
[[project.authors]]
|
|
18
|
+
name = "Tamas Nepusz"
|
|
19
|
+
email = "tamas@collmot.com"
|
|
20
|
+
|
|
21
|
+
[[project.authors]]
|
|
22
|
+
name = "Gabor Vasarhelyi"
|
|
23
|
+
email = "vasarhelyi@collmot.com"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
skybrush-log-analyzer = "skybrush_log_analyzer.main:run"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"mypy>=2.3.1",
|
|
31
|
+
"poethepoet>=0.48.0",
|
|
32
|
+
"ruff>=0.16.9",
|
|
33
|
+
"zensical>=0.0.65",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv.build-backend]
|
|
41
|
+
module-root = ""
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
|
45
|
+
show_error_codes = true
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 108
|
|
49
|
+
exclude = [
|
|
50
|
+
".git",
|
|
51
|
+
".mypy_cache",
|
|
52
|
+
".pytest_cache",
|
|
53
|
+
".ruff_cache",
|
|
54
|
+
".venv",
|
|
55
|
+
"dist",
|
|
56
|
+
"docs",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = [
|
|
61
|
+
"B",
|
|
62
|
+
"C",
|
|
63
|
+
"E",
|
|
64
|
+
"F",
|
|
65
|
+
"I",
|
|
66
|
+
"S",
|
|
67
|
+
"W",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.poe.tasks]
|
|
71
|
+
serve-docs = "zensical serve"
|
|
72
|
+
build-docs = "zensical build --clean"
|
|
73
|
+
format = "ruff format . --check"
|
|
74
|
+
format-fix = "ruff format ."
|
|
75
|
+
lint = "ruff check ."
|
|
76
|
+
lint-fix = "ruff check . --fix"
|
|
77
|
+
type = "mypy ."
|
|
78
|
+
|
|
79
|
+
[tool.poe.tasks.check]
|
|
80
|
+
sequence = [
|
|
81
|
+
"format",
|
|
82
|
+
"lint",
|
|
83
|
+
"type",
|
|
84
|
+
]
|
|
85
|
+
ignore_fail = "return_non_zero"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skybrush-log-analyzer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command line application for ArduPilot log analysis using https://logs.skybrush.io."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Peter Volf", email = "do.volfp@gmail.com" },
|
|
8
|
+
{ name = "Tamas Nepusz", email = "tamas@collmot.com" },
|
|
9
|
+
{ name = "Gabor Vasarhelyi", email = "vasarhelyi@collmot.com" },
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx2>=2.9.1",
|
|
14
|
+
"platformdirs>=4.11.0",
|
|
15
|
+
"typer>=0.27.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"mypy>=2.3.1",
|
|
21
|
+
"poethepoet>=0.48.0",
|
|
22
|
+
"ruff>=0.16.9",
|
|
23
|
+
"zensical>=0.0.65",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
skybrush-log-analyzer = "skybrush_log_analyzer.main:run"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-root=""
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
strict = true
|
|
38
|
+
show_error_codes = true
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 108
|
|
42
|
+
exclude = [
|
|
43
|
+
".git",
|
|
44
|
+
".mypy_cache",
|
|
45
|
+
".pytest_cache",
|
|
46
|
+
".ruff_cache",
|
|
47
|
+
".venv",
|
|
48
|
+
"dist",
|
|
49
|
+
"docs",
|
|
50
|
+
]
|
|
51
|
+
lint.select = [
|
|
52
|
+
"B", # flake8-bugbear
|
|
53
|
+
"C", # flake8-comprehensions
|
|
54
|
+
"E", # pycodestyle errors
|
|
55
|
+
"F", # pyflakes
|
|
56
|
+
"I", # isort
|
|
57
|
+
"S", # flake8-bandit - we must ignore these rules in tests
|
|
58
|
+
"W", # pycodestyle warnings
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.poe.tasks]
|
|
62
|
+
serve-docs = "zensical serve"
|
|
63
|
+
build-docs = "zensical build --clean"
|
|
64
|
+
|
|
65
|
+
format = "ruff format . --check"
|
|
66
|
+
format-fix = "ruff format ."
|
|
67
|
+
|
|
68
|
+
lint = "ruff check ."
|
|
69
|
+
lint-fix = "ruff check . --fix"
|
|
70
|
+
|
|
71
|
+
type = "mypy ."
|
|
72
|
+
|
|
73
|
+
check.sequence = ["format", "lint", "type"]
|
|
74
|
+
check.ignore_fail = "return_non_zero"
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_agent_view(analysis: dict[str, Any]) -> dict[str, Any]:
|
|
5
|
+
"""
|
|
6
|
+
Returns the compact, agent-facing view of an analysis API response.
|
|
7
|
+
|
|
8
|
+
Drops internal IDs and null fields, orders test results by severity,
|
|
9
|
+
counts the statuses, omits the messages of passing tests, and adds a
|
|
10
|
+
note when there are no test results.
|
|
11
|
+
"""
|
|
12
|
+
tests = [
|
|
13
|
+
_test_view(result)
|
|
14
|
+
for result in sorted(
|
|
15
|
+
analysis.get("test_results") or [],
|
|
16
|
+
key=lambda result: _STATUS_RANK.get(result.get("status"), len(_STATUS_RANK)),
|
|
17
|
+
)
|
|
18
|
+
]
|
|
19
|
+
view = {
|
|
20
|
+
"note_title": analysis.get("note_title"),
|
|
21
|
+
"note_description": analysis.get("note_description"),
|
|
22
|
+
"status_counts": _status_counts(tests),
|
|
23
|
+
"test_results": tests,
|
|
24
|
+
}
|
|
25
|
+
if not tests:
|
|
26
|
+
view["note"] = "No test results in this group; run the analyze command to produce them."
|
|
27
|
+
return _without_none_fields(view)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STATUS_RANK = {
|
|
31
|
+
status: rank
|
|
32
|
+
for rank, status in enumerate(
|
|
33
|
+
(
|
|
34
|
+
"FAIL",
|
|
35
|
+
"WARN",
|
|
36
|
+
"INFO",
|
|
37
|
+
"PASS",
|
|
38
|
+
"SKIP",
|
|
39
|
+
"NOT_APPLICABLE",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _test_view(result: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
messages = [] if result.get("status") == "PASS" else result.get("messages") or []
|
|
47
|
+
view: dict[str, Any] = {
|
|
48
|
+
"test_name": result.get("test_name"),
|
|
49
|
+
"status": result.get("status"),
|
|
50
|
+
"title": result.get("title"),
|
|
51
|
+
}
|
|
52
|
+
if messages:
|
|
53
|
+
view["messages"] = [_message_view(message) for message in messages]
|
|
54
|
+
return _without_none_fields(view)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _message_view(message: dict[str, Any]) -> dict[str, Any]:
|
|
58
|
+
return {key: value for key, value in message.items() if key != "id" and value is not None}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _status_counts(tests: list[dict[str, Any]]) -> dict[str, int]:
|
|
62
|
+
counts: dict[str, int] = {}
|
|
63
|
+
for test in tests:
|
|
64
|
+
status = test["status"]
|
|
65
|
+
counts[status] = counts.get(status, 0) + 1
|
|
66
|
+
return counts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _without_none_fields(view: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
return {key: value for key, value in view.items() if value is not None}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
|
|
9
|
+
from .errors import BadRequest, ResponseParsingFailed, ServerError, Unauthenticated
|
|
10
|
+
from .typing import SuggestionCategory
|
|
11
|
+
from .utils import calculate_file_hash
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .auth_client import AuthClient
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class APIClient:
|
|
20
|
+
config: Config
|
|
21
|
+
auth: AuthClient
|
|
22
|
+
|
|
23
|
+
def version(self) -> Any:
|
|
24
|
+
return self.request("/version")
|
|
25
|
+
|
|
26
|
+
def tests(self, group: str = "default") -> Any:
|
|
27
|
+
if group == "all":
|
|
28
|
+
# No "all" listing route on the backend; merge the two listings.
|
|
29
|
+
merged: list[dict[str, Any]] = []
|
|
30
|
+
seen: set[str] = set()
|
|
31
|
+
for listing in (self.request("/tests"), self.request("/tests/extras")):
|
|
32
|
+
for test in listing:
|
|
33
|
+
name = test.get("name")
|
|
34
|
+
if name not in seen:
|
|
35
|
+
seen.add(name)
|
|
36
|
+
merged.append(test)
|
|
37
|
+
return merged
|
|
38
|
+
return self.request("/tests/extras" if group == "extra" else "/tests")
|
|
39
|
+
|
|
40
|
+
def analyze(self, path: Path, *, test_names: list[str] | None = None) -> Any:
|
|
41
|
+
# POST returns the new run only; test results live on the merged by-hash view.
|
|
42
|
+
with path.open("rb") as f:
|
|
43
|
+
self.request(
|
|
44
|
+
"/analysis/",
|
|
45
|
+
method="POST",
|
|
46
|
+
files={"file": (path.name, f, "application/octet-stream")},
|
|
47
|
+
data={"test_names": test_names} if test_names is not None else None,
|
|
48
|
+
timeout=30,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return self.get_analysis(path)
|
|
52
|
+
|
|
53
|
+
def get_web_login_url(self) -> str:
|
|
54
|
+
"""Acquires a web login URL for the user."""
|
|
55
|
+
link = self.request("/login-ticket/", method="POST")
|
|
56
|
+
login_url = link.get("login_url") if isinstance(link, dict) else None
|
|
57
|
+
if not isinstance(login_url, str) or not login_url:
|
|
58
|
+
raise ResponseParsingFailed()
|
|
59
|
+
return login_url
|
|
60
|
+
|
|
61
|
+
def suggest(self, *, category: SuggestionCategory, title: str, content: str) -> Any:
|
|
62
|
+
return self.request(
|
|
63
|
+
"/suggestion/",
|
|
64
|
+
method="POST",
|
|
65
|
+
json={
|
|
66
|
+
"category": category,
|
|
67
|
+
"title": title,
|
|
68
|
+
"content": content,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def get_analysis(self, path: Path, *, extras: bool = False) -> Any:
|
|
73
|
+
suffix = "/extras" if extras else ""
|
|
74
|
+
return self.request(
|
|
75
|
+
f"/analysis/by-hash/{calculate_file_hash(path.read_bytes())}{suffix}",
|
|
76
|
+
params={"filename": path.name},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def request(
|
|
80
|
+
self,
|
|
81
|
+
path: str,
|
|
82
|
+
*,
|
|
83
|
+
method: str = "GET",
|
|
84
|
+
data: dict[str, Any] | None = None,
|
|
85
|
+
files: dict[str, tuple[str, IO[bytes], str]] | None = None,
|
|
86
|
+
params: dict[str, Any] | None = None,
|
|
87
|
+
json: dict[str, Any] | None = None,
|
|
88
|
+
timeout: int | float = 5,
|
|
89
|
+
) -> Any:
|
|
90
|
+
response = self._send_request(
|
|
91
|
+
path,
|
|
92
|
+
method=method,
|
|
93
|
+
data=data,
|
|
94
|
+
files=files,
|
|
95
|
+
params=params,
|
|
96
|
+
json=json,
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if response.status_code == 401:
|
|
101
|
+
self.auth.refresh_token()
|
|
102
|
+
# File bodies may have been consumed; rewind when possible.
|
|
103
|
+
if files is not None:
|
|
104
|
+
for _, file_obj, _ in files.values():
|
|
105
|
+
seek = getattr(file_obj, "seek", None)
|
|
106
|
+
if callable(seek):
|
|
107
|
+
seek(0)
|
|
108
|
+
response = self._send_request(
|
|
109
|
+
path,
|
|
110
|
+
method=method,
|
|
111
|
+
data=data,
|
|
112
|
+
files=files,
|
|
113
|
+
params=params,
|
|
114
|
+
json=json,
|
|
115
|
+
timeout=timeout,
|
|
116
|
+
)
|
|
117
|
+
if response.status_code == 401:
|
|
118
|
+
raise Unauthenticated()
|
|
119
|
+
|
|
120
|
+
status_code = response.status_code
|
|
121
|
+
if 400 <= status_code < 500:
|
|
122
|
+
print(f"Request failed ({status_code}): {response.text}")
|
|
123
|
+
raise BadRequest(response.text)
|
|
124
|
+
|
|
125
|
+
if status_code >= 500:
|
|
126
|
+
raise ServerError()
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return response.json()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise ResponseParsingFailed() from e
|
|
132
|
+
|
|
133
|
+
def _send_request(
|
|
134
|
+
self,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
method: str,
|
|
138
|
+
data: dict[str, Any] | None,
|
|
139
|
+
files: dict[str, tuple[str, IO[bytes], str]] | None,
|
|
140
|
+
params: dict[str, Any] | None,
|
|
141
|
+
json: dict[str, Any] | None,
|
|
142
|
+
timeout: int | float,
|
|
143
|
+
) -> httpx2.Response:
|
|
144
|
+
with httpx2.Client(
|
|
145
|
+
base_url=self.config.api_url,
|
|
146
|
+
headers=self.auth.make_auth_headers(),
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
) as client:
|
|
149
|
+
return client.request(method, path, files=files, data=data, params=params, json=json)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import httpx2
|
|
6
|
+
|
|
7
|
+
from .errors import AccountUnreachable, InvalidAPIKey, Unauthenticated
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthClient:
|
|
14
|
+
"""Acquires and stores API tokens from the account."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("_config", "_token")
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: Config) -> None:
|
|
19
|
+
self._config = config
|
|
20
|
+
self._token: str | None = None
|
|
21
|
+
|
|
22
|
+
def make_auth_headers(self) -> dict[str, str]:
|
|
23
|
+
"""
|
|
24
|
+
Returns authorization headers for API requests.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
Unauthenticated: No API key is configured.
|
|
28
|
+
InvalidAPIKey: The account rejected the API key.
|
|
29
|
+
AccountUnreachable: The account could not be reached or returned an unexpected response.
|
|
30
|
+
"""
|
|
31
|
+
return {"Authorization": f"Bearer {self._get_token()}"}
|
|
32
|
+
|
|
33
|
+
def refresh_token(self) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Discards any stored token and acquires a new one.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
Unauthenticated: No API key is configured.
|
|
39
|
+
InvalidAPIKey: The account rejected the API key.
|
|
40
|
+
AccountUnreachable: The account could not be reached or returned an unexpected response.
|
|
41
|
+
"""
|
|
42
|
+
# Invalidate everything
|
|
43
|
+
self._token = None
|
|
44
|
+
self._config.clear_token()
|
|
45
|
+
# Request fresh token
|
|
46
|
+
self._request_new_token()
|
|
47
|
+
|
|
48
|
+
def _get_token(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Returns token from memory, storage, or a fresh token request.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
Unauthenticated: No API key is configured.
|
|
54
|
+
InvalidAPIKey: The account rejected the API key.
|
|
55
|
+
AccountUnreachable: The account could not be reached or returned an unexpected response.
|
|
56
|
+
"""
|
|
57
|
+
if self._token is not None:
|
|
58
|
+
return self._token
|
|
59
|
+
|
|
60
|
+
token = self._config.load_token()
|
|
61
|
+
if token is not None:
|
|
62
|
+
self._token = token
|
|
63
|
+
return token
|
|
64
|
+
|
|
65
|
+
return self._request_new_token()
|
|
66
|
+
|
|
67
|
+
def _request_new_token(self) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Acquires a new token and stores it.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
Unauthenticated: No API key is configured.
|
|
73
|
+
InvalidAPIKey: The account rejected the API key.
|
|
74
|
+
AccountUnreachable: The account could not be reached or returned an unexpected response.
|
|
75
|
+
"""
|
|
76
|
+
if not self._config.api_key:
|
|
77
|
+
raise Unauthenticated()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with httpx2.Client(base_url=self._config.account_url, timeout=5) as client:
|
|
81
|
+
response = client.post(
|
|
82
|
+
"/api/get-api-token",
|
|
83
|
+
json={"apiKey": self._config.api_key},
|
|
84
|
+
)
|
|
85
|
+
except httpx2.HTTPError as e:
|
|
86
|
+
raise AccountUnreachable(str(e)) from e
|
|
87
|
+
|
|
88
|
+
if response.status_code in (401, 403):
|
|
89
|
+
raise InvalidAPIKey("API key rejected by account app")
|
|
90
|
+
|
|
91
|
+
if response.status_code != 200:
|
|
92
|
+
raise AccountUnreachable(f"Unexpected status from account app: {response.status_code}")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
body = response.json()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise AccountUnreachable("Account app returned non-JSON response") from e
|
|
98
|
+
|
|
99
|
+
token = body.get("result") if isinstance(body, dict) else None
|
|
100
|
+
if not isinstance(token, str) or not token:
|
|
101
|
+
raise AccountUnreachable("Account app response missing token result")
|
|
102
|
+
|
|
103
|
+
self._config.save_token(token)
|
|
104
|
+
self._token = token
|
|
105
|
+
return token
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import webbrowser
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .analysis import to_agent_view
|
|
9
|
+
from .api_client import APIClient
|
|
10
|
+
from .auth_client import AuthClient
|
|
11
|
+
from .config import Config, describe_config_location, load_config, login, logout
|
|
12
|
+
from .errors import (
|
|
13
|
+
AccountUnreachable,
|
|
14
|
+
ApplicationError,
|
|
15
|
+
BadRequest,
|
|
16
|
+
ConfigError,
|
|
17
|
+
InvalidAPIKey,
|
|
18
|
+
ResponseParsingFailed,
|
|
19
|
+
ServerError,
|
|
20
|
+
Unauthenticated,
|
|
21
|
+
typer_error_handler,
|
|
22
|
+
)
|
|
23
|
+
from .paths import ConfigPath, DestinationPath, ResourcePath
|
|
24
|
+
from .typing import SuggestionCategory, TestGroup
|
|
25
|
+
from .utils import print_json
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="ArduPilot log analysis CLI",
|
|
29
|
+
help="CLI application for ArduPilot log analysis services provided by the Skybrush.io team.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
config = load_config()
|
|
35
|
+
auth = AuthClient(config)
|
|
36
|
+
client = APIClient(config, auth)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def web() -> None:
|
|
41
|
+
"""Open the log analysis web interface in the default browser."""
|
|
42
|
+
with typer_error_handler(config):
|
|
43
|
+
url = client.get_web_login_url()
|
|
44
|
+
print(f"Opening {url} ...")
|
|
45
|
+
webbrowser.open(url)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def account() -> None:
|
|
50
|
+
"""Open the Skybrush account page in the default browser."""
|
|
51
|
+
print(f"Opening {config.account_url} ...")
|
|
52
|
+
webbrowser.open(config.account_url)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def version() -> None:
|
|
57
|
+
"""Show analysis backend version info."""
|
|
58
|
+
with typer_error_handler(config):
|
|
59
|
+
print_json(client.version())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def tests(
|
|
64
|
+
group: Annotated[
|
|
65
|
+
TestGroup,
|
|
66
|
+
typer.Argument(
|
|
67
|
+
help="Test group to list: the default tests, the extra (verbose) ones, or all of them."
|
|
68
|
+
),
|
|
69
|
+
] = "default",
|
|
70
|
+
) -> None:
|
|
71
|
+
"""List available ArduPilot log tests."""
|
|
72
|
+
with typer_error_handler(config):
|
|
73
|
+
print_json(client.tests(group))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def analyze(
|
|
78
|
+
file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
|
|
79
|
+
test: Annotated[
|
|
80
|
+
list[str] | None,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--test",
|
|
83
|
+
"-t",
|
|
84
|
+
help="Test name to run (repeatable). Omit to run all tests.",
|
|
85
|
+
),
|
|
86
|
+
] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Upload the given log file, run the analysis on it, and print the results."""
|
|
89
|
+
if not file.is_file():
|
|
90
|
+
print(f"Log file not found: {file}")
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
with typer_error_handler(config):
|
|
94
|
+
# Refresh the API token before file upload to try to avoid hitting
|
|
95
|
+
# token expiration during the operation.
|
|
96
|
+
auth.refresh_token()
|
|
97
|
+
result = client.analyze(file, test_names=test)
|
|
98
|
+
print_json(to_agent_view(result))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command(name="get-analysis")
|
|
102
|
+
def get_analysis(
|
|
103
|
+
file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
|
|
104
|
+
raw: Annotated[
|
|
105
|
+
bool,
|
|
106
|
+
typer.Option("--raw", help="Print the raw API response without trimming."),
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Fetch existing analysis for the given log file."""
|
|
110
|
+
if not file.is_file():
|
|
111
|
+
print(f"Log file not found: {file}")
|
|
112
|
+
raise typer.Exit(code=1)
|
|
113
|
+
|
|
114
|
+
with typer_error_handler(config):
|
|
115
|
+
result = client.get_analysis(file)
|
|
116
|
+
print_json(result if raw else to_agent_view(result))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command(name="get-analysis-extras")
|
|
120
|
+
def get_analysis_extras(
|
|
121
|
+
file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
|
|
122
|
+
raw: Annotated[
|
|
123
|
+
bool,
|
|
124
|
+
typer.Option("--raw", help="Print the raw API response without trimming."),
|
|
125
|
+
] = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Fetch the extra, verbose test results for the given log file."""
|
|
128
|
+
if not file.is_file():
|
|
129
|
+
print(f"Log file not found: {file}")
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
|
|
132
|
+
with typer_error_handler(config):
|
|
133
|
+
result = client.get_analysis(file, extras=True)
|
|
134
|
+
print_json(result if raw else to_agent_view(result))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command(name="suggest")
|
|
138
|
+
def suggest(
|
|
139
|
+
content: Annotated[
|
|
140
|
+
str,
|
|
141
|
+
typer.Argument(
|
|
142
|
+
help="Feedback content (10-2000 characters). "
|
|
143
|
+
"Describe the error noticed, the missing log test, or the user feedback."
|
|
144
|
+
),
|
|
145
|
+
],
|
|
146
|
+
title: Annotated[
|
|
147
|
+
str,
|
|
148
|
+
typer.Option(
|
|
149
|
+
"--title", "-t", help="Short summary (1-100 characters, excluding surrounding whitespace)."
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
category: Annotated[
|
|
153
|
+
SuggestionCategory,
|
|
154
|
+
typer.Option(
|
|
155
|
+
"--category", "-c", help="Feedback category. Use 'test_case' for missing or improved log tests."
|
|
156
|
+
),
|
|
157
|
+
],
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Submit feedback about the analysis service to the Skybrush team."""
|
|
160
|
+
content = content.strip()
|
|
161
|
+
if not (10 <= len(content) <= 2000):
|
|
162
|
+
raise typer.BadParameter(
|
|
163
|
+
"Feedback content must be 10-2000 characters long (excluding surrounding whitespace)."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
title = title.strip()
|
|
167
|
+
if not (1 <= len(title) <= 100):
|
|
168
|
+
raise typer.BadParameter(
|
|
169
|
+
"Feedback title must be 1-100 characters long (excluding surrounding whitespace)."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
with typer_error_handler(config):
|
|
173
|
+
result = client.suggest(category=category, title=title, content=content)
|
|
174
|
+
print_json(result)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command(name="login")
|
|
178
|
+
def login_cmd(
|
|
179
|
+
api_key: Annotated[str, typer.Option(prompt="API key")],
|
|
180
|
+
local: Annotated[
|
|
181
|
+
bool,
|
|
182
|
+
typer.Option("--local", help="Whether to update the local config instead of the global one."),
|
|
183
|
+
] = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Log in with a Skybrush API key."""
|
|
186
|
+
candidate = Config(
|
|
187
|
+
api_key=api_key,
|
|
188
|
+
api_url=config.api_url,
|
|
189
|
+
account_url=config.account_url,
|
|
190
|
+
config_dir=ConfigPath.dir(local=local),
|
|
191
|
+
)
|
|
192
|
+
candidate_auth = AuthClient(candidate)
|
|
193
|
+
candidate_client = APIClient(candidate, candidate_auth)
|
|
194
|
+
|
|
195
|
+
with typer_error_handler(candidate):
|
|
196
|
+
# Mint a fresh token with the candidate key and check backend acceptance.
|
|
197
|
+
candidate_auth.refresh_token()
|
|
198
|
+
candidate_client.version()
|
|
199
|
+
try:
|
|
200
|
+
login(api_key, local=local)
|
|
201
|
+
except OSError as e:
|
|
202
|
+
raise ConfigError(str(e)) from e
|
|
203
|
+
print("Logged in.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command(name="logout")
|
|
207
|
+
def logout_cmd() -> None:
|
|
208
|
+
"""Log out."""
|
|
209
|
+
if config.api_key is None:
|
|
210
|
+
print("Not logged in.")
|
|
211
|
+
raise typer.Exit(code=1)
|
|
212
|
+
|
|
213
|
+
with typer_error_handler(config):
|
|
214
|
+
try:
|
|
215
|
+
logout(config)
|
|
216
|
+
except OSError as e:
|
|
217
|
+
raise ConfigError(str(e)) from e
|
|
218
|
+
print(f"Logged out ({describe_config_location(config)}).")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command(name="add-skill")
|
|
222
|
+
def add_skill(
|
|
223
|
+
force: Annotated[
|
|
224
|
+
bool,
|
|
225
|
+
typer.Option("--force", help="Overwrite existing skill installation."),
|
|
226
|
+
] = False,
|
|
227
|
+
local: Annotated[
|
|
228
|
+
bool,
|
|
229
|
+
typer.Option(
|
|
230
|
+
"--local", help="Whether to install into the project-local or the global skills directory."
|
|
231
|
+
),
|
|
232
|
+
] = False,
|
|
233
|
+
claude: Annotated[
|
|
234
|
+
bool,
|
|
235
|
+
typer.Option("--claude", help="Whether to install for Claude Code."),
|
|
236
|
+
] = False,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Install the bundled agent skill for the current user."""
|
|
239
|
+
source = ResourcePath.skill()
|
|
240
|
+
dest = DestinationPath.skill(local=local, claude=claude)
|
|
241
|
+
if dest.exists():
|
|
242
|
+
if not force:
|
|
243
|
+
print(f"{dest} already exists, use --force to overwrite.")
|
|
244
|
+
raise typer.Exit(code=1)
|
|
245
|
+
shutil.rmtree(dest)
|
|
246
|
+
shutil.copytree(source, dest)
|
|
247
|
+
print(f"Skill installed in {dest}.")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command()
|
|
251
|
+
def auth_status() -> None:
|
|
252
|
+
"""Show current Skybrush account authentication status."""
|
|
253
|
+
if config.api_key is None:
|
|
254
|
+
print("No API key configured. Use the login command to log in.")
|
|
255
|
+
raise typer.Exit(code=1)
|
|
256
|
+
|
|
257
|
+
print(f"Using {describe_config_location(config)}.")
|
|
258
|
+
try:
|
|
259
|
+
auth.refresh_token()
|
|
260
|
+
client.version()
|
|
261
|
+
print(f"Authenticated at {config.account_url}.")
|
|
262
|
+
except InvalidAPIKey:
|
|
263
|
+
print(f"API key is invalid or has been revoked. Create a new one at {config.account_url}.")
|
|
264
|
+
raise typer.Exit(code=1) from None
|
|
265
|
+
except AccountUnreachable:
|
|
266
|
+
print(f"Could not reach {config.account_url} to refresh the API token.")
|
|
267
|
+
raise typer.Exit(code=1) from None
|
|
268
|
+
except Unauthenticated:
|
|
269
|
+
print("Backend rejected the API token.")
|
|
270
|
+
raise typer.Exit(code=1) from None
|
|
271
|
+
except (BadRequest, ResponseParsingFailed, ServerError):
|
|
272
|
+
print("Backend could not validate the API token.")
|
|
273
|
+
raise typer.Exit(code=1) from None
|
|
274
|
+
except ApplicationError:
|
|
275
|
+
print("Authentication status check failed.")
|
|
276
|
+
raise typer.Exit(code=1) from None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .paths import ConfigPath
|
|
8
|
+
|
|
9
|
+
_DEFAULT_API_URL = "https://logs.skybrush.io/api/v1"
|
|
10
|
+
_DEFAULT_ACCOUNT_URL = "https://account.skybrush.io"
|
|
11
|
+
_CONFIG_FILENAME = "config.toml"
|
|
12
|
+
_TOKEN_FILENAME = "token.txt" # noqa: S105
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(kw_only=True, frozen=True, slots=True)
|
|
16
|
+
class Config:
|
|
17
|
+
"""Application config."""
|
|
18
|
+
|
|
19
|
+
account_url: str
|
|
20
|
+
api_key: str | None
|
|
21
|
+
api_url: str
|
|
22
|
+
config_dir: Path
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def token_path(self) -> Path:
|
|
26
|
+
return self.config_dir / _TOKEN_FILENAME
|
|
27
|
+
|
|
28
|
+
def load_token(self) -> str | None:
|
|
29
|
+
"""Loads the saved token if it exists."""
|
|
30
|
+
try:
|
|
31
|
+
token = self.token_path.read_text().strip()
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
return token or None
|
|
35
|
+
|
|
36
|
+
def save_token(self, token: str) -> None:
|
|
37
|
+
"""Saves the given token, creating parent directories as needed."""
|
|
38
|
+
self.token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self.token_path.write_text(token)
|
|
40
|
+
|
|
41
|
+
def clear_token(self) -> None:
|
|
42
|
+
"""Deletes the saved token if it exists."""
|
|
43
|
+
try:
|
|
44
|
+
self.token_path.unlink()
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config() -> Config:
|
|
50
|
+
"""
|
|
51
|
+
Loads the application config.
|
|
52
|
+
|
|
53
|
+
Config directory search order:
|
|
54
|
+
|
|
55
|
+
- Project-local: `<current-working-directory>/.skybrush-log-analyzer/`
|
|
56
|
+
- Global: `<user-config-dir>/skybrush-log-analyzer/`
|
|
57
|
+
"""
|
|
58
|
+
for directory in (ConfigPath.dir(local=True), ConfigPath.dir(local=False)):
|
|
59
|
+
path = directory / _CONFIG_FILENAME
|
|
60
|
+
if path.is_file():
|
|
61
|
+
data = _read_toml(path)
|
|
62
|
+
return Config(
|
|
63
|
+
api_key=data.get("api_key"),
|
|
64
|
+
api_url=data.get("api_url", _DEFAULT_API_URL),
|
|
65
|
+
account_url=data.get("account_url", _DEFAULT_ACCOUNT_URL),
|
|
66
|
+
config_dir=directory,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return Config(
|
|
70
|
+
api_key=None,
|
|
71
|
+
api_url=_DEFAULT_API_URL,
|
|
72
|
+
account_url=_DEFAULT_ACCOUNT_URL,
|
|
73
|
+
config_dir=ConfigPath.dir(local=False),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def login(api_key: str, *, local: bool = False) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Stores the given API key in the application's config file.
|
|
80
|
+
|
|
81
|
+
Arguments:
|
|
82
|
+
api_key: The API key to store.
|
|
83
|
+
local: Whether the project-local or the global config file
|
|
84
|
+
should be updated.
|
|
85
|
+
"""
|
|
86
|
+
path = ConfigPath.dir(local=local) / _CONFIG_FILENAME
|
|
87
|
+
data = _read_toml(path)
|
|
88
|
+
data["api_key"] = api_key
|
|
89
|
+
_write_toml(path, data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def logout(config: Config) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Removes the config file for the given `Config` and
|
|
95
|
+
clears the corresponding token if it exists.
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
(config.config_dir / _CONFIG_FILENAME).unlink()
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
config.clear_token()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def describe_config_location(config: Config) -> str:
|
|
107
|
+
"""Human-readable label for the config directory corresponding to the given config."""
|
|
108
|
+
if config.config_dir == ConfigPath.dir(local=True):
|
|
109
|
+
return f"local config at {config.config_dir}"
|
|
110
|
+
if config.config_dir == ConfigPath.dir(local=False):
|
|
111
|
+
return f"global config at {config.config_dir}"
|
|
112
|
+
return f"custom config at {config.config_dir}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_toml(path: Path) -> dict[str, str]:
|
|
116
|
+
"""
|
|
117
|
+
Reads the given toml file, returning only flat string values.
|
|
118
|
+
|
|
119
|
+
Returns an empty dict for a missing or unparseable file.
|
|
120
|
+
"""
|
|
121
|
+
if not path.is_file():
|
|
122
|
+
return {}
|
|
123
|
+
try:
|
|
124
|
+
with path.open("rb") as fh:
|
|
125
|
+
data = tomllib.load(fh)
|
|
126
|
+
except tomllib.TOMLDecodeError as e:
|
|
127
|
+
import warnings
|
|
128
|
+
|
|
129
|
+
warnings.warn(f"Ignoring unparseable config file {path}: {e}", stacklevel=2)
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
# Keep flat string values only; ignore anything else.
|
|
133
|
+
return {k: v for k, v in data.items() if isinstance(v, str)}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_toml(path: Path, data: dict[str, str]) -> None:
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
lines = [f'{k} = "{_escape(v)}"' for k, v in data.items()]
|
|
139
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _escape(value: str) -> str:
|
|
143
|
+
# Minimal TOML basic-string escaping.
|
|
144
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApplicationError(Exception):
|
|
14
|
+
"""Base class for errors raised by the CLI application."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApplicationErrorWithMessage(ApplicationError):
|
|
18
|
+
def __init__(self, message: str) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def message(self) -> str:
|
|
23
|
+
return str(self.args[0])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(ApplicationErrorWithMessage): ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Unauthenticated(ApplicationError): ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidAPIKey(ApplicationErrorWithMessage): ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AccountUnreachable(ApplicationErrorWithMessage): ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BadRequest(ApplicationErrorWithMessage): ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ServerError(ApplicationError): ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResponseParsingFailed(ApplicationError): ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def typer_error_handler(config: Config) -> Iterator[None]: # noqa: C901
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
except Unauthenticated as e:
|
|
52
|
+
print("Authentication error. Check your API key.")
|
|
53
|
+
raise typer.Exit(1) from e
|
|
54
|
+
except InvalidAPIKey as e:
|
|
55
|
+
print(f"Your API key is invalid or has been revoked. Create a new one at {config.account_url}.")
|
|
56
|
+
raise typer.Exit(1) from e
|
|
57
|
+
except AccountUnreachable as e:
|
|
58
|
+
print(f"Could not reach {config.account_url} to acquire an API token.")
|
|
59
|
+
raise typer.Exit(1) from e
|
|
60
|
+
except BadRequest as e:
|
|
61
|
+
print(f"Bad request: {e.message}")
|
|
62
|
+
raise typer.Exit(1) from e
|
|
63
|
+
except ConfigError as e:
|
|
64
|
+
print(f"Config error: {e.message}")
|
|
65
|
+
raise typer.Exit(1) from e
|
|
66
|
+
except ServerError as e:
|
|
67
|
+
print("An error occurred on the server.")
|
|
68
|
+
raise typer.Exit(1) from e
|
|
69
|
+
except ResponseParsingFailed as e:
|
|
70
|
+
print("Server returned non-JSON response.")
|
|
71
|
+
raise typer.Exit(1) from e
|
|
72
|
+
except ApplicationErrorWithMessage as e:
|
|
73
|
+
print(f"An unexpected error occurred: {e.message}")
|
|
74
|
+
raise typer.Exit(1) from e
|
|
75
|
+
except ApplicationError as e:
|
|
76
|
+
print("An unexpected error occurred.")
|
|
77
|
+
raise typer.Exit(1) from e
|
|
78
|
+
except Exception as e:
|
|
79
|
+
print("An unexpected error occurred.")
|
|
80
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import platformdirs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ResourcePath:
|
|
9
|
+
@classmethod
|
|
10
|
+
def skill(cls) -> Path:
|
|
11
|
+
"""Path to the bundled agent skill."""
|
|
12
|
+
return cls._resources_root() / "skills" / "skybrush-log-analysis"
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def _resources_root(cls) -> Path:
|
|
16
|
+
"""Path to the root of the bundled resources."""
|
|
17
|
+
return cast(Path, importlib.resources.files("skybrush_log_analyzer") / "resources")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DestinationPath:
|
|
21
|
+
@classmethod
|
|
22
|
+
def skill(cls, *, local: bool, claude: bool = False) -> Path:
|
|
23
|
+
"""Installation path of the agent skill in the given scope."""
|
|
24
|
+
base = Path.cwd() if local else Path.home()
|
|
25
|
+
harness_dir = ".claude" if claude else ".agents"
|
|
26
|
+
return base / harness_dir / "skills" / "skybrush-log-analysis"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigPath:
|
|
30
|
+
@classmethod
|
|
31
|
+
def dir(cls, *, local: bool) -> Path:
|
|
32
|
+
"""Config directory in the given scope."""
|
|
33
|
+
if local:
|
|
34
|
+
return Path.cwd() / ".skybrush-log-analyzer"
|
|
35
|
+
|
|
36
|
+
return platformdirs.user_config_path("skybrush-log-analyzer")
|
skybrush_log_analyzer-0.1.0/skybrush_log_analyzer/resources/skills/skybrush-log-analysis/SKILL.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skybrush-log-analysis
|
|
3
|
+
description: Uses the Skybrush Log Analyzer to analyze ArduPilot logs. Use it to run deterministic log analysis, or check available log analysis tools and tests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Overview
|
|
7
|
+
|
|
8
|
+
Skybrush Log Analyzer is a Q&A style ArduPilot log analyzer with test algorithms covering many categories (motor, gps, battery etc.), answering questions about a single log file with human-readable output.
|
|
9
|
+
|
|
10
|
+
Invoke the CLI as `skybrush-log-analyzer <COMMAND>`. If `skybrush-log-analyzer` is not available, fall back to: `uvx skybrush-log-analyzer <COMMAND>`.
|
|
11
|
+
|
|
12
|
+
On Windows set `PYTHONIOENCODING=utf-8` (or `PYTHONUTF8=1`) before invoking the CLI.
|
|
13
|
+
|
|
14
|
+
If authentication fails, stop and tell the user! Do not try to fix it by reading env files or environment variables!
|
|
15
|
+
|
|
16
|
+
If you encounter an issue during the analysis process, stop and notify the user!
|
|
17
|
+
|
|
18
|
+
Do not attempt any extra, manual analysis unless directly requested by the user.
|
|
19
|
+
|
|
20
|
+
Avoid rabbit-holes!
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
- `--help`: Show help
|
|
25
|
+
- `version`: Get version info
|
|
26
|
+
- `tests`: List available log tests (`tests` lists the default tests; `tests extra` the extra, verbose ones; `tests all` both groups)
|
|
27
|
+
- `analyze PATH/TO/LOG.bin`: Analyze a log file (runs all tests by default) - uploads the log, runs the analysis, and prints a compact summary of the default test results (omitting passing tests). Extra, verbose tests are run and results are stored too, but only shown via `get-analysis-extras`.
|
|
28
|
+
- `analyze PATH/TO/LOG.bin -t TEST_NAME`: Run only the given tests, `-t` / `--test` can be repeated:`-t TEST_A -t TEST_B`
|
|
29
|
+
- `get-analysis PATH/TO/LOG.bin`: Fetch previously stored analysis results for the given log file (same compact summary as `analyze`, default test results only); add `--raw` for the untrimmed API response
|
|
30
|
+
- `get-analysis-extras PATH/TO/LOG.bin`: Fetch the extra, verbose test results for the given log file (e.g. full parameter value listing) that are excluded from the default output; add `--raw` for the untrimmed API response
|
|
31
|
+
- `suggest -t "TITLE" -c CATEGORY "FEEDBACK TEXT"`: Submit valuable feedback about the analysis service to the Skybrush team; `-t` / `--title` is a short summary (1-100 characters) and `-c` / `--category` is one of `test_case`, `feature`, `improvement`, `bug`, `other`
|
|
32
|
+
- `auth-status`: Show the current authentication status
|
|
33
|
+
|
|
34
|
+
## Standard results vs. extras
|
|
35
|
+
|
|
36
|
+
- `get-analysis` and `get-analysis-extras` are complementary: the former returns the default test results, the latter the extra, verbose ones.
|
|
37
|
+
- Empty `test_results` in either output means no tests from that group have been run for the given log file yet - run `analyze` to produce them.
|
|
38
|
+
- If a test the user asked about is missing from the standard output, check the extras.
|
|
39
|
+
|
|
40
|
+
## Analysis process
|
|
41
|
+
|
|
42
|
+
STEP 1: Look for existing analysis results using the `get-analysis` command
|
|
43
|
+
|
|
44
|
+
- Yes: Notify the user about existing results, ask if fresh log analysis should be triggered, and proceed accordingly.
|
|
45
|
+
- No: Move on to next step.
|
|
46
|
+
|
|
47
|
+
STEP 2: Did the user ask to test or analyze a specific component or topic?
|
|
48
|
+
|
|
49
|
+
- Yes: Query available tests using the `tests` command. Propose test list to user. The most cost-effective solution is often to run all tests once, and later query and filter existing results.
|
|
50
|
+
- No: Move on to next step with no test name filtering.
|
|
51
|
+
|
|
52
|
+
STEP 3: Analyze the log file using the `analyze` command
|
|
53
|
+
|
|
54
|
+
STEP 4: Interpret and summarize the relevant results to the user
|
|
55
|
+
|
|
56
|
+
## Submitting feedback
|
|
57
|
+
|
|
58
|
+
Submit feedback with the `suggest` command when you notice any of the following:
|
|
59
|
+
|
|
60
|
+
- A test result contradicts what the log actually shows (suspected bug) — category `bug`.
|
|
61
|
+
- No available test covers something you or the user needed, or an existing test would need a change to cover it — category `test_case`.
|
|
62
|
+
- The results are correct but confusing, incomplete, or hard to work with — category `improvement`, or `feature` if it requires a new capability.
|
|
63
|
+
- The user's reaction suggests that feedback or test suite changes would be useful (e.g. they question a result, seem dissatisfied with it, or pinpoint a key detail that would have helped the analysis) — category `other` unless it clearly matches one of the above.
|
|
64
|
+
|
|
65
|
+
Regarding the feedback message:
|
|
66
|
+
|
|
67
|
+
- Make it technical and self-contained
|
|
68
|
+
- Make it concise, but detailed enough for the reader to fully understand the details and the context
|
|
69
|
+
- Reference affected or related test names and log messages where relevant
|
|
70
|
+
- End the message with a line identifying yourself, in this format: `By: <model> (<harness>)`
|
|
71
|
+
|
|
72
|
+
Never include secrets in the feedback!
|
|
73
|
+
|
|
74
|
+
Submit as soon as the issue is understood well enough for meaningful feedback; do not let it interrupt or delay the analysis for the user.
|
|
75
|
+
|
|
76
|
+
Submit at most one suggestion per distinct issue.
|
|
77
|
+
|
|
78
|
+
You may submit feedback autonomously. If you are unsure whether something is worth submitting, ask the user first. The feedback API is rate limited.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def calculate_file_hash(content: bytes) -> str:
|
|
7
|
+
"""Calculate SHA256 hash of file content."""
|
|
8
|
+
return hashlib.sha256(content).hexdigest()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_json(data: Any) -> None:
|
|
12
|
+
"""Dumps the given data to JSON and prints the result."""
|
|
13
|
+
print(json.dumps(data, ensure_ascii=False, allow_nan=False))
|