data_pipeline_api 0.2.0__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.
@@ -0,0 +1,181 @@
1
+ from io import IOBase
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Union
5
+ from functools import wraps
6
+ from hashlib import sha1
7
+ import yaml
8
+
9
+ from data_pipeline_api.metadata import Metadata, MetadataKey
10
+ from data_pipeline_api.metadata_store import MetadataStore
11
+ from data_pipeline_api.overrides import Overrides
12
+
13
+
14
+ class FileAPI:
15
+ def __init__(
16
+ self, config_filename: Union[Path, str],
17
+ ):
18
+ self._open_timestamp = datetime.now()
19
+ self._accesses = []
20
+ config_filename = Path(config_filename)
21
+
22
+ try:
23
+ with open(config_filename) as config_file:
24
+ self._config = yaml.safe_load(config_file)
25
+ if "run_id" in self._config:
26
+ self.run_id = self._config["run_id"]
27
+ else:
28
+ # Compute a run_id.
29
+ with open(config_filename, "rb") as file:
30
+ m = sha1(file.read())
31
+ m.update(str(self._open_timestamp).encode())
32
+ self.run_id = m.hexdigest()
33
+ self.data_directory = Path(self._config.get("data_directory", "."))
34
+ self.fail_on_hash_mismatch = self._config.get(
35
+ "fail_on_hash_mismatch", True
36
+ )
37
+ self._read_overrides = Overrides(
38
+ (override.get("where", {}), override.get("use", {}))
39
+ for override in self._config.get("read", ())
40
+ )
41
+ self._write_overrides = Overrides(
42
+ (override.get("where", {}), override.get("use", {}))
43
+ for override in self._config.get("write", ())
44
+ )
45
+ access_log = self._config.get("access_log", "access-{run_id}.yaml")
46
+ if access_log is False:
47
+ self.access_log_path = None
48
+ else:
49
+ self.access_log_path = Path(access_log.format(run_id=self.run_id))
50
+ if not self.access_log_path.is_absolute():
51
+ self.access_log_path = (
52
+ config_filename.parent / self.access_log_path
53
+ )
54
+
55
+ except Exception as exception:
56
+ raise ValueError("could not parse config file") from exception
57
+
58
+ if self.data_directory.is_absolute():
59
+ self.normalised_data_directory = self.data_directory
60
+ else:
61
+ self.normalised_data_directory = (
62
+ config_filename.parent / self.data_directory
63
+ )
64
+
65
+ metadata_filename = self.normalised_data_directory / "metadata.yaml"
66
+ if metadata_filename.exists():
67
+ with open(metadata_filename) as metadata_store_file:
68
+ metadata_store = yaml.safe_load(metadata_store_file)
69
+ else:
70
+ metadata_store = {}
71
+ self._metadata_store = MetadataStore(metadata_store)
72
+
73
+ def _record_access(
74
+ self,
75
+ access_type: str,
76
+ call_metadata: Metadata,
77
+ access_metadata: Metadata,
78
+ path: Path,
79
+ verify_hash: bool,
80
+ ):
81
+ access_metadata = access_metadata.copy()
82
+ with open(path, "rb") as file:
83
+ access_metadata[MetadataKey.calculated_hash] = sha1(file.read()).hexdigest()
84
+ if verify_hash:
85
+ if (
86
+ access_metadata[MetadataKey.calculated_hash]
87
+ != access_metadata[MetadataKey.verified_hash]
88
+ ):
89
+ raise ValueError(
90
+ (
91
+ "calculated hash {calculated_hash} != "
92
+ "verified hash {verified_hash}"
93
+ ).format(**access_metadata)
94
+ )
95
+ self._accesses.append(
96
+ {
97
+ "type": access_type,
98
+ "timestamp": datetime.now(),
99
+ "call_metadata": call_metadata,
100
+ "access_metadata": access_metadata,
101
+ }
102
+ )
103
+
104
+ def open_for_read(self, **metadata) -> IOBase:
105
+ """Return a file open for reading corresponding to the given metadata.
106
+
107
+ The file contents are hashed, and a record is made of the read.
108
+ """
109
+ read_metadata = metadata.copy()
110
+ self._read_overrides.apply(read_metadata)
111
+ read_metadata = self._metadata_store.find(read_metadata) or read_metadata
112
+ try:
113
+ filename = Path(read_metadata[MetadataKey.filename])
114
+ except KeyError:
115
+ raise KeyError(f"could not find {MetadataKey.filename} in {read_metadata}")
116
+ path = self.normalised_data_directory / filename
117
+ self._record_access(
118
+ "read",
119
+ metadata,
120
+ read_metadata,
121
+ path,
122
+ verify_hash=self.fail_on_hash_mismatch,
123
+ )
124
+ return open(path, mode="rb")
125
+
126
+ def open_for_write(self, **metadata) -> IOBase:
127
+ """Return a file open for update corresponding to the given metadata.
128
+
129
+ When the file is closed the file contents are hashed, and a record is made of
130
+ the write.
131
+ """
132
+ write_metadata = metadata.copy()
133
+ self._write_overrides.apply(write_metadata)
134
+ if MetadataKey.filename not in write_metadata:
135
+ write_metadata[MetadataKey.filename] = str(
136
+ Path(write_metadata[MetadataKey.data_product])
137
+ / "{}.{}".format(self.run_id, write_metadata[MetadataKey.extension],)
138
+ )
139
+
140
+ path = self.normalised_data_directory / write_metadata[MetadataKey.filename]
141
+ if path.exists():
142
+ file = open(path, mode="r+b")
143
+ else:
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ file = open(path, mode="w+b")
146
+ # Wrap the file close method with something to record the file access.
147
+ close_file = file.close
148
+
149
+ @wraps(close_file)
150
+ def close():
151
+ file.flush()
152
+ self._record_access("write", metadata, write_metadata, path, False)
153
+ return close_file()
154
+
155
+ file.close = close
156
+ return file
157
+
158
+ def close(self):
159
+ """Close the session and write the access log.
160
+ """
161
+ if self.access_log_path:
162
+ with open(self.access_log_path, "w") as output_file:
163
+ yaml.dump(
164
+ {
165
+ "data_directory": str(self.data_directory),
166
+ "open_timestamp": self._open_timestamp,
167
+ "close_timestamp": datetime.now(),
168
+ "run_id": self.run_id,
169
+ "config": self._config,
170
+ "io": self._accesses,
171
+ },
172
+ output_file,
173
+ sort_keys=False,
174
+ )
175
+
176
+ def __enter__(self):
177
+ return self
178
+
179
+ def __exit__(self, exc_type, exc_value, traceback):
180
+ if exc_type is None:
181
+ self.close()
@@ -0,0 +1,21 @@
1
+ from typing import Mapping, Optional
2
+ from pathlib import Path
3
+
4
+ Metadata = Mapping[str, str]
5
+
6
+ class MetadataKey:
7
+ filename = "filename"
8
+ data_product = "data_product"
9
+ extension = "extension"
10
+ run_id = "run_id"
11
+ version = "version"
12
+ verified_hash = "verified_hash"
13
+ calculated_hash = "calculated_hash"
14
+
15
+
16
+ def is_superset(metadataA: Metadata, metadataB: Metadata) -> bool:
17
+ """Return True if metadataA is a superset of metadataB.
18
+ """
19
+ return all(
20
+ key in metadataA and metadataA[key] == value for key, value in metadataB.items()
21
+ )
@@ -0,0 +1,37 @@
1
+ from typing import Sequence, Optional, NamedTuple
2
+ from operator import attrgetter
3
+ from semver import VersionInfo
4
+ from data_pipeline_api.metadata import Metadata, MetadataKey, is_superset
5
+
6
+
7
+ class MetadataRecord(NamedTuple):
8
+ metadata: Metadata
9
+ version: Optional[VersionInfo]
10
+
11
+
12
+ class MetadataStore:
13
+ def __init__(self, metadata_sequence: Sequence[Metadata]):
14
+ try:
15
+ self._metadata_records = tuple(
16
+ MetadataRecord(
17
+ metadata=metadata,
18
+ version=VersionInfo.parse(metadata[MetadataKey.version])
19
+ if MetadataKey.version in metadata
20
+ else None,
21
+ )
22
+ for metadata in metadata_sequence
23
+ )
24
+ except Exception as exception:
25
+ raise ValueError("invalid metadata") from exception
26
+
27
+ def find(self, metadata: Metadata) -> Optional[Metadata]:
28
+ try:
29
+ return max(
30
+ filter(
31
+ lambda record: is_superset(record.metadata, metadata),
32
+ self._metadata_records,
33
+ ),
34
+ key=attrgetter("version"),
35
+ ).metadata
36
+ except ValueError:
37
+ return None
@@ -0,0 +1,21 @@
1
+ from typing import Iterable, NamedTuple, Sequence, Tuple
2
+ from data_pipeline_api.metadata import Metadata, is_superset
3
+
4
+
5
+ class Override(NamedTuple):
6
+ where: Metadata
7
+ use: Metadata
8
+
9
+
10
+ class Overrides:
11
+ def __init__(self, overrides: Sequence[Tuple[Metadata, Metadata]]):
12
+ self._overrides = tuple(Override(*override) for override in overrides)
13
+
14
+ def find(self, metadata: Metadata) -> Iterable[Override]:
15
+ return filter(
16
+ lambda override: is_superset(metadata, override.where), self._overrides,
17
+ )
18
+
19
+ def apply(self, metadata: Metadata):
20
+ for override in self.find(metadata):
21
+ metadata.update(override.use)
@@ -0,0 +1,13 @@
1
+ from io import TextIOWrapper
2
+ from pathlib import Path
3
+ from pandas import read_csv, DataFrame
4
+ from data_pipeline_api.file_api import FileAPI
5
+
6
+ class SimpleNetworkSimAPI(FileAPI):
7
+ def read_table(self, data_product: str) -> DataFrame:
8
+ with TextIOWrapper(self.open_for_read(data_product=data_product, extension="csv")) as csv_file:
9
+ return read_csv(csv_file)
10
+
11
+ def write_table(self, data_product: str, value: DataFrame):
12
+ with TextIOWrapper(self.open_for_write(data_product=data_product, extension="csv")) as csv_file:
13
+ value.to_csv(csv_file, index=False)
@@ -0,0 +1,136 @@
1
+ from io import TextIOWrapper
2
+ from pathlib import Path
3
+ from contextlib import contextmanager
4
+ import numpy as np
5
+ import pandas as pd
6
+ from data_pipeline_api.file_api import FileAPI
7
+ from data_pipeline_api.file_formats import parameter_file, object_file
8
+
9
+
10
+ class StandardAPI(FileAPI):
11
+ # ==================================================================================
12
+ # Parameter files
13
+ # ==================================================================================
14
+
15
+ @contextmanager
16
+ def open_parameter_file_for_read(self, data_product: str, component: str):
17
+ with TextIOWrapper(
18
+ self.open_for_read(data_product=data_product, component=component)
19
+ ) as parameter_file:
20
+ yield parameter_file
21
+
22
+ @contextmanager
23
+ def open_parameter_file_for_write(self, data_product: str, component: str):
24
+ with TextIOWrapper(
25
+ self.open_for_write(
26
+ data_product=data_product, component=component, extension="toml"
27
+ )
28
+ ) as parameter_file:
29
+ yield parameter_file
30
+
31
+ # ----------------------------------------------------------------------------------
32
+ # Estimate
33
+ # ----------------------------------------------------------------------------------
34
+
35
+ def read_estimate(
36
+ self, data_product: str, component: str
37
+ ) -> parameter_file.Estimate:
38
+ with self.open_parameter_file_for_read(data_product, component) as file:
39
+ parameter_type = parameter_file.read_type(file, component)
40
+ if parameter_type is parameter_file.Type.POINT_ESTIMATE:
41
+ return parameter_file.read_estimate(file, component)
42
+ elif parameter_type is parameter_file.Type.DISTRIBUTION:
43
+ return parameter_file.read_distribution(file, component).mean()
44
+ elif parameter_type is parameter_file.Type.SAMPLES:
45
+ return parameter_file.read_samples(file, component).mean()
46
+ else:
47
+ raise ValueError(f"unrecognised type {parameter_type}")
48
+
49
+ def write_estimate(
50
+ self, data_product: str, component: str, estimate: parameter_file.Estimate
51
+ ):
52
+ with self.open_parameter_file_for_write(data_product, component) as file:
53
+ parameter_file.write_estimate(file, component, estimate)
54
+
55
+ # ----------------------------------------------------------------------------------
56
+ # Distribution
57
+ # ----------------------------------------------------------------------------------
58
+
59
+ def read_distribution(
60
+ self, data_product: str, component: str
61
+ ) -> parameter_file.Distribution:
62
+ with self.open_parameter_file_for_read(data_product, component) as file:
63
+ parameter_type = parameter_file.read_type(file, component)
64
+ if parameter_type is parameter_file.Type.POINT_ESTIMATE:
65
+ raise ValueError("point-estimate cannot be read as a distribution")
66
+ elif parameter_type is parameter_file.Type.DISTRIBUTION:
67
+ return parameter_file.read_distribution(file, component)
68
+ elif parameter_type is parameter_file.Type.SAMPLES:
69
+ raise ValueError("samples cannot be read as a distribution")
70
+ else:
71
+ raise ValueError(f"unrecognised type {parameter_type}")
72
+
73
+ def write_distribution(
74
+ self,
75
+ data_product: str,
76
+ component: str,
77
+ distribution: parameter_file.Distribution,
78
+ ):
79
+ with self.open_parameter_file_for_write(data_product, component) as file:
80
+ parameter_file.write_distribution(file, component, distribution)
81
+
82
+ # ----------------------------------------------------------------------------------
83
+ # Samples
84
+ # ----------------------------------------------------------------------------------
85
+
86
+ def read_sample(self, data_product: str, component: str) -> float:
87
+ with self.open_parameter_file_for_read(data_product, component) as file:
88
+ parameter_type = parameter_file.read_type(file, component)
89
+ if parameter_type is parameter_file.Type.POINT_ESTIMATE:
90
+ return parameter_file.read_estimate(file, component)
91
+ elif parameter_type is parameter_file.Type.DISTRIBUTION:
92
+ return parameter_file.read_distribution(file, component).rvs()
93
+ elif parameter_type is parameter_file.Type.SAMPLES:
94
+ return np.random.choice(parameter_file.read_samples(file, component))
95
+ else:
96
+ raise ValueError(f"unrecognised type {parameter_type}")
97
+
98
+ def write_samples(
99
+ self, data_product: str, component: str, samples: parameter_file.Samples
100
+ ):
101
+ with self.open_parameter_file_for_write(data_product, component) as file:
102
+ parameter_file.write_samples(file, component, samples)
103
+
104
+ # ==================================================================================
105
+ # Object (hdf5) files
106
+ # ==================================================================================
107
+
108
+ @contextmanager
109
+ def open_object_file_for_read(self, data_product: str, component: str):
110
+ with self.open_for_read(
111
+ data_product=data_product, component=component
112
+ ) as object_file:
113
+ yield object_file
114
+
115
+ @contextmanager
116
+ def open_object_file_for_write(self, data_product: str, component: str):
117
+ with self.open_for_write(
118
+ data_product=data_product, component=component, extension="h5"
119
+ ) as object_file:
120
+ yield object_file
121
+
122
+ def read_table(self, data_product: str, component: str) -> object_file.Table:
123
+ with self.open_object_file_for_read(data_product, component) as file:
124
+ return object_file.read_table(file, component)
125
+
126
+ def write_table(self, data_product: str, component: str, table: object_file.Table):
127
+ with self.open_object_file_for_write(data_product, component) as file:
128
+ object_file.write_table(file, component, table)
129
+
130
+ def read_array(self, data_product: str, component: str) -> object_file.Array:
131
+ with self.open_object_file_for_read(data_product, component) as file:
132
+ return object_file.read_array(file, component)
133
+
134
+ def write_array(self, data_product: str, component: str, array: object_file.Array):
135
+ with self.open_object_file_for_write(data_product, component) as file:
136
+ object_file.write_array(file, component, array)
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2020, SCRC
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.1
2
+ Name: data-pipeline-api
3
+ Version: 0.2.0
4
+ Summary: Interacts with SCRC data pipeline
5
+ Home-page: UNKNOWN
6
+ Author: SCRC
7
+ Author-email: scrc@glasgow.ac.uk
8
+ License: UNKNOWN
9
+ Platform: UNKNOWN
10
+ Requires-Dist: networkx (>=2.4)
11
+ Requires-Dist: matplotlib (>=3.1.3)
12
+ Requires-Dist: pandas (>=1.0.3)
13
+ Requires-Dist: toml (>=0.9.4)
14
+ Requires-Dist: h5py (>=2.9.0)
15
+ Requires-Dist: scipy (>=1.4.1)
16
+ Requires-Dist: pyyaml (>=5.3.1)
17
+ Requires-Dist: semver (>=2.9.0)
18
+
19
+ UNKNOWN
20
+
21
+
@@ -0,0 +1,11 @@
1
+ data_pipeline_api/file_api.py,sha256=dwFovDj-KhVa4jMHjn5FV_lhTLExAMZ_8cqqpdtZ_3s,6860
2
+ data_pipeline_api/metadata.py,sha256=k6Wb1UAouI_IGtB-IGTmMAIlW6B0HRbUFSIljP9wrL4,570
3
+ data_pipeline_api/metadata_store.py,sha256=DXRO2sscGsFpbSVioibjh0cM73qpuoh4mr4v_rfz7RU,1222
4
+ data_pipeline_api/overrides.py,sha256=GXd6SMWfN4mKLUflhJMuTf3w4xx7d7eJqZ2PQD8yyCM,666
5
+ data_pipeline_api/simple_network_sim_api.py,sha256=iKFzxLWTSc0dLIhHfu11UX-osyxO1RO4Kqq8cgP39Co,595
6
+ data_pipeline_api/standard_api.py,sha256=0CEgrn8t4CtY1hsy5gAk4as2UIvVr-QMCYTs7Fc-x58,6414
7
+ data_pipeline_api-0.2.0.dist-info/LICENSE,sha256=ok0qSVR1GQvE2voRomNyZVHlq2aoO_Bo6HCZGCZUeZM,1512
8
+ data_pipeline_api-0.2.0.dist-info/METADATA,sha256=qWB2CiLgppz2V21fR9tbt5zX_rhPDrTrCw_Y6aRY2HM,470
9
+ data_pipeline_api-0.2.0.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
10
+ data_pipeline_api-0.2.0.dist-info/top_level.txt,sha256=f2wi2a5FngfSqjkTh5YGJg8ty1JsV-GExu6qZOeqlac,18
11
+ data_pipeline_api-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.34.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ data_pipeline_api