dirigent-dhis2 0.9.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.
- dirigent_dhis2-0.9.1/LICENSE +15 -0
- dirigent_dhis2-0.9.1/PKG-INFO +89 -0
- dirigent_dhis2-0.9.1/README.md +76 -0
- dirigent_dhis2-0.9.1/pyproject.toml +113 -0
- dirigent_dhis2-0.9.1/pyproject.toml.orig +83 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/__init__.py +108 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/analytics.py +199 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/analytics_query.py +144 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/complete.py +80 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/connection.py +102 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/export.py +124 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/formats.py +44 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/imports.py +197 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/metadata.py +117 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/py.typed +0 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/tracker.py +137 -0
- dirigent_dhis2-0.9.1/src/dirigent_dhis2/web.py +82 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This source code and accompanying documentation are the property of
|
|
4
|
+
Morten Olav Hansen. No license, express or implied, is granted to use, copy,
|
|
5
|
+
modify, merge, publish, distribute, sublicense, or sell copies of this
|
|
6
|
+
software or its derivatives.
|
|
7
|
+
|
|
8
|
+
The source is published for reference only. Any use beyond reading
|
|
9
|
+
requires written permission from the copyright holder.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
12
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
14
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
|
|
15
|
+
OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirigent-dhis2
|
|
3
|
+
Version: 0.9.1
|
|
4
|
+
Summary: The DHIS2 adapter pack for dirigent: a connection kind and the blocks for one instance.
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: dhis2w-client>=1.13.4,<2
|
|
8
|
+
Requires-Dist: dirigent-common==0.9.1
|
|
9
|
+
Requires-Dist: dirigent-plugin==0.9.1
|
|
10
|
+
Requires-Dist: httpx>=0.28
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# dirigent-dhis2
|
|
15
|
+
|
|
16
|
+
Documentation: <https://winterop-com.github.io/dirigent-dhis2/>
|
|
17
|
+
|
|
18
|
+
The DHIS2 adapter pack for [dirigent](https://github.com/winterop-com/dirigent). It
|
|
19
|
+
contributes the `dhis2` connection kind, two JSON Schema formats (`dhis2-uid` and
|
|
20
|
+
`dhis2-period`), and the blocks that speak DHIS2's asynchronous jobs, import summaries,
|
|
21
|
+
completeness registrations, metadata, tracker and analytics reads as first-class steps,
|
|
22
|
+
rather than composing them out of raw HTTP:
|
|
23
|
+
|
|
24
|
+
| Block | Kind | What it does |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `dhis2.analytics_run` | operator | Submits the analytics tables job and follows its task notifications to the end. |
|
|
27
|
+
| `dhis2.analytics_query` | operator | Runs one analytics query, aggregate or event/enrollment, and hands the grid on. |
|
|
28
|
+
| `dhis2.data_value_set_export` | operator | Reads a data value set for a data set, period and org unit, inline or to storage. |
|
|
29
|
+
| `dhis2.data_value_set_import` | operator | Imports a data value set, parsing the import summary and its conflicts. |
|
|
30
|
+
| `dhis2.metadata` | operator | Reads one metadata collection through the version-bound generic accessor. |
|
|
31
|
+
| `dhis2.tracker` | operator | Reads a page of tracked entities, enrollments or events from `/api/tracker`. |
|
|
32
|
+
| `dhis2.data_set_complete` | sensor | Holds a run until a data set is marked complete for the period. |
|
|
33
|
+
|
|
34
|
+
Every block classifies its failures the dhis2w-client way: an instance whose version the
|
|
35
|
+
client does not speak is refused rather than retried, and a transport failure is transient.
|
|
36
|
+
|
|
37
|
+
An instance discovers the pack by installing it: the `dirigent.plugins.v1` entry point in
|
|
38
|
+
`pyproject.toml` is the whole registration.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
Install the pack into the dirigent image:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install dirigent-dhis2
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
or add it to a project:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add dirigent-dhis2
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Develop
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv sync --locked
|
|
58
|
+
uv run ruff format --check . && uv run ruff check .
|
|
59
|
+
uv run mypy && uv run pyright
|
|
60
|
+
uv run pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That is what CI runs. The lock file is committed and CI syncs against it, so a pack build is
|
|
64
|
+
reproducible; the ecosystem's nightly integration is what proves the pack against the
|
|
65
|
+
|
|
66
|
+
`tests/` exercises every block against a mocked DHIS2 instance, and `tests/test_examples.py`
|
|
67
|
+
validates the example documents against the pack's own catalog. The examples run standalone,
|
|
68
|
+
each carrying the connection it uses.
|
|
69
|
+
|
|
70
|
+
## Examples
|
|
71
|
+
|
|
72
|
+
One file per operation, grouped by how it is built:
|
|
73
|
+
|
|
74
|
+
| Shelf | What is in it |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| [`examples/dhis2/`](examples/dhis2) | The native adapter: exports and their import strategies, the sign-off gate, the analytics reads and rebuilds, the three tracker collections, the metadata reads. |
|
|
77
|
+
| [`examples/dhis2-compose/`](examples/dhis2-compose) | A `dhis2.*` block beside one of the engine's own: a schema gate, a jq reshape, a fan-out over org units. |
|
|
78
|
+
| [`examples/dhis2-http/`](examples/dhis2-http) | The generic-HTTP way, for what no adapter covers: a CSV export, a period range, a completion registration, a stage-scoped event read. |
|
|
79
|
+
| [`examples/validate/`](examples/validate) | A metadata read held to a shape: a `fields=` projection gated on `validate.schema`, with the schema carried and named. |
|
|
80
|
+
| [`examples/schemas/`](examples/schemas) | The JSON Schemas that pin the reads the DHIS2 series makes, applied on their own. |
|
|
81
|
+
|
|
82
|
+
Each shelf's README lists its files one line each.
|
|
83
|
+
|
|
84
|
+
## Licence
|
|
85
|
+
|
|
86
|
+
Copyright (c) 2026 Morten Olav Hansen. All rights reserved. See [LICENSE](LICENSE).
|
|
87
|
+
|
|
88
|
+
The source is published for reference only: no licence to use, copy, modify or distribute it
|
|
89
|
+
is granted, and any use beyond reading requires written permission.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# dirigent-dhis2
|
|
2
|
+
|
|
3
|
+
Documentation: <https://winterop-com.github.io/dirigent-dhis2/>
|
|
4
|
+
|
|
5
|
+
The DHIS2 adapter pack for [dirigent](https://github.com/winterop-com/dirigent). It
|
|
6
|
+
contributes the `dhis2` connection kind, two JSON Schema formats (`dhis2-uid` and
|
|
7
|
+
`dhis2-period`), and the blocks that speak DHIS2's asynchronous jobs, import summaries,
|
|
8
|
+
completeness registrations, metadata, tracker and analytics reads as first-class steps,
|
|
9
|
+
rather than composing them out of raw HTTP:
|
|
10
|
+
|
|
11
|
+
| Block | Kind | What it does |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `dhis2.analytics_run` | operator | Submits the analytics tables job and follows its task notifications to the end. |
|
|
14
|
+
| `dhis2.analytics_query` | operator | Runs one analytics query, aggregate or event/enrollment, and hands the grid on. |
|
|
15
|
+
| `dhis2.data_value_set_export` | operator | Reads a data value set for a data set, period and org unit, inline or to storage. |
|
|
16
|
+
| `dhis2.data_value_set_import` | operator | Imports a data value set, parsing the import summary and its conflicts. |
|
|
17
|
+
| `dhis2.metadata` | operator | Reads one metadata collection through the version-bound generic accessor. |
|
|
18
|
+
| `dhis2.tracker` | operator | Reads a page of tracked entities, enrollments or events from `/api/tracker`. |
|
|
19
|
+
| `dhis2.data_set_complete` | sensor | Holds a run until a data set is marked complete for the period. |
|
|
20
|
+
|
|
21
|
+
Every block classifies its failures the dhis2w-client way: an instance whose version the
|
|
22
|
+
client does not speak is refused rather than retried, and a transport failure is transient.
|
|
23
|
+
|
|
24
|
+
An instance discovers the pack by installing it: the `dirigent.plugins.v1` entry point in
|
|
25
|
+
`pyproject.toml` is the whole registration.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Install the pack into the dirigent image:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv pip install dirigent-dhis2
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or add it to a project:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add dirigent-dhis2
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Develop
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync --locked
|
|
45
|
+
uv run ruff format --check . && uv run ruff check .
|
|
46
|
+
uv run mypy && uv run pyright
|
|
47
|
+
uv run pytest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That is what CI runs. The lock file is committed and CI syncs against it, so a pack build is
|
|
51
|
+
reproducible; the ecosystem's nightly integration is what proves the pack against the
|
|
52
|
+
|
|
53
|
+
`tests/` exercises every block against a mocked DHIS2 instance, and `tests/test_examples.py`
|
|
54
|
+
validates the example documents against the pack's own catalog. The examples run standalone,
|
|
55
|
+
each carrying the connection it uses.
|
|
56
|
+
|
|
57
|
+
## Examples
|
|
58
|
+
|
|
59
|
+
One file per operation, grouped by how it is built:
|
|
60
|
+
|
|
61
|
+
| Shelf | What is in it |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| [`examples/dhis2/`](examples/dhis2) | The native adapter: exports and their import strategies, the sign-off gate, the analytics reads and rebuilds, the three tracker collections, the metadata reads. |
|
|
64
|
+
| [`examples/dhis2-compose/`](examples/dhis2-compose) | A `dhis2.*` block beside one of the engine's own: a schema gate, a jq reshape, a fan-out over org units. |
|
|
65
|
+
| [`examples/dhis2-http/`](examples/dhis2-http) | The generic-HTTP way, for what no adapter covers: a CSV export, a period range, a completion registration, a stage-scoped event read. |
|
|
66
|
+
| [`examples/validate/`](examples/validate) | A metadata read held to a shape: a `fields=` projection gated on `validate.schema`, with the schema carried and named. |
|
|
67
|
+
| [`examples/schemas/`](examples/schemas) | The JSON Schemas that pin the reads the DHIS2 series makes, applied on their own. |
|
|
68
|
+
|
|
69
|
+
Each shelf's README lists its files one line each.
|
|
70
|
+
|
|
71
|
+
## Licence
|
|
72
|
+
|
|
73
|
+
Copyright (c) 2026 Morten Olav Hansen. All rights reserved. See [LICENSE](LICENSE).
|
|
74
|
+
|
|
75
|
+
The source is published for reference only: no licence to use, copy, modify or distribute it
|
|
76
|
+
is granted, and any use beyond reading requires written permission.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-dhis2"
|
|
3
|
+
version = "0.9.1"
|
|
4
|
+
description = "The DHIS2 adapter pack for dirigent: a connection kind and the blocks for one instance."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dhis2w-client>=1.13.4,<2",
|
|
11
|
+
"dirigent-common==0.9.1",
|
|
12
|
+
"dirigent-plugin==0.9.1",
|
|
13
|
+
"httpx>=0.28",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.entry-points."dirigent.plugins.v1"]
|
|
17
|
+
dhis2 = "dirigent_dhis2:plugin"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"dirigent-testing==0.9.1",
|
|
22
|
+
"mkdocs>=1.6.1",
|
|
23
|
+
"mkdocs-material>=9.7.7",
|
|
24
|
+
"mypy>=1.19",
|
|
25
|
+
"pymdown-extensions>=11.0.2",
|
|
26
|
+
"pyright>=1.1",
|
|
27
|
+
"pytest>=8",
|
|
28
|
+
"pytest-asyncio>=0.24",
|
|
29
|
+
"pyyaml>=6",
|
|
30
|
+
"respx>=0.21",
|
|
31
|
+
"ruff>=0.16",
|
|
32
|
+
"types-jsonschema>=4",
|
|
33
|
+
"types-pyyaml>=6",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py313"
|
|
46
|
+
line-length = 120
|
|
47
|
+
src = [
|
|
48
|
+
"src",
|
|
49
|
+
"tests",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = [
|
|
54
|
+
"E",
|
|
55
|
+
"W",
|
|
56
|
+
"F",
|
|
57
|
+
"I",
|
|
58
|
+
"D",
|
|
59
|
+
"SIM",
|
|
60
|
+
"UP",
|
|
61
|
+
"B",
|
|
62
|
+
"TID",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.per-file-ignores]
|
|
66
|
+
"tests/**" = [
|
|
67
|
+
"D103",
|
|
68
|
+
"D100",
|
|
69
|
+
"D101",
|
|
70
|
+
"D102",
|
|
71
|
+
"D104",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.pydocstyle]
|
|
75
|
+
convention = "google"
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.isort]
|
|
78
|
+
known-first-party = [
|
|
79
|
+
"dirigent_common",
|
|
80
|
+
"dirigent_dhis2",
|
|
81
|
+
"dirigent_plugin",
|
|
82
|
+
"dirigent_testing",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.format]
|
|
86
|
+
quote-style = "double"
|
|
87
|
+
docstring-code-format = true
|
|
88
|
+
|
|
89
|
+
[tool.mypy]
|
|
90
|
+
python_version = "3.13"
|
|
91
|
+
disallow_untyped_defs = true
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
warn_redundant_casts = true
|
|
95
|
+
warn_unused_ignores = true
|
|
96
|
+
no_implicit_optional = true
|
|
97
|
+
strict_equality = true
|
|
98
|
+
plugins = ["pydantic.mypy"]
|
|
99
|
+
files = [
|
|
100
|
+
"src",
|
|
101
|
+
"tests",
|
|
102
|
+
]
|
|
103
|
+
mypy_path = ["tests"]
|
|
104
|
+
|
|
105
|
+
[tool.pyright]
|
|
106
|
+
pythonVersion = "3.13"
|
|
107
|
+
typeCheckingMode = "strict"
|
|
108
|
+
include = [
|
|
109
|
+
"src",
|
|
110
|
+
"tests",
|
|
111
|
+
]
|
|
112
|
+
extraPaths = ["tests"]
|
|
113
|
+
reportMissingTypeStubs = false
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-dhis2"
|
|
3
|
+
version = "0.9.1"
|
|
4
|
+
description = "The DHIS2 adapter pack for dirigent: a connection kind and the blocks for one instance."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dhis2w-client>=1.13.4,<2",
|
|
11
|
+
"dirigent-common==0.9.1",
|
|
12
|
+
"dirigent-plugin==0.9.1",
|
|
13
|
+
"httpx>=0.28",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.entry-points."dirigent.plugins.v1"]
|
|
17
|
+
dhis2 = "dirigent_dhis2:plugin"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"dirigent-testing==0.9.1",
|
|
22
|
+
"mkdocs>=1.6.1",
|
|
23
|
+
"mkdocs-material>=9.7.7",
|
|
24
|
+
"mypy>=1.19",
|
|
25
|
+
"pymdown-extensions>=11.0.2",
|
|
26
|
+
"pyright>=1.1",
|
|
27
|
+
"pytest>=8",
|
|
28
|
+
"pytest-asyncio>=0.24",
|
|
29
|
+
"pyyaml>=6",
|
|
30
|
+
"respx>=0.21",
|
|
31
|
+
"ruff>=0.16",
|
|
32
|
+
"types-jsonschema>=4",
|
|
33
|
+
"types-pyyaml>=6",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py313"
|
|
46
|
+
line-length = 120
|
|
47
|
+
src = ["src", "tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "W", "F", "I", "D", "SIM", "UP", "B", "TID"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"tests/**" = ["D103", "D100", "D101", "D102", "D104"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.pydocstyle]
|
|
56
|
+
convention = "google"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.isort]
|
|
59
|
+
known-first-party = ["dirigent_common", "dirigent_dhis2", "dirigent_plugin", "dirigent_testing"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.format]
|
|
62
|
+
quote-style = "double"
|
|
63
|
+
docstring-code-format = true
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.13"
|
|
67
|
+
disallow_untyped_defs = true
|
|
68
|
+
warn_return_any = true
|
|
69
|
+
warn_unused_configs = true
|
|
70
|
+
warn_redundant_casts = true
|
|
71
|
+
warn_unused_ignores = true
|
|
72
|
+
no_implicit_optional = true
|
|
73
|
+
strict_equality = true
|
|
74
|
+
plugins = ["pydantic.mypy"]
|
|
75
|
+
files = ["src", "tests"]
|
|
76
|
+
mypy_path = ["tests"]
|
|
77
|
+
|
|
78
|
+
[tool.pyright]
|
|
79
|
+
pythonVersion = "3.13"
|
|
80
|
+
typeCheckingMode = "strict"
|
|
81
|
+
include = ["src", "tests"]
|
|
82
|
+
extraPaths = ["tests"]
|
|
83
|
+
reportMissingTypeStubs = false
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The DHIS2 adapter pack: the ``dhis2`` connection kind and the blocks for one instance."""
|
|
2
|
+
|
|
3
|
+
from dirigent_dhis2.analytics import (
|
|
4
|
+
Dhis2AnalyticsRunConfig,
|
|
5
|
+
Dhis2AnalyticsRunOperator,
|
|
6
|
+
Dhis2AnalyticsRunOutput,
|
|
7
|
+
)
|
|
8
|
+
from dirigent_dhis2.analytics_query import (
|
|
9
|
+
Dhis2AnalyticsQueryConfig,
|
|
10
|
+
Dhis2AnalyticsQueryOperator,
|
|
11
|
+
Dhis2AnalyticsQueryOutput,
|
|
12
|
+
)
|
|
13
|
+
from dirigent_dhis2.complete import (
|
|
14
|
+
Dhis2DataSetCompleteConfig,
|
|
15
|
+
Dhis2DataSetCompleteOutput,
|
|
16
|
+
Dhis2DataSetCompleteSensor,
|
|
17
|
+
)
|
|
18
|
+
from dirigent_dhis2.connection import (
|
|
19
|
+
Dhis2ConnectionConfig,
|
|
20
|
+
Dhis2ConnectionKind,
|
|
21
|
+
build_client,
|
|
22
|
+
client_for,
|
|
23
|
+
)
|
|
24
|
+
from dirigent_dhis2.export import (
|
|
25
|
+
Dhis2DataValueSetExportConfig,
|
|
26
|
+
Dhis2DataValueSetExportOperator,
|
|
27
|
+
Dhis2DataValueSetExportOutput,
|
|
28
|
+
)
|
|
29
|
+
from dirigent_dhis2.formats import DHIS2_FORMATS, is_period, is_uid
|
|
30
|
+
from dirigent_dhis2.imports import (
|
|
31
|
+
Dhis2DataValueSetImportConfig,
|
|
32
|
+
Dhis2DataValueSetImportOperator,
|
|
33
|
+
Dhis2DataValueSetImportOutput,
|
|
34
|
+
Dhis2ImportConflict,
|
|
35
|
+
)
|
|
36
|
+
from dirigent_dhis2.metadata import (
|
|
37
|
+
Dhis2MetadataConfig,
|
|
38
|
+
Dhis2MetadataOperator,
|
|
39
|
+
Dhis2MetadataOutput,
|
|
40
|
+
)
|
|
41
|
+
from dirigent_dhis2.tracker import (
|
|
42
|
+
Dhis2TrackerConfig,
|
|
43
|
+
Dhis2TrackerOperator,
|
|
44
|
+
Dhis2TrackerOutput,
|
|
45
|
+
)
|
|
46
|
+
from dirigent_dhis2.web import Dhis2Operator, Dhis2Sensor, classify
|
|
47
|
+
from dirigent_plugin import Contribution, extension
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Dhis2Plugin:
|
|
51
|
+
"""The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
|
|
52
|
+
|
|
53
|
+
@extension
|
|
54
|
+
def contribute(self) -> Contribution:
|
|
55
|
+
"""Contribute the DHIS2 blocks and the ``dhis2`` connection kind they are configured from."""
|
|
56
|
+
return Contribution(
|
|
57
|
+
operators=[
|
|
58
|
+
Dhis2AnalyticsRunOperator(),
|
|
59
|
+
Dhis2AnalyticsQueryOperator(),
|
|
60
|
+
Dhis2DataValueSetExportOperator(),
|
|
61
|
+
Dhis2DataValueSetImportOperator(),
|
|
62
|
+
Dhis2MetadataOperator(),
|
|
63
|
+
Dhis2TrackerOperator(),
|
|
64
|
+
],
|
|
65
|
+
sensors=[Dhis2DataSetCompleteSensor()],
|
|
66
|
+
connection_kinds=[Dhis2ConnectionKind()],
|
|
67
|
+
formats=DHIS2_FORMATS,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
plugin = Dhis2Plugin()
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"DHIS2_FORMATS",
|
|
75
|
+
"Dhis2AnalyticsQueryConfig",
|
|
76
|
+
"Dhis2AnalyticsQueryOperator",
|
|
77
|
+
"Dhis2AnalyticsQueryOutput",
|
|
78
|
+
"Dhis2AnalyticsRunConfig",
|
|
79
|
+
"Dhis2AnalyticsRunOperator",
|
|
80
|
+
"Dhis2AnalyticsRunOutput",
|
|
81
|
+
"Dhis2ConnectionConfig",
|
|
82
|
+
"Dhis2ConnectionKind",
|
|
83
|
+
"Dhis2DataSetCompleteConfig",
|
|
84
|
+
"Dhis2DataSetCompleteOutput",
|
|
85
|
+
"Dhis2DataSetCompleteSensor",
|
|
86
|
+
"Dhis2DataValueSetExportConfig",
|
|
87
|
+
"Dhis2DataValueSetExportOperator",
|
|
88
|
+
"Dhis2DataValueSetExportOutput",
|
|
89
|
+
"Dhis2DataValueSetImportConfig",
|
|
90
|
+
"Dhis2DataValueSetImportOperator",
|
|
91
|
+
"Dhis2DataValueSetImportOutput",
|
|
92
|
+
"Dhis2ImportConflict",
|
|
93
|
+
"Dhis2MetadataConfig",
|
|
94
|
+
"Dhis2MetadataOperator",
|
|
95
|
+
"Dhis2MetadataOutput",
|
|
96
|
+
"Dhis2Operator",
|
|
97
|
+
"Dhis2Plugin",
|
|
98
|
+
"Dhis2Sensor",
|
|
99
|
+
"Dhis2TrackerConfig",
|
|
100
|
+
"Dhis2TrackerOperator",
|
|
101
|
+
"Dhis2TrackerOutput",
|
|
102
|
+
"build_client",
|
|
103
|
+
"classify",
|
|
104
|
+
"client_for",
|
|
105
|
+
"is_period",
|
|
106
|
+
"is_uid",
|
|
107
|
+
"plugin",
|
|
108
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""``dhis2.analytics_run``: run the analytics tables job, submitted once and then probed."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from typing import Any, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from dhis2w_client.errors import AuthenticationError, Dhis2ApiError
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from dirigent_common import BlockModel
|
|
11
|
+
from dirigent_dhis2.connection import client_for
|
|
12
|
+
from dirigent_dhis2.web import Dhis2Operator, refuse
|
|
13
|
+
from dirigent_plugin import (
|
|
14
|
+
BlockFailure,
|
|
15
|
+
ErrorClass,
|
|
16
|
+
OperatorSpec,
|
|
17
|
+
ProbeResult,
|
|
18
|
+
ProbeStatus,
|
|
19
|
+
RemoteHandle,
|
|
20
|
+
StepContext,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
#: Where the analytics tables job is submitted.
|
|
24
|
+
ANALYTICS_PATH: Final = "/api/resourceTables/analytics"
|
|
25
|
+
|
|
26
|
+
#: The handle key holding the notifier endpoint the submission answered with.
|
|
27
|
+
NOTIFIER = "notifier"
|
|
28
|
+
|
|
29
|
+
#: The handle key holding the job type the task poll is keyed by, beside the handle's task uid.
|
|
30
|
+
JOB_TYPE = "job_type"
|
|
31
|
+
|
|
32
|
+
#: The handle key holding the poll cursor: the notification identifiers already streamed in.
|
|
33
|
+
CURSOR = "cursor"
|
|
34
|
+
|
|
35
|
+
#: How many notification messages the output keeps, from the end of the task's story.
|
|
36
|
+
MESSAGE_TAIL = 10
|
|
37
|
+
|
|
38
|
+
#: How long a task may stay silent from the attempt's start before it is taken to be lost.
|
|
39
|
+
#:
|
|
40
|
+
#: DHIS2 answers a task it has never heard of exactly the way it answers one that has not
|
|
41
|
+
#: written its first notification yet: 200 and an empty feed. The two are told apart by time.
|
|
42
|
+
#: A submitted job writes its first line within seconds of starting, so a feed still empty
|
|
43
|
+
#: this long after the submission is a task the instance lost, restarted away, or never had.
|
|
44
|
+
GONE_AFTER: Final = timedelta(minutes=15)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Dhis2AnalyticsRunConfig(BlockModel):
|
|
48
|
+
"""What one analytics tables run asks of the instance."""
|
|
49
|
+
|
|
50
|
+
connection: str
|
|
51
|
+
"""The code of the dhis2 connection naming the instance."""
|
|
52
|
+
|
|
53
|
+
last_years: int | None = None
|
|
54
|
+
"""Limit the tables to this many years back, or leave unset to build them all."""
|
|
55
|
+
|
|
56
|
+
skip_resource_tables: bool = False
|
|
57
|
+
"""Whether the resource tables are left as they are."""
|
|
58
|
+
|
|
59
|
+
skip_aggregate: bool = False
|
|
60
|
+
"""Whether aggregate data analytics tables are left as they are."""
|
|
61
|
+
|
|
62
|
+
skip_events: bool = False
|
|
63
|
+
"""Whether event analytics tables are left as they are."""
|
|
64
|
+
|
|
65
|
+
skip_enrollment: bool = False
|
|
66
|
+
"""Whether enrollment analytics tables are left as they are."""
|
|
67
|
+
|
|
68
|
+
skip_org_unit_ownership: bool = False
|
|
69
|
+
"""Whether the org unit ownership table is left as it is."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Dhis2AnalyticsRunOutput(BlockModel):
|
|
73
|
+
"""What the finished analytics job reported."""
|
|
74
|
+
|
|
75
|
+
task_id: str
|
|
76
|
+
"""The id the instance gave the job."""
|
|
77
|
+
|
|
78
|
+
completed_at: str | None = None
|
|
79
|
+
"""When the task said it was done, in the instance's own timestamp."""
|
|
80
|
+
|
|
81
|
+
messages: list[str]
|
|
82
|
+
"""The last few notification messages, oldest first."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _level(entry: Any) -> str:
|
|
86
|
+
"""Read a notification's level, defaulting the one a version left unset."""
|
|
87
|
+
return (entry.level or "INFO").upper()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _completed_at(entry: Any) -> str | None:
|
|
91
|
+
"""Read the completing notification's timestamp as the instance's own ISO instant."""
|
|
92
|
+
return entry.time.isoformat() if entry is not None and entry.time is not None else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _silent_for(ctx: StepContext) -> timedelta:
|
|
96
|
+
"""How long this attempt has been waiting on the task, measured from its first start."""
|
|
97
|
+
started = ctx.started_at if ctx.started_at.tzinfo is not None else ctx.started_at.replace(tzinfo=UTC)
|
|
98
|
+
return datetime.now(UTC) - started
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Dhis2AnalyticsRunOperator(Dhis2Operator[Dhis2AnalyticsRunConfig, Dhis2AnalyticsRunOutput]):
|
|
102
|
+
"""Submits the analytics tables job and follows its notifications until it settles."""
|
|
103
|
+
|
|
104
|
+
spec = OperatorSpec(
|
|
105
|
+
id="dhis2.analytics_run",
|
|
106
|
+
summary="Run the DHIS2 analytics tables job.",
|
|
107
|
+
default_poll=timedelta(minutes=1),
|
|
108
|
+
)
|
|
109
|
+
config_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsRunConfig
|
|
110
|
+
output_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsRunOutput
|
|
111
|
+
|
|
112
|
+
async def execute(self, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> RemoteHandle:
|
|
113
|
+
"""Submit the job and hand back the task reference the engine probes."""
|
|
114
|
+
async with client_for(ctx, config.connection) as client:
|
|
115
|
+
try:
|
|
116
|
+
envelope = await client.maintenance.run_analytics_tables(
|
|
117
|
+
last_years=config.last_years,
|
|
118
|
+
skip_resource_tables=config.skip_resource_tables,
|
|
119
|
+
skip_aggregate=config.skip_aggregate,
|
|
120
|
+
skip_events=config.skip_events,
|
|
121
|
+
skip_enrollment=config.skip_enrollment,
|
|
122
|
+
skip_org_unit_ownership=config.skip_org_unit_ownership,
|
|
123
|
+
)
|
|
124
|
+
except (Dhis2ApiError, AuthenticationError) as error:
|
|
125
|
+
raise refuse(error, f"POST {ANALYTICS_PATH}") from error
|
|
126
|
+
task_ref = envelope.task_ref()
|
|
127
|
+
endpoint = envelope.notifier_endpoint()
|
|
128
|
+
if task_ref is None or endpoint is None:
|
|
129
|
+
raise BlockFailure(
|
|
130
|
+
"the analytics job submission answered without a task reference to follow",
|
|
131
|
+
error_class=ErrorClass.REJECTED,
|
|
132
|
+
)
|
|
133
|
+
job_type, task_uid = task_ref
|
|
134
|
+
ctx.log.info("analytics job submitted", notifier=endpoint)
|
|
135
|
+
return RemoteHandle(block_id=self.spec.id, ref=task_uid, meta={NOTIFIER: endpoint, JOB_TYPE: job_type})
|
|
136
|
+
|
|
137
|
+
async def probe(self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> ProbeResult:
|
|
138
|
+
"""Poll the task once, stream the notifications new since the cursor, and map its state."""
|
|
139
|
+
task_ref = (handle.meta[JOB_TYPE], handle.ref)
|
|
140
|
+
cursor = json.loads(handle.meta.get(CURSOR, "[]"))
|
|
141
|
+
async with client_for(ctx, config.connection) as client:
|
|
142
|
+
try:
|
|
143
|
+
poll = await client.tasks.poll_once(task_ref, cursor=cursor)
|
|
144
|
+
except Dhis2ApiError as error:
|
|
145
|
+
if error.status_code == 404:
|
|
146
|
+
return ProbeResult(
|
|
147
|
+
status=ProbeStatus.GONE, message=f"the instance no longer knows task {handle.ref}"
|
|
148
|
+
)
|
|
149
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
150
|
+
except AuthenticationError as error:
|
|
151
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
152
|
+
for entry in poll.new:
|
|
153
|
+
log = ctx.log.warning if _level(entry) == "ERROR" else ctx.log.info
|
|
154
|
+
log(entry.message or "", level=_level(entry))
|
|
155
|
+
advanced = {**handle.meta, CURSOR: json.dumps(sorted(poll.cursor))}
|
|
156
|
+
if not poll.completed:
|
|
157
|
+
if not poll.cursor and _silent_for(ctx) >= GONE_AFTER:
|
|
158
|
+
return ProbeResult(
|
|
159
|
+
status=ProbeStatus.GONE,
|
|
160
|
+
message=f"the instance has reported nothing for task {handle.ref} since it was submitted",
|
|
161
|
+
)
|
|
162
|
+
return ProbeResult(status=ProbeStatus.RUNNING, message="the task is still running", meta=advanced)
|
|
163
|
+
terminal = poll.new[-1] if poll.new else None
|
|
164
|
+
if terminal is not None and _level(terminal) == "ERROR":
|
|
165
|
+
return ProbeResult(status=ProbeStatus.FAILED, message=f"the task failed: {terminal.message or ''}")
|
|
166
|
+
return ProbeResult(
|
|
167
|
+
status=ProbeStatus.SUCCEEDED,
|
|
168
|
+
message=terminal.message if terminal is not None else None,
|
|
169
|
+
meta=advanced,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def fetch(
|
|
173
|
+
self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext
|
|
174
|
+
) -> Dhis2AnalyticsRunOutput:
|
|
175
|
+
"""Collect the finished task's story; safe to call again."""
|
|
176
|
+
task_ref = (handle.meta[JOB_TYPE], handle.ref)
|
|
177
|
+
async with client_for(ctx, config.connection) as client:
|
|
178
|
+
try:
|
|
179
|
+
poll = await client.tasks.poll_once(task_ref)
|
|
180
|
+
except Dhis2ApiError as error:
|
|
181
|
+
if error.status_code == 404:
|
|
182
|
+
raise BlockFailure(
|
|
183
|
+
f"task {handle.ref} disappeared before its result could be collected",
|
|
184
|
+
error_class=ErrorClass.TRANSIENT,
|
|
185
|
+
) from error
|
|
186
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
187
|
+
except AuthenticationError as error:
|
|
188
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
189
|
+
story = poll.new
|
|
190
|
+
terminal = story[-1] if story and story[-1].completed else None
|
|
191
|
+
return Dhis2AnalyticsRunOutput(
|
|
192
|
+
task_id=handle.ref,
|
|
193
|
+
completed_at=_completed_at(terminal),
|
|
194
|
+
messages=[entry.message or "" for entry in story][-MESSAGE_TAIL:],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def cancel(self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> bool:
|
|
198
|
+
"""Report that the job could not be told: DHIS2 offers no way to stop a running analytics job."""
|
|
199
|
+
return False
|