doover-cli 0.3.1__tar.gz → 0.3.3__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 (93) hide show
  1. {doover_cli-0.3.1 → doover_cli-0.3.3}/.gitignore +2 -1
  2. doover_cli-0.3.3/AGENT.md +8 -0
  3. {doover_cli-0.3.1 → doover_cli-0.3.3}/PKG-INFO +2 -2
  4. {doover_cli-0.3.1 → doover_cli-0.3.3}/pyproject.toml +2 -6
  5. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/__init__.py +13 -2
  6. doover_cli-0.3.3/src/doover_cli/agent.py +239 -0
  7. doover_cli-0.3.3/src/doover_cli/apps/app_install.py +3625 -0
  8. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/apps/apps.py +32 -34
  9. doover_cli-0.3.3/src/doover_cli/apps/device.py +446 -0
  10. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/apps/device_type.py +1 -2
  11. doover_cli-0.3.3/src/doover_cli/apps/tunnel.py +767 -0
  12. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/channel.py +2 -2
  13. doover_cli-0.3.3/src/doover_cli/colours.py +10 -0
  14. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/renderer/__init__.py +2 -1
  15. doover_cli-0.3.3/src/doover_cli/renderer/_base.py +107 -0
  16. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/renderer/_basic.py +86 -2
  17. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/renderer/_default.py +253 -12
  18. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/renderer/_json.py +4 -1
  19. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/ui_schema.py +4 -0
  20. doover_cli-0.3.3/src/doover_cli/user.py +846 -0
  21. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/apps.py +30 -5
  22. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/commands.py +55 -7
  23. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/lookup.py +6 -1
  24. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/prompting.py +26 -10
  25. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/values.py +95 -1
  26. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/parsers.py +9 -2
  27. doover_cli-0.3.3/tests/test_agent.py +319 -0
  28. doover_cli-0.3.3/tests/test_app_install.py +973 -0
  29. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_apps.py +108 -8
  30. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_crud_commands.py +103 -4
  31. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_crud_prompting.py +67 -1
  32. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_crud_values.py +65 -0
  33. doover_cli-0.3.3/tests/test_default_renderer.py +458 -0
  34. doover_cli-0.3.3/tests/test_device.py +859 -0
  35. doover_cli-0.3.3/tests/test_device_cli_integration.py +91 -0
  36. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_device_type.py +62 -0
  37. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_device_type_cli_integration.py +3 -1
  38. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_sentry.py +2 -2
  39. doover_cli-0.3.3/tests/test_user.py +721 -0
  40. doover_cli-0.3.3/tests/test_utils_apps.py +264 -0
  41. {doover_cli-0.3.1 → doover_cli-0.3.3}/uv.lock +383 -374
  42. doover_cli-0.3.1/src/doover_cli/agent.py +0 -12
  43. doover_cli-0.3.1/src/doover_cli/renderer/_base.py +0 -37
  44. doover_cli-0.3.1/src/doover_cli/tunnel.py +0 -71
  45. doover_cli-0.3.1/tests/test_default_renderer.py +0 -196
  46. doover_cli-0.3.1/tests/test_utils_apps.py +0 -116
  47. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/CONTRIBUTING.md +0 -0
  48. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  49. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  50. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  51. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  52. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/workflows/lint-test.yml +0 -0
  53. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/workflows/release-brew.yml +0 -0
  54. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/workflows/release-pypi.yml +0 -0
  55. {doover_cli-0.3.1 → doover_cli-0.3.3}/.github/workflows/release.yml +0 -0
  56. {doover_cli-0.3.1 → doover_cli-0.3.3}/.pre-commit-config.yaml +0 -0
  57. {doover_cli-0.3.1 → doover_cli-0.3.3}/LICENSE +0 -0
  58. {doover_cli-0.3.1 → doover_cli-0.3.3}/README.md +0 -0
  59. {doover_cli-0.3.1 → doover_cli-0.3.3}/debian/changelog +0 -0
  60. {doover_cli-0.3.1 → doover_cli-0.3.3}/debian/control +0 -0
  61. {doover_cli-0.3.1 → doover_cli-0.3.3}/debian/rules +0 -0
  62. {doover_cli-0.3.1 → doover_cli-0.3.3}/docs/user_stories.md +0 -0
  63. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/api/__init__.py +0 -0
  64. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/api/auth.py +0 -0
  65. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/api/errors.py +0 -0
  66. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/api/session.py +0 -0
  67. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/config_schema.py +0 -0
  68. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/dda_logs.py +0 -0
  69. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/doover_config.py +0 -0
  70. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/grpc.py +0 -0
  71. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/login.py +0 -0
  72. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/report.py +0 -0
  73. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/simulator.py +0 -0
  74. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/__init__.py +0 -0
  75. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/api.py +0 -0
  76. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/context.py +0 -0
  77. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/__init__.py +0 -0
  78. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/crud/schema.py +0 -0
  79. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/errors.py +0 -0
  80. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/formatters.py +0 -0
  81. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/misc.py +0 -0
  82. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/prompt.py +0 -0
  83. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/sentry.py +0 -0
  84. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/shell_commands.py +0 -0
  85. {doover_cli-0.3.1 → doover_cli-0.3.3}/src/doover_cli/utils/state.py +0 -0
  86. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/__init__.py +0 -0
  87. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/conftest.py +0 -0
  88. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_auth_integration.py +0 -0
  89. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_auth_unit.py +0 -0
  90. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_basic.py +0 -0
  91. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_crud_lookup.py +0 -0
  92. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_crud_schema.py +0 -0
  93. {doover_cli-0.3.1 → doover_cli-0.3.3}/tests/test_prompt.py +0 -0
