oapi-profile-builder 3.6.1__tar.gz → 3.6.3__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.3}/PKG-INFO +23 -7
  2. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/README.md +22 -6
  3. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/cover.py +63 -22
  5. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/generate.py +72 -45
  6. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/models.py +5 -0
  7. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3/src/oapi_profile_builder.egg-info}/PKG-INFO +23 -7
  8. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/LICENSE +0 -0
  9. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/setup.cfg +0 -0
  10. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/__init__.py +0 -0
  11. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/cite.py +0 -0
  12. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/cite_features.py +0 -0
  13. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/cli.py +0 -0
  14. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/compile.py +0 -0
  15. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.6.1 → oapi_profile_builder-3.6.3}/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.3
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.3"
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
@@ -210,6 +223,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
210
223
  properties named in ``profile.locations_feature_required_properties`` are
211
224
  marked required so the generated OpenAPI enforces them.
212
225
  """
226
+ version = profile.openapi_version if profile else "3.0.0"
213
227
  required_props = list(getattr(profile, "locations_feature_required_properties", None) or [])
214
228
  properties_schema: dict = {
215
229
  "type": "object",
@@ -223,7 +237,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
223
237
  "type": "object",
224
238
  "required": ["type", "id", "properties"],
225
239
  "properties": {
226
- "type": {"type": "string", "const": "Feature"},
240
+ "type": {"type": "string", **_const("Feature", version)},
227
241
  "id": {"type": "string", "description": "Location identifier usable at /locations/{locId}"},
228
242
  "geometry": {"type": "object", "description": "GeoJSON geometry of the location"},
229
243
  "properties": properties_schema,
@@ -237,7 +251,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
237
251
  "type": "object",
238
252
  "required": ["type", "features"],
239
253
  "properties": {
240
- "type": {"type": "string", "const": "FeatureCollection"},
254
+ "type": {"type": "string", **_const("FeatureCollection", version)},
241
255
  "features": {"type": "array", "items": feature_schema},
242
256
  },
243
257
  }
@@ -267,11 +281,11 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
267
281
  media_type = "application/prs.coverage+json"
268
282
  # Also accept the newer media type
269
283
  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
- )
284
+ if schema_ref:
285
+ schema = {"$ref": schema_ref}
286
+ else:
287
+ # Fallback to an inline object if external ref is problematic
288
+ schema = {"type": "object"}
275
289
  content[media_type] = {"schema": schema}
276
290
  content[alt_media] = {"schema": schema}
277
291
  elif fmt_name == "GeoJSON":
@@ -517,6 +531,7 @@ def _collection_response_schema(coll: Collection,
517
531
  - Full parameter_names objects with all fields
518
532
  - parameter_name_pattern as propertyNames constraint
519
533
  """
534
+ version = profile.openapi_version if profile else "3.0.0"
520
535
  crs_schema: dict = {"type": "string"} # extent.spatial.crs
521
536
  trs_schema: dict = {"type": "string"} # extent.temporal.trs
522
537
  vrs_schema: dict = {"type": "string"} # extent.vertical.vrs
@@ -549,13 +564,17 @@ def _collection_response_schema(coll: Collection,
549
564
  import copy
550
565
  param_item_schema = copy.deepcopy(profile.parameter_schema)
551
566
  else:
552
- param_item_schema = _parameter_schema()
567
+ param_item_schema = _parameter_schema(profile.openapi_version)
553
568
 
554
569
  param_names_schema: dict = {
555
570
  "type": "object",
556
571
  "additionalProperties": param_item_schema,
557
572
  }
558
- if profile and profile.parameter_name_pattern:
573
+ if coll.parameter_names:
574
+ param_names_schema["properties"] = {
575
+ name: param_item_schema for name in coll.parameter_names.root
576
+ }
577
+ if version == "3.1.0" and profile and profile.parameter_name_pattern:
559
578
  param_names_schema["propertyNames"] = {
560
579
  "type": "string",
561
580
  "pattern": profile.parameter_name_pattern,
@@ -614,7 +633,7 @@ def _collection_response_schema(coll: Collection,
614
633
  "type": "array",
615
634
  "items": {
616
635
  "type": "array",
617
- "items": {
636
+ "items": {"type": "string", "format": "date-time", "nullable": True} if version == "3.0.0" else {
618
637
  "oneOf": [
619
638
  {"type": "string", "format": "date-time"},
620
639
  {"type": "null"},
@@ -734,13 +753,13 @@ def _collection_response_schema(coll: Collection,
734
753
  }
735
754
 
736
755
 
737
- def _parameter_schema() -> dict:
756
+ def _parameter_schema(version: str) -> dict:
738
757
  """OpenAPI schema for a single EDR Parameter object."""
739
758
  return {
740
759
  "type": "object",
741
760
  "required": ["type", "observedProperty"],
742
761
  "properties": {
743
- "type": {"type": "string", "const": "Parameter"},
762
+ "type": {"type": "string", **_const("Parameter", version)},
744
763
  "id": {"type": "string"},
745
764
  "label": {"type": "string"},
746
765
  "description": {"type": "string"},
@@ -930,7 +949,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
930
949
  "tags": [tag],
931
950
  "parameters": items_params,
932
951
  "responses": {
933
- "200": _R200_FEATURES,
952
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
934
953
  "400": _ERR_400, "404": _ERR_404, "500": _ERR_500,
935
954
  },
936
955
  }}
@@ -944,7 +963,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
944
963
  _F, _LANG,
945
964
  ],
946
965
  "responses": {
947
- "200": _R200_FEATURES,
966
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
948
967
  "400": _ERR_400, "404": _ERR_404, "500": _ERR_500,
949
968
  },
950
969
  }}
@@ -970,7 +989,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
970
989
  "tags": [tag],
971
990
  "parameters": [_F],
972
991
  "responses": {
973
- "200": _R200_FEATURES,
992
+ "200": _features_schema(profile.openapi_version if profile else "3.0.0"),
974
993
  "400": _ERR_400, "500": _ERR_500,
975
994
  },
976
995
  }}
@@ -988,7 +1007,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
988
1007
  "operationId": _operation_id("getInstance", coll.id),
989
1008
  "tags": [tag],
990
1009
  "parameters": [instance_id_param, _F],
991
- "responses": {"200": _R200_FEATURES},
1010
+ "responses": {"200": _features_schema(profile.openapi_version if profile else "3.0.0")},
992
1011
  }}
