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.
Files changed (101) hide show
  1. dhis2w_core-0.5.1/PKG-INFO +23 -0
  2. dhis2w_core-0.5.1/README.md +0 -0
  3. dhis2w_core-0.5.1/pyproject.toml +27 -0
  4. dhis2w_core-0.5.1/src/dhis2w_core/__init__.py +1 -0
  5. dhis2w_core-0.5.1/src/dhis2w_core/cli_errors.py +144 -0
  6. dhis2w_core-0.5.1/src/dhis2w_core/cli_output.py +339 -0
  7. dhis2w_core-0.5.1/src/dhis2w_core/cli_task_watch.py +63 -0
  8. dhis2w_core-0.5.1/src/dhis2w_core/client_context.py +150 -0
  9. dhis2w_core-0.5.1/src/dhis2w_core/oauth2_preflight.py +112 -0
  10. dhis2w_core-0.5.1/src/dhis2w_core/oauth2_redirect.py +137 -0
  11. dhis2w_core-0.5.1/src/dhis2w_core/oauth2_registration.py +108 -0
  12. dhis2w_core-0.5.1/src/dhis2w_core/pat_registration.py +64 -0
  13. dhis2w_core-0.5.1/src/dhis2w_core/plugin.py +64 -0
  14. dhis2w_core-0.5.1/src/dhis2w_core/plugins/__init__.py +1 -0
  15. dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/__init__.py +1 -0
  16. dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/cli.py +163 -0
  17. dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/mcp.py +116 -0
  18. dhis2w_core-0.5.1/src/dhis2w_core/plugins/aggregate/service.py +142 -0
  19. dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/__init__.py +30 -0
  20. dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/cli.py +347 -0
  21. dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/mcp.py +203 -0
  22. dhis2w_core-0.5.1/src/dhis2w_core/plugins/analytics/service.py +360 -0
  23. dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/__init__.py +33 -0
  24. dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/cli.py +411 -0
  25. dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/mcp.py +125 -0
  26. dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/models.py +51 -0
  27. dhis2w_core-0.5.1/src/dhis2w_core/plugins/apps/service.py +244 -0
  28. dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/__init__.py +41 -0
  29. dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/cli.py +318 -0
  30. dhis2w_core-0.5.1/src/dhis2w_core/plugins/browser/service.py +485 -0
  31. dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/__init__.py +38 -0
  32. dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/cli.py +122 -0
  33. dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/mcp.py +78 -0
  34. dhis2w_core-0.5.1/src/dhis2w_core/plugins/customize/service.py +77 -0
  35. dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/__init__.py +30 -0
  36. dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/cli.py +19 -0
  37. dhis2w_core-0.5.1/src/dhis2w_core/plugins/data/mcp.py +14 -0
  38. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/__init__.py +29 -0
  39. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/admin_auth.py +34 -0
  40. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/cli.py +31 -0
  41. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/oauth2.py +60 -0
  42. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/pat.py +74 -0
  43. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/sample.py +252 -0
  44. dhis2w_core-0.5.1/src/dhis2w_core/plugins/dev/uid.py +22 -0
  45. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/__init__.py +33 -0
  46. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/_models.py +63 -0
  47. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/cli.py +128 -0
  48. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/mcp.py +53 -0
  49. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_bugs.py +301 -0
  50. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_integrity.py +118 -0
  51. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/probes_metadata.py +664 -0
  52. dhis2w_core-0.5.1/src/dhis2w_core/plugins/doctor/service.py +94 -0
  53. dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/__init__.py +34 -0
  54. dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/cli.py +229 -0
  55. dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/mcp.py +66 -0
  56. dhis2w_core-0.5.1/src/dhis2w_core/plugins/files/service.py +110 -0
  57. dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/__init__.py +30 -0
  58. dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/cli.py +748 -0
  59. dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/mcp.py +230 -0
  60. dhis2w_core-0.5.1/src/dhis2w_core/plugins/maintenance/service.py +359 -0
  61. dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/__init__.py +34 -0
  62. dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/cli.py +260 -0
  63. dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/mcp.py +121 -0
  64. dhis2w_core-0.5.1/src/dhis2w_core/plugins/messaging/service.py +102 -0
  65. dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/__init__.py +30 -0
  66. dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/cli.py +8072 -0
  67. dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/mcp.py +3750 -0
  68. dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/models.py +136 -0
  69. dhis2w_core-0.5.1/src/dhis2w_core/plugins/metadata/service.py +4431 -0
  70. dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/__init__.py +30 -0
  71. dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/cli.py +723 -0
  72. dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/mcp.py +46 -0
  73. dhis2w_core-0.5.1/src/dhis2w_core/plugins/profile/service.py +470 -0
  74. dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/__init__.py +30 -0
  75. dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/cli.py +312 -0
  76. dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/mcp.py +88 -0
  77. dhis2w_core-0.5.1/src/dhis2w_core/plugins/route/service.py +211 -0
  78. dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/__init__.py +30 -0
  79. dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/cli.py +107 -0
  80. dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/mcp.py +45 -0
  81. dhis2w_core-0.5.1/src/dhis2w_core/plugins/system/service.py +52 -0
  82. dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/__init__.py +1 -0
  83. dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/cli.py +608 -0
  84. dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/mcp.py +303 -0
  85. dhis2w_core-0.5.1/src/dhis2w_core/plugins/tracker/service.py +404 -0
  86. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/__init__.py +30 -0
  87. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/cli.py +241 -0
  88. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/mcp.py +98 -0
  89. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user/service.py +191 -0
  90. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/__init__.py +30 -0
  91. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/cli.py +185 -0
  92. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/mcp.py +63 -0
  93. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_group/service.py +95 -0
  94. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/__init__.py +30 -0
  95. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/cli.py +131 -0
  96. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/mcp.py +62 -0
  97. dhis2w_core-0.5.1/src/dhis2w_core/plugins/user_role/service.py +77 -0
  98. dhis2w_core-0.5.1/src/dhis2w_core/profile.py +294 -0
  99. dhis2w_core-0.5.1/src/dhis2w_core/py.typed +0 -0
  100. dhis2w_core-0.5.1/src/dhis2w_core/rich_console.py +14 -0
  101. 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}")