dirigent-examples 0.15.0__py3-none-any.whl

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 (216) hide show
  1. dirigent_examples/__init__.py +22 -0
  2. dirigent_examples/py.typed +0 -0
  3. dirigent_examples/shelves/README.md +299 -0
  4. dirigent_examples/shelves/composition/README.md +18 -0
  5. dirigent_examples/shelves/composition/chained-instances.yaml +97 -0
  6. dirigent_examples/shelves/composition/composition-child.yaml +64 -0
  7. dirigent_examples/shelves/composition/composition-parent.yaml +89 -0
  8. dirigent_examples/shelves/connections.yaml +52 -0
  9. dirigent_examples/shelves/demo/README.md +19 -0
  10. dirigent_examples/shelves/demo/markdown-showcase.yaml +117 -0
  11. dirigent_examples/shelves/demo/params-showcase.yaml +92 -0
  12. dirigent_examples/shelves/demo/requires.yaml +65 -0
  13. dirigent_examples/shelves/demo/weekly-import-malawi.yaml +54 -0
  14. dirigent_examples/shelves/demo/weekly-import-nepal.yaml +75 -0
  15. dirigent_examples/shelves/docker/README.md +29 -0
  16. dirigent_examples/shelves/docker/docker-build-push.yaml +110 -0
  17. dirigent_examples/shelves/docker/docker-build-run.yaml +106 -0
  18. dirigent_examples/shelves/docker/docker-compose-database.yaml +124 -0
  19. dirigent_examples/shelves/docker/docker-compose-failing-up.yaml +83 -0
  20. dirigent_examples/shelves/docker/docker-compose-file.yaml +117 -0
  21. dirigent_examples/shelves/docker/docker-compose-profiles-env.yaml +133 -0
  22. dirigent_examples/shelves/docker/docker-compose-stack.yaml +70 -0
  23. dirigent_examples/shelves/docker/docker-hello.yaml +53 -0
  24. dirigent_examples/shelves/docker/docker-remote-daemon.yaml +92 -0
  25. dirigent_examples/shelves/docker/docker-run-failing-teardown.yaml +94 -0
  26. dirigent_examples/shelves/docker/docker-ticker.yaml +49 -0
  27. dirigent_examples/shelves/execute/README.md +16 -0
  28. dirigent_examples/shelves/execute/long-log.yaml +89 -0
  29. dirigent_examples/shelves/failure/README.md +20 -0
  30. dirigent_examples/shelves/failure/error-handler.yaml +89 -0
  31. dirigent_examples/shelves/failure/optional-step.yaml +82 -0
  32. dirigent_examples/shelves/failure/retries.yaml +75 -0
  33. dirigent_examples/shelves/failure/retry-budget.yaml +82 -0
  34. dirigent_examples/shelves/failure/step-timeout.yaml +96 -0
  35. dirigent_examples/shelves/git/README.md +32 -0
  36. dirigent_examples/shelves/git/git-checkout-build.yaml +125 -0
  37. dirigent_examples/shelves/git/git-checkout-compose.yaml +138 -0
  38. dirigent_examples/shelves/git/git-checkout-public.yaml +84 -0
  39. dirigent_examples/shelves/graph/README.md +22 -0
  40. dirigent_examples/shelves/graph/deep-chain.yaml +119 -0
  41. dirigent_examples/shelves/graph/fan-in.yaml +80 -0
  42. dirigent_examples/shelves/graph/fan-out.yaml +66 -0
  43. dirigent_examples/shelves/graph/linear.yaml +66 -0
  44. dirigent_examples/shelves/graph/parallel-branches.yaml +57 -0
  45. dirigent_examples/shelves/graph/parallel-sleep.yaml +55 -0
  46. dirigent_examples/shelves/graph/skip-diamond.yaml +105 -0
  47. dirigent_examples/shelves/graph/wide-fan.yaml +147 -0
  48. dirigent_examples/shelves/hello-world.yaml +30 -0
  49. dirigent_examples/shelves/open-data/README.md +67 -0
  50. dirigent_examples/shelves/open-data/feeds-composition.yaml +120 -0
  51. dirigent_examples/shelves/open-data/gdacs-disaster-updates.yaml +230 -0
  52. dirigent_examples/shelves/open-data/github-releases-relay.yaml +225 -0
  53. dirigent_examples/shelves/open-data/hdx-dataset-watch.yaml +211 -0
  54. dirigent_examples/shelves/open-data/kobo-submissions-to-csv.yaml +149 -0
  55. dirigent_examples/shelves/open-data/nominatim-geocode-facilities.yaml +169 -0
  56. dirigent_examples/shelves/open-data/odk-central-submissions.yaml +146 -0
  57. dirigent_examples/shelves/open-data/open-meteo-weekly-report.yaml +142 -0
  58. dirigent_examples/shelves/open-data/overpass-health-facilities.yaml +154 -0
  59. dirigent_examples/shelves/open-data/usgs-earthquakes-alert.yaml +208 -0
  60. dirigent_examples/shelves/open-data/who-gho-indicators-to-parquet.yaml +163 -0
  61. dirigent_examples/shelves/open-data/wikidata-country-reference.yaml +146 -0
  62. dirigent_examples/shelves/open-data/world-bank-population-trend.yaml +166 -0
  63. dirigent_examples/shelves/patterns/README.md +144 -0
  64. dirigent_examples/shelves/patterns/concurrency-queue.yaml +89 -0
  65. dirigent_examples/shelves/patterns/concurrency-replace.yaml +92 -0
  66. dirigent_examples/shelves/patterns/concurrency-skip.yaml +97 -0
  67. dirigent_examples/shelves/patterns/connections-referenced-vs-carried.yaml +140 -0
  68. dirigent_examples/shelves/patterns/deadline-on-a-sensor.yaml +106 -0
  69. dirigent_examples/shelves/patterns/fan-out-continue.yaml +88 -0
  70. dirigent_examples/shelves/patterns/fan-out-fail-fast.yaml +80 -0
  71. dirigent_examples/shelves/patterns/fan-out-from-params.yaml +84 -0
  72. dirigent_examples/shelves/patterns/fan-out-item-wise.yaml +111 -0
  73. dirigent_examples/shelves/patterns/fan-out-literal-list.yaml +81 -0
  74. dirigent_examples/shelves/patterns/fan-out-nested-objects.yaml +107 -0
  75. dirigent_examples/shelves/patterns/fan-out-then-join.yaml +86 -0
  76. dirigent_examples/shelves/patterns/log-levels.yaml +119 -0
  77. dirigent_examples/shelves/patterns/outputs-inline-vs-storage.yaml +140 -0
  78. dirigent_examples/shelves/patterns/params-every-type.yaml +259 -0
  79. dirigent_examples/shelves/patterns/params-validation-refuses.yaml +131 -0
  80. dirigent_examples/shelves/patterns/pipeline-run-child.yaml +96 -0
  81. dirigent_examples/shelves/patterns/pipeline-run-fire-and-forget.yaml +99 -0
  82. dirigent_examples/shelves/patterns/pipeline-run-strict.yaml +103 -0
  83. dirigent_examples/shelves/patterns/pipeline-run-wait.yaml +107 -0
  84. dirigent_examples/shelves/patterns/pipeline-run-with-params.yaml +124 -0
  85. dirigent_examples/shelves/patterns/poll-cadence.yaml +102 -0
  86. dirigent_examples/shelves/patterns/priority-layered.yaml +120 -0
  87. dirigent_examples/shelves/patterns/references-cheat-sheet.yaml +186 -0
  88. dirigent_examples/shelves/patterns/retry-budget-exhausted.yaml +92 -0
  89. dirigent_examples/shelves/patterns/retry-exponential-backoff.yaml +88 -0
  90. dirigent_examples/shelves/patterns/retry-only-transient.yaml +108 -0
  91. dirigent_examples/shelves/patterns/retry-with-jitter.yaml +102 -0
  92. dirigent_examples/shelves/patterns/rule-all-done.yaml +83 -0
  93. dirigent_examples/shelves/patterns/rule-all-success.yaml +79 -0
  94. dirigent_examples/shelves/patterns/rule-always.yaml +92 -0
  95. dirigent_examples/shelves/patterns/rule-one-failed.yaml +86 -0
  96. dirigent_examples/shelves/patterns/schedule-at-once.yaml +110 -0
  97. dirigent_examples/shelves/patterns/schedule-cron-timezone.yaml +121 -0
  98. dirigent_examples/shelves/patterns/schedule-interval.yaml +109 -0
  99. dirigent_examples/shelves/patterns/schedule-window-half-open.yaml +105 -0
  100. dirigent_examples/shelves/patterns/sensor-http-ready.yaml +121 -0
  101. dirigent_examples/shelves/patterns/sensor-storage-exists.yaml +134 -0
  102. dirigent_examples/shelves/patterns/step-names-and-keys.yaml +99 -0
  103. dirigent_examples/shelves/patterns/timeout-fails-the-step.yaml +94 -0
  104. dirigent_examples/shelves/patterns/timeout-skips-the-step.yaml +102 -0
  105. dirigent_examples/shelves/patterns/webhook-mapping-nested-payload.yaml +125 -0
  106. dirigent_examples/shelves/patterns/webhook-signed.yaml +144 -0
  107. dirigent_examples/shelves/preview/s3-parquet-to-ingestion.yaml +92 -0
  108. dirigent_examples/shelves/python/README.md +31 -0
  109. dirigent_examples/shelves/python/apply_and_run.py +52 -0
  110. dirigent_examples/shelves/python/ci_gate.py +76 -0
  111. dirigent_examples/shelves/python/connections.py +61 -0
  112. dirigent_examples/shelves/python/error_handling.py +84 -0
  113. dirigent_examples/shelves/python/follow_logs.py +39 -0
  114. dirigent_examples/shelves/python/list_and_filter.py +52 -0
  115. dirigent_examples/shelves/queues/README.md +59 -0
  116. dirigent_examples/shelves/queues/kafka-consume-then-transform.yaml +105 -0
  117. dirigent_examples/shelves/queues/kafka-produce-then-consume.yaml +124 -0
  118. dirigent_examples/shelves/queues/rabbitmq-consume-ack-on-success.yaml +102 -0
  119. dirigent_examples/shelves/queues/report-to-kafka.yaml +105 -0
  120. dirigent_examples/shelves/queues/report-to-rabbitmq.yaml +105 -0
  121. dirigent_examples/shelves/recipes/README.md +130 -0
  122. dirigent_examples/shelves/recipes/csv-header-rules.yaml +147 -0
  123. dirigent_examples/shelves/recipes/csv-to-ndjson.yaml +107 -0
  124. dirigent_examples/shelves/recipes/etl-csv-clean-validate-parquet.yaml +207 -0
  125. dirigent_examples/shelves/recipes/filter-by-predicate.yaml +116 -0
  126. dirigent_examples/shelves/recipes/filter-then-map-then-reduce.yaml +109 -0
  127. dirigent_examples/shelves/recipes/http-fetch-validate-post.yaml +142 -0
  128. dirigent_examples/shelves/recipes/http-follow-redirects.yaml +100 -0
  129. dirigent_examples/shelves/recipes/http-get-with-query.yaml +96 -0
  130. dirigent_examples/shelves/recipes/http-headers-and-auth-connection.yaml +110 -0
  131. dirigent_examples/shelves/recipes/http-post-file-from-storage.yaml +117 -0
  132. dirigent_examples/shelves/recipes/http-post-json-echo.yaml +105 -0
  133. dirigent_examples/shelves/recipes/http-post-report.yaml +183 -0
  134. dirigent_examples/shelves/recipes/http-save-body-to-storage.yaml +106 -0
  135. dirigent_examples/shelves/recipes/http-success-status-list.yaml +80 -0
  136. dirigent_examples/shelves/recipes/http-timeout-override.yaml +104 -0
  137. dirigent_examples/shelves/recipes/jq-dedupe-by-key.yaml +78 -0
  138. dirigent_examples/shelves/recipes/jq-defaults-and-nulls.yaml +91 -0
  139. dirigent_examples/shelves/recipes/jq-group-by-and-sum.yaml +74 -0
  140. dirigent_examples/shelves/recipes/jq-join-two-lists.yaml +77 -0
  141. dirigent_examples/shelves/recipes/jq-long-to-wide.yaml +76 -0
  142. dirigent_examples/shelves/recipes/jq-nested-to-flat.yaml +89 -0
  143. dirigent_examples/shelves/recipes/jq-pivot-wide-to-long.yaml +65 -0
  144. dirigent_examples/shelves/recipes/jq-running-totals.yaml +82 -0
  145. dirigent_examples/shelves/recipes/jq-string-cleaning.yaml +88 -0
  146. dirigent_examples/shelves/recipes/jq-top-n.yaml +85 -0
  147. dirigent_examples/shelves/recipes/jq-validate-in-jq-vs-schema.yaml +124 -0
  148. dirigent_examples/shelves/recipes/jq-window-dates.yaml +82 -0
  149. dirigent_examples/shelves/recipes/json-to-csv-flattening.yaml +141 -0
  150. dirigent_examples/shelves/recipes/large-output-to-storage.yaml +134 -0
  151. dirigent_examples/shelves/recipes/map-enrich-with-lookup.yaml +96 -0
  152. dirigent_examples/shelves/recipes/ndjson-to-parquet.yaml +130 -0
  153. dirigent_examples/shelves/recipes/pagination-by-fan-out.yaml +115 -0
  154. dirigent_examples/shelves/recipes/parquet-round-trip-types.yaml +163 -0
  155. dirigent_examples/shelves/recipes/reconcile-two-sources.yaml +159 -0
  156. dirigent_examples/shelves/recipes/report-built-in.yaml +72 -0
  157. dirigent_examples/shelves/recipes/report-daily-digest.yaml +186 -0
  158. dirigent_examples/shelves/recipes/report-to-file.yaml +131 -0
  159. dirigent_examples/shelves/recipes/report-to-webhook.yaml +136 -0
  160. dirigent_examples/shelves/recipes/schema-carried.yaml +112 -0
  161. dirigent_examples/shelves/recipes/schema-formats.yaml +107 -0
  162. dirigent_examples/shelves/recipes/schema-referenced.yaml +86 -0
  163. dirigent_examples/shelves/recipes/schema-refuses-then-rule.yaml +127 -0
  164. dirigent_examples/shelves/recipes/storage-copy-dated-archive.yaml +114 -0
  165. dirigent_examples/shelves/recipes/storage-exists-gate.yaml +127 -0
  166. dirigent_examples/shelves/recipes/storage-manifest-of-a-fan-out.yaml +104 -0
  167. dirigent_examples/shelves/recipes/storage-write-then-read.yaml +119 -0
  168. dirigent_examples/shelves/recipes/webhook-post-hmac.yaml +132 -0
  169. dirigent_examples/shelves/recipes/webhook-post-summary.yaml +142 -0
  170. dirigent_examples/shelves/s3/README.md +34 -0
  171. dirigent_examples/shelves/s3/report-to-s3.yaml +93 -0
  172. dirigent_examples/shelves/s3/s3-copy-and-verify.yaml +105 -0
  173. dirigent_examples/shelves/s3/s3-csv-report.yaml +87 -0
  174. dirigent_examples/shelves/s3/s3-parquet-report.yaml +77 -0
  175. dirigent_examples/shelves/s3/s3-round-trip.yaml +125 -0
  176. dirigent_examples/shelves/schemas/README.md +36 -0
  177. dirigent_examples/shelves/schemas/echo-reading.json +18 -0
  178. dirigent_examples/shelves/schemas/ou-record.json +13 -0
  179. dirigent_examples/shelves/schemas/station-reading.json +13 -0
  180. dirigent_examples/shelves/sensors/README.md +16 -0
  181. dirigent_examples/shelves/sensors/sensor-gate.yaml +65 -0
  182. dirigent_examples/shelves/sensors/time-window.yaml +61 -0
  183. dirigent_examples/shelves/sql/README.md +52 -0
  184. dirigent_examples/shelves/sql/duckdb-parquet-to-report.yaml +146 -0
  185. dirigent_examples/shelves/sql/sql-postgres-readonly.yaml +111 -0
  186. dirigent_examples/shelves/sql/sql-query-to-storage.yaml +85 -0
  187. dirigent_examples/shelves/sql/sql-sqlite-roundtrip.yaml +114 -0
  188. dirigent_examples/shelves/sql/warehouse.sql +42 -0
  189. dirigent_examples/shelves/transform/README.md +36 -0
  190. dirigent_examples/shelves/transform/csv-report.yaml +55 -0
  191. dirigent_examples/shelves/transform/jq-filter-and-map.yaml +70 -0
  192. dirigent_examples/shelves/transform/jq-group-and-aggregate.yaml +70 -0
  193. dirigent_examples/shelves/transform/jq-join-two-sources.yaml +98 -0
  194. dirigent_examples/shelves/transform/jq-reshape.yaml +91 -0
  195. dirigent_examples/shelves/transform/jq-stream-through-storage.yaml +112 -0
  196. dirigent_examples/shelves/transform/ndjson-round-trip.yaml +56 -0
  197. dirigent_examples/shelves/transform/parquet-round-trip.yaml +68 -0
  198. dirigent_examples/shelves/transform/std-convert-fan-out.yaml +142 -0
  199. dirigent_examples/shelves/transform/xml-feed-to-ndjson.yaml +116 -0
  200. dirigent_examples/shelves/transform/yaml-config-to-json.yaml +104 -0
  201. dirigent_examples/shelves/triggers/README.md +45 -0
  202. dirigent_examples/shelves/triggers/at-one-time.yaml +78 -0
  203. dirigent_examples/shelves/triggers/cron-nightly.yaml +79 -0
  204. dirigent_examples/shelves/triggers/cron-windowed.yaml +86 -0
  205. dirigent_examples/shelves/triggers/document-nightly.yaml +80 -0
  206. dirigent_examples/shelves/triggers/interval-rolling.yaml +88 -0
  207. dirigent_examples/shelves/triggers/managed-and-manual.yaml +109 -0
  208. dirigent_examples/shelves/triggers/webhook-trigger.yaml +75 -0
  209. dirigent_examples/shelves/validate/README.md +31 -0
  210. dirigent_examples/shelves/validate/expects-a-shape.yaml +56 -0
  211. dirigent_examples/shelves/validate/the-shape-is-wrong.yaml +46 -0
  212. dirigent_examples-0.15.0.dist-info/METADATA +21 -0
  213. dirigent_examples-0.15.0.dist-info/RECORD +216 -0
  214. dirigent_examples-0.15.0.dist-info/WHEEL +4 -0
  215. dirigent_examples-0.15.0.dist-info/entry_points.txt +3 -0
  216. dirigent_examples-0.15.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,142 @@
