9
9
import warnings
10
10
from collections import defaultdict
11
11
from contextlib import contextmanager
12
- from copy import deepcopy
13
12
from glob import glob
14
13
from io import StringIO
15
14
from pathlib import Path
23
22
from babel .messages .pofile import read_po
24
23
from docutils .utils import relative_path
25
24
from lxml import etree
25
+ from ocdsextensionregistry import get_versioned_release_schema
26
26
from ocdskit .schema import get_schema_fields
27
27
28
28
basedir = Path (__file__ ).resolve ().parent
@@ -40,77 +40,6 @@ def custom_warning_formatter(message, category, filename, lineno, line=None):
40
40
41
41
warnings .formatwarning = custom_warning_formatter
42
42
43
- versioned_template = json .loads ("""
44
- {
45
- "type": "array",
46
- "items": {
47
- "type": "object",
48
- "properties": {
49
- "releaseDate": {
50
- "format": "date-time",
51
- "type": "string"
52
- },
53
- "releaseID": {
54
- "type": "string"
55
- },
56
- "value": {},
57
- "releaseTag": {
58
- "type": "array",
59
- "items": {
60
- "type": "string"
61
- }
62
- }
63
- }
64
- }
65
- }
66
- """ )
67
-
68
- common_versioned_definitions = {
69
- "StringNullUriVersioned" : {
70
- "type" : ["string" , "null" ],
71
- "format" : "uri" ,
72
- },
73
- "StringNullDateTimeVersioned" : {
74
- "type" : ["string" , "null" ],
75
- "format" : "date-time" ,
76
- },
77
- "StringNullVersioned" : {
78
- "type" : ["string" , "null" ],
79
- "format" : None ,
80
- },
81
- }
82
-
83
- recognized_types = (
84
- # Array
85
- ["array" ],
86
- ["array" , "null" ], # optional string arrays
87
- # Object
88
- ["object" ],
89
- ["object" , "null" ], # /Organization/details
90
- # String
91
- ["string" ],
92
- ["string" , "null" ],
93
- # Literal
94
- ["boolean" , "null" ],
95
- ["integer" , "null" ],
96
- ["number" , "null" ],
97
- # Mixed
98
- ["string" , "integer" ],
99
- ["string" , "integer" , "null" ],
100
- )
101
-
102
- keywords_to_remove = (
103
- # Metadata keywords
104
- # https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-6
105
- "title" ,
106
- "description" ,
107
- "default" ,
108
- # Extended keywords
109
- # http://os4d.opendataservices.coop/development/schema/#extended-json-schema
110
- "omitWhenMerged" ,
111
- "wholeListMerge" ,
112
- )
113
-
114
43
115
44
def json_load (filename , library = json , ** kwargs ):
116
45
"""Load JSON data from the given filename."""
@@ -149,249 +78,13 @@ def get(url):
149
78
return response
150
79
151
80
152
- def coerce_to_list (data , key ):
153
- """Return the value of the ``key`` key in the ``data`` mapping. If the value is a string, wrap it in an array."""
154
- item = data .get (key , [])
155
- if isinstance (item , str ):
156
- return [item ]
157
- return item
158
-
159
-
160
81
def get_metaschema ():
161
82
"""Patches and returns the JSON Schema Draft 4 metaschema."""
162
83
return json_merge_patch .merge (
163
84
json_load ("metaschema/json-schema-draft-4.json" ), json_load ("metaschema/meta-schema-patch.json" )
164
85
)
165
86
166
87
167
- def get_common_definition_ref (item ):
168
- """
169
- Return a schema that references the common definition that the ``item`` matches: "StringNullUriVersioned",
170
- "StringNullDateTimeVersioned" or "StringNullVersioned".
171
- """
172
- for name , keywords in common_versioned_definitions .items ():
173
- # If the item matches the definition.
174
- if any (item .get (keyword ) != value for keyword , value in keywords .items ()):
175
- continue
176
- # And adds no keywords to the definition.
177
- if any (keyword not in {* keywords , * keywords_to_remove } for keyword in item ):
178
- continue
179
- return {"$ref" : f"#/definitions/{ name } " }
180
- return None
181
-
182
-
183
- def add_versioned (schema , unversioned_pointers , pointer = "" ):
184
- """Call ``_add_versioned`` on each field."""
185
- for key , value in schema ["properties" ].items ():
186
- new_pointer = f"{ pointer } /properties/{ key } "
187
- _add_versioned (schema , unversioned_pointers , new_pointer , key , value )
188
-
189
- for key , value in schema .get ("definitions" , {}).items ():
190
- new_pointer = f"{ pointer } /definitions/{ key } "
191
- add_versioned (value , unversioned_pointers , pointer = new_pointer )
192
-
193
-
194
- def _add_versioned (schema , unversioned_pointers , pointer , key , value ):
195
- """
196
- Perform the changes to the schema to refer to versioned/unversioned definitions.
197
-
198
- :param schema dict: the schema of the object on which the field is defined
199
- :param unversioned_pointers set: JSON Pointers to ``id`` fields to leave unversioned if the object is in an array
200
- :param pointer str: the field's pointer
201
- :param key str: the field's name
202
- :param value str: the field's schema
203
- """
204
- # Skip unversioned fields.
205
- if pointer in unversioned_pointers :
206
- return
207
-
208
- types = coerce_to_list (value , "type" )
209
-
210
- # If a type is unrecognized, we might need to update this script.
211
- if (
212
- "$ref" not in value
213
- and types not in recognized_types
214
- and not (pointer == "/definitions/Quantity/properties/value" and types == ["string" , "number" , "null" ])
215
- ):
216
- warnings .warn (f"{ pointer } has unrecognized type { types } " )
217
-
218
- # For example, if $ref is used.
219
- if not types :
220
- # Ignore the `amendment` field, which had no `id` field in OCDS 1.0.
221
- if "deprecated" not in value :
222
- versioned_pointer = f"{ value ['$ref' ][1 :]} /properties/id"
223
- # If the `id` field is on an object not in an array, it needs to be versioned (e.g. buyer/properties/id).
224
- if versioned_pointer in unversioned_pointers :
225
- value ["$ref" ] = value ["$ref" ] + "VersionedId"
226
- return
227
-
228
- # Reference a common versioned definition if possible, to limit the size of the schema.
229
- ref = get_common_definition_ref (value )
230
- if ref :
231
- schema ["properties" ][key ] = ref
232
-
233
- # Iterate into objects with properties like `Item.unit`. Otherwise, version objects with no properties as a
234
- # whole, like `Organization.details`.
235
- elif types == ["object" ] and "properties" in value :
236
- add_versioned (value , unversioned_pointers , pointer = pointer )
237
-
238
- else :
239
- new_value = deepcopy (value )
240
-
241
- if types == ["array" ]:
242
- item_types = coerce_to_list (value ["items" ], "type" )
243
-
244
- # See https://standard.open-contracting.org/latest/en/schema/merging/#whole-list-merge
245
- if value .get ("wholeListMerge" ):
246
- # Update `$ref` to the unversioned definition.
247
- if "$ref" in value ["items" ]:
248
- new_value ["items" ]["$ref" ] = value ["items" ]["$ref" ] + "Unversioned"
249
- # Otherwise, similarly, don't iterate over item properties.
250
- # See https://standard.open-contracting.org/latest/en/schema/merging/#lists
251
- elif "$ref" in value ["items" ]:
252
- # Leave `$ref` to the versioned definition.
253
- return
254
- # Exceptional case for deprecated `Amendment.changes`.
255
- elif item_types == ["object" ] and pointer == "/definitions/Amendment/properties/changes" :
256
- return
257
- # Warn in case new combinations are added to the release schema.
258
- elif item_types != ["string" ]:
259
- # Note: Versioning the properties of un-$ref'erenced objects in arrays isn't implemented. However,
260
- # this combination hasn't occurred, with the exception of `Amendment/changes`.
261
- warnings .warn (f"{ pointer } /items has unexpected type { item_types } " )
262
-
263
- versioned = deepcopy (versioned_template )
264
- versioned ["items" ]["properties" ]["value" ] = new_value
265
- schema ["properties" ][key ] = versioned
266
-
267
-
268
- def update_refs_to_unversioned_definitions (schema ):
269
- """Replace ``$ref`` values with unversioned definitions."""
270
- for key , value in schema .items ():
271
- if key == "$ref" :
272
- schema [key ] = value + "Unversioned"
273
- elif isinstance (value , dict ):
274
- update_refs_to_unversioned_definitions (value )
275
-
276
-
277
- def get_unversioned_pointers (schema , fields , pointer = "" ):
278
- """Return the JSON Pointers to ``id`` fields that must not be versioned if the object is in an array."""
279
- if isinstance (schema , list ):
280
- for index , item in enumerate (schema ):
281
- get_unversioned_pointers (item , fields , pointer = f"{ pointer } /{ index } " )
282
- elif isinstance (schema , dict ):
283
- # Follows the logic of _get_merge_rules in merge.py from ocds-merge.
284
- types = coerce_to_list (schema , "type" )
285
-
286
- # If an array is whole list merge, its items are unversioned.
287
- if "array" in types and schema .get ("wholeListMerge" ):
288
- return
289
- if "array" in types and "items" in schema :
290
- item_types = coerce_to_list (schema ["items" ], "type" )
291
- # If an array mixes objects and non-objects, it is whole list merge.
292
- if any (item_type != "object" for item_type in item_types ):
293
- return
294
- # If it is an array of objects, any `id` fields are unversioned.
295
- if "id" in schema ["items" ]["properties" ]:
296
- if hasattr (schema ["items" ], "__reference__" ):
297
- reference = schema ["items" ].__reference__ ["$ref" ][1 :]
298
- else :
299
- reference = pointer
300
- fields .add (f"{ reference } /properties/id" )
301
-
302
- for key , value in schema .items ():
303
- get_unversioned_pointers (value , fields , pointer = f"{ pointer } /{ key } " )
304
-
305
-
306
- def remove_omit_when_merged (schema ):
307
- """Remove properties that set ``omitWhenMerged``."""
308
- if isinstance (schema , list ):
309
- for item in schema :
310
- remove_omit_when_merged (item )
311
- elif isinstance (schema , dict ):
312
- for key , value in schema .items ():
313
- if key == "properties" :
314
- for prop in list (value ):
315
- if value [prop ].get ("omitWhenMerged" ):
316
- del value [prop ]
317
- if prop in schema ["required" ]:
318
- schema ["required" ].remove (prop )
319
- remove_omit_when_merged (value )
320
-
321
-
322
- def remove_metadata_and_extended_keywords (schema ):
323
- """Remove metadata and extended keywords from properties and definitions."""
324
- if isinstance (schema , list ):
325
- for item in schema :
326
- remove_metadata_and_extended_keywords (item )
327
- elif isinstance (schema , dict ):
328
- for key , value in schema .items ():
329
- if key in {"definitions" , "properties" }:
330
- for subschema in value .values ():
331
- for keyword in keywords_to_remove :
332
- subschema .pop (keyword , None )
333
- remove_metadata_and_extended_keywords (value )
334
-
335
-
336
- def get_versioned_release_schema (schema ):
337
- """Return the versioned release schema."""
338
- # Update schema metadata.
339
- release_with_underscores = release .replace ("." , "__" )
340
- schema ["id" ] = (
341
- f"https://standard.open-contracting.org/schema/{ release_with_underscores } /versioned-release-validation-schema.json"
342
- )
343
- schema ["title" ] = "Schema for a compiled, versioned Open Contracting Release."
344
-
345
- # Release IDs, dates and tags appear alongside values in the versioned release schema.
346
- remove_omit_when_merged (schema )
347
-
348
- # Create unversioned copies of all definitions.
349
- unversioned_definitions = {k + "Unversioned" : deepcopy (v ) for k , v in schema ["definitions" ].items ()}
350
- update_refs_to_unversioned_definitions (unversioned_definitions )
351
-
352
- # Determine which `id` fields occur on objects in arrays.
353
- unversioned_pointers = set ()
354
- get_unversioned_pointers (jsonref .replace_refs (schema ), unversioned_pointers )
355
-
356
- # Omit `ocid` from versioning.
357
- ocid = schema ["properties" ].pop ("ocid" )
358
- add_versioned (schema , unversioned_pointers )
359
- schema ["properties" ]["ocid" ] = ocid
360
-
361
- # Add the common versioned definitions.
362
- for name , keywords in common_versioned_definitions .items ():
363
- versioned = deepcopy (versioned_template )
364
- for keyword , value in keywords .items ():
365
- if value :
366
- versioned ["items" ]["properties" ]["value" ][keyword ] = value
367
- schema ["definitions" ][name ] = versioned
368
-
369
- # Add missing definitions.
370
- while True :
371
- try :
372
- jsonref .replace_refs (schema , lazy_load = False )
373
- break
374
- except jsonref .JsonRefError as e :
375
- name = e .cause .args [0 ]
376
-
377
- if name .endswith ("VersionedId" ):
378
- # Add a copy of an definition with a versioned `id` field, using the same logic as before.
379
- definition = deepcopy (schema ["definitions" ][name [:- 11 ]])
380
- pointer = f"/definitions/{ name [:- 11 ]} /properties/id"
381
- pointers = unversioned_pointers - {pointer }
382
- _add_versioned (definition , pointers , pointer , "id" , definition ["properties" ]["id" ])
383
- else :
384
- # Add a copy of an definition with no versioned fields.
385
- definition = unversioned_definitions [name ]
386
-
387
- schema ["definitions" ][name ] = definition
388
-
389
- # Remove all metadata and extended keywords.
390
- remove_metadata_and_extended_keywords (schema )
391
-
392
- return schema
393
-
394
-
395
88
@click .group ()
396
89
def cli ():
397
90
pass
@@ -523,7 +216,7 @@ def pre_commit():
523
216
for field in get_schema_fields (jsonref_release_schema ):
524
217
name = field .path_components [- 1 ]
525
218
# Skip definitions (output dereferenced properties only). Skip deprecated fields.
526
- if field .definition_pointer_components or field .deprecated :
219
+ if field .definition or field .deprecated :
527
220
continue
528
221
multilingual = (
529
222
# If a field can be a non-string, it is not multilingual.
@@ -566,7 +259,10 @@ def pre_commit():
566
259
567
260
json_dump ("meta-schema.json" , get_metaschema ())
568
261
json_dump ("dereferenced-release-schema.json" , jsonref_release_schema )
569
- json_dump ("versioned-release-validation-schema.json" , get_versioned_release_schema (release_schema ))
262
+ json_dump (
263
+ "versioned-release-validation-schema.json" ,
264
+ get_versioned_release_schema (release_schema , release .replace ("." , "__" )),
265
+ )
570
266
571
267
572
268
@cli .command ()
0 commit comments