doover-cli 0.2.0__tar.gz → 0.3.2__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.
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.gitignore +5 -1
- doover_cli-0.3.2/AGENT.md +8 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/PKG-INFO +8 -5
- {doover_cli-0.2.0 → doover_cli-0.3.2}/README.md +20 -2
- {doover_cli-0.2.0 → doover_cli-0.3.2}/pyproject.toml +12 -7
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/__init__.py +47 -6
- doover_cli-0.3.2/src/doover_cli/agent.py +239 -0
- doover_cli-0.3.2/src/doover_cli/api/__init__.py +9 -0
- doover_cli-0.3.2/src/doover_cli/api/auth.py +165 -0
- doover_cli-0.3.2/src/doover_cli/api/errors.py +6 -0
- doover_cli-0.3.2/src/doover_cli/api/session.py +100 -0
- doover_cli-0.3.2/src/doover_cli/apps/app_install.py +2757 -0
- doover_cli-0.3.2/src/doover_cli/apps/apps.py +1170 -0
- doover_cli-0.3.2/src/doover_cli/apps/device.py +440 -0
- doover_cli-0.3.2/src/doover_cli/apps/device_type.py +363 -0
- doover_cli-0.3.2/src/doover_cli/channel.py +300 -0
- doover_cli-0.3.2/src/doover_cli/colours.py +10 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/config_schema.py +2 -2
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/dda_logs.py +7 -0
- doover_cli-0.3.2/src/doover_cli/doover_config.py +28 -0
- doover_cli-0.3.2/src/doover_cli/login.py +49 -0
- doover_cli-0.3.2/src/doover_cli/renderer/__init__.py +31 -0
- doover_cli-0.3.2/src/doover_cli/renderer/_base.py +107 -0
- doover_cli-0.3.2/src/doover_cli/renderer/_basic.py +115 -0
- doover_cli-0.3.2/src/doover_cli/renderer/_default.py +482 -0
- doover_cli-0.3.2/src/doover_cli/renderer/_json.py +27 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/report.py +14 -9
- doover_cli-0.3.2/src/doover_cli/tunnel.py +71 -0
- doover_cli-0.3.2/src/doover_cli/ui_schema.py +91 -0
- doover_cli-0.3.2/src/doover_cli/user.py +846 -0
- doover_cli-0.3.2/src/doover_cli/utils/api.py +81 -0
- doover_cli-0.3.2/src/doover_cli/utils/apps.py +400 -0
- doover_cli-0.3.2/src/doover_cli/utils/context.py +15 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/__init__.py +15 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/commands.py +340 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/lookup.py +317 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/prompting.py +219 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/schema.py +91 -0
- doover_cli-0.3.2/src/doover_cli/utils/crud/values.py +269 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/formatters.py +12 -12
- doover_cli-0.3.2/src/doover_cli/utils/prompt.py +102 -0
- doover_cli-0.3.2/src/doover_cli/utils/sentry.py +107 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/shell_commands.py +9 -2
- doover_cli-0.3.2/src/doover_cli/utils/state.py +37 -0
- doover_cli-0.3.2/tests/conftest.py +30 -0
- doover_cli-0.3.2/tests/test_agent.py +319 -0
- doover_cli-0.3.2/tests/test_app_install.py +860 -0
- doover_cli-0.3.2/tests/test_apps.py +868 -0
- doover_cli-0.3.2/tests/test_auth_integration.py +166 -0
- doover_cli-0.3.2/tests/test_auth_unit.py +189 -0
- doover_cli-0.3.2/tests/test_crud_commands.py +305 -0
- doover_cli-0.3.2/tests/test_crud_lookup.py +138 -0
- doover_cli-0.3.2/tests/test_crud_prompting.py +242 -0
- doover_cli-0.3.2/tests/test_crud_schema.py +108 -0
- doover_cli-0.3.2/tests/test_crud_values.py +194 -0
- doover_cli-0.3.2/tests/test_default_renderer.py +390 -0
- doover_cli-0.3.2/tests/test_device.py +859 -0
- doover_cli-0.3.2/tests/test_device_cli_integration.py +91 -0
- doover_cli-0.3.2/tests/test_device_type.py +867 -0
- doover_cli-0.3.2/tests/test_device_type_cli_integration.py +84 -0
- doover_cli-0.3.2/tests/test_prompt.py +26 -0
- doover_cli-0.3.2/tests/test_sentry.py +376 -0
- doover_cli-0.3.2/tests/test_user.py +721 -0
- doover_cli-0.3.2/tests/test_utils_apps.py +260 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/uv.lock +724 -531
- doover_cli-0.2.0/src/doover_cli/agent.py +0 -15
- doover_cli-0.2.0/src/doover_cli/apps.py +0 -558
- doover_cli-0.2.0/src/doover_cli/channel.py +0 -335
- doover_cli-0.2.0/src/doover_cli/device_type.py +0 -58
- doover_cli-0.2.0/src/doover_cli/doover_config.py +0 -92
- doover_cli-0.2.0/src/doover_cli/login.py +0 -208
- doover_cli-0.2.0/src/doover_cli/tunnel.py +0 -177
- doover_cli-0.2.0/src/doover_cli/utils/api.py +0 -152
- doover_cli-0.2.0/src/doover_cli/utils/apps.py +0 -107
- doover_cli-0.2.0/src/doover_cli/utils/context.py +0 -14
- doover_cli-0.2.0/src/doover_cli/utils/decorators.py +0 -0
- doover_cli-0.2.0/src/doover_cli/utils/prompt.py +0 -72
- doover_cli-0.2.0/src/doover_cli/utils/state.py +0 -38
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/CONTRIBUTING.md +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/lint-test.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release-brew.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release-pypi.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release.yml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/.pre-commit-config.yaml +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/LICENSE +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/changelog +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/control +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/rules +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/docs/user_stories.md +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/grpc.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/simulator.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/__init__.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/errors.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/misc.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/parsers.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/tests/__init__.py +0 -0
- {doover_cli-0.2.0 → doover_cli-0.3.2}/tests/test_basic.py +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Agent Guidance
|
|
2
|
+
|
|
3
|
+
The doover-cli maintainers also have access to and control over pydoover.
|
|
4
|
+
|
|
5
|
+
Do not work around pydoover bugs in doover-cli when the correct fix belongs in
|
|
6
|
+
pydoover. If a doover-cli issue is caused by generated pydoover behavior,
|
|
7
|
+
request or propose the pydoover change instead of patching doover-cli to bypass
|
|
8
|
+
pydoover internals such as `_execute`.
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: doover-cli
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: CLI for Doover
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Requires-Dist: click-prompt>=0.6.3
|
|
8
|
-
Requires-Dist: click
|
|
8
|
+
Requires-Dist: click>=8.3.1
|
|
9
9
|
Requires-Dist: docker>=7.1.0
|
|
10
10
|
Requires-Dist: jsf>=0.11.2
|
|
11
11
|
Requires-Dist: paramiko>=3.5.1
|
|
12
|
-
Requires-Dist: prompt-toolkit
|
|
13
|
-
Requires-Dist: pydoover
|
|
12
|
+
Requires-Dist: prompt-toolkit>=3.0.52
|
|
13
|
+
Requires-Dist: pydoover==1.0.0a3
|
|
14
14
|
Requires-Dist: pytz>=2025.2
|
|
15
15
|
Requires-Dist: questionary>=1.10.0
|
|
16
16
|
Requires-Dist: requests>=2.32.3
|
|
17
|
-
Requires-Dist:
|
|
17
|
+
Requires-Dist: rich>=14.3.3
|
|
18
|
+
Requires-Dist: sentry-sdk<3,>=2
|
|
19
|
+
Requires-Dist: textual>=8.1.1
|
|
20
|
+
Requires-Dist: typer==0.24.1
|
|
18
21
|
Requires-Dist: tzlocal>=5.3.1
|
|
19
22
|
Requires-Dist: xlsxwriter>=3.2.3
|
|
@@ -61,11 +61,29 @@ doover login
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
If you're using the CLI in a script or CI/CD pipeline, you can set the `DOOVER_API_TOKEN` environment variable to an API token to bypass login mechanisms.
|
|
64
|
-
|
|
64
|
+
If you need to target a custom data API endpoint in that mode, set `DOOVER_DATA_API_BASE_URL`.
|
|
65
|
+
|
|
66
|
+
Channel commands on the new API surface require an explicit target agent:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
doover channel get my-channel --agent 12345
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Error Reporting
|
|
73
|
+
|
|
74
|
+
The CLI reports exception-based command failures to Sentry by default. It avoids normal control-flow exits such as `--help`, `--version`, and user aborts, and it does not intentionally attach secrets such as API tokens as structured metadata.
|
|
75
|
+
|
|
76
|
+
You can control Sentry with these environment variables:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
DOOVER_SENTRY_ENABLED=0
|
|
80
|
+
DOOVER_SENTRY_DSN=<dsn>
|
|
81
|
+
DOOVER_SENTRY_ENVIRONMENT=<name>
|
|
82
|
+
```
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
# Contributing
|
|
68
86
|
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more information on how to contribute to this project.
|
|
69
87
|
|
|
70
88
|
# License
|
|
71
|
-
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
|
89
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "doover-cli"
|
|
3
|
-
version = "0.2
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "CLI for Doover"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = [
|
|
7
|
-
"click
|
|
7
|
+
"click>=8.3.1",
|
|
8
8
|
"click-prompt>=0.6.3",
|
|
9
|
-
"prompt-toolkit
|
|
9
|
+
"prompt-toolkit>=3.0.52",
|
|
10
10
|
"docker>=7.1.0",
|
|
11
11
|
"jsf>=0.11.2",
|
|
12
12
|
"paramiko>=3.5.1",
|
|
13
|
-
"pydoover
|
|
13
|
+
"pydoover==1.0.0a3",
|
|
14
14
|
"pytz>=2025.2",
|
|
15
15
|
"questionary>=1.10.0",
|
|
16
16
|
"requests>=2.32.3",
|
|
17
|
-
"
|
|
17
|
+
"sentry-sdk>=2,<3",
|
|
18
|
+
"typer==0.24.1",
|
|
18
19
|
"tzlocal>=5.3.1",
|
|
19
20
|
"xlsxwriter>=3.2.3",
|
|
20
|
-
"
|
|
21
|
+
"rich>=14.3.3",
|
|
22
|
+
"textual>=8.1.1",
|
|
21
23
|
]
|
|
22
24
|
|
|
23
25
|
[build-system]
|
|
@@ -34,4 +36,7 @@ dev = [
|
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
[tool.uv.sources]
|
|
37
|
-
pydoover = { git = "https://github.com/getdoover/pydoover", branch = "
|
|
39
|
+
pydoover = { git = "https://github.com/getdoover/pydoover", branch = "chore/update-control-api" }
|
|
40
|
+
|
|
41
|
+
[tool.ty.environment]
|
|
42
|
+
python = "./.venv"
|
|
@@ -1,30 +1,43 @@
|
|
|
1
|
+
from doover_cli.renderer import Renderer
|
|
1
2
|
from typing import Annotated, Optional
|
|
2
3
|
|
|
4
|
+
import click
|
|
3
5
|
import typer
|
|
4
6
|
|
|
5
|
-
from pydoover.
|
|
7
|
+
from pydoover.api.auth import ConfigManager
|
|
6
8
|
|
|
7
|
-
from .apps import app as apps_app
|
|
9
|
+
from .apps.apps import app as apps_app
|
|
10
|
+
from .apps.app_install import app as app_install_app
|
|
8
11
|
from .config_schema import app as config_schema_app
|
|
12
|
+
from .ui_schema import app as ui_schema_app
|
|
9
13
|
from .simulator import app as simulators_app
|
|
10
14
|
from .agent import app as agents_app
|
|
11
15
|
from .channel import app as channels_app
|
|
12
16
|
from .doover_config import app as doover_config_app
|
|
13
17
|
from .dda_logs import app as dda_logs_app
|
|
14
|
-
from .
|
|
18
|
+
from .apps.device import app as device_app
|
|
19
|
+
from .apps.device_type import app as device_type_app
|
|
15
20
|
from .grpc import app as grpc_app
|
|
16
21
|
from .login import app as login_app
|
|
17
22
|
from .report import app as reports_app
|
|
18
23
|
from .tunnel import app as tunnels_app
|
|
24
|
+
from .user import org_app, users_app
|
|
25
|
+
from .utils import sentry as sentry_utils
|
|
19
26
|
from .utils.state import state
|
|
20
27
|
|
|
21
28
|
app = typer.Typer(no_args_is_help=True)
|
|
22
29
|
app.add_typer(
|
|
23
30
|
apps_app, name="app", help="Manage applications and their configurations."
|
|
24
31
|
)
|
|
32
|
+
app.add_typer(
|
|
33
|
+
app_install_app,
|
|
34
|
+
name="app-install",
|
|
35
|
+
help="Manage application installations in Doover 2.0.",
|
|
36
|
+
)
|
|
25
37
|
app.add_typer(
|
|
26
38
|
config_schema_app, name="config-schema", help="Manage application config schemas."
|
|
27
39
|
)
|
|
40
|
+
app.add_typer(ui_schema_app, name="ui-schema", help="Manage application UI schemas.")
|
|
28
41
|
app.add_typer(
|
|
29
42
|
simulators_app, name="simulator", help="Manage simulators and their configurations."
|
|
30
43
|
)
|
|
@@ -40,9 +53,12 @@ app.add_typer(reports_app, name="report", help="Generate and manage reports.")
|
|
|
40
53
|
app.add_typer(tunnels_app, name="tunnel", help="Manage SSH tunnels for remote access.")
|
|
41
54
|
app.add_typer(grpc_app, name="grpc", help="Interact with running gRPC servers.")
|
|
42
55
|
app.add_typer(dda_logs_app, name="dda-logs", help="Convert DDA message logs to JSON.")
|
|
56
|
+
app.add_typer(device_app, name="device", help="Manipulate devices in Doover 2.0")
|
|
43
57
|
app.add_typer(
|
|
44
58
|
device_type_app, name="device-type", help="Manipulate device types in Doover 2.0"
|
|
45
59
|
)
|
|
60
|
+
app.add_typer(org_app, name="org", help="Manage the current organisation.")
|
|
61
|
+
app.add_typer(users_app, name="users", help="Manage users.")
|
|
46
62
|
|
|
47
63
|
|
|
48
64
|
def version_callback(value: bool):
|
|
@@ -62,14 +78,23 @@ def load_ctx(
|
|
|
62
78
|
json: Annotated[
|
|
63
79
|
bool, typer.Option(help="Set flag to output results in json format")
|
|
64
80
|
] = False,
|
|
81
|
+
render: Annotated[
|
|
82
|
+
Renderer, typer.Option(help="Set flag to output results in json format")
|
|
83
|
+
] = Renderer.default,
|
|
65
84
|
version: Annotated[
|
|
66
85
|
Optional[bool], typer.Option("--version", callback=version_callback)
|
|
67
86
|
] = None,
|
|
68
87
|
):
|
|
69
|
-
state.
|
|
88
|
+
state.agent_id = None
|
|
89
|
+
state.profile_name = "default"
|
|
70
90
|
state.config_manager = ConfigManager("default")
|
|
91
|
+
state._session = None
|
|
71
92
|
state.debug = debug
|
|
72
|
-
|
|
93
|
+
|
|
94
|
+
if render is not None and json:
|
|
95
|
+
raise typer.BadParameter("Cannot use --json and --renderer together.")
|
|
96
|
+
|
|
97
|
+
state.renderer_name = render
|
|
73
98
|
|
|
74
99
|
# return ctx.invoke(ctx.obj, *args, **kwargs)
|
|
75
100
|
|
|
@@ -78,4 +103,20 @@ def main():
|
|
|
78
103
|
"""
|
|
79
104
|
Main entry point for the Doover CLI.
|
|
80
105
|
"""
|
|
81
|
-
|
|
106
|
+
sentry_utils.init_sentry()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
app()
|
|
110
|
+
except click.exceptions.Exit:
|
|
111
|
+
raise
|
|
112
|
+
except click.Abort:
|
|
113
|
+
raise
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
sentry_utils._capture_exception(
|
|
116
|
+
exc,
|
|
117
|
+
handled=False,
|
|
118
|
+
command=sentry_utils.current_command_path(),
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
121
|
+
finally:
|
|
122
|
+
sentry_utils.flush_sentry()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from pydoover.models.control import Agent, Agents, Group
|
|
8
|
+
from typer import Typer
|
|
9
|
+
|
|
10
|
+
from .renderer import TreeNode
|
|
11
|
+
from .renderer._base import normalize_render_data
|
|
12
|
+
from .utils.api import ProfileAnnotation
|
|
13
|
+
from .utils.state import state
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pydoover.api import ControlClient
|
|
17
|
+
from .renderer import RendererBase
|
|
18
|
+
|
|
19
|
+
app = Typer(no_args_is_help=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_state() -> tuple["ControlClient", "RendererBase"]:
|
|
23
|
+
session = state.session
|
|
24
|
+
return session.get_control_client(), state.renderer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command(name="list")
|
|
28
|
+
def list_(
|
|
29
|
+
tree: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--tree",
|
|
33
|
+
help="Show agents grouped into a tree instead of a table.",
|
|
34
|
+
),
|
|
35
|
+
] = False,
|
|
36
|
+
include_archived: Annotated[
|
|
37
|
+
bool,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--include-archived",
|
|
40
|
+
help="Include archived agents and groups.",
|
|
41
|
+
),
|
|
42
|
+
] = False,
|
|
43
|
+
_profile: ProfileAnnotation = None,
|
|
44
|
+
):
|
|
45
|
+
"""List available agents."""
|
|
46
|
+
_ = _profile
|
|
47
|
+
client, renderer = get_state()
|
|
48
|
+
|
|
49
|
+
with renderer.loading("Loading agents..."):
|
|
50
|
+
response = client.agents.retrieve(include_archived=include_archived)
|
|
51
|
+
|
|
52
|
+
if tree:
|
|
53
|
+
renderer.tree(build_agents_tree(response))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
renderer.render_list(response.agents)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_agents_tree(response: Agents) -> TreeNode:
|
|
60
|
+
group_entries = _flatten_groups(response.groups or [])
|
|
61
|
+
groups = [group for group, _parent_id in group_entries]
|
|
62
|
+
agents = list(response.agents or [])
|
|
63
|
+
root = TreeNode(response)
|
|
64
|
+
groups_by_id = {
|
|
65
|
+
group_id: group
|
|
66
|
+
for group in groups
|
|
67
|
+
if (group_id := _resource_id(group)) is not None
|
|
68
|
+
}
|
|
69
|
+
group_ids = set(groups_by_id)
|
|
70
|
+
group_ids_by_name = {
|
|
71
|
+
str(group_name): group_id
|
|
72
|
+
for group_id, group in groups_by_id.items()
|
|
73
|
+
if (group_name := _field_value(group, "name"))
|
|
74
|
+
}
|
|
75
|
+
groups_by_parent_id: dict[int | None, list[Group]] = defaultdict(list)
|
|
76
|
+
agents_by_group_id, agents_by_unknown_group = _group_agents(
|
|
77
|
+
agents,
|
|
78
|
+
group_ids=group_ids,
|
|
79
|
+
group_ids_by_name=group_ids_by_name,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for group, inferred_parent_id in group_entries:
|
|
83
|
+
parent_id = _resource_id(_field_value(group, "parent"))
|
|
84
|
+
if parent_id is None:
|
|
85
|
+
parent_id = inferred_parent_id
|
|
86
|
+
if parent_id not in group_ids:
|
|
87
|
+
parent_id = None
|
|
88
|
+
groups_by_parent_id[parent_id].append(group)
|
|
89
|
+
|
|
90
|
+
for siblings in groups_by_parent_id.values():
|
|
91
|
+
siblings.sort(key=_group_sort_key)
|
|
92
|
+
for grouped_agents in agents_by_group_id.values():
|
|
93
|
+
grouped_agents.sort(key=_agent_sort_key)
|
|
94
|
+
for grouped_agents in agents_by_unknown_group.values():
|
|
95
|
+
grouped_agents.sort(key=_agent_sort_key)
|
|
96
|
+
|
|
97
|
+
root.children.extend(
|
|
98
|
+
_build_group_branch(group, groups_by_parent_id, agents_by_group_id)
|
|
99
|
+
for group in groups_by_parent_id[None]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for group_name in sorted(name for name in agents_by_unknown_group if name):
|
|
103
|
+
root.children.append(
|
|
104
|
+
TreeNode(
|
|
105
|
+
Group(name=group_name),
|
|
106
|
+
children=[
|
|
107
|
+
TreeNode(agent)
|
|
108
|
+
for agent in agents_by_unknown_group[group_name]
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if "" in agents_by_unknown_group:
|
|
114
|
+
root.children.append(
|
|
115
|
+
TreeNode(
|
|
116
|
+
Group(name="Ungrouped"),
|
|
117
|
+
children=[
|
|
118
|
+
TreeNode(agent) for agent in agents_by_unknown_group[""]
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if not root.children and agents:
|
|
124
|
+
root.children.extend(
|
|
125
|
+
TreeNode(agent) for agent in sorted(agents, key=_agent_sort_key)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return root
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _flatten_groups(groups: list[Group]) -> list[tuple[Group, int | None]]:
|
|
132
|
+
flattened: list[tuple[Group, int | None]] = []
|
|
133
|
+
|
|
134
|
+
def visit(group: Group, parent_id: int | None, path: set[int]) -> None:
|
|
135
|
+
group_id = _resource_id(group)
|
|
136
|
+
flattened.append((group, parent_id))
|
|
137
|
+
|
|
138
|
+
if group_id is not None:
|
|
139
|
+
if group_id in path:
|
|
140
|
+
return
|
|
141
|
+
path = {*path, group_id}
|
|
142
|
+
|
|
143
|
+
for child in _field_value(group, "children", []) or []:
|
|
144
|
+
visit(child, group_id, path)
|
|
145
|
+
|
|
146
|
+
for group in groups:
|
|
147
|
+
visit(group, None, set())
|
|
148
|
+
|
|
149
|
+
return flattened
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_group_branch(
|
|
153
|
+
group: Group,
|
|
154
|
+
groups_by_parent_id: dict[int | None, list[Group]],
|
|
155
|
+
agents_by_group_id: dict[int, list[Agent]],
|
|
156
|
+
) -> TreeNode:
|
|
157
|
+
group_id = _resource_id(group)
|
|
158
|
+
children = [
|
|
159
|
+
_build_group_branch(child, groups_by_parent_id, agents_by_group_id)
|
|
160
|
+
for child in groups_by_parent_id.get(group_id, [])
|
|
161
|
+
]
|
|
162
|
+
if group_id is not None:
|
|
163
|
+
children.extend(
|
|
164
|
+
TreeNode(agent) for agent in agents_by_group_id.get(group_id, [])
|
|
165
|
+
)
|
|
166
|
+
return TreeNode(group, children=children)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _group_agents(
|
|
170
|
+
agents: list[Agent],
|
|
171
|
+
*,
|
|
172
|
+
group_ids: set[int],
|
|
173
|
+
group_ids_by_name: dict[str, int],
|
|
174
|
+
) -> tuple[dict[int, list[Agent]], dict[str, list[Agent]]]:
|
|
175
|
+
by_id: dict[int, list[Agent]] = defaultdict(list)
|
|
176
|
+
by_unknown_label: dict[str, list[Agent]] = defaultdict(list)
|
|
177
|
+
|
|
178
|
+
for agent in agents:
|
|
179
|
+
group_value = _field_value(agent, "group")
|
|
180
|
+
group_id = _resource_id(group_value)
|
|
181
|
+
if group_id in group_ids:
|
|
182
|
+
by_id[group_id].append(agent)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
group_label = str(group_value or "")
|
|
186
|
+
if (named_group_id := group_ids_by_name.get(group_label)) is not None:
|
|
187
|
+
by_id[named_group_id].append(agent)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
by_unknown_label[group_label].append(agent)
|
|
191
|
+
|
|
192
|
+
return by_id, by_unknown_label
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _field_value(item: Any, key: str, default: Any = None) -> Any:
|
|
196
|
+
if isinstance(item, dict):
|
|
197
|
+
return item.get(key, default)
|
|
198
|
+
|
|
199
|
+
value = getattr(item, key, default)
|
|
200
|
+
if value is not None:
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
normalized = normalize_render_data(item)
|
|
204
|
+
if isinstance(normalized, dict):
|
|
205
|
+
return normalized.get(key, default)
|
|
206
|
+
return default
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resource_id(value: Any) -> int | None:
|
|
210
|
+
if value is None:
|
|
211
|
+
return None
|
|
212
|
+
if isinstance(value, dict):
|
|
213
|
+
return _coerce_int(value.get("id"))
|
|
214
|
+
return _coerce_int(getattr(value, "id", value))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _coerce_int(value: Any) -> int | None:
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
return int(value)
|
|
222
|
+
except (TypeError, ValueError):
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _group_label(group: Group) -> str:
|
|
227
|
+
return str(_field_value(group, "name") or _field_value(group, "id") or "Unnamed")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _group_sort_key(group: Group) -> tuple[str, int]:
|
|
231
|
+
return (_group_label(group).casefold(), _resource_id(group) or 0)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _agent_sort_key(agent: Agent) -> tuple[str, str, int]:
|
|
235
|
+
return (
|
|
236
|
+
str(_field_value(agent, "display_name", "") or "").casefold(),
|
|
237
|
+
str(_field_value(agent, "name", "") or "").casefold(),
|
|
238
|
+
_resource_id(agent) or 0,
|
|
239
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import webbrowser
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
import rich
|
|
8
|
+
|
|
9
|
+
from pydoover.api.auth import AuthProfile, ConfigManager, Doover2AuthClient
|
|
10
|
+
|
|
11
|
+
DEFAULT_AUTH_CLIENT_ID = "08a9ae8c-0668-428b-a691-f7eaa526aca0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DooverCLIAuthClient(Doover2AuthClient):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
config_manager: ConfigManager | None = None,
|
|
19
|
+
profile_name: str | None = None,
|
|
20
|
+
**kwargs,
|
|
21
|
+
):
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self._config_manager = config_manager
|
|
24
|
+
self._profile_name = profile_name
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def device_login(
|
|
28
|
+
cls,
|
|
29
|
+
*,
|
|
30
|
+
staging: bool = False,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
open_browser: bool = True,
|
|
33
|
+
) -> "DooverCLIAuthClient":
|
|
34
|
+
if staging:
|
|
35
|
+
auth_server_url = "https://auth.staging.udoover.com"
|
|
36
|
+
control_base_url = "https://api.staging.udoover.com"
|
|
37
|
+
data_base_url = "https://data.staging.udoover.com/api"
|
|
38
|
+
else:
|
|
39
|
+
auth_server_url = "https://auth.doover.com"
|
|
40
|
+
control_base_url = "https://api.doover.com"
|
|
41
|
+
data_base_url = "https://data.doover.com/api"
|
|
42
|
+
|
|
43
|
+
config = requests.get(
|
|
44
|
+
f"{auth_server_url}/.well-known/openid-configuration",
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
).json()
|
|
47
|
+
endpoint = config["device_authorization_endpoint"]
|
|
48
|
+
|
|
49
|
+
device_config = requests.post(
|
|
50
|
+
endpoint,
|
|
51
|
+
params={
|
|
52
|
+
"client_id": DEFAULT_AUTH_CLIENT_ID,
|
|
53
|
+
"scope": "offline_access",
|
|
54
|
+
"metaData.device.name": "Doover CLI - Python",
|
|
55
|
+
"metaData.device.type": "other",
|
|
56
|
+
},
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
).json()
|
|
59
|
+
|
|
60
|
+
rich.print(
|
|
61
|
+
f"[green]User Code: \n\n[bold cyan]{device_config['user_code']}[/bold cyan]\n[/green]"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
login_url = f"{auth_server_url}/oauth2/device?" + urlencode(
|
|
65
|
+
{
|
|
66
|
+
"user_code": device_config["user_code"],
|
|
67
|
+
"client_id": DEFAULT_AUTH_CLIENT_ID,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
print(
|
|
71
|
+
f"Alternatively, copy this link into your browser to complete the login:\n{login_url}"
|
|
72
|
+
)
|
|
73
|
+
if open_browser:
|
|
74
|
+
webbrowser.open(login_url, new=0, autoraise=True)
|
|
75
|
+
|
|
76
|
+
for _ in range(device_config["expires_in"] // device_config["interval"]):
|
|
77
|
+
time.sleep(device_config["interval"])
|
|
78
|
+
|
|
79
|
+
resp = requests.post(
|
|
80
|
+
config["token_endpoint"],
|
|
81
|
+
params={
|
|
82
|
+
"device_code": device_config["device_code"],
|
|
83
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
84
|
+
"client_id": DEFAULT_AUTH_CLIENT_ID,
|
|
85
|
+
},
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
)
|
|
88
|
+
if not resp.ok:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
token_data = resp.json()
|
|
92
|
+
return cls(
|
|
93
|
+
token=token_data["access_token"],
|
|
94
|
+
token_expires=datetime.now(timezone.utc)
|
|
95
|
+
+ timedelta(seconds=token_data["expires_in"]),
|
|
96
|
+
control_base_url=control_base_url,
|
|
97
|
+
data_base_url=data_base_url,
|
|
98
|
+
auth_server_url=auth_server_url,
|
|
99
|
+
auth_server_client_id=DEFAULT_AUTH_CLIENT_ID,
|
|
100
|
+
refresh_token=token_data["refresh_token"],
|
|
101
|
+
refresh_token_id=token_data["refresh_token_id"],
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
raise RuntimeError("Auth login expired. Please try again later.")
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_profile_name(
|
|
109
|
+
cls,
|
|
110
|
+
profile_name: str,
|
|
111
|
+
*,
|
|
112
|
+
config_manager: ConfigManager | None = None,
|
|
113
|
+
timeout: float = 60.0,
|
|
114
|
+
) -> "DooverCLIAuthClient":
|
|
115
|
+
manager = config_manager or ConfigManager(profile_name)
|
|
116
|
+
profile = manager.get(profile_name)
|
|
117
|
+
if profile is None:
|
|
118
|
+
raise RuntimeError(f"No configuration found for profile {profile_name}.")
|
|
119
|
+
|
|
120
|
+
return cls(
|
|
121
|
+
token=profile.token,
|
|
122
|
+
token_expires=profile.token_expires,
|
|
123
|
+
refresh_token=profile.refresh_token,
|
|
124
|
+
refresh_token_id=profile.refresh_token_id,
|
|
125
|
+
control_base_url=profile.control_base_url,
|
|
126
|
+
data_base_url=profile.data_base_url,
|
|
127
|
+
auth_server_url=profile.auth_server_url,
|
|
128
|
+
auth_server_client_id=profile.auth_server_client_id,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
config_manager=manager,
|
|
131
|
+
profile_name=profile_name,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def to_profile(self, profile_name: str) -> AuthProfile:
|
|
135
|
+
return AuthProfile(
|
|
136
|
+
profile=profile_name,
|
|
137
|
+
token=self.token,
|
|
138
|
+
token_expires=self.token_expires,
|
|
139
|
+
control_base_url=self.control_base_url,
|
|
140
|
+
data_base_url=self.data_base_url,
|
|
141
|
+
refresh_token=self.refresh_token,
|
|
142
|
+
refresh_token_id=self.refresh_token_id,
|
|
143
|
+
auth_server_url=self.auth_server_url,
|
|
144
|
+
auth_server_client_id=self.auth_server_client_id,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def persist_profile(
|
|
148
|
+
self,
|
|
149
|
+
profile_name: str | None = None,
|
|
150
|
+
config_manager: ConfigManager | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
manager = config_manager or self._config_manager
|
|
153
|
+
name = profile_name or self._profile_name
|
|
154
|
+
if manager is None or name is None:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
manager.create(self.to_profile(name))
|
|
158
|
+
manager.current_profile = name
|
|
159
|
+
manager.write()
|
|
160
|
+
self._config_manager = manager
|
|
161
|
+
self._profile_name = name
|
|
162
|
+
|
|
163
|
+
def refresh_access_token(self):
|
|
164
|
+
super().refresh_access_token()
|
|
165
|
+
self.persist_profile()
|