dhis2w-core 0.5.1__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.
- dhis2w_core-0.5.1/PKG-INFO +23 -0
- dhis2w_core-0.5.1/README.md +0 -0
- dhis2w_core-0.5.1/pyproject.toml +27 -0
- dhis2w_core-0.5.1/src/dhis2w_core/__init__.py +1 -0
- dhis2w_core-0.5.1/src/dhis2w_core/cli_errors.py +144 -0
- dhis2w_core-0.5.1/src/dhis2w_core/cli_output.py +339 -0
- dhis2w_core-0.5.1/src/dhis2w_core/cli_task_watch.py +63 -0
- dhis2w_core-0.5.1/src/dhis2w_core/client_context.py +150 -0
- dhis2w_core-0.5.1/src/dhis2w_core/oauth2_preflight.py +112 -0
- dhis2w_core-0.5.1/src/dhis2w_core/oauth2_redirect.py +137 -0
- dhis2w_core-0.5.1/src/dhis2w_core/oauth2_registration.py +108 -0
- dhis2w_core-0.5.1/src/dhis2w_core/pat_registration.py +64 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugin.py +64 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/__init__.py +1 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/__init__.py +1 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/cli.py +163 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/mcp.py +116 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/service.py +142 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/cli.py +347 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/mcp.py +203 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/service.py +360 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/__init__.py +33 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/cli.py +411 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/mcp.py +125 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/models.py +51 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/service.py +244 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/__init__.py +41 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/cli.py +318 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/service.py +485 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/__init__.py +38 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/cli.py +122 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/mcp.py +78 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/service.py +77 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/cli.py +19 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/mcp.py +14 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/__init__.py +29 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/admin_auth.py +34 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/cli.py +31 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/oauth2.py +60 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/pat.py +74 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/sample.py +252 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/uid.py +22 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/__init__.py +33 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/_models.py +63 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/cli.py +128 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/mcp.py +53 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_bugs.py +301 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_integrity.py +118 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_metadata.py +664 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/service.py +94 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/__init__.py +34 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/cli.py +229 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/mcp.py +66 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/service.py +110 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/cli.py +748 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/mcp.py +230 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/service.py +359 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/__init__.py +34 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/cli.py +260 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/mcp.py +121 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/service.py +102 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/cli.py +8072 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/mcp.py +3750 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/models.py +136 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/service.py +4431 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/cli.py +723 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/mcp.py +46 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/service.py +470 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/cli.py +312 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/mcp.py +88 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/service.py +211 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/cli.py +107 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/mcp.py +45 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/service.py +52 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/__init__.py +1 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/cli.py +608 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/mcp.py +303 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/service.py +404 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/cli.py +241 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/mcp.py +98 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/service.py +191 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/cli.py +185 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/mcp.py +63 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/service.py +95 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/__init__.py +30 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/cli.py +131 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/mcp.py +62 -0
- dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/service.py +77 -0
- dhis2w_core-0.5.1/src/dhis2w_core/profile.py +294 -0
- dhis2w_core-0.5.1/src/dhis2w_core/py.typed +0 -0
- dhis2w_core-0.5.1/src/dhis2w_core/rich_console.py +14 -0
- dhis2w_core-0.5.1/src/dhis2w_core/token_store.py +131 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dhis2w-core
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Shared DHIS2 tooling runtime: profile discovery, plugin registry, auth factory, token store, first-party plugins.
|
|
5
|
+
Author: Morten Hansen
|
|
6
|
+
Author-email: Morten Hansen <morten@winterop.com>
|
|
7
|
+
Requires-Dist: dhis2w-client>=0.5.0,<0.6
|
|
8
|
+
Requires-Dist: pydantic>=2.13
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.13
|
|
10
|
+
Requires-Dist: tomli-w>=1.2
|
|
11
|
+
Requires-Dist: typer>=0.24
|
|
12
|
+
Requires-Dist: rich>=15
|
|
13
|
+
Requires-Dist: fastmcp>=3.2
|
|
14
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
15
|
+
Requires-Dist: aiosqlite>=0.22
|
|
16
|
+
Requires-Dist: alembic>=1.18
|
|
17
|
+
Requires-Dist: fastapi>=0.115
|
|
18
|
+
Requires-Dist: uvicorn>=0.32
|
|
19
|
+
Requires-Dist: bcrypt>=5.0.0
|
|
20
|
+
Requires-Dist: questionary>=2.1.1
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dhis2w-core"
|
|
3
|
+
version = "0.5.1"
|
|
4
|
+
description = "Shared DHIS2 tooling runtime: profile discovery, plugin registry, auth factory, token store, first-party plugins."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"dhis2w-client>=0.5.0,<0.6",
|
|
10
|
+
"pydantic>=2.13",
|
|
11
|
+
"pydantic-settings>=2.13",
|
|
12
|
+
"tomli-w>=1.2",
|
|
13
|
+
"typer>=0.24",
|
|
14
|
+
"rich>=15",
|
|
15
|
+
"fastmcp>=3.2",
|
|
16
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
17
|
+
"aiosqlite>=0.22",
|
|
18
|
+
"alembic>=1.18",
|
|
19
|
+
"fastapi>=0.115",
|
|
20
|
+
"uvicorn>=0.32",
|
|
21
|
+
"bcrypt>=5.0.0",
|
|
22
|
+
"questionary>=2.1.1",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
27
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared DHIS2 tooling runtime: profile discovery, plugin registry, auth factory, first-party plugins."""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Clean-error rendering for CLI commands.
|
|
2
|
+
|
|
3
|
+
Expected user-facing errors get printed as a one-line message (in red, on
|
|
4
|
+
stderr) plus a short hint block, then exit 1. Programming bugs (AssertionError,
|
|
5
|
+
KeyError, etc.) still propagate and show a full traceback so they can be
|
|
6
|
+
triaged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import NoReturn
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from dhis2w_client.envelopes import WebMessageResponse
|
|
16
|
+
from dhis2w_client.errors import AuthenticationError, Dhis2ApiError, Dhis2ClientError, OAuth2FlowError
|
|
17
|
+
|
|
18
|
+
from dhis2w_core.plugins.profile.service import ProfileAlreadyExistsError
|
|
19
|
+
from dhis2w_core.profile import (
|
|
20
|
+
InvalidProfileNameError,
|
|
21
|
+
NoProfileError,
|
|
22
|
+
UnknownProfileError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_NO_PROFILE_HINT = [
|
|
26
|
+
"run `dhis2 profile --help` for setup options, or try:",
|
|
27
|
+
" dhis2 profile list # see what's configured",
|
|
28
|
+
" dhis2 profile add <name> --scope global \\",
|
|
29
|
+
" --url https://dhis2.example.org \\",
|
|
30
|
+
" --auth pat --token d2p_... --default",
|
|
31
|
+
" dhis2 profile verify <name> # confirm auth works",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_UNKNOWN_PROFILE_HINT = [
|
|
35
|
+
"run `dhis2 profile list` to see available profiles",
|
|
36
|
+
"or `dhis2 profile add <name> ...` to create one",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_INVALID_NAME_HINT = [
|
|
40
|
+
"profile names must start with a letter and contain only letters,",
|
|
41
|
+
"digits, and underscores (e.g. 'local', 'prod_eu', 'laohis42').",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_AUTH_HINT = [
|
|
45
|
+
"run `dhis2 profile verify <name>` to confirm auth",
|
|
46
|
+
"or `dhis2 profile show <name>` to inspect the stored credentials",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_ALREADY_EXISTS_HINT = [
|
|
50
|
+
"run `dhis2 profile list` to see existing profiles",
|
|
51
|
+
"or `dhis2 profile remove <name>` first to free the name",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_OAUTH2_HINT = [
|
|
55
|
+
"run `dhis2 profile login <name>` to re-authorise the OAuth2 flow",
|
|
56
|
+
"or `dhis2 profile verify <name>` to confirm the current state",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_app(app: typer.Typer) -> NoReturn:
|
|
61
|
+
"""Invoke a Typer app with clean error rendering for known exceptions."""
|
|
62
|
+
try:
|
|
63
|
+
app()
|
|
64
|
+
except NoProfileError as exc:
|
|
65
|
+
_render("error", str(exc), _NO_PROFILE_HINT)
|
|
66
|
+
except UnknownProfileError as exc:
|
|
67
|
+
_render("error", str(exc), _UNKNOWN_PROFILE_HINT)
|
|
68
|
+
except InvalidProfileNameError as exc:
|
|
69
|
+
_render("error", str(exc), _INVALID_NAME_HINT)
|
|
70
|
+
except ProfileAlreadyExistsError as exc:
|
|
71
|
+
_render("error", str(exc), _ALREADY_EXISTS_HINT)
|
|
72
|
+
except AuthenticationError as exc:
|
|
73
|
+
_render("auth error", str(exc), _AUTH_HINT)
|
|
74
|
+
except OAuth2FlowError as exc:
|
|
75
|
+
_render("oauth2 error", str(exc), _OAUTH2_HINT)
|
|
76
|
+
except Dhis2ApiError as exc:
|
|
77
|
+
_render_api_error(exc)
|
|
78
|
+
except Dhis2ClientError as exc:
|
|
79
|
+
_render("DHIS2 error", str(exc))
|
|
80
|
+
except LookupError as exc:
|
|
81
|
+
_render("error", str(exc))
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_api_error(exc: Dhis2ApiError) -> NoReturn:
|
|
86
|
+
"""Render a Dhis2ApiError — extract the WebMessage envelope when DHIS2 ships one."""
|
|
87
|
+
envelope = exc.web_message
|
|
88
|
+
detail = exc.message or ""
|
|
89
|
+
body_msg = envelope.message if envelope and envelope.message else _extract_body_message(exc.body)
|
|
90
|
+
if body_msg:
|
|
91
|
+
detail = f"{detail}: {body_msg}" if detail else body_msg
|
|
92
|
+
extras = _webmessage_detail_lines(envelope) if envelope else []
|
|
93
|
+
# Render the Rich conflict table BEFORE calling `_render` — the latter is
|
|
94
|
+
# a `NoReturn` sys.exit wrapper, so nothing after it executes.
|
|
95
|
+
if envelope:
|
|
96
|
+
rows = envelope.conflict_rows()
|
|
97
|
+
if rows:
|
|
98
|
+
from dhis2w_core.cli_output import render_conflicts # noqa: PLC0415 — lazy for the cheap-happy-path
|
|
99
|
+
|
|
100
|
+
render_conflicts(rows)
|
|
101
|
+
_render(f"DHIS2 API error ({exc.status_code})", detail or "(no further detail)", extras=extras)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _webmessage_detail_lines(envelope: WebMessageResponse) -> list[str]:
|
|
105
|
+
"""Format the import-count + rejected-indexes summary lines for end-user output.
|
|
106
|
+
|
|
107
|
+
Conflicts themselves render separately as a Rich table via
|
|
108
|
+
`render_conflicts(envelope.conflict_rows())` — this function handles
|
|
109
|
+
only the one-line summary bits DHIS2 tucks under `response.*`.
|
|
110
|
+
"""
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
counts = envelope.import_count()
|
|
113
|
+
if counts is not None and any((counts.imported, counts.updated, counts.ignored, counts.deleted)):
|
|
114
|
+
lines.append(
|
|
115
|
+
f"import_count: imported={counts.imported} updated={counts.updated} "
|
|
116
|
+
f"ignored={counts.ignored} deleted={counts.deleted}"
|
|
117
|
+
)
|
|
118
|
+
rejected = envelope.rejected_indexes()
|
|
119
|
+
if rejected:
|
|
120
|
+
lines.append(f"rejected_indexes: {rejected}")
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render(label: str, message: str, hint: list[str] | None = None, extras: list[str] | None = None) -> NoReturn:
|
|
125
|
+
"""Print `label: message` + optional extras + optional hint block to stderr and exit 1."""
|
|
126
|
+
typer.secho(f"{label}: {message}", err=True, fg=typer.colors.RED)
|
|
127
|
+
if extras:
|
|
128
|
+
for line in extras:
|
|
129
|
+
typer.echo(line, err=True)
|
|
130
|
+
if hint:
|
|
131
|
+
typer.echo("", err=True)
|
|
132
|
+
typer.echo("hint:", err=True)
|
|
133
|
+
for line in hint:
|
|
134
|
+
typer.echo(f" {line}", err=True)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _extract_body_message(body: object) -> str | None:
|
|
139
|
+
"""Pull a useful error message out of the DHIS2 JSON body, if present."""
|
|
140
|
+
if isinstance(body, dict):
|
|
141
|
+
message = body.get("message")
|
|
142
|
+
if isinstance(message, str) and message:
|
|
143
|
+
return message
|
|
144
|
+
return None
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Shared CLI output helpers.
|
|
2
|
+
|
|
3
|
+
Two layers:
|
|
4
|
+
|
|
5
|
+
- **WebMessage rendering** — `render_webmessage` picks the right one-liner
|
|
6
|
+
for every DHIS2 write response (ImportSummary, ObjectReport,
|
|
7
|
+
JobConfiguration). Same hook across every plugin's write commands.
|
|
8
|
+
- **Detail / list rendering** — `render_detail` and `render_list` are the
|
|
9
|
+
standard shapes every `get` / `list` output uses. Single source of truth
|
|
10
|
+
for Rich styling, reference formatting, and truthy cells so every
|
|
11
|
+
plugin's output looks and feels the same.
|
|
12
|
+
|
|
13
|
+
Convention across all plugins:
|
|
14
|
+
|
|
15
|
+
- Default output is a Rich table — bold title, bold-cyan labels, plain
|
|
16
|
+
values. References render as `"name (id)"` via `format_ref`; lists
|
|
17
|
+
render as `", ".join(format_ref(x) for x in values)` via `format_reflist`
|
|
18
|
+
with a "... +N more" tail past the preview limit.
|
|
19
|
+
- Raw JSON is a global mode — the root CLI accepts `--json` once
|
|
20
|
+
(`dhis2 --json metadata get ...`) and stores it in `JSON_OUTPUT`.
|
|
21
|
+
Every plugin checks `is_json_output()` to decide whether to emit
|
|
22
|
+
`model_dump_json(indent=2, exclude_none=True)` instead of a Rich table.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable, Iterable
|
|
28
|
+
from contextvars import ContextVar
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import typer
|
|
32
|
+
from dhis2w_client import ConflictRow, WebMessageResponse
|
|
33
|
+
from pydantic import BaseModel, ConfigDict
|
|
34
|
+
from rich.console import Console
|
|
35
|
+
from rich.table import Table
|
|
36
|
+
|
|
37
|
+
_console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
JSON_OUTPUT: ContextVar[bool] = ContextVar("dhis2_json_output", default=False)
|
|
41
|
+
"""Global toggle set by the CLI root callback when `--json` is passed.
|
|
42
|
+
|
|
43
|
+
Plugins read it via `is_json_output()` instead of declaring a per-command
|
|
44
|
+
flag. ContextVar (rather than a module global) so test fixtures can scope
|
|
45
|
+
the toggle with `JSON_OUTPUT.set(True)` + reset tokens without leaking
|
|
46
|
+
across tests.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_json_output() -> bool:
|
|
51
|
+
"""True when the current invocation was launched with `--json`."""
|
|
52
|
+
return JSON_OUTPUT.get()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_webmessage(
|
|
56
|
+
envelope: WebMessageResponse,
|
|
57
|
+
*,
|
|
58
|
+
as_json: bool | None = None,
|
|
59
|
+
action: str = "",
|
|
60
|
+
max_conflicts: int = 25,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Print one line describing `envelope`, or the full JSON when `--json` is set.
|
|
63
|
+
|
|
64
|
+
`action` labels the user intent (`created`, `updated`, `deleted`, `set`,
|
|
65
|
+
`pushed`). For kickoff envelopes (job configs) the action is ignored —
|
|
66
|
+
the summary names the job type. For import-summary envelopes the action
|
|
67
|
+
prefixes the import-count line (`pushed: imported=1 updated=0 ...`).
|
|
68
|
+
|
|
69
|
+
When the envelope carries conflicts / error reports (either
|
|
70
|
+
`response.conflicts[]` from data-value imports or
|
|
71
|
+
`response.typeReports[*].objectReports[*].errorReports[*]` from
|
|
72
|
+
metadata imports), renders a Rich table of the first `max_conflicts`
|
|
73
|
+
rows grouped by resource + errorCode. Pass `max_conflicts=0` to suppress
|
|
74
|
+
the table (for callers that render elsewhere).
|
|
75
|
+
|
|
76
|
+
`as_json` is normally `None` — the helper reads the global `JSON_OUTPUT`
|
|
77
|
+
contextvar set by the CLI root callback. Pass an explicit `bool` only
|
|
78
|
+
when a caller needs to override the global (e.g. a refresh-status action
|
|
79
|
+
that always renders human output regardless of the user's `--json`).
|
|
80
|
+
"""
|
|
81
|
+
if is_json_output() if as_json is None else as_json:
|
|
82
|
+
typer.echo(envelope.model_dump_json(indent=2, exclude_none=True))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
ref = envelope.task_ref()
|
|
86
|
+
if ref is not None:
|
|
87
|
+
job_type, task_uid = ref
|
|
88
|
+
typer.echo(f"kicked off {job_type} (task={task_uid})")
|
|
89
|
+
typer.echo(f" poll: dhis2 maintenance task watch {job_type} {task_uid}")
|
|
90
|
+
typer.echo(" or re-run with --watch / -w to stream progress")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
counts = envelope.import_count()
|
|
94
|
+
if counts is not None and any((counts.imported, counts.updated, counts.ignored, counts.deleted)):
|
|
95
|
+
prefix = f"{action}: " if action else ""
|
|
96
|
+
typer.echo(
|
|
97
|
+
f"{prefix}imported={counts.imported} updated={counts.updated} "
|
|
98
|
+
f"ignored={counts.ignored} deleted={counts.deleted}"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
uid = envelope.created_uid
|
|
102
|
+
if uid:
|
|
103
|
+
verb = action or "ok"
|
|
104
|
+
typer.echo(f"{verb} {uid}")
|
|
105
|
+
else:
|
|
106
|
+
message = envelope.message or envelope.httpStatus or "ok"
|
|
107
|
+
typer.echo(f"{action or 'ok'}: {message}" if action else message)
|
|
108
|
+
|
|
109
|
+
if max_conflicts > 0:
|
|
110
|
+
rows = envelope.conflict_rows()
|
|
111
|
+
if rows:
|
|
112
|
+
render_conflicts(rows, limit=max_conflicts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render_conflicts(rows: list[ConflictRow], *, limit: int = 25, console: Console | None = None) -> None:
|
|
116
|
+
"""Render a list of `ConflictRow` as a Rich table grouped by resource + errorCode.
|
|
117
|
+
|
|
118
|
+
Useful both on metadata imports (each `ErrorReport` becomes a row) and
|
|
119
|
+
on data-value / tracker imports (each `conflicts[]` entry becomes a row).
|
|
120
|
+
Caller gets one scannable table regardless of where DHIS2 tucked the
|
|
121
|
+
errors on the wire.
|
|
122
|
+
|
|
123
|
+
`limit` caps the rendered rows — conflicts past the cap render as a
|
|
124
|
+
`... +N more` footer line so the terminal doesn't drown on 500-conflict
|
|
125
|
+
imports. Pass `limit=0` for "show everything".
|
|
126
|
+
"""
|
|
127
|
+
if not rows:
|
|
128
|
+
return
|
|
129
|
+
target = console or _console
|
|
130
|
+
total = len(rows)
|
|
131
|
+
# Newest information first: show rows with an errorCode / resource before
|
|
132
|
+
# the "bare message" ones, then stable-sort so same-resource + same-code
|
|
133
|
+
# rows cluster together.
|
|
134
|
+
rows = sorted(rows, key=lambda r: (r.resource or "~", r.error_code or "~", r.property or "", r.uid or ""))
|
|
135
|
+
visible = rows if limit <= 0 else rows[:limit]
|
|
136
|
+
|
|
137
|
+
table = Table(
|
|
138
|
+
title=f"[red]conflicts[/red] ({total})",
|
|
139
|
+
title_style="bold",
|
|
140
|
+
pad_edge=False,
|
|
141
|
+
expand=False,
|
|
142
|
+
)
|
|
143
|
+
table.add_column("resource", style="cyan", overflow="fold")
|
|
144
|
+
table.add_column("uid", style="dim", overflow="fold")
|
|
145
|
+
table.add_column("property", overflow="fold")
|
|
146
|
+
table.add_column("value", overflow="fold")
|
|
147
|
+
table.add_column("code", style="yellow", overflow="fold")
|
|
148
|
+
table.add_column("message", overflow="fold")
|
|
149
|
+
|
|
150
|
+
for row in visible:
|
|
151
|
+
table.add_row(
|
|
152
|
+
row.resource or "-",
|
|
153
|
+
row.uid or "-",
|
|
154
|
+
row.property or "-",
|
|
155
|
+
(row.value or "-")[:80],
|
|
156
|
+
row.error_code or "-",
|
|
157
|
+
(row.message or "-")[:120],
|
|
158
|
+
)
|
|
159
|
+
target.print(table)
|
|
160
|
+
if limit > 0 and total > limit:
|
|
161
|
+
target.print(f"[dim]... +{total - limit} more conflict{'s' if total - limit != 1 else ''}[/dim]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Detail + list rendering
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_REF_ID_KEYS = ("id", "uid")
|
|
170
|
+
_REF_LABEL_KEYS = ("displayName", "name", "code", "username")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_ref(value: Any) -> str:
|
|
174
|
+
"""Render any DHIS2-style reference as the best human form.
|
|
175
|
+
|
|
176
|
+
Precedence:
|
|
177
|
+
1. `"name (id)"` when both name-ish and id-ish are present.
|
|
178
|
+
2. Just the name.
|
|
179
|
+
3. Just the UID.
|
|
180
|
+
4. `str(value)` fallback.
|
|
181
|
+
5. `'-'` on None.
|
|
182
|
+
|
|
183
|
+
Accepts pydantic models, plain dicts, and bare strings. Strings pass
|
|
184
|
+
through unchanged (they're already "name" or already a UID).
|
|
185
|
+
"""
|
|
186
|
+
if value is None:
|
|
187
|
+
return "-"
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
return value or "-"
|
|
190
|
+
if isinstance(value, dict):
|
|
191
|
+
label = next((value[k] for k in _REF_LABEL_KEYS if value.get(k)), None)
|
|
192
|
+
uid = next((value[k] for k in _REF_ID_KEYS if value.get(k)), None)
|
|
193
|
+
else:
|
|
194
|
+
label = next((getattr(value, k, None) for k in _REF_LABEL_KEYS if getattr(value, k, None)), None)
|
|
195
|
+
uid = next((getattr(value, k, None) for k in _REF_ID_KEYS if getattr(value, k, None)), None)
|
|
196
|
+
if label and uid:
|
|
197
|
+
return f"{label} [dim]({uid})[/dim]"
|
|
198
|
+
if label:
|
|
199
|
+
return str(label)
|
|
200
|
+
if uid:
|
|
201
|
+
return str(uid)
|
|
202
|
+
return str(value)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_reflist(values: Iterable[Any] | None, *, limit: int = 10, separator: str = ", ") -> str:
|
|
206
|
+
"""Render a list of references as comma-separated `name (id)` with a `+N more` tail."""
|
|
207
|
+
if not values:
|
|
208
|
+
return "-"
|
|
209
|
+
items = list(values)
|
|
210
|
+
preview = [format_ref(v) for v in items[:limit]]
|
|
211
|
+
tail = f" [dim]+{len(items) - limit} more[/dim]" if len(items) > limit else ""
|
|
212
|
+
return separator.join(preview) + tail
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def format_bool(value: Any, *, true_label: str = "yes", false_label: str = "no") -> str:
|
|
216
|
+
"""Render a boolean as a plain label; `None` collapses to `-`."""
|
|
217
|
+
if value is None:
|
|
218
|
+
return "-"
|
|
219
|
+
return true_label if bool(value) else false_label
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_disabled(value: Any) -> str:
|
|
223
|
+
"""`disabled`-style booleans: red when true, dim when false, `-` when None."""
|
|
224
|
+
if value is None:
|
|
225
|
+
return "-"
|
|
226
|
+
return "[red]yes[/red]" if value else "[green]no[/green]"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_access_string(access: str | None) -> str:
|
|
230
|
+
"""Render an 8-char DHIS2 access string — highlight the meaningful chars."""
|
|
231
|
+
if not access:
|
|
232
|
+
return "[dim]--------[/dim]"
|
|
233
|
+
return f"[bold]{access}[/bold]"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class DetailRow(BaseModel):
|
|
237
|
+
"""One line of a key/value detail table."""
|
|
238
|
+
|
|
239
|
+
model_config = ConfigDict(frozen=True)
|
|
240
|
+
|
|
241
|
+
label: str
|
|
242
|
+
value: str
|
|
243
|
+
label_style: str = "bold cyan"
|
|
244
|
+
|
|
245
|
+
def __init__(self, label: str, value: str, *, label_style: str = "bold cyan") -> None:
|
|
246
|
+
"""Accept positional `(label, value)` for terse call sites in plugin CLIs."""
|
|
247
|
+
super().__init__(label=label, value=value, label_style=label_style)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def render_detail(title: str, rows: Iterable[DetailRow | tuple[str, Any]], *, console: Console | None = None) -> None:
|
|
251
|
+
"""Render a two-column key/value detail table.
|
|
252
|
+
|
|
253
|
+
Rows are either `DetailRow` or `(label, value)` tuples. Value is
|
|
254
|
+
stringified as-is (already-formatted Rich markup passes through); wrap
|
|
255
|
+
your own values with `format_ref` / `format_reflist` / `format_bool`
|
|
256
|
+
etc. before passing them in.
|
|
257
|
+
"""
|
|
258
|
+
table = Table(title=title, show_header=False, title_style="bold", pad_edge=False, expand=False)
|
|
259
|
+
table.add_column("field", style="bold cyan", no_wrap=True)
|
|
260
|
+
table.add_column("value", overflow="fold")
|
|
261
|
+
for row in rows:
|
|
262
|
+
if isinstance(row, DetailRow):
|
|
263
|
+
table.add_row(row.label, row.value)
|
|
264
|
+
else:
|
|
265
|
+
label, value = row
|
|
266
|
+
table.add_row(label, _coerce_cell(value))
|
|
267
|
+
(console or _console).print(table)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ColumnSpec(BaseModel):
|
|
271
|
+
"""Declarative spec for a list-table column.
|
|
272
|
+
|
|
273
|
+
`key` is the dict key to pull from each row. `formatter` optionally
|
|
274
|
+
transforms the raw cell value into the displayed string (defaults to
|
|
275
|
+
`format_ref`). `style` sets a rich style (e.g. `'cyan'` for an ID
|
|
276
|
+
column, `'dim'` for timestamps).
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
280
|
+
|
|
281
|
+
label: str
|
|
282
|
+
key: str
|
|
283
|
+
formatter: Callable[[Any], str] | None = None
|
|
284
|
+
style: str | None = None
|
|
285
|
+
no_wrap: bool = False
|
|
286
|
+
|
|
287
|
+
def __init__(
|
|
288
|
+
self,
|
|
289
|
+
label: str,
|
|
290
|
+
key: str,
|
|
291
|
+
*,
|
|
292
|
+
formatter: Callable[[Any], str] | None = None,
|
|
293
|
+
style: str | None = None,
|
|
294
|
+
no_wrap: bool = False,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Accept positional `(label, key)` for terse call sites in plugin CLIs."""
|
|
297
|
+
super().__init__(label=label, key=key, formatter=formatter, style=style, no_wrap=no_wrap)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def render_list(
|
|
301
|
+
title: str,
|
|
302
|
+
rows: Iterable[dict[str, Any]],
|
|
303
|
+
columns: Iterable[ColumnSpec],
|
|
304
|
+
*,
|
|
305
|
+
console: Console | None = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Render a list of rows as a rich Table with typed column formatting.
|
|
308
|
+
|
|
309
|
+
Every column's value passes through `format_ref` by default so references
|
|
310
|
+
(dicts with `id`/`name` etc.) render cleanly, not as raw JSON blobs.
|
|
311
|
+
"""
|
|
312
|
+
rows_list = list(rows)
|
|
313
|
+
cols = list(columns)
|
|
314
|
+
table = Table(title=f"{title} ({len(rows_list)})", title_style="bold", pad_edge=False, expand=False)
|
|
315
|
+
for spec in cols:
|
|
316
|
+
table.add_column(spec.label, style=spec.style, no_wrap=spec.no_wrap, overflow="fold")
|
|
317
|
+
for row in rows_list:
|
|
318
|
+
cells = []
|
|
319
|
+
for spec in cols:
|
|
320
|
+
raw = row.get(spec.key)
|
|
321
|
+
formatter = spec.formatter or format_ref
|
|
322
|
+
cells.append(formatter(raw))
|
|
323
|
+
table.add_row(*cells)
|
|
324
|
+
(console or _console).print(table)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _coerce_cell(value: Any) -> str:
|
|
328
|
+
"""Best-effort stringifier used by `render_detail`."""
|
|
329
|
+
if value is None:
|
|
330
|
+
return "-"
|
|
331
|
+
if isinstance(value, bool):
|
|
332
|
+
return format_bool(value)
|
|
333
|
+
if isinstance(value, str):
|
|
334
|
+
return value or "-"
|
|
335
|
+
if isinstance(value, (list, tuple)):
|
|
336
|
+
return format_reflist(value)
|
|
337
|
+
if isinstance(value, dict):
|
|
338
|
+
return format_ref(value)
|
|
339
|
+
return str(value)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Shared CLI helper — render a DHIS2 background-task's notifications with Rich.
|
|
2
|
+
|
|
3
|
+
Used by every `--watch` flag across plugins (analytics refresh, maintenance
|
|
4
|
+
dataintegrity run, future async ops). Pointed at one `(job_type, task_uid)`,
|
|
5
|
+
reads the `/api/system/tasks/{type}/{uid}` feed via
|
|
6
|
+
`maintenance.service.watch_task` and renders each notification as a coloured
|
|
7
|
+
line (by level) above an animated spinner showing total elapsed time.
|
|
8
|
+
Writes to stderr so stdout stays usable for piping the job-kickoff JSON.
|
|
9
|
+
Exits on the first `completed=true` row; raises `TimeoutError` if `timeout`
|
|
10
|
+
elapses first.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
16
|
+
|
|
17
|
+
from dhis2w_core.plugins.maintenance import service as maintenance_service
|
|
18
|
+
from dhis2w_core.profile import Profile
|
|
19
|
+
from dhis2w_core.rich_console import STDERR_CONSOLE
|
|
20
|
+
|
|
21
|
+
_LEVEL_STYLE: dict[str, str] = {
|
|
22
|
+
"INFO": "cyan",
|
|
23
|
+
"WARN": "yellow",
|
|
24
|
+
"WARNING": "yellow",
|
|
25
|
+
"ERROR": "red bold",
|
|
26
|
+
"LOOP": "magenta",
|
|
27
|
+
"DEBUG": "dim",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def stream_task_to_stdout(
|
|
32
|
+
profile: Profile,
|
|
33
|
+
job_type: str,
|
|
34
|
+
task_uid: str,
|
|
35
|
+
*,
|
|
36
|
+
interval: float = 2.0,
|
|
37
|
+
timeout: float | None = 600.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Poll `/api/system/tasks/{job_type}/{task_uid}` and render with Rich."""
|
|
40
|
+
STDERR_CONSOLE.print(f"[bold cyan]watching[/] [bold]{job_type}/{task_uid}[/] [dim]interval={interval}s[/]")
|
|
41
|
+
final_message: str | None = None
|
|
42
|
+
with Progress(
|
|
43
|
+
SpinnerColumn(),
|
|
44
|
+
TextColumn("[progress.description]{task.description}"),
|
|
45
|
+
TimeElapsedColumn(),
|
|
46
|
+
console=STDERR_CONSOLE,
|
|
47
|
+
transient=True,
|
|
48
|
+
) as progress:
|
|
49
|
+
task_row = progress.add_task("polling...", total=None)
|
|
50
|
+
async for notification in maintenance_service.watch_task(
|
|
51
|
+
profile, job_type, task_uid, interval=interval, timeout=timeout
|
|
52
|
+
):
|
|
53
|
+
level = (notification.level or "INFO").upper()
|
|
54
|
+
style = _LEVEL_STYLE.get(level, "white")
|
|
55
|
+
marker = "[x]" if notification.completed else "[ ]"
|
|
56
|
+
message = notification.message or "-"
|
|
57
|
+
progress.console.print(f" {level:<5} {marker} {message}", style=style, markup=False)
|
|
58
|
+
progress.update(task_row, description=message)
|
|
59
|
+
if notification.completed:
|
|
60
|
+
final_message = message
|
|
61
|
+
break
|
|
62
|
+
summary = final_message or "task completed"
|
|
63
|
+
STDERR_CONSOLE.print(f"[bold green]done[/] [dim]{job_type}/{task_uid}[/] {summary}")
|