oapi-profile-builder 3.5.0__tar.gz → 3.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {oapi_profile_builder-3.5.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.6.2}/PKG-INFO +74 -9
  2. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/README.md +73 -8
  3. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cover.py +71 -27
  5. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/generate.py +70 -49
  6. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/models.py +33 -6
  7. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2/src/oapi_profile_builder.egg-info}/PKG-INFO +74 -9
  8. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/LICENSE +0 -0
  9. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/setup.cfg +0 -0
  10. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/__init__.py +0 -0
  11. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cite.py +0 -0
  12. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cite_features.py +0 -0
  13. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cli.py +0 -0
  14. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/compile.py +0 -0
  15. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.5.0 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.5.0
3
+ Version: 3.6.2
4
4
  Summary: Authoritative tooling for creating OGC API Service Profiles (EDR, Features)
5
5
  Author-email: Shane Mill <shane.mill@noaa.gov>
6
6
  License: Apache License
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
 
48
48
  # OGC API Service Profile Builder
49
49
 
50
- Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
50
+ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.0.0/3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
51
51
 
52
52
  [![PyPI](https://img.shields.io/pypi/v/oapi-profile-builder)](https://pypi.org/project/oapi-profile-builder/)
53
53
  [![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE)
@@ -57,7 +57,9 @@ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — O
57
57
  ## Quick Start
58
58
 
59
59
  ```bash
60
- pip install oapi-profile-builder
60
+ # Base install. Adds Pillow (cover image generation) with the [pdf] extra —
61
+ # required whenever your profile sets document_metadata.cover.logo.
62
+ pip install 'oapi-profile-builder[pdf]'
61
63
 
62
64
  # Copy an example config and edit it
63
65
  cp examples/minimal_profile.yaml my_profile.yaml
@@ -70,7 +72,7 @@ That's it. The `output/` directory will contain:
70
72
 
71
73
  ```
72
74
  output/
73
- ├── openapi.yaml # OpenAPI 3.1.0 — ready for Swagger UI, Redoc, schemathesis
75
+ ├── openapi.yaml # OpenAPI 3.0.0 or 3.1.0 — ready for Swagger UI, Redoc, schemathesis
74
76
  ├── profile_config.json # Round-trip serialized profile
75
77
  ├── document.adoc # Metanorma root document
76
78
  ├── sections/ # Abstract, Preface, Scope, Conformance, References, Terms
@@ -181,6 +183,30 @@ OGC API - EDR CITE Results
181
183
 
182
184
  ## GitHub Actions
183
185
 
186
+ ### Self-service: generate artifacts from an issue (no fork, no install)
187
+
188
+ Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
189
+
190
+ 1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
191
+ 2. Paste your profile YAML into the config field.
192
+ 3. If your profile references local files (typically a cover logo via `document_metadata.cover.logo`), drag-and-drop them into the **"Additional assets (optional)"** field. Reference each uploaded file in your YAML as `uploads/<original-filename>` — for example:
193
+
194
+ ```yaml
195
+ document_metadata:
196
+ cover:
197
+ logo: uploads/my_logo.png
198
+ ```
199
+
200
+ The workflow downloads each dropped file into `./uploads/` before generation. It parses your YAML to figure out the target filename, so images that GitHub renders as an `<img>` tag with a UUID URL still land at the exact path your config references. Tip: to skip the upload entirely for the DGIWG logo, point at the copy that already ships in this repo: `logo: examples/assets/DGIWG_logo.png`.
201
+ 4. Tick "Compile a PDF?" if you also want a Metanorma PDF, then submit.
202
+ 5. A workflow validates the config, generates the artifacts, and comments on the issue with a link to download them as a zip (retained for 14 days).
203
+
204
+ The workflow only runs the generator over your declarative config — it never executes user-supplied code — and comments back on both success and failure. Don't paste secrets: the artifacts are attached to a public workflow run.
205
+
206
+ > Maintainers: create a `profile-request` label so requests are easy to filter (the workflow gates on a marker in the issue body, so it works even before the label exists).
207
+
208
+ ### Use the action in your own workflow
209
+
184
210
  No local install needed. Add this to any workflow to generate profile artifacts from a config file:
185
211
 
186
212
  ```yaml
@@ -239,7 +265,7 @@ Everything about the document — cover, title-page metadata, legal page, footer
239
265
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
240
266
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
241
267
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
242
- | Cover layout (logo size/position, tagline/title size, bold edition) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, bold_edition}` | Generated cover image |
268
+ | Cover layout (logo size/position, tagline/title/edition-date size, bold edition+date) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, edition_date_font_size, bold_edition}` | Generated cover image |
243
269
  | Cover date | `document_metadata.doc_pub_date` | Long-format date on the generated cover + `:published-date:` |
244
270
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
245
271
  | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
@@ -352,6 +378,7 @@ abstract_tests: []
352
378
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
353
379
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
354
380
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
381
+ | `abstract_tests[].method` | string | no | Optional test method description |
355
382
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
356
383
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
357
384
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -646,6 +673,7 @@ requirements:
646
673
  abstract_tests:
647
674
  - id: position-coveragejson # must equal requirement_id
648
675
  requirement_id: position-coveragejson
676
+ method: Manual inspection of the /position response.
649
677
  steps:
650
678
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
651
679
  - Verify Content-Type is application/prs.coverage+json.
@@ -695,7 +723,7 @@ When present, generates `asyncapi.yaml` alongside `openapi.yaml`.
695
723
  pubsub:
696
724
  broker_host: my-broker.example.com
697
725
  broker_port: 5672
698
- protocol: amqp # amqp | mqtt | kafka
726
+ protocol: amqp # amqp | amqp1 | mqtt | kafka
699
727
  collections:
700
728
  - my_collection
701
729
  filters:
@@ -704,6 +732,40 @@ pubsub:
704
732
  type: string
705
733
  ```
706
734
 
735
+ `broker_host`/`broker_port`/`protocol` synthesise a single implicit
736
+ `production` AsyncAPI server. To describe an exact transport topology instead —
737
+ e.g. a native WebSocket `/ws` endpoint plus AMQP 1.0 and MQTT brokers, as the
738
+ `nws_connect_profile.yaml` example does — list them explicitly under `servers`.
739
+ When `servers` is set, the implicit `production` server is **not** emitted:
740
+
741
+ ```yaml
742
+ pubsub:
743
+ broker_host: edr-broker
744
+ broker_port: 5672
745
+ protocol: amqp1 # AMQP 1.0 (e.g. RabbitMQ 4.x native)
746
+ collections: [water_gauge, wwa]
747
+ servers:
748
+ - name: websocket_secure
749
+ description: Native WebSocket live map (production)
750
+ host: ''
751
+ protocol: wss # amqp | amqp1 | mqtt | kafka | ws | wss
752
+ pathname: /ws
753
+ - name: amqp
754
+ description: AMQP 1.0 broker (RabbitMQ 4.x native AMQP 1.0)
755
+ host: 'edr-broker:5672'
756
+ protocol: amqp1
757
+ - name: mqtt
758
+ description: MQTT broker (RabbitMQ MQTT plugin)
759
+ host: 'edr-broker:1883'
760
+ protocol: mqtt
761
+ collection_filters: # per-collection filter overrides
762
+ water_gauge:
763
+ filters:
764
+ - name: flood_stages
765
+ description: Alert for selected flood stages
766
+ type: array
767
+ ```
768
+
707
769
  ---
708
770
 
709
771
  ### `document_metadata`
@@ -764,7 +826,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
764
826
  document_metadata:
765
827
  doc_number: "DGIWG 134"
766
828
  cover:
767
- logo: assets/dgiwg_logo.png # path relative to the working directory (or absolute)
829
+ logo: examples/assets/DGIWG_logo.png # path relative to the working directory (or absolute).
830
+ # From the self-service issue form use uploads/<file>
831
+ # instead and drag-drop the file into "Additional assets".
768
832
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
769
833
  background: "#FFFFFF" # optional cover background (default white)
770
834
  text_color: "#1F3864" # optional cover text colour (default black)
@@ -773,11 +837,12 @@ document_metadata:
773
837
  font_italic: "Source Sans Pro" # optional italic face for the tagline
774
838
  watermark: "DRAFT" # optional diagonal watermark stamped on the cover
775
839
  # --- cover layout (all optional) ---
776
- logo_width: 200 # target logo width in px (default 420)
840
+ logo_width: 300 # target logo width in px (default 420)
777
841
  logo_y: 100 # logo vertical offset from top in px (default 180)
778
842
  tagline_font_size: 20 # tagline point size (default 30)
779
843
  title_font_size: 40 # title point size (default 54)
780
- bold_edition: true # render the "Edition X" line in bold (default false)
844
+ edition_date_font_size: 22 # edition/date point size (default 24)
845
+ bold_edition: true # render the "Edition X" and "Dated ..." lines in bold (default false)
781
846
  colors:
782
847
  text: "#000000" # body text
783
848
  cover_text: "#1F3864" # cover text / section numbers / ToC
@@ -1,6 +1,6 @@
1
1
  # OGC API Service Profile Builder
2
2
 
3
- Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
3
+ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.0.0/3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
4
4
 
5
5
  [![PyPI](https://img.shields.io/pypi/v/oapi-profile-builder)](https://pypi.org/project/oapi-profile-builder/)
6
6
  [![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE)
@@ -10,7 +10,9 @@ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — O
10
10
  ## Quick Start
11
11
 
12
12
  ```bash
13
- pip install oapi-profile-builder
13
+ # Base install. Adds Pillow (cover image generation) with the [pdf] extra —
14
+ # required whenever your profile sets document_metadata.cover.logo.
15
+ pip install 'oapi-profile-builder[pdf]'
14
16
 
15
17
  # Copy an example config and edit it
16
18
  cp examples/minimal_profile.yaml my_profile.yaml
@@ -23,7 +25,7 @@ That's it. The `output/` directory will contain:
23
25
 
24
26
  ```
25
27
  output/
26
- ├── openapi.yaml # OpenAPI 3.1.0 — ready for Swagger UI, Redoc, schemathesis
28
+ ├── openapi.yaml # OpenAPI 3.0.0 or 3.1.0 — ready for Swagger UI, Redoc, schemathesis
27
29
  ├── profile_config.json # Round-trip serialized profile
28
30
  ├── document.adoc # Metanorma root document
29
31
  ├── sections/ # Abstract, Preface, Scope, Conformance, References, Terms
@@ -134,6 +136,30 @@ OGC API - EDR CITE Results
134
136
 
135
137
  ## GitHub Actions
136
138
 
139
+ ### Self-service: generate artifacts from an issue (no fork, no install)
140
+
141
+ Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
142
+
143
+ 1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
144
+ 2. Paste your profile YAML into the config field.
145
+ 3. If your profile references local files (typically a cover logo via `document_metadata.cover.logo`), drag-and-drop them into the **"Additional assets (optional)"** field. Reference each uploaded file in your YAML as `uploads/<original-filename>` — for example:
146
+
147
+ ```yaml
148
+ document_metadata:
149
+ cover:
150
+ logo: uploads/my_logo.png
151
+ ```
152
+
153
+ The workflow downloads each dropped file into `./uploads/` before generation. It parses your YAML to figure out the target filename, so images that GitHub renders as an `<img>` tag with a UUID URL still land at the exact path your config references. Tip: to skip the upload entirely for the DGIWG logo, point at the copy that already ships in this repo: `logo: examples/assets/DGIWG_logo.png`.
154
+ 4. Tick "Compile a PDF?" if you also want a Metanorma PDF, then submit.
155
+ 5. A workflow validates the config, generates the artifacts, and comments on the issue with a link to download them as a zip (retained for 14 days).
156
+
157
+ The workflow only runs the generator over your declarative config — it never executes user-supplied code — and comments back on both success and failure. Don't paste secrets: the artifacts are attached to a public workflow run.
158
+
159
+ > Maintainers: create a `profile-request` label so requests are easy to filter (the workflow gates on a marker in the issue body, so it works even before the label exists).
160
+
161
+ ### Use the action in your own workflow
162
+
137
163
  No local install needed. Add this to any workflow to generate profile artifacts from a config file:
138
164
 
139
165
  ```yaml
@@ -192,7 +218,7 @@ Everything about the document — cover, title-page metadata, legal page, footer
192
218
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
193
219
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
194
220
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
195
- | Cover layout (logo size/position, tagline/title size, bold edition) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, bold_edition}` | Generated cover image |
221
+ | Cover layout (logo size/position, tagline/title/edition-date size, bold edition+date) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, edition_date_font_size, bold_edition}` | Generated cover image |
196
222
  | Cover date | `document_metadata.doc_pub_date` | Long-format date on the generated cover + `:published-date:` |
197
223
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
198
224
  | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
@@ -305,6 +331,7 @@ abstract_tests: []
305
331
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
306
332
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
307
333
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
334
+ | `abstract_tests[].method` | string | no | Optional test method description |
308
335
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
309
336
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
310
337
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -599,6 +626,7 @@ requirements:
599
626
  abstract_tests:
600
627
  - id: position-coveragejson # must equal requirement_id
601
628
  requirement_id: position-coveragejson
629
+ method: Manual inspection of the /position response.
602
630
  steps:
603
631
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
604
632
  - Verify Content-Type is application/prs.coverage+json.
@@ -648,7 +676,7 @@ When present, generates `asyncapi.yaml` alongside `openapi.yaml`.
648
676
  pubsub:
649
677
  broker_host: my-broker.example.com
650
678
  broker_port: 5672
651
- protocol: amqp # amqp | mqtt | kafka
679
+ protocol: amqp # amqp | amqp1 | mqtt | kafka
652
680
  collections:
653
681
  - my_collection
654
682
  filters:
@@ -657,6 +685,40 @@ pubsub:
657
685
  type: string
658
686
  ```
659
687
 
688
+ `broker_host`/`broker_port`/`protocol` synthesise a single implicit
689
+ `production` AsyncAPI server. To describe an exact transport topology instead —
690
+ e.g. a native WebSocket `/ws` endpoint plus AMQP 1.0 and MQTT brokers, as the
691
+ `nws_connect_profile.yaml` example does — list them explicitly under `servers`.
692
+ When `servers` is set, the implicit `production` server is **not** emitted:
693
+
694
+ ```yaml
695
+ pubsub:
696
+ broker_host: edr-broker
697
+ broker_port: 5672
698
+ protocol: amqp1 # AMQP 1.0 (e.g. RabbitMQ 4.x native)
699
+ collections: [water_gauge, wwa]
700
+ servers:
701
+ - name: websocket_secure
702
+ description: Native WebSocket live map (production)
703
+ host: ''
704
+ protocol: wss # amqp | amqp1 | mqtt | kafka | ws | wss
705
+ pathname: /ws
706
+ - name: amqp
707
+ description: AMQP 1.0 broker (RabbitMQ 4.x native AMQP 1.0)
708
+ host: 'edr-broker:5672'
709
+ protocol: amqp1
710
+ - name: mqtt
711
+ description: MQTT broker (RabbitMQ MQTT plugin)
712
+ host: 'edr-broker:1883'
713
+ protocol: mqtt
714
+ collection_filters: # per-collection filter overrides
715
+ water_gauge:
716
+ filters:
717
+ - name: flood_stages
718
+ description: Alert for selected flood stages
719
+ type: array
720
+ ```
721
+
660
722
  ---
661
723
 
662
724
  ### `document_metadata`
@@ -717,7 +779,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
717
779
  document_metadata:
718
780
  doc_number: "DGIWG 134"
719
781
  cover:
720
- logo: assets/dgiwg_logo.png # path relative to the working directory (or absolute)
782
+ logo: examples/assets/DGIWG_logo.png # path relative to the working directory (or absolute).
783
+ # From the self-service issue form use uploads/<file>
784
+ # instead and drag-drop the file into "Additional assets".
721
785
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
722
786
  background: "#FFFFFF" # optional cover background (default white)
723
787
  text_color: "#1F3864" # optional cover text colour (default black)
@@ -726,11 +790,12 @@ document_metadata:
726
790
  font_italic: "Source Sans Pro" # optional italic face for the tagline
727
791
  watermark: "DRAFT" # optional diagonal watermark stamped on the cover
728
792
  # --- cover layout (all optional) ---
729
- logo_width: 200 # target logo width in px (default 420)
793
+ logo_width: 300 # target logo width in px (default 420)
730
794
  logo_y: 100 # logo vertical offset from top in px (default 180)
731
795
  tagline_font_size: 20 # tagline point size (default 30)
732
796
  title_font_size: 40 # title point size (default 54)
733
- bold_edition: true # render the "Edition X" line in bold (default false)
797
+ edition_date_font_size: 22 # edition/date point size (default 24)
798
+ bold_edition: true # render the "Edition X" and "Dated ..." lines in bold (default false)
734
799
  colors:
735
800
  text: "#000000" # body text
736
801
  cover_text: "#1F3864" # cover text / section numbers / ToC
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.5.0"
7
+ version = "3.6.2"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -43,9 +43,9 @@ _PAGE_H = 1754
43
43
  _COVER_FILENAME = "cover.png"
44
44
 
45
45
  # Default font family (bundled with Pillow, resolved by name).
46
- _DEFAULT_REGULAR = "DejaVuSans.ttf"
47
- _DEFAULT_BOLD = "DejaVuSans-Bold.ttf"
48
- _DEFAULT_ITALIC = "DejaVuSans-Oblique.ttf"
46
+ _DEFAULT_REGULAR = "DejaVuSans"
47
+ _DEFAULT_BOLD = "DejaVuSans-Bold"
48
+ _DEFAULT_ITALIC = "DejaVuSans:style=Oblique"
49
49
 
50
50
 
51
51
  def _resolve_font_file(spec: str | None, *, bold: bool = False, italic: bool = False) -> str | None:
@@ -62,20 +62,40 @@ def _resolve_font_file(spec: str | None, *, bold: bool = False, italic: bool = F
62
62
  if p.is_file():
63
63
  return str(p)
64
64
  if shutil.which("fc-match"):
65
- styles = []
66
- if bold:
67
- styles.append("Bold")
68
- if italic:
69
- styles.append("Italic")
70
- query = spec if not styles else f"{spec}:style={' '.join(styles)}"
65
+ # If the spec already contains a colon, treat it as a full fontconfig query
66
+ if ":" in spec:
67
+ query = spec
68
+ else:
69
+ styles = []
70
+ if bold:
71
+ styles.append("Bold")
72
+ if italic:
73
+ styles.append("Italic")
74
+ query = spec if not styles else f"{spec}:style={' '.join(styles)}"
75
+
71
76
  try:
77
+ # Try the primary query
72
78
  out = subprocess.run(
73
79
  ["fc-match", "-f", "%{file}", query],
74
80
  capture_output=True, text=True, timeout=5, check=False,
75
81
  )
76
82
  path = out.stdout.strip()
77
83
  if path and Path(path).is_file():
78
- return path
84
+ # Verify that the result matches the requested style if possible.
85
+ # If we asked for Italic but got a Regular file, fc-match just fell back.
86
+ if italic and "Italic" not in path and "Oblique" not in path:
87
+ # Try a second attempt explicitly asking for Oblique if Italic failed
88
+ if ":" not in spec:
89
+ query_oblique = f"{spec}:style=Oblique"
90
+ out = subprocess.run(
91
+ ["fc-match", "-f", "%{file}", query_oblique],
92
+ capture_output=True, text=True, timeout=5, check=False,
93
+ )
94
+ path = out.stdout.strip()
95
+ if path and Path(path).is_file() and ("Italic" in path or "Oblique" in path):
96
+ return path
97
+ else:
98
+ return path
79
99
  except Exception:
80
100
  pass
81
101
  return None
@@ -89,22 +109,36 @@ class _FontSet:
89
109
  ultimately fall back to the bundled DejaVu family.
90
110
  """
91
111
 
92
- def __init__(self, cover=None):
112
+ def __init__(self, cover=None, metadata=None):
93
113
  self.regular = getattr(cover, "font_regular", None) if cover else None
94
114
  self.bold = getattr(cover, "font_bold", None) if cover else None
95
115
  self.italic = getattr(cover, "font_italic", None) if cover else None
116
+
117
+ # Fallback to metadata-level font overrides if set
118
+ if not self.regular and metadata:
119
+ self.regular = getattr(metadata, "body_font", None)
120
+ if not self.bold and metadata:
121
+ self.bold = getattr(metadata, "header_font", None)
96
122
 
97
123
  def _font(self, size: int, *, bold: bool = False, italic: bool = False):
98
124
  from PIL import ImageFont
99
125
 
100
- # Prefer an explicit per-style spec, else derive from the regular family.
101
- explicit = self.bold if bold else (self.italic if italic else self.regular)
102
- candidates = [
103
- _resolve_font_file(explicit, bold=bold, italic=italic),
104
- _resolve_font_file(self.regular, bold=bold, italic=italic),
105
- ]
106
- default = _DEFAULT_BOLD if bold else (_DEFAULT_ITALIC if italic else _DEFAULT_REGULAR)
107
- candidates.append(default)
126
+ candidates = []
127
+ # 1. Try explicit style override (path or family name)
128
+ style_spec = self.bold if bold else (self.italic if italic else None)
129
+ if style_spec:
130
+ candidates.append(_resolve_font_file(style_spec, bold=bold, italic=italic))
131
+
132
+ # 2. Try deriving from the regular spec if it is a family name
133
+ if (bold or italic) and self.regular and not Path(self.regular).expanduser().is_file():
134
+ candidates.append(_resolve_font_file(self.regular, bold=bold, italic=italic))
135
+
136
+ # 3. Try standard defaults for the requested style
137
+ candidates.append(_DEFAULT_BOLD if bold else (_DEFAULT_ITALIC if italic else _DEFAULT_REGULAR))
138
+
139
+ # 4. Fallback to the regular font spec (even if not the right style)
140
+ candidates.append(_resolve_font_file(self.regular))
141
+
108
142
  for cand in candidates:
109
143
  if not cand:
110
144
  continue
@@ -160,7 +194,7 @@ def build_cover_image(profile, output_dir: Path) -> str | None:
160
194
 
161
195
  bg = cover.background or "#FFFFFF"
162
196
  fg = cover.text_color or "#000000"
163
- fonts = _FontSet(cover)
197
+ fonts = _FontSet(cover, m)
164
198
 
165
199
  page = Image.new("RGB", (_PAGE_W, _PAGE_H), bg)
166
200
  draw = ImageDraw.Draw(page)
@@ -191,23 +225,33 @@ def build_cover_image(profile, output_dir: Path) -> str | None:
191
225
  # --- Tagline (italic) ---
192
226
  if cover.tagline:
193
227
  tagline_fs = getattr(cover, "tagline_font_size", 30)
194
- y = centered(cover.tagline, fonts.italic_font(tagline_fs), y, fg) + 60
228
+ tagline_font = fonts.italic_font(tagline_fs)
229
+ for line in _wrap(draw, cover.tagline, tagline_font, _PAGE_W - 240):
230
+ y = centered(line, tagline_font, y, fg) + 10
231
+ y += 50
195
232
 
196
233
  # --- Document number ---
197
234
  if m and m.doc_number:
198
- y = centered(m.doc_number, fonts.regular_font(46, bold=True), y, fg) + 40
235
+ doc_num_font = fonts.regular_font(46, bold=True)
236
+ for line in _wrap(draw, m.doc_number, doc_num_font, _PAGE_W - 240):
237
+ y = centered(line, doc_num_font, y, fg) + 16
238
+ y += 24
239
+
240
+
241
+ # --- Edition & date ---
242
+ # Per DGIWG feedback these two lines are both bold and set a little smaller
243
+ # than the document number so they read as a compact sub-heading.
244
+ ed_date_fs = getattr(cover, "edition_date_font_size", 24)
245
+ bold_ed = bool(getattr(cover, "bold_edition", False))
199
246
 
200
- # --- Edition ---
201
247
  edition = getattr(profile, "version", None)
202
248
  if edition:
203
- bold_ed = bool(getattr(cover, "bold_edition", False))
204
- y = centered(f"Edition {edition}", fonts.regular_font(28, bold=bold_ed), y, fg) + 24
249
+ y = centered(f"Edition {edition}", fonts.regular_font(ed_date_fs, bold=bold_ed), y, fg) + 20
205
250
 
206
- # --- Date ---
207
251
  pub_date = (m.doc_pub_date or m.publication_date) if m else None
208
252
  date_str = _format_date(pub_date)
209
253
  if date_str:
210
- y = centered(f"Dated {date_str}", fonts.regular_font(28), y, fg) + 80
254
+ y = centered(f"Dated {date_str}", fonts.regular_font(ed_date_fs, bold=bold_ed), y, fg) + 80
211
255
 
212
256
  # --- Title (large, bold, wrapped) ---
213
257
  title = getattr(profile, "title", "") or ""
@@ -41,6 +41,16 @@ import yaml
41
41
 
42
42
  from edr_pydantic.collections import Collection
43
43
  from oapi_profile_builder.models import ServiceProfile
44
+ def _const(val, version: str) -> dict:
45
+ if version == "3.1.0":
46
+ return {"const": val}
47
+ return {"enum": [val]}
48
+
49
+ def _contains(schema: dict, version: str) -> dict:
50
+ if version == "3.1.0":
51
+ return {"contains": schema}
52
+ return {}
53
+
44
54
 
45
55
 
46
56
  class _NoAliasDumper(yaml.Dumper):
@@ -125,30 +135,30 @@ _LINK_SCHEMA = {
125
135
  _LINKS_ARRAY = {"type": "array", "items": _LINK_SCHEMA}
126
136
 
127
137
  # Landing page response with required profile link per REQ_publishing
128
- def _landing_page_schema(profile_uri: str, title: str = "", description: str = "", keywords: list | None = None) -> dict:
138
+ def _landing_page_schema(profile_uri: str, version: str, title: str = "", description: str = "", keywords: list | None = None) -> dict:
129
139
  schema_props: dict = {
130
140
  "links": {
131
141
  "type": "array",
132
142
  "items": _LINK_SCHEMA,
133
- "contains": {
143
+ **(_contains({
134
144
  "type": "object",
135
145
  "required": ["href", "rel"],
136
146
  "properties": {
137
- "rel": {"const": "profile"},
138
- "href": {"const": profile_uri},
147
+ "rel": _const("profile", version),
148
+ "href": _const(profile_uri, version),
139
149
  },
140
- },
150
+ }, version) if version == "3.1.0" else {}),
141
151
  },
142
152
  }
143
153
  if title:
144
- schema_props["title"] = {"type": "string", "const": title}
154
+ schema_props["title"] = {"type": "string", **_const(title, version)}
145
155
  if description:
146
156
  schema_props["description"] = {"type": "string"}
147
157
  if keywords:
148
158
  schema_props["keywords"] = {
149
159
  "type": "array",
150
160
  "items": {"type": "string"},
151
- "contains": {"enum": keywords},
161
+ **(_contains({"enum": keywords}, version) if version == "3.1.0" else {"items": {"type": "string", "enum": keywords}}),
152
162
  }
153
163
 
154
164
  return {
@@ -182,27 +192,30 @@ _R200_CONFORMANCE = {
182
192
  }
183
193
  }
184
194
  _R200_COLLECTION = {"description": "Collection metadata", "content": {"application/json": {"schema": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string"}, "title": {"type": "string"}, "description": {"type": "string"}, "links": _LINKS_ARRAY}}}}}
185
- _R200_FEATURES = {
186
- "description": "Feature collection",
187
- "content": {
188
- "application/json": {
189
- "schema": {
190
- "type": "object",
191
- "properties": {
192
- "type": {"type": "string", "const": "FeatureCollection"},
193
- "features": {
194
- "type": "array",
195
- "items": {"type": "object"},
195
+ def _features_schema(version: str) -> dict:
196
+ return {
197
+ "description": "Feature collection",
198
+ "content": {
199
+ "application/json": {
200
+ "schema": {
201
+ "type": "object",
202
+ "properties": {
203
+ "type": {"type": "string", **_const("FeatureCollection", version)},
204
+ "features": {
205
+ "type": "array",
206
+ "items": {"type": "object"},
207
+ },
196
208
  },
197
209
  },
198
210
  }
199
- }
200
- },
201
- }
211
+ },
212
+ }
202
213
 
203
214
  def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
204
215
  """Build the 200 response for a /locations query.
205
216
 
217
+ version = profile.openapi_version if profile else "3.0.0"
218
+
206
219
  Per the EDR locations-response requirement (and the MetOcean profile's
207
220
  /req/core/locations_query_response_format), the response is a GeoJSON
208
221
  FeatureCollection where every feature carries a string ``id`` and a
@@ -223,7 +236,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
223
236
  "type": "object",
224
237
  "required": ["type", "id", "properties"],
225
238
  "properties": {
226
- "type": {"type": "string", "const": "Feature"},
239
+ "type": {"type": "string", **_const("Feature", version)},
227
240
  "id": {"type": "string", "description": "Location identifier usable at /locations/{locId}"},
228
241
  "geometry": {"type": "object", "description": "GeoJSON geometry of the location"},
229
242
  "properties": properties_schema,
@@ -237,7 +250,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
237
250
  "type": "object",
238
251
  "required": ["type", "features"],
239
252
  "properties": {
240
- "type": {"type": "string", "const": "FeatureCollection"},
253
+ "type": {"type": "string", **_const("FeatureCollection", version)},
241
254
  "features": {"type": "array", "items": feature_schema},
242
255
  },
243
256
  }
@@ -267,11 +280,11 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
267
280
  media_type = "application/prs.coverage+json"
268
281
  # Also accept the newer media type
269
282
  alt_media = "application/vnd.cov+json"
270
- schema = (
271
- {"$ref": schema_ref}
272
- if schema_ref
273
- else {"$ref": f"{_EDR}/schemas/coverageJSON.yaml"}
274
- )
283
+ if schema_ref:
284
+ schema = {"$ref": schema_ref}
285
+ else:
286
+ # Fallback to an inline object if external ref is problematic
287
+ schema = {"type": "object"}
275
288
  content[media_type] = {"schema": schema}
276
289
  content[alt_media] = {"schema": schema}
277
290
  elif fmt_name == "GeoJSON":
@@ -517,6 +530,7 @@ def _collection_response_schema(coll: Collection,
517
530
  - Full parameter_names objects with all fields
518
531
  - parameter_name_pattern as propertyNames constraint
519
532
  """
533
+ version = profile.openapi_version if profile else "3.0.0"
520
534
  crs_schema: dict = {"type": "string"} # extent.spatial.crs
521
535
  trs_schema: dict = {"type": "string"} # extent.temporal.trs
522
536
  vrs_schema: dict = {"type": "string"} # extent.vertical.vrs
@@ -549,13 +563,17 @@ def _collection_response_schema(coll: Collection,
549
563
  import copy
550
564
  param_item_schema = copy.deepcopy(profile.parameter_schema)
551
565
  else:
552
- param_item_schema = _parameter_schema()
566
+ param_item_schema = _parameter_schema(profile.openapi_version)
553
567
 
554
568
  param_names_schema: dict = {
555
569
  "type": "object",
556
570
  "additionalProperties": param_item_schema,
557
571
  }
558
- if profile and profile.parameter_name_pattern:
572
+ if coll.parameter_names:
573
+ param_names_schema["properties"] = {
574
+ name: param_item_schema for name in coll.parameter_names.root
575
+ }
576
+ if version == "3.1.0" and profile and profile.parameter_name_pattern:
559
577
  param_names_schema["propertyNames"] = {
560
578
  "type": "string",
561
579
  "pattern": profile.parameter_name_pattern,
@@ -614,7 +632,7 @@ def _collection_response_schema(coll: Collection,
614
632
  "type": "array",
615
633
  "items": {
616
634
  "type": "array",
617
- "items": {
635
+ "items": {"type": "string", "format": "date-time", "nullable": True} if version == "3.0.0" else {
618
636
  "oneOf": [
619
637
  {"type": "string", "format": "date-time"},
620
638
  {"type": "null"},
@@ -734,13 +752,13 @@ def _collection_response_schema(coll: Collection,
734
752
  }
735
753
 
736
754
 
737
- def _parameter_schema() -> dict:
755
+ def _parameter_schema(version: str) -> dict:
738
756
  """OpenAPI schema for a single EDR Parameter object."""
739
757
  return {
740
758
  "type": "object",
741
759
  "required": ["type", "observedProperty"],
742
760
  "properties": {
743
- "type": {"type": "string", "const": "Parameter"},
761
+ "type": {"type": "string", **_const("Parameter", version)},
744
762
  "id": {"type": "string"},
745
763
  "label": {"type": "string"},
746
764
  "description": {"type": "string"},
@@ -930,7 +948,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
930
948
  "tags": [tag],
931
949
  "parameters": items_params,
932
950
  "responses": {
933
- "200": _R200_FEATURES,
951
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
934
952
  "400": _ERR_400, "404": _ERR_404, "500": _ERR_500,
935
953
  },
936
954
  }}
@@ -944,7 +962,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
944
962
  _F, _LANG,
945
963
  ],
946
964
  "responses": {
947
- "200": _R200_FEATURES,
965
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
948
966
  "400": _ERR_400, "404": _ERR_404, "500": _ERR_500,
949
967
  },
950
968
  }}
@@ -970,7 +988,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
970
988
  "tags": [tag],
971
989
  "parameters": [_F],
972
990
  "responses": {
973
- "200": _R200_FEATURES,
991
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
974
992
  "400": _ERR_400, "500": _ERR_500,
975
993
  },
976
994
  }}
@@ -988,7 +1006,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
988
1006
  "operationId": _operation_id("getInstance", coll.id),
989
1007
  "tags": [tag],
990
1008
  "parameters": [instance_id_param, _F],
991
- "responses": {"200": _R200_FEATURES},
1009
+ "responses": {"200": _features_schema(profile.openapi_version if profile else "3.0.0")},
992
1010
  }}
993
1011
  # instance-level query sub-paths — GET + optional POST
994
1012
  for sub_qt in (active - {"instances"}):
@@ -1077,7 +1095,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1077
1095
 
1078
1096
  def _core_paths(profile: ServiceProfile) -> dict:
1079
1097
  landing_response = _landing_page_schema(
1080
- profile.req_uri,
1098
+ profile.req_uri, profile.openapi_version,
1081
1099
  title=profile.title,
1082
1100
  description=profile.description or "",
1083
1101
  keywords=profile.keywords or None,
@@ -1086,7 +1104,8 @@ def _core_paths(profile: ServiceProfile) -> dict:
1086
1104
  # Conformance response with required conformance classes
1087
1105
  conformance_response = _R200_CONFORMANCE.copy()
1088
1106
  if profile.required_conformance_classes:
1089
- conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
1107
+ conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["items"] = {
1108
+ "type": "string",
1090
1109
  "enum": profile.required_conformance_classes
1091
1110
  }
1092
1111
 
@@ -1305,7 +1324,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1305
1324
  info["x-default-locale"] = profile.resource_default_locale
1306
1325
 
1307
1326
  return {
1308
- "openapi": "3.1.0",
1327
+ "openapi": profile.openapi_version,
1309
1328
  "info": info,
1310
1329
  # Per OGC API - EDR Part 3 REQ_publishing: this profile is implementation-independent.
1311
1330
  # The placeholder server URL "/" is required by OpenAPI validators but implementations
@@ -1348,6 +1367,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1348
1367
  # ---------------------------------------------------------------------------
1349
1368
 
1350
1369
  def build_asyncapi(profile: ServiceProfile) -> dict:
1370
+ version = profile.openapi_version
1351
1371
  if not profile.pubsub:
1352
1372
  raise ValueError("profile has no pubsub configuration")
1353
1373
 
@@ -1392,7 +1412,7 @@ def build_asyncapi(profile: ServiceProfile) -> dict:
1392
1412
  "type": "object",
1393
1413
  "required": ["type", "properties"],
1394
1414
  "properties": {
1395
- "type": {"type": "string", "const": "Feature"},
1415
+ "type": {"type": "string", **_const("Feature", version)},
1396
1416
  "properties": {
1397
1417
  "type": "object",
1398
1418
  "required": ["id", "timestamp"],
@@ -1405,13 +1425,16 @@ def build_asyncapi(profile: ServiceProfile) -> dict:
1405
1425
  }
1406
1426
  }
1407
1427
 
1408
- # Build servers: default production + additional servers
1409
- servers = {
1410
- "production": {
1428
+ # Build servers. When the profile lists explicit servers, use those
1429
+ # verbatim so it can describe its exact transport topology (WebSocket /ws,
1430
+ # AMQP 1.0, MQTT, ...). Otherwise synthesise a single implicit production
1431
+ # server from broker_host/broker_port/protocol.
1432
+ servers: dict = {}
1433
+ if not pub.servers:
1434
+ servers["production"] = {
1411
1435
  "host": f"{pub.broker_host}:{pub.broker_port}",
1412
1436
  "protocol": pub.protocol,
1413
1437
  }
1414
- }
1415
1438
  for srv in pub.servers:
1416
1439
  server_def = {
1417
1440
  "description": srv.description,
@@ -1419,8 +1442,6 @@ def build_asyncapi(profile: ServiceProfile) -> dict:
1419
1442
  "protocol": srv.protocol,
1420
1443
  "security": [],
1421
1444
  }
1422
- if srv.port:
1423
- server_def["variables"] = {"port": {"default": str(srv.port), "enum": [str(srv.port)]}}
1424
1445
  if srv.pathname:
1425
1446
  server_def["pathname"] = srv.pathname
1426
1447
  servers[srv.name] = server_def
@@ -1497,7 +1518,7 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1497
1518
  f"identifier:: /conf/{profile.name}/{test.id}",
1498
1519
  f"target:: /req/{profile.name}/{test.requirement_id}",
1499
1520
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1500
- "test-method::",
1521
+ f"test-method:: {test.method or ''}",
1501
1522
  ]
1502
1523
  for step in test.steps:
1503
1524
  lines.append(f"step:: {step}")
@@ -1600,7 +1621,7 @@ def _individual_test_adoc_for(profile: ServiceProfile, cls: str, test) -> str:
1600
1621
  f"identifier:: /conf/{cls}/{test.id}",
1601
1622
  f"target:: /req/{cls}/{test.requirement_id}",
1602
1623
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1603
- "test-method::",
1624
+ f"test-method:: {test.method or ''}",
1604
1625
  ]
1605
1626
  for step in test.steps:
1606
1627
  lines.append(f"step:: {step}")
@@ -261,6 +261,7 @@ class AbstractTest(BaseModel):
261
261
  id: str # mirrors the requirement id it tests
262
262
  requirement_id: str
263
263
  steps: list[str] = Field(min_length=1)
264
+ method: str | None = Field(default=None, description="Optional test method description")
264
265
 
265
266
  @model_validator(mode="after")
266
267
  def ids_must_match(self) -> AbstractTest:
@@ -526,7 +527,14 @@ class CoverPage(BaseModel):
526
527
  logo_y: int = Field(default=180, description="Logo y-offset from top of cover page")
527
528
  tagline_font_size: int = Field(default=30, description="Font size of cover page tagline")
528
529
  title_font_size: int = Field(default=54, description="Font size of cover page title")
529
- bold_edition: bool = Field(default=False, description="When true, render edition in bold font")
530
+ edition_date_font_size: int = Field(
531
+ default=24,
532
+ description="Font size of the cover page edition and date lines (rendered a little smaller than the doc number)",
533
+ )
534
+ bold_edition: bool = Field(
535
+ default=False,
536
+ description="When true, render both the edition and date lines in bold font",
537
+ )
530
538
 
531
539
  @field_validator("background", "text_color")
532
540
  @classmethod
@@ -747,12 +755,18 @@ class DocumentMetadata(BaseModel):
747
755
 
748
756
 
749
757
  class PubSubServer(BaseModel):
750
- """A single pub/sub server endpoint."""
758
+ """A single pub/sub server endpoint.
759
+
760
+ ``protocol`` accepts the AsyncAPI protocol identifiers used by the EDR
761
+ Part 2 PubSub bindings: ``amqp`` (AMQP 0-9-1), ``amqp1`` (AMQP 1.0, e.g.
762
+ RabbitMQ 4.x native AMQP 1.0), ``mqtt``, ``kafka``, and the WebSocket
763
+ transports ``ws`` / ``wss``.
764
+ """
751
765
  name: str
752
766
  description: str = ""
753
767
  host: str
754
768
  port: int | None = None
755
- protocol: Literal["amqp", "mqtt", "kafka", "ws", "wss"] = "amqp"
769
+ protocol: Literal["amqp", "amqp1", "mqtt", "kafka", "ws", "wss"] = "amqp"
756
770
  pathname: str | None = None
757
771
 
758
772
 
@@ -762,13 +776,22 @@ class CollectionPubSub(BaseModel):
762
776
 
763
777
 
764
778
  class PubSubConfig(BaseModel):
765
- """Optional OGC API - EDR Part 2 (PubSub) configuration."""
779
+ """Optional OGC API - EDR Part 2 (PubSub) configuration.
780
+
781
+ When ``servers`` is populated the generated AsyncAPI uses those entries
782
+ verbatim (keyed by ``name``) and does not synthesise the implicit
783
+ ``broker_host``/``broker_port`` ``production`` server, letting a profile
784
+ describe its exact transport topology (e.g. a native WebSocket ``/ws``
785
+ endpoint plus AMQP 1.0 and MQTT brokers). When ``servers`` is empty the
786
+ generator falls back to emitting a single ``production`` server built from
787
+ ``broker_host``/``broker_port``/``protocol``.
788
+ """
766
789
  broker_host: str = "localhost"
767
790
  broker_port: int = Field(default=5672, ge=1, le=65535)
768
- protocol: Literal["amqp", "mqtt", "kafka"] = "amqp"
791
+ protocol: Literal["amqp", "amqp1", "mqtt", "kafka"] = "amqp"
769
792
  collections: list[str] = Field(default_factory=list, description="Collection IDs that support PubSub")
770
793
  filters: list[SubscriptionFilter] = Field(default_factory=list)
771
- servers: list[PubSubServer] = Field(default_factory=list, description="Additional server endpoints (ws, wss)")
794
+ servers: list[PubSubServer] = Field(default_factory=list, description="Server endpoints (ws, wss, amqp, amqp1, mqtt); when set, replaces the implicit production server")
772
795
  collection_filters: dict[str, CollectionPubSub] = Field(default_factory=dict, description="Per-collection filter overrides")
773
796
 
774
797
 
@@ -826,6 +849,10 @@ class ServiceProfile(BaseModel):
826
849
  name: Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]
827
850
  title: str
828
851
  version: str = "1.0"
852
+ openapi_version: Literal["3.0.0", "3.1.0"] = Field(
853
+ default="3.0.0",
854
+ description="OpenAPI version to generate (3.0.0 for compatibility, 3.1.0 for latest features)"
855
+ )
829
856
  description: str | None = Field(
830
857
  default=None,
831
858
  description=(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.5.0
3
+ Version: 3.6.2
4
4
  Summary: Authoritative tooling for creating OGC API Service Profiles (EDR, Features)
5
5
  Author-email: Shane Mill <shane.mill@noaa.gov>
6
6
  License: Apache License
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
 
48
48
  # OGC API Service Profile Builder
49
49
 
50
- Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
50
+ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.0.0/3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
51
51
 
52
52
  [![PyPI](https://img.shields.io/pypi/v/oapi-profile-builder)](https://pypi.org/project/oapi-profile-builder/)
53
53
  [![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE)
@@ -57,7 +57,9 @@ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — O
57
57
  ## Quick Start
58
58
 
59
59
  ```bash
60
- pip install oapi-profile-builder
60
+ # Base install. Adds Pillow (cover image generation) with the [pdf] extra —
61
+ # required whenever your profile sets document_metadata.cover.logo.
62
+ pip install 'oapi-profile-builder[pdf]'
61
63
 
62
64
  # Copy an example config and edit it
63
65
  cp examples/minimal_profile.yaml my_profile.yaml
@@ -70,7 +72,7 @@ That's it. The `output/` directory will contain:
70
72
 
71
73
  ```
72
74
  output/
73
- ├── openapi.yaml # OpenAPI 3.1.0 — ready for Swagger UI, Redoc, schemathesis
75
+ ├── openapi.yaml # OpenAPI 3.0.0 or 3.1.0 — ready for Swagger UI, Redoc, schemathesis
74
76
  ├── profile_config.json # Round-trip serialized profile
75
77
  ├── document.adoc # Metanorma root document
76
78
  ├── sections/ # Abstract, Preface, Scope, Conformance, References, Terms
@@ -181,6 +183,30 @@ OGC API - EDR CITE Results
181
183
 
182
184
  ## GitHub Actions
183
185
 
186
+ ### Self-service: generate artifacts from an issue (no fork, no install)
187
+
188
+ Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
189
+
190
+ 1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
191
+ 2. Paste your profile YAML into the config field.
192
+ 3. If your profile references local files (typically a cover logo via `document_metadata.cover.logo`), drag-and-drop them into the **"Additional assets (optional)"** field. Reference each uploaded file in your YAML as `uploads/<original-filename>` — for example:
193
+
194
+ ```yaml
195
+ document_metadata:
196
+ cover:
197
+ logo: uploads/my_logo.png
198
+ ```
199
+
200
+ The workflow downloads each dropped file into `./uploads/` before generation. It parses your YAML to figure out the target filename, so images that GitHub renders as an `<img>` tag with a UUID URL still land at the exact path your config references. Tip: to skip the upload entirely for the DGIWG logo, point at the copy that already ships in this repo: `logo: examples/assets/DGIWG_logo.png`.
201
+ 4. Tick "Compile a PDF?" if you also want a Metanorma PDF, then submit.
202
+ 5. A workflow validates the config, generates the artifacts, and comments on the issue with a link to download them as a zip (retained for 14 days).
203
+
204
+ The workflow only runs the generator over your declarative config — it never executes user-supplied code — and comments back on both success and failure. Don't paste secrets: the artifacts are attached to a public workflow run.
205
+
206
+ > Maintainers: create a `profile-request` label so requests are easy to filter (the workflow gates on a marker in the issue body, so it works even before the label exists).
207
+
208
+ ### Use the action in your own workflow
209
+
184
210
  No local install needed. Add this to any workflow to generate profile artifacts from a config file:
185
211
 
186
212
  ```yaml
@@ -239,7 +265,7 @@ Everything about the document — cover, title-page metadata, legal page, footer
239
265
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
240
266
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
241
267
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
242
- | Cover layout (logo size/position, tagline/title size, bold edition) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, bold_edition}` | Generated cover image |
268
+ | Cover layout (logo size/position, tagline/title/edition-date size, bold edition+date) | `document_metadata.cover.{logo_width, logo_y, tagline_font_size, title_font_size, edition_date_font_size, bold_edition}` | Generated cover image |
243
269
  | Cover date | `document_metadata.doc_pub_date` | Long-format date on the generated cover + `:published-date:` |
244
270
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
245
271
  | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
@@ -352,6 +378,7 @@ abstract_tests: []
352
378
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
353
379
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
354
380
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
381
+ | `abstract_tests[].method` | string | no | Optional test method description |
355
382
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
356
383
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
357
384
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -646,6 +673,7 @@ requirements:
646
673
  abstract_tests:
647
674
  - id: position-coveragejson # must equal requirement_id
648
675
  requirement_id: position-coveragejson
676
+ method: Manual inspection of the /position response.
649
677
  steps:
650
678
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
651
679
  - Verify Content-Type is application/prs.coverage+json.
@@ -695,7 +723,7 @@ When present, generates `asyncapi.yaml` alongside `openapi.yaml`.
695
723
  pubsub:
696
724
  broker_host: my-broker.example.com
697
725
  broker_port: 5672
698
- protocol: amqp # amqp | mqtt | kafka
726
+ protocol: amqp # amqp | amqp1 | mqtt | kafka
699
727
  collections:
700
728
  - my_collection
701
729
  filters:
@@ -704,6 +732,40 @@ pubsub:
704
732
  type: string
705
733
  ```
706
734
 
735
+ `broker_host`/`broker_port`/`protocol` synthesise a single implicit
736
+ `production` AsyncAPI server. To describe an exact transport topology instead —
737
+ e.g. a native WebSocket `/ws` endpoint plus AMQP 1.0 and MQTT brokers, as the
738
+ `nws_connect_profile.yaml` example does — list them explicitly under `servers`.
739
+ When `servers` is set, the implicit `production` server is **not** emitted:
740
+
741
+ ```yaml
742
+ pubsub:
743
+ broker_host: edr-broker
744
+ broker_port: 5672
745
+ protocol: amqp1 # AMQP 1.0 (e.g. RabbitMQ 4.x native)
746
+ collections: [water_gauge, wwa]
747
+ servers:
748
+ - name: websocket_secure
749
+ description: Native WebSocket live map (production)
750
+ host: ''
751
+ protocol: wss # amqp | amqp1 | mqtt | kafka | ws | wss
752
+ pathname: /ws
753
+ - name: amqp
754
+ description: AMQP 1.0 broker (RabbitMQ 4.x native AMQP 1.0)
755
+ host: 'edr-broker:5672'
756
+ protocol: amqp1
757
+ - name: mqtt
758
+ description: MQTT broker (RabbitMQ MQTT plugin)
759
+ host: 'edr-broker:1883'
760
+ protocol: mqtt
761
+ collection_filters: # per-collection filter overrides
762
+ water_gauge:
763
+ filters:
764
+ - name: flood_stages
765
+ description: Alert for selected flood stages
766
+ type: array
767
+ ```
768
+
707
769
  ---
708
770
 
709
771
  ### `document_metadata`
@@ -764,7 +826,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
764
826
  document_metadata:
765
827
  doc_number: "DGIWG 134"
766
828
  cover:
767
- logo: assets/dgiwg_logo.png # path relative to the working directory (or absolute)
829
+ logo: examples/assets/DGIWG_logo.png # path relative to the working directory (or absolute).
830
+ # From the self-service issue form use uploads/<file>
831
+ # instead and drag-drop the file into "Additional assets".
768
832
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
769
833
  background: "#FFFFFF" # optional cover background (default white)
770
834
  text_color: "#1F3864" # optional cover text colour (default black)
@@ -773,11 +837,12 @@ document_metadata:
773
837
  font_italic: "Source Sans Pro" # optional italic face for the tagline
774
838
  watermark: "DRAFT" # optional diagonal watermark stamped on the cover
775
839
  # --- cover layout (all optional) ---
776
- logo_width: 200 # target logo width in px (default 420)
840
+ logo_width: 300 # target logo width in px (default 420)
777
841
  logo_y: 100 # logo vertical offset from top in px (default 180)
778
842
  tagline_font_size: 20 # tagline point size (default 30)
779
843
  title_font_size: 40 # title point size (default 54)
780
- bold_edition: true # render the "Edition X" line in bold (default false)
844
+ edition_date_font_size: 22 # edition/date point size (default 24)
845
+ bold_edition: true # render the "Edition X" and "Dated ..." lines in bold (default false)
781
846
  colors:
782
847
  text: "#000000" # body text
783
848
  cover_text: "#1F3864" # cover text / section numbers / ToC