@@ -130,4 +130,5 @@ dmypy.json
130
130
 
131
131
  # User testing files
132
132
  tmp/
133
- local/
133
+ local/
134
+ .DS_Store
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doover-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: CLI for Doover
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -10,7 +10,7 @@ Requires-Dist: docker>=7.1.0
10
10
  Requires-Dist: jsf>=0.11.2
11
11
  Requires-Dist: paramiko>=3.5.1
12
12
  Requires-Dist: prompt-toolkit>=3.0.52
13
- Requires-Dist: pydoover==1.0.0a3
13
+ Requires-Dist: pydoover>=1.5.1
14
14
  Requires-Dist: pytz>=2025.2
15
15
  Requires-Dist: questionary>=1.10.0
16
16
  Requires-Dist: requests>=2.32.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "doover-cli"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "CLI for Doover"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "docker>=7.1.0",
11
11
  "jsf>=0.11.2",
12
12
  "paramiko>=3.5.1",
13
- "pydoover==1.0.0a3",
13
+ "pydoover>=1.5.1",
14
14
  "pytz>=2025.2",
15
15
  "questionary>=1.10.0",
16
16
  "requests>=2.32.3",
@@ -35,9 +35,5 @@ dev = [
35
35
  "pytest>=8.3.5",
36
36
  ]
37
37
 
38
- [tool.uv.sources]
39
- pydoover = { git = "https://github.com/getdoover/pydoover" }
40
-
41
38
  [tool.ty.environment]
42
39
  python = "./.venv"
43
-
@@ -7,6 +7,7 @@ import typer
7
7
  from pydoover.api.auth import ConfigManager
8
8
 
9
9
  from .apps.apps import app as apps_app
10
+ from .apps.app_install import app as app_install_app
10
11
  from .config_schema import app as config_schema_app
11
12
  from .ui_schema import app as ui_schema_app
12
13
  from .simulator import app as simulators_app
@@ -14,11 +15,13 @@ from .agent import app as agents_app
14
15
  from .channel import app as channels_app
15
16
  from .doover_config import app as doover_config_app
16
17
  from .dda_logs import app as dda_logs_app
18
+ from .apps.device import app as device_app
17
19
  from .apps.device_type import app as device_type_app
18
20
  from .grpc import app as grpc_app
19
21
  from .login import app as login_app
20
22
  from .report import app as reports_app
21
- from .tunnel import app as tunnels_app
23
+ from .apps.tunnel import app as tunnels_app
24
+ from .user import org_app, users_app
22
25
  from .utils import sentry as sentry_utils
23
26
  from .utils.state import state
24
27
 
@@ -26,6 +29,11 @@ app = typer.Typer(no_args_is_help=True)
26
29
  app.add_typer(
27
30
  apps_app, name="app", help="Manage applications and their configurations."
28
31
  )
32
+ app.add_typer(
33
+ app_install_app,
34
+ name="app-install",
35
+ help="Manage application installations in Doover 2.0.",
36
+ )
29
37
  app.add_typer(
30
38
  config_schema_app, name="config-schema", help="Manage application config schemas."
31
39
  )
@@ -42,12 +50,15 @@ app.add_typer(
42
50
  )
43
51
  app.add_typer(login_app)
44
52
  app.add_typer(reports_app, name="report", help="Generate and manage reports.")
45
- app.add_typer(tunnels_app, name="tunnel", help="Manage SSH tunnels for remote access.")
53
+ app.add_typer(tunnels_app, name="tunnel", help="Manage tunnels for remote access.")
46
54
  app.add_typer(grpc_app, name="grpc", help="Interact with running gRPC servers.")
47
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")
48
57
  app.add_typer(
49
58
  device_type_app, name="device-type", help="Manipulate device types in Doover 2.0"
50
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.")
51
62
 
52
63
 
53
64
  def version_callback(value: bool):
@@ -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
+ )