oapi-profile-builder 3.6.1__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.6.1/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.6.2}/PKG-INFO +23 -7
  2. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/README.md +22 -6
  3. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cover.py +63 -22
  5. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/generate.py +63 -43
  6. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/models.py +5 -0
  7. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2/src/oapi_profile_builder.egg-info}/PKG-INFO +23 -7
  8. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/LICENSE +0 -0
  9. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/setup.cfg +0 -0
  10. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/__init__.py +0 -0
  11. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cite.py +0 -0
  12. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cite_features.py +0 -0
  13. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/cli.py +0 -0
  14. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/compile.py +0 -0
  15. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.2}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.6.1 → 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.6.1
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
@@ -186,8 +188,18 @@ OGC API - EDR CITE Results
186
188
  Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
187
189
 
188
190
  1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
189
- 2. Paste your profile YAML into the config field and submit.
190
- 3. 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). Optionally tick "Compile a PDF?" to also get a Metanorma PDF.
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).
191
203
 
192
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.
193
205
 
@@ -366,6 +378,7 @@ abstract_tests: []
366
378
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
367
379
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
368
380
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
381
+ | `abstract_tests[].method` | string | no | Optional test method description |
369
382
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
370
383
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
371
384
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -660,6 +673,7 @@ requirements:
660
673
  abstract_tests:
661
674
  - id: position-coveragejson # must equal requirement_id
662
675
  requirement_id: position-coveragejson
676
+ method: Manual inspection of the /position response.
663
677
  steps:
664
678
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
665
679
  - Verify Content-Type is application/prs.coverage+json.
@@ -812,7 +826,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
812
826
  document_metadata:
813
827
  doc_number: "DGIWG 134"
814
828
  cover:
815
- 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".
816
832
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
817
833
  background: "#FFFFFF" # optional cover background (default white)
818
834
  text_color: "#1F3864" # optional cover text colour (default black)
@@ -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
@@ -139,8 +141,18 @@ OGC API - EDR CITE Results
139
141
  Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
140
142
 
141
143
  1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
142
- 2. Paste your profile YAML into the config field and submit.
143
- 3. 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). Optionally tick "Compile a PDF?" to also get a Metanorma PDF.
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).
144
156
 
145
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.
146
158
 
@@ -319,6 +331,7 @@ abstract_tests: []
319
331
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
320
332
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
321
333
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
334
+ | `abstract_tests[].method` | string | no | Optional test method description |
322
335
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
323
336
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
324
337
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -613,6 +626,7 @@ requirements:
613
626
  abstract_tests:
614
627
  - id: position-coveragejson # must equal requirement_id
615
628
  requirement_id: position-coveragejson
629
+ method: Manual inspection of the /position response.
616
630
  steps:
617
631
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
618
632
  - Verify Content-Type is application/prs.coverage+json.
@@ -765,7 +779,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
765
779
  document_metadata:
766
780
  doc_number: "DGIWG 134"
767
781
  cover:
768
- 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".
769
785
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
770
786
  background: "#FFFFFF" # optional cover background (default white)
771
787
  text_color: "#1F3864" # optional cover text colour (default black)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.6.1"
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,11 +225,18 @@ 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
+
199
240
 
200
241
  # --- Edition & date ---
201
242
  # Per DGIWG feedback these two lines are both bold and set a little smaller
@@ -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"],
@@ -1498,7 +1518,7 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1498
1518
  f"identifier:: /conf/{profile.name}/{test.id}",
1499
1519
  f"target:: /req/{profile.name}/{test.requirement_id}",
1500
1520
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1501
- "test-method::",
1521
+ f"test-method:: {test.method or ''}",
1502
1522
  ]
1503
1523
  for step in test.steps:
1504
1524
  lines.append(f"step:: {step}")
@@ -1601,7 +1621,7 @@ def _individual_test_adoc_for(profile: ServiceProfile, cls: str, test) -> str:
1601
1621
  f"identifier:: /conf/{cls}/{test.id}",
1602
1622
  f"target:: /req/{cls}/{test.requirement_id}",
1603
1623
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1604
- "test-method::",
1624
+ f"test-method:: {test.method or ''}",
1605
1625
  ]
1606
1626
  for step in test.steps:
1607
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:
@@ -848,6 +849,10 @@ class ServiceProfile(BaseModel):
848
849
  name: Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]
849
850
  title: str
850
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
+ )
851
856
  description: str | None = Field(
852
857
  default=None,
853
858
  description=(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.6.1
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
@@ -186,8 +188,18 @@ OGC API - EDR CITE Results
186
188
  Anyone with a GitHub account can generate artifacts from their own config without cloning or installing anything:
187
189
 
188
190
  1. Open a new issue using the **"Generate profile artifacts"** template ([New issue](../../issues/new/choose)).
189
- 2. Paste your profile YAML into the config field and submit.
190
- 3. 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). Optionally tick "Compile a PDF?" to also get a Metanorma PDF.
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).
191
203
 
192
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.
193
205
 
@@ -366,6 +378,7 @@ abstract_tests: []
366
378
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
367
379
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
368
380
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
381
+ | `abstract_tests[].method` | string | no | Optional test method description |
369
382
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
370
383
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
371
384
  | `paging` | object | no | Features `/items` paging — adds a validated `limit` query parameter to the generated OpenAPI (see below) |
@@ -660,6 +673,7 @@ requirements:
660
673
  abstract_tests:
661
674
  - id: position-coveragejson # must equal requirement_id
662
675
  requirement_id: position-coveragejson
676
+ method: Manual inspection of the /position response.
663
677
  steps:
664
678
  - Send GET /collections/{id}/position?coords=POINT(lon lat).
665
679
  - Verify Content-Type is application/prs.coverage+json.
@@ -812,7 +826,9 @@ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without
812
826
  document_metadata:
813
827
  doc_number: "DGIWG 134"
814
828
  cover:
815
- 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".
816
832
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
817
833
  background: "#FFFFFF" # optional cover background (default white)
818
834
  text_color: "#1F3864" # optional cover text colour (default black)