1
+ # A week of weather for one point, from a public archive, ending as a csv in storage.
2
+ #
3
+ # Open-Meteo's archive API serves reanalysed historical weather for any coordinate, with no
4
+ # key, no account and no quota worth worrying about. It answers columnar: one `daily.time`
5
+ # array of dates and one array per variable, aligned by index. That shape is convenient for a
6
+ # chart and wrong for everything else, so the first thing this pipeline does is turn it into
7
+ # rows.
8
+ #
9
+ # What happens, hop by hop:
10
+ #
11
+ # covered the run's window, an interval of instants, turned into the two dates the API
12
+ # asks for. The window is half-open -- start included, end excluded -- and
13
+ # Open-Meteo's end_date is inclusive, so the end is pulled back one day here
14
+ # rather than being quietly off by one in the output.
15
+ # fetch one GET. The response is the columnar payload above, held inline: a week of
16
+ # three variables is a few kilobytes, so nothing needs to go to storage yet.
17
+ # rows the transposition. `daily.time` indexes everything, so walking its indices and
18
+ # reading each variable at the same offset is the whole job.
19
+ # staged storage.write, the rows as one json object. A converter reads one URI and writes
20
+ # another, so a value the run is holding is put down before it is re-encoded.
21
+ # report the codec, json to csv, from that object to the one a person opens.
22
+ #
23
+ # The archive lags real weather by about five days, so a schedule that reads the week that
24
+ # just closed is reading a week the archive already has. That is why the cadence below fires
25
+ # on Monday for the week before, and why the ad hoc window in the run line is in the past.
26
+ #
27
+ # To make it yours: point latitude and longitude at your site, name the variables you care
28
+ # about in `daily`, and change the schedule's timezone to the one your week is counted in.
29
+ # For many points, this is the pipeline a fan-out calls once per site.
30
+ #
31
+ # dg run --local examples/open-data/open-meteo-weekly-report.yaml --window 2026-08-17..2026-08-24
32
+ # dg run --local examples/open-data/open-meteo-weekly-report.yaml --window 2026-08-17..2026-08-24 \
33
+ # -p latitude=-1.29 -p longitude=36.82
34
+
35
+ format: dirigent/v1
36
+ kind: pipeline
37
+ code: open-meteo-weekly-report
38
+ name: Weekly weather report
39
+ description: |
40
+ A week of daily temperature and precipitation for one coordinate, read from the
41
+ **Open-Meteo archive API**, transposed from columns to rows and written as csv.
42
+
43
+ The week is the run's window, so the same document serves the Monday schedule and a
44
+ backfill of any week the archive holds.
45
+
46
+ tags: [open-data, http, storage, transform, csv, schedule, starter]
47
+
48
+ requires:
49
+ blocks:
50
+ - transform.jq
51
+ - http.request
52
+ - storage.write
53
+ - convert.std
54
+
55
+ params:
56
+ type: object
57
+ additionalProperties: false
58
+ properties:
59
+ latitude:
60
+ type: number
61
+ default: -13.98
62
+ description: Degrees north; the default is Lilongwe, Malawi.
63
+ longitude:
64
+ type: number
65
+ default: 33.79
66
+ description: Degrees east.
67
+ daily:
68
+ type: string
69
+ default: temperature_2m_max,temperature_2m_min,precipitation_sum
70
+ description: The archive's daily variables, comma-separated, as the API names them.
71
+
72
+ steps:
73
+ covered:
74
+ block: transform.jq
75
+ config:
76
+ input:
77
+ start: ${run.window.start}
78
+ end: ${run.window.end}
79
+ # fromdateiso8601 wants a Z, and a window edge carries a numeric offset, so the string
80
+ # is cut to seconds and given one. The subtraction is the half-open-to-inclusive fix.
81
+ program: |
82
+ def as_date: .[0:19] + "Z" | fromdateiso8601;
83
+ {
84
+ start_date: .start[0:10],
85
+ end_date: (.end | as_date | . - 86400 | strftime("%Y-%m-%d"))
86
+ }
87
+
88
+ fetch:
89
+ block: http.request
90
+ depends_on: [covered]
91
+ config:
92
+ url: https://archive-api.open-meteo.com/v1/archive
93
+ query:
94
+ latitude: ${params.latitude}
95
+ longitude: ${params.longitude}
96
+ start_date: ${steps.covered.output.value.start_date}
97
+ end_date: ${steps.covered.output.value.end_date}
98
+ daily: ${params.daily}
99
+ # UTC rather than the station's local zone, so a row's date means the same thing here
100
+ # as it does in every other pipeline on this shelf.
101
+ timezone: UTC
102
+
103
+ rows:
104
+ block: transform.jq
105
+ depends_on: [fetch]
106
+ config:
107
+ input: ${steps.fetch.output.body}
108
+ # The arrays under daily are parallel, so the index is the join key. reduce over the
109
+ # keys rather than naming the variables keeps this program correct when the `daily`
110
+ # parameter asks for a different set.
111
+ program: |
112
+ . as $body
113
+ | ($body.daily | keys_unsorted - ["time"]) as $vars
114
+ | [range($body.daily.time | length) as $i
115
+ | reduce $vars[] as $var
116
+ ({date: $body.daily.time[$i]}; .[$var] = $body.daily[$var][$i])]
117
+
118
+ staged:
119
+ block: storage.write
120
+ depends_on: [rows]
121
+ config:
122
+ target: ${run.scratch}/weather/${run.window.start}.json
123
+ value: ${steps.rows.output.value}
124
+
125
+ report:
126
+ block: convert.std
127
+ depends_on: [staged]
128
+ config:
129
+ source: ${steps.staged.output.uri}
130
+ # The window start names the file, so a backfill of ten weeks writes ten objects and
131
+ # overwrites none of them.
132
+ target: ${run.scratch}/weather/${run.window.start}.csv
133
+ from: json
134
+ to: csv
135
+
136
+ triggers:
137
+ schedules:
138
+ - code: weekly
139
+ name: Monday morning, for the week before
140
+ description: Fires once the archive has settled on the week that just closed.
141
+ cron: "0 6 * * 1"
142
+ timezone: UTC
@@ -0,0 +1,154 @@
1
+ # Every hospital and clinic OpenStreetMap knows about inside a box, as csv and as parquet.
2
+ #
3
+ # Overpass is a query engine over live OSM data, keyless and public. Its query language is its
4
+ # own -- `[out:json];(node[...](bbox);way[...](bbox););out center;` -- and it answers
5
+ # {"version": ..., "elements": [...]}. An element is a node, which carries lat and lon itself,
6
+ # or a way, which is a shape and carries a computed `center` instead because `out center` asked
7
+ # for one. Everything else about a facility lives in `tags`, a free-form map: `name`, `amenity`,
8
+ # `healthcare`, `operator`, whatever a mapper typed.
9
+ #
10
+ # On the request: Overpass takes the query as the body of a POST, written in its own language
11
+ # and not in JSON. `http.request` sends a string body as it stands and serialises anything else
12
+ # as JSON, so the query goes in `body` as text -- a JSON-quoted query is a parse error at
13
+ # Overpass, not a query -- and `content_type` says what the server is being handed.
14
+ #
15
+ # What happens, hop by hop:
16
+ #
17
+ # fetch one POST carrying the query, holding the answer inline. The box below is a city,
18
+ # which is a few hundred elements.
19
+ # rows the flattening. Nodes and ways are reconciled to one lat/lon pair, and the four
20
+ # tags worth having become columns; an element with no name still becomes a row,
21
+ # because an unnamed clinic is a fact about the map rather than a broken record.
22
+ # staged storage.write, the rows as one json object. A converter reads one URI and writes
23
+ # another, so both codecs below read this one object.
24
+ # csv for a person: opens in anything, loses the types.
25
+ # parquet for a pipeline: keeps the types, and is what any later analysis wants to read.
26
+ # The same rows are written twice on purpose; that is the point of having both
27
+ # codecs, and neither one re-fetches anything.
28
+ #
29
+ # Overpass is a shared public instance with a per-IP quota, and it answers a slow query with a
30
+ # 504 rather than queueing it. The retry below is not decoration: it is how this pipeline
31
+ # behaves on an ordinary busy afternoon. Heavy or scheduled use belongs on your own instance.
32
+ #
33
+ # To make it yours: move the four bbox edges, and change amenities to whatever OSM calls the
34
+ # thing you are mapping -- `pharmacy`, `doctors`, `school`. The regex in the query is what
35
+ # makes the list a list.
36
+ #
37
+ # dg run --local examples/open-data/overpass-health-facilities.yaml
38
+ # dg run --local examples/open-data/overpass-health-facilities.yaml \
39
+ # -p south=27.65 -p west=85.25 -p north=27.78 -p east=85.40 -p amenities='pharmacy|doctors'
40
+
41
+ format: dirigent/v1
42
+ kind: pipeline
43
+ code: overpass-health-facilities
44
+ name: Health facilities from OpenStreetMap
45
+ description: |
46
+ Hospitals and clinics inside a bounding box, read from the **Overpass API**, flattened into
47
+ rows and written twice: csv for a person and parquet for the next pipeline.
48
+
49
+ tags: [open-data, http, storage, transform, csv, parquet, starter]
50
+
51
+ requires:
52
+ blocks:
53
+ - http.request
54
+ - transform.jq
55
+ - storage.write
56
+ - convert.std
57
+ - convert.arrow
58
+
59
+ params:
60
+ type: object
61
+ additionalProperties: false
62
+ properties:
63
+ south:
64
+ type: number
65
+ default: -14.05
66
+ west:
67
+ type: number
68
+ default: 33.70
69
+ north:
70
+ type: number
71
+ default: -13.90
72
+ east:
73
+ type: number
74
+ default: 33.90
75
+ description: The four edges of the box; the default is Lilongwe, Malawi.
76
+ amenities:
77
+ type: string
78
+ default: hospital|clinic
79
+ description: An Overpass regex alternation of amenity values.
80
+
81
+ steps:
82
+ fetch:
83
+ block: http.request
84
+ # A public instance under load answers 504 in seconds rather than queueing the query, so
85
+ # the wait between tries is long enough for the queue in front of it to drain.
86
+ retry:
87
+ max_attempts: 3
88
+ backoff: 30s
89
+ multiplier: 2.0
90
+ config:
91
+ url: https://overpass-api.de/api/interpreter
92
+ method: POST
93
+ # The [timeout:60] inside the query is Overpass's own budget for running it; the
94
+ # timeout below is how long this step waits for the whole exchange. The inner
95
+ # one has to be the smaller of the two, or the server is still working when the
96
+ # client has given up.
97
+ body: |
98
+ [out:json][timeout:60];
99
+ (
100
+ node["amenity"~"^(${params.amenities})$"](${params.south},${params.west},${params.north},${params.east});
101
+ way["amenity"~"^(${params.amenities})$"](${params.south},${params.west},${params.north},${params.east});
102
+ );
103
+ out center;
104
+ # What the interpreter is handed: OverpassQL as plain text, said by the step rather than
105
+ # left for the client to guess.
106
+ content_type: text/plain; charset=utf-8
107
+ timeout: 2m
108
+
109
+ rows:
110
+ block: transform.jq
111
+ depends_on: [fetch]
112
+ config:
113
+ input: ${steps.fetch.output.body}
114
+ # A way has no coordinates of its own; `out center` gives it a centroid, and `// ` is
115
+ # what reconciles the two shapes into one pair of columns.
116
+ program: |
117
+ [.elements[]
118
+ | {
119
+ osm_kind: .type,
120
+ osm_id: .id,
121
+ name: (.tags.name // null),
122
+ amenity: (.tags.amenity // null),
123
+ healthcare: (.tags.healthcare // null),
124
+ operator: (.tags.operator // null),
125
+ latitude: (.lat // .center.lat),
126
+ longitude: (.lon // .center.lon)
127
+ }]
128
+ | sort_by(.name // "")
129
+
130
+ staged:
131
+ block: storage.write
132
+ depends_on: [rows]
133
+ config:
134
+ target: ${run.scratch}/facilities.json
135
+ value: ${steps.rows.output.value}
136
+
137
+ csv:
138
+ block: convert.std
139
+ depends_on: [staged]
140
+ config:
141
+ source: ${steps.staged.output.uri}
142
+ target: ${run.scratch}/facilities.csv
143
+ from: json
144
+ to: csv
145
+
146
+ parquet:
147
+ block: convert.arrow
148
+ depends_on: [staged]
149
+ config:
150
+ # The same object the csv is made from, read a second time: two codecs, one source.
151
+ source: ${steps.staged.output.uri}
152
+ target: ${run.scratch}/facilities.parquet
153
+ from: json
154
+ to: parquet
@@ -0,0 +1,208 @@
1
+ # Everything the ground did in the last day, cut down to what is near you and big enough to care.
2
+ #
3
+ # The USGS summary feeds are keyless GeoJSON, updated every minute: all_day.geojson is a
4
+ # FeatureCollection of every event in the past 24 hours, a couple of hundred of them on an
5
+ # ordinary day. Each feature carries the magnitude, the place, and the epoch-milliseconds time
6
+ # in `properties`, and the position as [longitude, latitude, depth_km] in `geometry.coordinates`
7
+ # -- longitude first, which is the GeoJSON order and the opposite of how people say it.
8
+ #
9
+ # The interesting part is not the fetch, it is the quiet day. An alert that posts "nothing
10
+ # happened" every hour is an alert nobody reads, so this pipeline has to be able to do nothing.
11
+ # There is no `if` in the format, and there is no conditional edge; what there is is a sensor
12
+ # that can skip, and a step behind a skipped sensor is skipped with it. So the digest is written
13
+ # to storage first, and `storage.exists` is asked for it with a floor of one byte: an empty
14
+ # digest writes an empty file, the floor is never met, the sensor times out and skips, the
15
+ # webhook skips behind it, and the run still ends succeeded. A matching event writes bytes, the
16
+ # very first poke sees them, and the post goes out.
17
+ #
18
+ # What happens, hop by hop:
19
+ #
20
+ # feed one GET, held inline: the whole day is well under a megabyte.
21
+ # candidates every feature flattened into a row, with the great-circle distance worked out
22
+ # and the two thresholds copied onto it. jq has the trigonometry, so geography
23
+ # needs no subprocess and no allowlist entry. The thresholds ride along as data
24
+ # because that is the only way the filter below can see them: a jq program is
25
+ # compiled once and never has a value spliced into its text.
26
+ # nearby filter.jq, comparing each row's own numbers against the limits it carries.
27
+ # digest map.jq drops the limits again, leaving the row a person reads.
28
+ # ordered the digest, biggest magnitude first, which is the order the file below holds.
29
+ # staged storage.write, that list as one json object. A converter reads one URI and writes
30
+ # another, so a value the run is holding is put down before it is re-encoded.
31
+ # written json to ndjson. ndjson is what makes the gate work: an empty list encodes as an
32
+ # empty file, where an empty json array would still be two bytes.
33
+ # matched the gate. Three seconds is plenty when the file is already there; on a quiet
34
+ # day it is three seconds of waiting and then a clean skip.
35
+ # notify the digest, posted. It points at Postman Echo so this example runs for real
36
+ # without standing anything up; on your instance it is your own receiver, and
37
+ # `sign_with` naming a connection with an hmac_secret is how the receiver knows
38
+ # the post came from you.
39
+ #
40
+ # To make it yours: set latitude, longitude and radius_km to your area of responsibility, raise
41
+ # min_magnitude until the alert is worth waking up for, and point webhook_url at your receiver.
42
+ # A schedule firing hourly is the natural cadence; the feed only ever holds a day.
43
+ #
44
+ # dg run --local examples/open-data/usgs-earthquakes-alert.yaml
45
+ # dg run --local examples/open-data/usgs-earthquakes-alert.yaml -p min_magnitude=7 -p radius_km=50
46
+
47
+ format: dirigent/v1
48
+ kind: pipeline
49
+ code: usgs-earthquakes-alert
50
+ name: Earthquakes near a point
51
+ description: |
52
+ The **USGS** past-day earthquake feed, filtered by magnitude and great-circle distance from a
53
+ point, posted onward as a digest -- and posted only when something matched.
54
+
55
+ A quiet day ends `succeeded` with the gate and the post skipped, which is what an alert
56
+ pipeline has to be able to do.
57
+
58
+ tags: [open-data, http, sensor, storage, transform, webhook, filter, starter]
59
+
60
+ requires:
61
+ blocks:
62
+ - http.request
63
+ - filter.jq
64
+ - map.jq
65
+ - transform.jq
66
+ - storage.write
67
+ - convert.std
68
+ - storage.exists
69
+ - webhook.post
70
+
71
+ params:
72
+ type: object
73
+ additionalProperties: false
74
+ properties:
75
+ latitude:
76
+ type: number
77
+ default: 36.0
78
+ description: Degrees north of the point everything is measured from.
79
+ longitude:
80
+ type: number
81
+ default: -119.5
82
+ description: Degrees east; the default point is California's Central Valley.
83
+ radius_km:
84
+ type: number
85
+ default: 800
86
+ description: How far from that point an event still counts.
87
+ min_magnitude:
88
+ type: number
89
+ default: 2.0
90
+ description: Raise this until the digest is worth reading.
91
+ webhook_url:
92
+ type: string
93
+ default: https://postman-echo.com/post
94
+ description: Where the digest goes; Postman Echo so the example posts for real.
95
+
96
+ steps:
97
+ feed:
98
+ block: http.request
99
+ # The feed is regenerated every minute and served from a cache that is occasionally slow;
100
+ # a second try is cheaper than a missed hour.
101
+ retry:
102
+ max_attempts: 2
103
+ backoff: 10s
104
+ config:
105
+ url: https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson
106
+
107
+ candidates:
108
+ block: transform.jq
109
+ depends_on: [feed]
110
+ config:
111
+ # Everything the program needs arrives as data: the features, the point, and the two
112
+ # limits. Nothing about this input is spliced into the program's text.
113
+ input:
114
+ features: ${steps.feed.output.body.features}
115
+ latitude: ${params.latitude}
116
+ longitude: ${params.longitude}
117
+ radius_km: ${params.radius_km}
118
+ min_magnitude: ${params.min_magnitude}
119
+ # GeoJSON coordinates are [longitude, latitude, depth], longitude first. USGS times are
120
+ # epoch milliseconds, so a digest a person reads has to divide before it formats.
121
+ program: |
122
+ def radians: . * 3.141592653589793 / 180;
123
+ . as {$latitude, $longitude, $radius_km, $min_magnitude}
124
+ | [.features[]
125
+ | .geometry.coordinates as $c
126
+ | ((($latitude - $c[1]) | radians / 2 | sin)) as $dp
127
+ | ((($longitude - $c[0]) | radians / 2 | sin)) as $dl
128
+ | ($dp * $dp + ($c[1] | radians | cos) * ($latitude | radians | cos) * $dl * $dl) as $a
129
+ | {
130
+ id: .id,
131
+ magnitude: (.properties.mag // -1),
132
+ place: .properties.place,
133
+ at: (.properties.time / 1000 | todate),
134
+ depth_km: $c[2],
135
+ distance_km: (2 * 6371 * (($a | sqrt) | asin) | round),
136
+ url: .properties.url,
137
+ radius_km: $radius_km,
138
+ min_magnitude: $min_magnitude
139
+ }]
140
+
141
+ nearby:
142
+ block: filter.jq
143
+ depends_on: [candidates]
144
+ config:
145
+ input: ${steps.candidates.output.value}
146
+ # Every number in this comparison is a field of the row being tested, which is why the
147
+ # program is three words long and needs no parameters of its own.
148
+ program: |
149
+ .magnitude >= .min_magnitude and .distance_km <= .radius_km
150
+
151
+ digest:
152
+ block: map.jq
153
+ depends_on: [nearby]
154
+ config:
155
+ input: ${steps.nearby.output.value}
156
+ # The limits did their job in the filter; what goes out is the event.
157
+ program: |
158
+ del(.radius_km, .min_magnitude)
159
+
160
+ ordered:
161
+ block: transform.jq
162
+ depends_on: [digest]
163
+ config:
164
+ input: ${steps.digest.output.value}
165
+ program: |
166
+ sort_by(-.magnitude)
167
+
168
+ staged:
169
+ block: storage.write
170
+ depends_on: [ordered]
171
+ config:
172
+ target: ${run.scratch}/matched.json
173
+ value: ${steps.ordered.output.value}
174
+
175
+ written:
176
+ block: convert.std
177
+ depends_on: [staged]
178
+ config:
179
+ source: ${steps.staged.output.uri}
180
+ target: ${run.scratch}/matched.ndjson
181
+ from: json
182
+ to: ndjson
183
+
184
+ matched:
185
+ block: storage.exists
186
+ depends_on: [written]
187
+ poll: 1s
188
+ deadline: 3s
189
+ # The whole point: nothing matched is not a failure, it is a run with nothing to say.
190
+ on_timeout: skip
191
+ config:
192
+ uri: ${steps.written.output.target}
193
+ # One byte is the difference between an empty digest and a digest.
194
+ min_size: 1b
195
+
196
+ notify:
197
+ block: webhook.post
198
+ depends_on: [digest, matched]
199
+ config:
200
+ url: ${params.webhook_url}
201
+ body:
202
+ kind: earthquake-digest
203
+ near:
204
+ - ${params.latitude}
205
+ - ${params.longitude}
206
+ radius_km: ${params.radius_km}
207
+ min_magnitude: ${params.min_magnitude}
208
+ events: ${steps.digest.output.value}
@@ -0,0 +1,163 @@
1
+ # One WHO indicator, several countries, one table, one parquet file, and a manifest over the batch.
2
+ #
3
+ # The WHO Global Health Observatory serves its whole indicator catalogue as OData, keyless:
4
+ # /api/<INDICATOR_CODE> answers {"@odata.context": ..., "value": [ ... ]}, one object per
5
+ # observation, each carrying the country in SpatialDim, the year in TimeDim, the disaggregation
6
+ # in Dim1, and the number in NumericValue. The envelope is OData's; the rows inside it are what
7
+ # anyone actually wants.
8
+ #
9
+ # What this shows, beyond the fetch: how a fan-out is read back. `for_each` is expanded when the
10
+ # run is created, so it can read params, run and item -- and never another step's output. And a
11
+ # fan-out step stores one output: the list of its items' outputs, in item order, with a failed
12
+ # item absent from it. So there is no way for item three of one step to ask item three of another
13
+ # what it produced; what a downstream step gets is the whole list at once. That is why the
14
+ # flattening below is a single step over every envelope, and why the country is read out of
15
+ # SpatialDim: the join key lives in the data, where a fan-in can see it.
16
+ #
17
+ # What happens, hop by hop:
18
+ #
19
+ # fetch one GET per country. items: continue means a country the indicator has no data
20
+ # for, or one the endpoint is slow for, does not take the rest of the batch down
21
+ # with it -- and its absence from this step's output list is what the manifest
22
+ # below counts.
23
+ # rows the fan-in: every envelope this step is handed, opened at `.value` and flattened
24
+ # into one table sorted by country and year.
25
+ # staged storage.write, that table as one json object. A converter reads one URI and
26
+ # writes another, so a value the run is holding is put down before it is
27
+ # re-encoded.
28
+ # parquet convert.arrow, json to parquet. Parquet is bytes, so it never travels as a value:
29
+ # the step names a source URI and a target URI and nothing is carried between them.
30
+ # manifest what the run has to say for itself: how many countries were asked for, how many
31
+ # answered, how many rows landed, and where the file is.
32
+ # index the manifest written beside the parquet, which is the object another pipeline
33
+ # lists a dataset from.
34
+ #
35
+ # To make it yours: change indicator to any code from https://ghoapi.azureedge.net/api/Indicator
36
+ # and countries to the ISO3 codes you report on. Point the targets at s3:// on an instance with
37
+ # an object store, and the manifest becomes a dataset index other pipelines read.
38
+ #
39
+ # dg run --local examples/open-data/who-gho-indicators-to-parquet.yaml
40
+ # dg run --local examples/open-data/who-gho-indicators-to-parquet.yaml \
41
+ # -p indicator=WHOSIS_000015 -p countries='["NPL","BGD"]'
42
+
43
+ format: dirigent/v1
44
+ kind: pipeline
45
+ code: who-gho-indicators-to-parquet
46
+ name: WHO GHO indicator to parquet
47
+ description: |
48
+ One **GHO** indicator fetched once per country, flattened out of the OData envelopes into one
49
+ table and written as parquet, with a manifest counting what came back.
50
+
51
+ A fan-out's results are read back as one list, because a fan-out step stores one output and
52
+ `for_each` cannot read an upstream step's output at all.
53
+
54
+ tags: [open-data, http, storage, transform, fan-out, parquet, starter]
55
+
56
+ requires:
57
+ blocks:
58
+ - http.request
59
+ - transform.jq
60
+ - storage.write
61
+ - convert.arrow
62
+
63
+ params:
64
+ type: object
65
+ additionalProperties: false
66
+ properties:
67
+ indicator:
68
+ type: string
69
+ default: WHOSIS_000001
70
+ description: A GHO indicator code; the default is life expectancy at birth.
71
+ countries:
72
+ type: array
73
+ default: [MWI, NPL, ETH]
74
+ items:
75
+ type: string
76
+ description: ISO3 country code, as GHO writes it in SpatialDim.
77
+
78
+ steps:
79
+ fetch:
80
+ block: http.request
81
+ for_each: ${params.countries}
82
+ # A country with no data for this indicator answers an empty value list rather than an
83
+ # error, so what this policy really tolerates is the endpoint being slow or briefly down.
84
+ items: continue
85
+ # The GHO endpoint sits behind a CDN that occasionally takes seconds to answer a cold
86
+ # indicator; one retry costs nothing and saves the batch.
87
+ retry:
88
+ max_attempts: 3
89
+ backoff: 5s
90
+ config:
91
+ url: https://ghoapi.azureedge.net/api/${params.indicator}
92
+ query:
93
+ # OData's filter language, not a URL convention: the quotes around the code are part
94
+ # of the expression and the server parses them.
95
+ $filter: SpatialDim eq '${item}'
96
+ timeout: 1m
97
+
98
+ rows:
99
+ block: transform.jq
100
+ depends_on: [fetch]
101
+ config:
102
+ # One fan-out step, one output: the list of what its items answered. A country whose
103
+ # item failed is simply not in it.
104
+ input: ${steps.fetch.output}
105
+ # NumericValue rather than Value: Value is the display string, "48.0 [46.7-49.6]", and
106
+ # a column that has to be parsed before it can be added up is not a number.
107
+ program: |
108
+ [.[]
109
+ | .body.value[]
110
+ | {country: .SpatialDim,
111
+ year: .TimeDim,
112
+ dimension: .Dim1,
113
+ value: .NumericValue,
114
+ low: .Low,
115
+ high: .High}]
116
+ | sort_by(.country, .year, .dimension)
117
+
118
+ staged:
119
+ block: storage.write
120
+ depends_on: [rows]
121
+ config:
122
+ target: ${run.scratch}/rows/${params.indicator}.json
123
+ value: ${steps.rows.output.value}
124
+
125
+ parquet:
126
+ block: convert.arrow
127
+ depends_on: [staged]
128
+ config:
129
+ source: ${steps.staged.output.uri}
130
+ target: ${run.scratch}/parquet/${params.indicator}.parquet
131
+ from: json
132
+ to: parquet
133
+
134
+ manifest:
135
+ block: transform.jq
136
+ depends_on: [fetch, rows, parquet]
137
+ config:
138
+ input:
139
+ indicator: ${params.indicator}
140
+ requested: ${params.countries}
141
+ answered: ${steps.fetch.output}
142
+ rows: ${steps.rows.output.value}
143
+ file: ${steps.parquet.output.target}
144
+ bytes: ${steps.parquet.output.bytes_written}
145
+ # `answered` is shorter than `requested` exactly when an item failed, which is the only
146
+ # place in the run where that difference is visible as data rather than as a status.
147
+ program: |
148
+ {
149
+ indicator,
150
+ file,
151
+ bytes,
152
+ requested: (.requested | length),
153
+ answered: (.answered | length),
154
+ rows: (.rows | length),
155
+ countries: ([.rows[].country] | unique)
156
+ }
157
+
158
+ index:
159
+ block: storage.write
160
+ depends_on: [manifest]
161
+ config:
162
+ target: ${run.scratch}/parquet/${params.indicator}-manifest.json
163
+ value: ${steps.manifest.output.value}