doover-cli 0.3.1__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.3.2/AGENT.md +8 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/PKG-INFO +1 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/pyproject.toml +2 -3
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/__init__.py +11 -0
- doover_cli-0.3.2/src/doover_cli/agent.py +239 -0
- doover_cli-0.3.2/src/doover_cli/apps/app_install.py +2757 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/apps/apps.py +23 -32
- doover_cli-0.3.2/src/doover_cli/apps/device.py +440 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/apps/device_type.py +1 -2
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/channel.py +2 -2
- doover_cli-0.3.2/src/doover_cli/colours.py +10 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/renderer/__init__.py +2 -1
- doover_cli-0.3.2/src/doover_cli/renderer/_base.py +107 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/renderer/_basic.py +26 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/renderer/_default.py +160 -10
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/renderer/_json.py +4 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/ui_schema.py +4 -0
- doover_cli-0.3.2/src/doover_cli/user.py +846 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/apps.py +26 -5
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/commands.py +55 -7
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/lookup.py +6 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/prompting.py +15 -10
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/values.py +95 -1
- 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.1 → doover_cli-0.3.2}/tests/test_apps.py +7 -8
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_crud_commands.py +103 -4
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_crud_prompting.py +54 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_crud_values.py +65 -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.1 → doover_cli-0.3.2}/tests/test_device_type.py +62 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_device_type_cli_integration.py +3 -1
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_sentry.py +2 -2
- 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.3.1 → doover_cli-0.3.2}/uv.lock +377 -371
- doover_cli-0.3.1/src/doover_cli/agent.py +0 -12
- doover_cli-0.3.1/src/doover_cli/renderer/_base.py +0 -37
- doover_cli-0.3.1/tests/test_default_renderer.py +0 -196
- doover_cli-0.3.1/tests/test_utils_apps.py +0 -116
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/CONTRIBUTING.md +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/workflows/lint-test.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/workflows/release-brew.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/workflows/release-pypi.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.github/workflows/release.yml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.gitignore +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/.pre-commit-config.yaml +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/LICENSE +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/README.md +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/debian/changelog +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/debian/control +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/debian/rules +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/docs/user_stories.md +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/api/__init__.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/api/auth.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/api/errors.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/api/session.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/config_schema.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/dda_logs.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/doover_config.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/grpc.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/login.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/report.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/simulator.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/tunnel.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/__init__.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/api.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/context.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/__init__.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/crud/schema.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/errors.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/formatters.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/misc.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/parsers.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/prompt.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/sentry.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/shell_commands.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/src/doover_cli/utils/state.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/__init__.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/conftest.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_auth_integration.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_auth_unit.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_basic.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_crud_lookup.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_crud_schema.py +0 -0
- {doover_cli-0.3.1 → doover_cli-0.3.2}/tests/test_prompt.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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "doover-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "CLI for Doover"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = [
|
|
@@ -36,8 +36,7 @@ dev = [
|
|
|
36
36
|
]
|
|
37
37
|
|
|
38
38
|
[tool.uv.sources]
|
|
39
|
-
pydoover = { git = "https://github.com/getdoover/pydoover" }
|
|
39
|
+
pydoover = { git = "https://github.com/getdoover/pydoover", branch = "chore/update-control-api" }
|
|
40
40
|
|
|
41
41
|
[tool.ty.environment]
|
|
42
42
|
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
23
|
from .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
|
)
|
|
@@ -45,9 +53,12 @@ app.add_typer(reports_app, name="report", help="Generate and manage reports.")
|
|
|
45
53
|
app.add_typer(tunnels_app, name="tunnel", help="Manage SSH 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
|
+
)
|