993
1012
  # instance-level query sub-paths — GET + optional POST
994
1013
  for sub_qt in (active - {"instances"}):
@@ -1076,8 +1095,9 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1076
1095
 
1077
1096
 
1078
1097
  def _core_paths(profile: ServiceProfile) -> dict:
1098
+ version = profile.openapi_version
1079
1099
  landing_response = _landing_page_schema(
1080
- profile.req_uri,
1100
+ profile.req_uri, version,
1081
1101
  title=profile.title,
1082
1102
  description=profile.description or "",
1083
1103
  keywords=profile.keywords or None,
@@ -1086,9 +1106,15 @@ def _core_paths(profile: ServiceProfile) -> dict:
1086
1106
  # Conformance response with required conformance classes
1087
1107
  conformance_response = _R200_CONFORMANCE.copy()
1088
1108
  if profile.required_conformance_classes:
1089
- conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
1090
- "enum": profile.required_conformance_classes
1091
- }
1109
+ if version == "3.1.0":
1110
+ conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
1111
+ "enum": profile.required_conformance_classes
1112
+ }
1113
+ else:
1114
+ conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["items"] = {
1115
+ "type": "string",
1116
+ "enum": profile.required_conformance_classes
1117
+ }
1092
1118
 
1093
1119
  return {
1094
1120
  "/": {"get": {
@@ -1305,7 +1331,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1305
1331
  info["x-default-locale"] = profile.resource_default_locale
1306
1332
 
1307
1333
  return {
1308
- "openapi": "3.1.0",
1334
+ "openapi": profile.openapi_version,
1309
1335
  "info": info,
1310
1336
  # Per OGC API - EDR Part 3 REQ_publishing: this profile is implementation-independent.
1311
1337
  # The placeholder server URL "/" is required by OpenAPI validators but implementations
@@ -1348,6 +1374,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1348
1374
  # ---------------------------------------------------------------------------
1349
1375
 
1350
1376
  def build_asyncapi(profile: ServiceProfile) -> dict:
1377
+ version = profile.openapi_version
1351
1378
  if not profile.pubsub:
1352
1379
  raise ValueError("profile has no pubsub configuration")
1353
1380
 
@@ -1392,7 +1419,7 @@ def build_asyncapi(profile: ServiceProfile) -> dict:
1392
1419
  "type": "object",
1393
1420
  "required": ["type", "properties"],
1394
1421
  "properties": {
1395
- "type": {"type": "string", "const": "Feature"},
1422
+ "type": {"type": "string", **_const("Feature", version)},
1396
1423
  "properties": {
1397
1424
  "type": "object",
1398
1425
  "required": ["id", "timestamp"],
@@ -1498,7 +1525,7 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1498
1525
  f"identifier:: /conf/{profile.name}/{test.id}",
1499
1526
  f"target:: /req/{profile.name}/{test.requirement_id}",
1500
1527
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1501
- "test-method::",
1528
+ f"test-method:: {test.method or ''}",
1502
1529
  ]
1503
1530
  for step in test.steps:
1504
1531
  lines.append(f"step:: {step}")
@@ -1601,7 +1628,7 @@ def _individual_test_adoc_for(profile: ServiceProfile, cls: str, test) -> str:
1601
1628
  f"identifier:: /conf/{cls}/{test.id}",
1602
1629
  f"target:: /req/{cls}/{test.requirement_id}",
1603
1630
  f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1604
- "test-method::",
1631
+ f"test-method:: {test.method or ''}",
1605
1632
  ]
1606
1633
  for step in test.steps:
1607
1634
  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.3
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)