pywmdr 0.2.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pywmdr/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ ###############################################################################
2
+ #
3
+ # Authors: Tom Kralidis <tomkralidis@gmail.com>
4
+ #
5
+ # Copyright (c) 2026 Tom Kralidis
6
+ #
7
+ # Licensed to the Apache Software Foundation (ASF) under one
8
+ # or more contributor license agreements. See the NOTICE file
9
+ # distributed with this work for additional information
10
+ # regarding copyright ownership. The ASF licenses this file
11
+ # to you under the Apache License, Version 2.0 (the
12
+ # "License"); you may not use this file except in compliance
13
+ # with the License. You may obtain a copy of the License at
14
+ #
15
+ # http://www.apache.org/licenses/LICENSE-2.0
16
+ #
17
+ # Unless required by applicable law or agreed to in writing,
18
+ # software distributed under the License is distributed on an
19
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
20
+ # KIND, either express or implied. See the License for the
21
+ # specific language governing permissions and limitations
22
+ # under the License.
23
+ #
24
+ ###############################################################################
25
+
26
+ import click
27
+
28
+ from pywmdr.record import record
29
+ from pywmdr.bundle import bundle
30
+ from pywmdr.util import get_package_version
31
+
32
+ __version__ = get_package_version()
33
+
34
+
35
+ @click.group()
36
+ @click.version_option(version=__version__)
37
+ def cli():
38
+ pass
39
+
40
+
41
+ cli.add_command(bundle)
42
+ cli.add_command(record)
pywmdr/bundle.py ADDED
@@ -0,0 +1,75 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ import logging
23
+ from pathlib import Path
24
+ import shutil
25
+ import tempfile
26
+
27
+ import click
28
+
29
+ from pywmdr import cli_options
30
+ from pywmdr.util import get_userdir, urlopen_
31
+
32
+ LOGGER = logging.getLogger(__name__)
33
+
34
+ USERDIR = get_userdir()
35
+
36
+ TEMPDIR = tempfile.TemporaryDirectory()
37
+ TEMPDIR2 = Path(tempfile.TemporaryDirectory().name)
38
+
39
+ WMDR2_FILES = get_userdir() / 'wmdr2'
40
+ WMDR2_FILES_TEMP = TEMPDIR2 / 'wmdr2'
41
+
42
+
43
+ @click.group()
44
+ def bundle():
45
+ """Configuration bundle management"""
46
+ pass
47
+
48
+
49
+ @click.command()
50
+ @click.pass_context
51
+ @cli_options.OPTION_VERBOSITY
52
+ def sync(ctx, verbosity):
53
+ """Sync configuration bundle"""
54
+
55
+ LOGGER.debug('Caching schema')
56
+ LOGGER.debug(f'Downloading WMDR2 schema to {WMDR2_FILES_TEMP}')
57
+ WMDR2_FILES_TEMP.mkdir(parents=True, exist_ok=True)
58
+ WMDR2_SCHEMA = 'https://raw.githubusercontent.com/wmo-im/wmdr2/refs/heads/main/schemas/wmdr2-bundled.json' # noqa
59
+
60
+ json_schema = WMDR2_FILES_TEMP / 'wmdr2-bundled.json'
61
+ with json_schema.open('wb') as fh:
62
+ fh.write(urlopen_(f'{WMDR2_SCHEMA}').read())
63
+
64
+ LOGGER.debug(f'Removing {USERDIR}')
65
+ if USERDIR.exists():
66
+ shutil.rmtree(USERDIR)
67
+
68
+ LOGGER.debug(f'Moving files from {TEMPDIR2} to {USERDIR}')
69
+ shutil.move(TEMPDIR2, USERDIR)
70
+
71
+ LOGGER.debug(f'Cleaning up {TEMPDIR}')
72
+ TEMPDIR.cleanup()
73
+
74
+
75
+ bundle.add_command(sync)
pywmdr/cli_options.py ADDED
@@ -0,0 +1,45 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ import logging
23
+ import sys
24
+
25
+ import click
26
+
27
+
28
+ def OPTION_VERBOSITY(f):
29
+ logging_options = ['ERROR', 'WARNING', 'INFO', 'DEBUG']
30
+
31
+ def callback(ctx, param, value):
32
+ value2 = value or 'INFO'
33
+ logging.basicConfig(stream=sys.stdout,
34
+ level=getattr(logging, value2))
35
+ return True
36
+
37
+ return click.option('--verbosity', '-v',
38
+ type=click.Choice(logging_options),
39
+ help='Verbosity',
40
+ callback=callback)(f)
41
+
42
+
43
+ def cli_callbacks(f):
44
+ f = OPTION_VERBOSITY(f)
45
+ return f
pywmdr/errors.py ADDED
@@ -0,0 +1,27 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ class TestSuiteError(Exception):
23
+ """custom exception handler"""
24
+ def __init__(self, message, errors):
25
+ """set error list/stack"""
26
+ super(TestSuiteError, self).__init__(message)
27
+ self.errors = errors
@@ -0,0 +1,152 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ #
23
+ # pywmdr as a service
24
+ # -------------------
25
+ #
26
+ # This file is intended to be used as a pygeoapi process plugin which will
27
+ # provide pywmdr functionality via OGC API - Processes.
28
+ #
29
+ # To integrate this plugin in pygeoapi:
30
+ #
31
+ # 1. ensure pywmdr is installed into the pygeoapi deployment environment
32
+ #
33
+ # 2. add the processes to the pygeoapi configuration as follows:
34
+ #
35
+ # pywmdr-record-validate:
36
+ # type: process
37
+ # processor:
38
+ # name: pywmdr.pygeoapi_plugin.WMDR2ETSProcessor
39
+ #
40
+ # 3. (re)start pygeoapi
41
+ #
42
+ # The resulting processes will be available at the following endpoints:
43
+ #
44
+ # /processes/pywmdr-record-validate
45
+ #
46
+ # Note that pygeoapi's OpenAPI/Swagger interface (at /openapi) will also
47
+ # provide a developer-friendly interface to test and run requests
48
+ #
49
+
50
+ import json
51
+ import logging
52
+
53
+ from pygeoapi.process.base import BaseProcessor, ProcessorExecuteError
54
+
55
+ from pywmdr.wmdr2.ets import WMDR2TestSuite
56
+ from pywmdr.util import get_package_version, THISDIR, urlopen_
57
+
58
+ LOGGER = logging.getLogger(__name__)
59
+
60
+ with (THISDIR / 'resources' / 'ets-report.json').open() as fh:
61
+ ETS_REPORT_SCHEMA = json.load(fh)
62
+
63
+ with (THISDIR / 'resources' / '20250504_0-20008-0-NRB.json').open() as fh:
64
+ EXAMPLE_WMDR2 = json.load(fh)
65
+
66
+
67
+ PROCESS_WMDR2_ETS = {
68
+ 'version': get_package_version(),
69
+ 'id': 'pywmdr-record-validate',
70
+ 'title': {
71
+ 'en': 'WMDR2 record validator'
72
+ },
73
+ 'description': {
74
+ 'en': 'Validate a WMDR2 record against the ETS'
75
+ },
76
+ 'keywords': ['wigos', 'wmdr2', 'ets', 'test suite', 'metadata'],
77
+ 'links': [{
78
+ 'type': 'text/html',
79
+ 'rel': 'about',
80
+ 'title': 'information',
81
+ 'href': 'https://github.com/wmo-im/wmdr2',
82
+ 'hreflang': 'en-US'
83
+ }],
84
+ 'inputs': {
85
+ 'record': {
86
+ 'title': 'WMDR2 record',
87
+ 'description': 'WMDR2 record (can be inline or remote link)',
88
+ 'schema': {
89
+ 'type': ['object', 'string']
90
+ },
91
+ 'minOccurs': 1,
92
+ 'maxOccurs': 1,
93
+ 'metadata': None,
94
+ 'keywords': ['wmdr2']
95
+ }
96
+ },
97
+ 'outputs': {
98
+ 'result': {
99
+ 'title': 'Report of ETS results',
100
+ 'description': 'Report of ETS results',
101
+ 'schema': {
102
+ 'contentMediaType': 'application/json',
103
+ **ETS_REPORT_SCHEMA
104
+ }
105
+ }
106
+ },
107
+ 'example': {
108
+ 'inputs': {
109
+ 'record': EXAMPLE_WMDR2
110
+ }
111
+ }
112
+ }
113
+
114
+
115
+ class WMDR2ETSProcessor(BaseProcessor):
116
+ """WMDR2 ETS"""
117
+
118
+ def __init__(self, processor_def):
119
+ """
120
+ Initialize object
121
+
122
+ :param processor_def: provider definition
123
+
124
+ :returns: pywmdr.pygeoapi_plugin.WMDR2ETSProcessor
125
+ """
126
+
127
+ super().__init__(processor_def, PROCESS_WMDR2_ETS)
128
+
129
+ def execute(self, data, outputs=None):
130
+
131
+ response = None
132
+ mimetype = 'application/json'
133
+ record = data.get('record')
134
+
135
+ if record is None:
136
+ msg = 'Missing record'
137
+ LOGGER.error(msg)
138
+ raise ProcessorExecuteError(msg)
139
+
140
+ if isinstance(record, str) and record.startswith('http'):
141
+ LOGGER.debug('Record is a link')
142
+ record = json.loads(urlopen_(record).read())
143
+ else:
144
+ LOGGER.debug('Record is inline')
145
+
146
+ LOGGER.debug('Running ETS against record')
147
+ response = WMDR2TestSuite(record).run_tests()
148
+
149
+ return mimetype, response
150
+
151
+ def __repr__(self):
152
+ return '<WMDR2ETSProcessor>'
pywmdr/record.py ADDED
@@ -0,0 +1,72 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ import json
23
+
24
+ import click
25
+
26
+ from pywmdr.wmdr2.ets import WMDR2TestSuite
27
+ from pywmdr import cli_options
28
+ from pywmdr.util import parse_wmdr2, urlopen_
29
+
30
+
31
+ @click.group()
32
+ def record():
33
+ """WMDR2 record utilities"""
34
+ pass
35
+
36
+
37
+ @click.command()
38
+ @click.pass_context
39
+ @click.argument('file_or_url')
40
+ @cli_options.OPTION_VERBOSITY
41
+ def validate(ctx, file_or_url, verbosity):
42
+ """validate WMDR2 record against the specification"""
43
+
44
+ click.echo(f'Opening {file_or_url}')
45
+
46
+ if file_or_url.startswith('http'):
47
+ content = urlopen_(file_or_url).read()
48
+ else:
49
+ with open(file_or_url) as fh:
50
+ content = fh.read()
51
+
52
+ click.echo(f'Validating {file_or_url}')
53
+
54
+ try:
55
+ data = parse_wmdr2(content)
56
+ except Exception as err:
57
+ raise click.ClickException(err)
58
+ ctx.exit(1)
59
+
60
+ click.echo('Detected WMDR2 record')
61
+ ts = WMDR2TestSuite(data)
62
+ try:
63
+ results = ts.run_tests()
64
+ except Exception as err:
65
+ raise click.ClickException(err)
66
+ ctx.exit(1)
67
+
68
+ click.echo(json.dumps(results, indent=4))
69
+ ctx.exit(results['summary']['FAILED'])
70
+
71
+
72
+ record.add_command(validate)
@@ -0,0 +1,94 @@
1
+ {
2
+ "id": "urn:wmo:md:ke-meteo:0-20008-0-NRB",
3
+ "type": "Feature",
4
+ "conformsTo": [
5
+ "http://wis.wmo.int/spec/wmdr/2/conf/core"
6
+ ],
7
+ "geometry": {
8
+ "type": "Point",
9
+ "coordinates": [
10
+ 36.75919,
11
+ -1.30169,
12
+ 1795.0
13
+ ]
14
+ },
15
+ "properties": {
16
+ "created": "1996-01-01T00:00:00Z",
17
+ "wmo:dataPolicy": "core",
18
+ "contacts": [
19
+ {
20
+ "name": "World Meteorological Organization WMO and Federal Office for Meteorology and Climatology MeteoSwiss",
21
+ "organization": "World Meteorological Organization WMO and Federal Office for Meteorology and Climatology MeteoSwiss",
22
+ "emails": [
23
+ {
24
+ "value": "oscar@wmo.int"
25
+ }
26
+ ],
27
+ "addresses": [
28
+ {
29
+ "deliveryPoint": [
30
+ "7bis, avenue de la Paix"
31
+ ],
32
+ "city": "Geneva",
33
+ "postalCode": "CH-1211",
34
+ "country": "Switzerland"
35
+ }
36
+ ],
37
+ "contactInstructions": "email",
38
+ "links": [
39
+ {
40
+ "rel": "canonical",
41
+ "type": "text/html",
42
+ "href": "https://oscar.wmo.int/surface"
43
+ }
44
+ ],
45
+ "roles": [
46
+ "host"
47
+ ]
48
+ },
49
+ {
50
+ "name": "Waweru,Amos,Mr",
51
+ "organization": "Kenyan Meteorological Department",
52
+ "phones": [
53
+ {
54
+ "value": "+254723521586"
55
+ }
56
+ ],
57
+ "emails": [
58
+ {
59
+ "value": "waweru_k@yahoo.com"
60
+ },
61
+ {
62
+ "value": "amoskamau1969@gmail.com"
63
+ }
64
+ ],
65
+ "addresses": [
66
+ {
67
+ "deliveryPoint": [
68
+ "Kenya Meteorological Department 30259\nNairobi \nKenya"
69
+ ],
70
+ "city": "Nairobi",
71
+ "administrativeArea": "NB",
72
+ "postalCode": "00100",
73
+ "country": "Kenya"
74
+ }
75
+ ],
76
+ "contactInstructions": "africa",
77
+ "roles": [
78
+ "processor",
79
+ "producer"
80
+ ]
81
+ }
82
+ ]
83
+ },
84
+ "links": [
85
+ {
86
+ "rel": "license",
87
+ "href": "https://creativecommons.org/licenses/by/4.0/"
88
+ },
89
+ {
90
+ "rel": "stations",
91
+ "href": "http://www.meteo.go.ke/obsv/ozone.html"
92
+ }
93
+ ]
94
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema: https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/wmo-im/pywmdr/refs/heads/main/pywmdr/resources/ets-report.json",
4
+ "type": "object",
5
+ "title": "WMDR2 Executable Test Suite report",
6
+ "description": "WMDR2 Executable Test Suite report",
7
+ "properties": {
8
+ "id": {
9
+ "type": "string",
10
+ "format": "uuid"
11
+ },
12
+ "report_type": {
13
+ "type": "string",
14
+ "enum": [
15
+ "ets"
16
+ ]
17
+ },
18
+ "summary": {
19
+ "type": "object",
20
+ "description": "summary information",
21
+ "properties": {
22
+ "PASSED": {
23
+ "type": "integer"
24
+ },
25
+ "FAILED": {
26
+ "type": "integer"
27
+ },
28
+ "SKIPPED": {
29
+ "type": "integer"
30
+ }
31
+ },
32
+ "required": [
33
+ "PASSED",
34
+ "FAILED",
35
+ "SKIPPED"
36
+ ]
37
+ },
38
+ "tests": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "object",
42
+ "properties": {
43
+ "id": {
44
+ "type": "string",
45
+ "format": "uri"
46
+ },
47
+ "code": {
48
+ "type": "string",
49
+ "enum": [
50
+ "PASSED",
51
+ "FAILED",
52
+ "SKIPPED"
53
+ ]
54
+ },
55
+ "message": {
56
+ "type": "string"
57
+ }
58
+ },
59
+ "required": [
60
+ "id",
61
+ "code"
62
+ ]
63
+ }
64
+ },
65
+ "generated_by": {
66
+ "type": "string"
67
+ },
68
+ "datetime": {
69
+ "type": "string",
70
+ "format": "date-time"
71
+ },
72
+ "metadata_id": {
73
+ "type": "string"
74
+ }
75
+ },
76
+ "required": [
77
+ "id",
78
+ "report_type",
79
+ "summary",
80
+ "generated_by",
81
+ "datetime",
82
+ "metadata_id",
83
+ "tests"
84
+ ]
85
+ }
pywmdr/util.py ADDED
@@ -0,0 +1,103 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ from datetime import datetime, timezone
23
+ import importlib.metadata
24
+ import json
25
+ import logging
26
+ from pathlib import Path
27
+ import ssl
28
+ from urllib.error import URLError
29
+ from urllib.request import urlopen
30
+
31
+ LOGGER = logging.getLogger(__name__)
32
+
33
+ THISDIR = Path(__file__).parent.resolve()
34
+
35
+
36
+ def get_current_datetime_rfc3339() -> str:
37
+ """
38
+ Gets the current datetime in RFC3339 format
39
+
40
+ :returns: `str` of RFC3339
41
+ """
42
+
43
+ return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
44
+
45
+
46
+ def get_userdir() -> str:
47
+ """
48
+ Helper function to get userdir
49
+
50
+ :returns: user's home directory
51
+ """
52
+
53
+ return Path.home() / '.pywmdr'
54
+
55
+
56
+ def get_package_version() -> str:
57
+ """
58
+ Helper function to get package version
59
+
60
+ :returns: `str` of version of package
61
+ """
62
+
63
+ return importlib.metadata.version('pywmdr')
64
+
65
+
66
+ def parse_wmdr2(content: str) -> dict:
67
+ """
68
+ Parse a string of WMDR2 JSON into a dict
69
+
70
+ :param content: `str` of JSON
71
+
72
+ :returns: `dict` object of WMDR2
73
+ """
74
+
75
+ LOGGER.debug('Attempting to parse as JSON')
76
+ try:
77
+ data = json.loads(content)
78
+ except json.decoder.JSONDecodeError as err:
79
+ LOGGER.error(err)
80
+ raise RuntimeError(f'Encoding error: {err}')
81
+
82
+ return data
83
+
84
+
85
+ def urlopen_(url: str):
86
+ """
87
+ Helper function for downloading a URL
88
+
89
+ :param url: URL to download
90
+
91
+ :returns: `http.client.HTTPResponse`
92
+ """
93
+
94
+ try:
95
+ response = urlopen(url)
96
+ except (ssl.SSLError, URLError) as err:
97
+ LOGGER.warning(err)
98
+ LOGGER.warning('Creating unverified context')
99
+ context = ssl._create_unverified_context()
100
+
101
+ response = urlopen(url, context=context)
102
+
103
+ return response
@@ -0,0 +1,24 @@
1
+ ###############################################################################
2
+ #
3
+ # Authors: Tom Kralidis <tomkralidis@gmail.com>
4
+ #
5
+ # Copyright (c) 2026 Tom Kralidis
6
+ #
7
+ # Licensed to the Apache Software Foundation (ASF) under one
8
+ # or more contributor license agreements. See the NOTICE file
9
+ # distributed with this work for additional information
10
+ # regarding copyright ownership. The ASF licenses this file
11
+ # to you under the Apache License, Version 2.0 (the
12
+ # "License"); you may not use this file except in compliance
13
+ # with the License. You may obtain a copy of the License at
14
+ #
15
+ # http://www.apache.org/licenses/LICENSE-2.0
16
+ #
17
+ # Unless required by applicable law or agreed to in writing,
18
+ # software distributed under the License is distributed on an
19
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
20
+ # KIND, either express or implied. See the License for the
21
+ # specific language governing permissions and limitations
22
+ # under the License.
23
+ #
24
+ ###############################################################################
pywmdr/wmdr2/ets.py ADDED
@@ -0,0 +1,155 @@
1
+ ##############################################################################
2
+ #
3
+ # Licensed to the Apache Software Foundation (ASF) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The ASF licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+ #
20
+ ###############################################################################
21
+
22
+ import json
23
+ import logging
24
+ import uuid
25
+
26
+ from jsonschema import FormatChecker
27
+ from jsonschema.validators import Draft202012Validator
28
+
29
+ from pywmdr.bundle import WMDR2_FILES
30
+ from pywmdr.errors import TestSuiteError
31
+ from pywmdr.util import get_current_datetime_rfc3339, get_package_version
32
+
33
+ LOGGER = logging.getLogger(__name__)
34
+
35
+
36
+ def gen_test_id(test_id: str) -> str:
37
+ """
38
+ Convenience function to print test identifier as URI
39
+
40
+ :param test_id: test suite identifier
41
+
42
+ :returns: test identifier as URI
43
+ """
44
+
45
+ return f'http://wis.wmo.int/spec/wmdr/2/conf/core/{test_id}'
46
+
47
+
48
+ class WMDR2TestSuite:
49
+ def __init__(self, record):
50
+ """
51
+ initializer
52
+
53
+ :param data: dict of WMDR2 JSON
54
+
55
+ :returns: `pywmdr.wmdr2.ets.WMDR2TestSuite`
56
+ """
57
+
58
+ self.version = get_package_version()
59
+ self.errors = []
60
+ self.record = record
61
+
62
+ def run_tests(self):
63
+
64
+ results = []
65
+ tests = []
66
+
67
+ ets_report = {
68
+ 'id': str(uuid.uuid4()),
69
+ 'report_type': 'ets',
70
+ 'summary': {
71
+ 'PASSED': 0,
72
+ 'FAILED': 0,
73
+ 'SKIPPED': 0,
74
+ 'WARNINGS': 0
75
+ },
76
+ 'generated_by': f'pywmdr {self.version} (https://github.com/wmo-im/pywmdr)' # noqa
77
+ }
78
+
79
+ for f in dir(WMDR2TestSuite):
80
+ if all([callable(getattr(WMDR2TestSuite, f)),
81
+ f.startswith('test_requirement'),
82
+ not f.endswith('validation')]):
83
+ tests.append(f)
84
+
85
+ LOGGER.debug('Running schema validation')
86
+ result = self.test_requirement_validation()
87
+ results.append(result)
88
+ if result['code'] == 'FAILED':
89
+ self.errors.append(result)
90
+
91
+ for t in tests:
92
+ result = getattr(self, t)()
93
+ results.append(result)
94
+ if result['code'] == 'FAILED':
95
+ self.errors.append(result)
96
+
97
+ for code in ['PASSED', 'FAILED', 'SKIPPED', 'WARNINGS']:
98
+ r = len([t for t in results if t['code'] == code])
99
+ ets_report['summary'][code] = r
100
+
101
+ ets_report['tests'] = results
102
+ ets_report['datetime'] = get_current_datetime_rfc3339()
103
+ ets_report['metadata_id'] = self.record['id']
104
+
105
+ return ets_report
106
+
107
+ def test_requirement_validation(self):
108
+ """
109
+ Validate that an WMDR2 record is valid to the authoritative schema.
110
+ """
111
+
112
+ validation_errors = []
113
+
114
+ format_checkers = ['date-time', 'email', 'regex',
115
+ 'uri', 'uri-reference']
116
+
117
+ status = {
118
+ 'id': gen_test_id('validation'),
119
+ 'code': 'PASSED'
120
+ }
121
+
122
+ schema = WMDR2_FILES / 'eomp-bundled.json'
123
+
124
+ if not schema.exists():
125
+ msg = "WMDR2 schema missing. Run 'pywmdr bundle sync' to cache"
126
+ LOGGER.error(msg)
127
+ raise RuntimeError(msg)
128
+
129
+ with schema.open() as fh:
130
+ LOGGER.debug(f'Validating {self.record} against {schema}')
131
+ validator = Draft202012Validator(
132
+ json.load(fh),
133
+ format_checker=FormatChecker(formats=format_checkers)
134
+ )
135
+
136
+ for error in validator.iter_errors(self.record):
137
+ LOGGER.debug(f'{error.json_path}: {error.message}')
138
+ validation_errors.append(f'{error.json_path}: {error.message}')
139
+
140
+ if validation_errors:
141
+ status['code'] = 'FAILED'
142
+ status['message'] = f'{len(validation_errors)} error(s)'
143
+ status['errors'] = validation_errors
144
+
145
+ return status
146
+
147
+ def raise_for_status(self):
148
+ """
149
+ Raise error if one or more failures were found during validation.
150
+
151
+ :returns: `pywmdr.errors.TestSuiteError` or `None`
152
+ """
153
+
154
+ if len(self.errors) > 0:
155
+ raise TestSuiteError('Invalid WMDR2 record', self.errors)
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywmdr
3
+ Version: 0.2.dev0
4
+ Summary: A Python implementation of the test suite for WIGOS Metadata Record
5
+ Author-email: Tom Kralidis <tomkralidis@gmail.com>
6
+ Maintainer-email: Tom Kralidis <tomkralidis@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Project-URL: homepage, https://github.com/wmo-im/pywmdr
9
+ Project-URL: source, https://github.com/wmo-im/pywmdr
10
+ Project-URL: documentation, https://github.com/wmo-im/pywmdr
11
+ Project-URL: issues, https://github.com/wmo-im/pywmdr/issues
12
+ Keywords: wmo,wigos,station metadata,test suite
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE.md
24
+ Requires-Dist: click
25
+ Requires-Dist: jsonschema
26
+ Requires-Dist: rfc3339-validator
27
+ Requires-Dist: rfc3987
28
+ Requires-Dist: shapely
29
+ Provides-Extra: dev
30
+ Requires-Dist: flake8; extra == "dev"
31
+ Provides-Extra: release
32
+ Requires-Dist: build; extra == "release"
33
+ Requires-Dist: twine; extra == "release"
34
+ Requires-Dist: wheel; extra == "release"
35
+ Provides-Extra: test
36
+ Requires-Dist: pytest; extra == "test"
37
+ Dynamic: license-file
38
+
39
+ # pywmdr
40
+
41
+ [![Build Status](https://github.com/wmo-im/pywmdr/workflows/build%20%E2%9A%99%EF%B8%8F/badge.svg)](https://github.com/wmo-im/pywmdr/actions)
42
+
43
+ # WIGOS Metadata Record Test Suite
44
+
45
+ pywmdr provides validation and quality assessment capabilities for the [WIGOS Metadata Record](https://wmo-im.github.io/wmdr2) (WMDR2) standard.
46
+
47
+ - validation against [WMDR2](https://wmo-im.github.io/wmdr2/standard/wmdr2-DRAFT.html), specifically [Annex A: Conformance Class Abstract Test Suite](https://wmo-im.github.io/wmdr2/standard/wmdr2-DRAFT.html#_conformance_class_abstract_test_suite_normative)), implementing an executable test suite against the ATS
48
+
49
+ ## Installation
50
+
51
+ ### pip
52
+
53
+ Install latest stable version from [PyPI](https://pypi.org/project/pywmdr).
54
+
55
+ ```bash
56
+ pip3 install pywmdr
57
+ ```
58
+
59
+ ### From source
60
+
61
+ Install latest development version.
62
+
63
+ ```bash
64
+ python3 -m venv pywmdr
65
+ cd pywmdr
66
+ . bin/activate
67
+ git clone https://github.com/wmo-im/pywmdr.git
68
+ cd pywmdr
69
+ pip3 install .
70
+ ```
71
+
72
+ ## Running
73
+
74
+ From command line:
75
+ ```bash
76
+ # fetch version
77
+ pywmdr --version
78
+
79
+ # sync supporting configuration bundle (schemas, topics, etc.)
80
+ pywmdr bundle sync
81
+
82
+ # abstract test suite
83
+
84
+ # validate WMDR2 metadata against abstract test suite (file on disk)
85
+ pywmdr ets validate /path/to/file.json
86
+
87
+ # validate WMDR2 metadata against abstract test suite (URL)
88
+ pywmdr ets validate https://example.org/path/to/file.json
89
+
90
+ # validate WMDR2 metadata against abstract test suite (URL), but turn JSON Schema validation off
91
+ pywmdr ets validate https://example.org/path/to/file.json --no-fail-on-schema-validation
92
+
93
+ # adjust debugging messages (CRITICAL, ERROR, WARNING, INFO, DEBUG) to stdout
94
+ pywmdr ets validate https://example.org/path/to/file.json --verbosity DEBUG
95
+
96
+ # write results to logfile
97
+ pywmdr ets validate https://example.org/path/to/file.json --verbosity DEBUG --logfile /tmp/foo.txt
98
+ ```
99
+
100
+ ## Using the API
101
+ ```pycon
102
+ >>> # test a file on disk
103
+ >>> import json
104
+ >>> from pywmdr.wmdr2.ets import WIGOSMetadataRepresentationTestSuite2
105
+ >>> from pywmdr.errors import TestSuiteError
106
+ >>> with open('/path/to/file.json') as fh:
107
+ ... data = json.load(fh)
108
+ >>> # test ETS
109
+ >>> ts = WMOCoreMetadataProfileTestSuite2(datal)
110
+ >>> ts.run_tests()
111
+ >>> ts.raise_for_status() # raises pywmdr.errors.TestSuiteError on exception with list of errors captured in .errors property
112
+ >>> # test a URL
113
+ >>> from urllib2 import urlopen
114
+ >>> from StringIO import StringIO
115
+ >>> content = StringIO(urlopen('https://....').read())
116
+ >>> data = json.loads(content)
117
+ >>> ts = WMOCoreMetadataProfileTestSuite2(data)
118
+ >>> ts.run_tests()
119
+ >>> ts.raise_for_status() # raises pywmdr.errors.TestSuiteError on exception with list of errors captured in .errors property
120
+ ```
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ python3 -m venv pywmdr
126
+ cd pywmdr
127
+ source bin/activate
128
+ git clone https://github.com/World-Meteorological-Organization/pywmdr.git
129
+ pip3 install .
130
+ pip3 install ".[dev]"
131
+ ```
132
+
133
+ ### Running tests
134
+
135
+ ```bash
136
+ pytest tests
137
+ ```
138
+
139
+ ## Releasing
140
+
141
+ ```bash
142
+ # create release (x.y.z is the release version)
143
+ vi pyproject.toml # update [project]/version
144
+ git commit -am 'update release version x.y.z'
145
+ git push origin master
146
+ git tag -a x.y.z -m 'tagging release version x.y.z'
147
+ git push --tags
148
+
149
+ # upload to PyPI
150
+ rm -fr build dist *.egg-info
151
+ python3 -m build
152
+ twine upload dist/*
153
+
154
+ # publish release on GitHub (https://github.com/wmo-im/pywmdr/releases/new)
155
+
156
+ # bump version back to dev
157
+ vi pyproject.toml # update [project]/version
158
+ git commit -am 'back to dev'
159
+ git push origin master
160
+ ```
161
+
162
+ ## Code Conventions
163
+
164
+ [PEP8](https://www.python.org/dev/peps/pep-0008)
165
+
166
+ ## Issues
167
+
168
+ Issues are managed at https://github.com/wmo-im/pywmdr/issues
169
+
170
+ ## Contact
171
+
172
+ * [Tom Kralidis](https://github.com/tomkralidis)
@@ -0,0 +1,17 @@
1
+ pywmdr/__init__.py,sha256=3JVAiKN_rLYTMR5kOl52qEGRtO7hki7PpxdKS0MhanE,1327
2
+ pywmdr/bundle.py,sha256=bwqO-pVVeF2shVBKMj2lwgKuVeXbmHPEsU4o-jutCfk,2276
3
+ pywmdr/cli_options.py,sha256=N70OlGeonJvkVNdDoh-X606J1JCUWnKwpa5gduoJ_aM,1533
4
+ pywmdr/errors.py,sha256=Uu2J6vmTjaWuNJaW-mtdZ_KCztc9x4wjaLRVDZP6Lk0,1176
5
+ pywmdr/pygeoapi_plugin.py,sha256=Ib8RH3fh62p4ajAOhk1f73etqXkBsnDtYUqVgjzvh_k,4449
6
+ pywmdr/record.py,sha256=L4wDXuWtCaqK4yO6rcrndJ8X-xN8fJSHB9CQJbTclM4,2083
7
+ pywmdr/util.py,sha256=LE_Ds6OuG7vNQxjFQcogWiPD5CNQyHhqIXeW6TNXJZE,2639
8
+ pywmdr/resources/20250504_0-20008-0-NRB.json,sha256=gvSgRDp4U_Oh5Wyu54a3IhcOvaxg-cpjrNNALyOGhR4,2958
9
+ pywmdr/resources/ets-report.json,sha256=nHIjU-9Hw8C72N0HntkGJweAYWnqZkonIFuwgDSW_NQ,2241
10
+ pywmdr/wmdr2/__init__.py,sha256=VK88WG3wpt5RqksGgutMnr02OhUlksWcoAnoeSRRWic,1035
11
+ pywmdr/wmdr2/ets.py,sha256=gQLzaM7AEPgCAfMWlXSQGi7CL5h8t1FibyvjmUpzfb0,4858
12
+ pywmdr-0.2.dev0.dist-info/licenses/LICENSE.md,sha256=Q8E0TXZpH75ydPcip7DXr21Y3uwJNgQUFv3ipMXwPkw,839
13
+ pywmdr-0.2.dev0.dist-info/METADATA,sha256=C5G9J09OS00I_BE4q3Qww0ugxHg_0_1GPyd16iKrrPI,5104
14
+ pywmdr-0.2.dev0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
15
+ pywmdr-0.2.dev0.dist-info/entry_points.txt,sha256=Ga7GlkDlThWwprQDc5OawyWtV_RT9FXilgXjwKacynU,38
16
+ pywmdr-0.2.dev0.dist-info/top_level.txt,sha256=_Zdw0CS7tVYk-CJZmu9xEqIzOuY98RsI6CUMh8Kh818,7
17
+ pywmdr-0.2.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pywmdr = pywmdr:cli
@@ -0,0 +1,24 @@
1
+ Authors:
2
+
3
+ Tom Kralidis <tomkralidis@gmail.com>
4
+
5
+ Copyright (c) 2025 Tom Kralidis
6
+
7
+ ***
8
+
9
+ Licensed to the Apache Software Foundation (ASF) under one
10
+ or more contributor license agreements. See the NOTICE file
11
+ distributed with this work for additional information
12
+ regarding copyright ownership. The ASF licenses this file
13
+ to you under the Apache License, Version 2.0 (the
14
+ "License"); you may not use this file except in compliance
15
+ with the License. You may obtain a copy of the License at
16
+
17
+ http://www.apache.org/licenses/LICENSE-2.0
18
+
19
+ Unless required by applicable law or agreed to in writing,
20
+ software distributed under the License is distributed on an
21
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
22
+ KIND, either express or implied. See the License for the
23
+ specific language governing permissions and limitations
24
+ under the License.
@@ -0,0 +1 @@
1
+ pywmdr