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.
Files changed (101) hide show
  1. {doover_cli-0.2.0 → doover_cli-0.3.2}/.gitignore +5 -1
  2. doover_cli-0.3.2/AGENT.md +8 -0
  3. {doover_cli-0.2.0 → doover_cli-0.3.2}/PKG-INFO +8 -5
  4. {doover_cli-0.2.0 → doover_cli-0.3.2}/README.md +20 -2
  5. {doover_cli-0.2.0 → doover_cli-0.3.2}/pyproject.toml +12 -7
  6. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/__init__.py +47 -6
  7. doover_cli-0.3.2/src/doover_cli/agent.py +239 -0
  8. doover_cli-0.3.2/src/doover_cli/api/__init__.py +9 -0
  9. doover_cli-0.3.2/src/doover_cli/api/auth.py +165 -0
  10. doover_cli-0.3.2/src/doover_cli/api/errors.py +6 -0
  11. doover_cli-0.3.2/src/doover_cli/api/session.py +100 -0
  12. doover_cli-0.3.2/src/doover_cli/apps/app_install.py +2757 -0
  13. doover_cli-0.3.2/src/doover_cli/apps/apps.py +1170 -0
  14. doover_cli-0.3.2/src/doover_cli/apps/device.py +440 -0
  15. doover_cli-0.3.2/src/doover_cli/apps/device_type.py +363 -0
  16. doover_cli-0.3.2/src/doover_cli/channel.py +300 -0
  17. doover_cli-0.3.2/src/doover_cli/colours.py +10 -0
  18. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/config_schema.py +2 -2
  19. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/dda_logs.py +7 -0
  20. doover_cli-0.3.2/src/doover_cli/doover_config.py +28 -0
  21. doover_cli-0.3.2/src/doover_cli/login.py +49 -0
  22. doover_cli-0.3.2/src/doover_cli/renderer/__init__.py +31 -0
  23. doover_cli-0.3.2/src/doover_cli/renderer/_base.py +107 -0
  24. doover_cli-0.3.2/src/doover_cli/renderer/_basic.py +115 -0
  25. doover_cli-0.3.2/src/doover_cli/renderer/_default.py +482 -0
  26. doover_cli-0.3.2/src/doover_cli/renderer/_json.py +27 -0
  27. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/report.py +14 -9
  28. doover_cli-0.3.2/src/doover_cli/tunnel.py +71 -0
  29. doover_cli-0.3.2/src/doover_cli/ui_schema.py +91 -0
  30. doover_cli-0.3.2/src/doover_cli/user.py +846 -0
  31. doover_cli-0.3.2/src/doover_cli/utils/api.py +81 -0
  32. doover_cli-0.3.2/src/doover_cli/utils/apps.py +400 -0
  33. doover_cli-0.3.2/src/doover_cli/utils/context.py +15 -0
  34. doover_cli-0.3.2/src/doover_cli/utils/crud/__init__.py +15 -0
  35. doover_cli-0.3.2/src/doover_cli/utils/crud/commands.py +340 -0
  36. doover_cli-0.3.2/src/doover_cli/utils/crud/lookup.py +317 -0
  37. doover_cli-0.3.2/src/doover_cli/utils/crud/prompting.py +219 -0
  38. doover_cli-0.3.2/src/doover_cli/utils/crud/schema.py +91 -0
  39. doover_cli-0.3.2/src/doover_cli/utils/crud/values.py +269 -0
  40. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/formatters.py +12 -12
  41. doover_cli-0.3.2/src/doover_cli/utils/prompt.py +102 -0
  42. doover_cli-0.3.2/src/doover_cli/utils/sentry.py +107 -0
  43. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/shell_commands.py +9 -2
  44. doover_cli-0.3.2/src/doover_cli/utils/state.py +37 -0
  45. doover_cli-0.3.2/tests/conftest.py +30 -0
  46. doover_cli-0.3.2/tests/test_agent.py +319 -0
  47. doover_cli-0.3.2/tests/test_app_install.py +860 -0
  48. doover_cli-0.3.2/tests/test_apps.py +868 -0
  49. doover_cli-0.3.2/tests/test_auth_integration.py +166 -0
  50. doover_cli-0.3.2/tests/test_auth_unit.py +189 -0
  51. doover_cli-0.3.2/tests/test_crud_commands.py +305 -0
  52. doover_cli-0.3.2/tests/test_crud_lookup.py +138 -0
  53. doover_cli-0.3.2/tests/test_crud_prompting.py +242 -0
  54. doover_cli-0.3.2/tests/test_crud_schema.py +108 -0
  55. doover_cli-0.3.2/tests/test_crud_values.py +194 -0
  56. doover_cli-0.3.2/tests/test_default_renderer.py +390 -0
  57. doover_cli-0.3.2/tests/test_device.py +859 -0
  58. doover_cli-0.3.2/tests/test_device_cli_integration.py +91 -0
  59. doover_cli-0.3.2/tests/test_device_type.py +867 -0
  60. doover_cli-0.3.2/tests/test_device_type_cli_integration.py +84 -0
  61. doover_cli-0.3.2/tests/test_prompt.py +26 -0
  62. doover_cli-0.3.2/tests/test_sentry.py +376 -0
  63. doover_cli-0.3.2/tests/test_user.py +721 -0
  64. doover_cli-0.3.2/tests/test_utils_apps.py +260 -0
  65. {doover_cli-0.2.0 → doover_cli-0.3.2}/uv.lock +724 -531
  66. doover_cli-0.2.0/src/doover_cli/agent.py +0 -15
  67. doover_cli-0.2.0/src/doover_cli/apps.py +0 -558
  68. doover_cli-0.2.0/src/doover_cli/channel.py +0 -335
  69. doover_cli-0.2.0/src/doover_cli/device_type.py +0 -58
  70. doover_cli-0.2.0/src/doover_cli/doover_config.py +0 -92
  71. doover_cli-0.2.0/src/doover_cli/login.py +0 -208
  72. doover_cli-0.2.0/src/doover_cli/tunnel.py +0 -177
  73. doover_cli-0.2.0/src/doover_cli/utils/api.py +0 -152
  74. doover_cli-0.2.0/src/doover_cli/utils/apps.py +0 -107
  75. doover_cli-0.2.0/src/doover_cli/utils/context.py +0 -14
  76. doover_cli-0.2.0/src/doover_cli/utils/decorators.py +0 -0
  77. doover_cli-0.2.0/src/doover_cli/utils/prompt.py +0 -72
  78. doover_cli-0.2.0/src/doover_cli/utils/state.py +0 -38
  79. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/CONTRIBUTING.md +0 -0
  80. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  81. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  82. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  83. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  84. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/lint-test.yml +0 -0
  85. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release-brew.yml +0 -0
  86. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release-pypi.yml +0 -0
  87. {doover_cli-0.2.0 → doover_cli-0.3.2}/.github/workflows/release.yml +0 -0
  88. {doover_cli-0.2.0 → doover_cli-0.3.2}/.pre-commit-config.yaml +0 -0
  89. {doover_cli-0.2.0 → doover_cli-0.3.2}/LICENSE +0 -0
  90. {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/changelog +0 -0
  91. {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/control +0 -0
  92. {doover_cli-0.2.0 → doover_cli-0.3.2}/debian/rules +0 -0
  93. {doover_cli-0.2.0 → doover_cli-0.3.2}/docs/user_stories.md +0 -0
  94. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/grpc.py +0 -0
  95. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/simulator.py +0 -0
  96. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/__init__.py +0 -0
  97. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/errors.py +0 -0
  98. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/misc.py +0 -0
  99. {doover_cli-0.2.0 → doover_cli-0.3.2}/src/doover_cli/utils/parsers.py +0 -0
  100. {doover_cli-0.2.0 → doover_cli-0.3.2}/tests/__init__.py +0 -0
  101. {doover_cli-0.2.0 → doover_cli-0.3.2}/tests/test_basic.py +0 -0
@@ -126,4 +126,8 @@ venv.bak/
126
126
  dmypy.json
127
127
 
128
128
  # Pyre type checker
129
- .pyre/
129
+ .pyre/
130
+
131
+ # User testing files
132
+ tmp/
133
+ local/
@@ -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.0
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.2.0
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==3.0.51
13
- Requires-Dist: pydoover>=0.4.11
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: typer==0.15.1
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
- Similarly, you can also set the `DOOVER_API_BASE_URL` environment variable to point to a custom Doover API URL if you're using a different server.
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.0"
3
+ version = "0.3.2"
4
4
  description = "CLI for Doover"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
7
- "click<8.2.0",
7
+ "click>=8.3.1",
8
8
  "click-prompt>=0.6.3",
9
- "prompt-toolkit==3.0.51",
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>=0.4.11",
13
+ "pydoover==1.0.0a3",
14
14
  "pytz>=2025.2",
15
15
  "questionary>=1.10.0",
16
16
  "requests>=2.32.3",
17
- "typer==0.15.1",
17
+ "sentry-sdk>=2,<3",
18
+ "typer==0.24.1",
18
19
  "tzlocal>=5.3.1",
19
20
  "xlsxwriter>=3.2.3",
20
- "prompt-toolkit==3.0.51",
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 = "doover-2" }
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.cloud.api import ConfigManager
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 .device_type import app as device_type_app
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.agent_query = None
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
- state.json = json
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
- app()
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,9 @@
1
+ from .auth import DooverCLIAuthClient
2
+ from .errors import ControlClientUnavailableError
3
+ from .session import DooverCLISession
4
+
5
+ __all__ = [
6
+ "ControlClientUnavailableError",
7
+ "DooverCLIAuthClient",
8
+ "DooverCLISession",
9
+ ]
@@ -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()
@@ -0,0 +1,6 @@
1
+ class ControlClientUnavailableError(RuntimeError):
2
+ def __init__(self, command_name: str):
3
+ self.command_name = command_name
4
+ super().__init__(
5
+ "This command requires pydoover.api.control, which is not available in this release."
6
+ )