opensampl 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.
- opensampl/__init__.py +4 -0
- opensampl/cli.py +237 -0
- opensampl/constants.py +84 -0
- opensampl/db/__init__.py +1 -0
- opensampl/db/access_orm.py +128 -0
- opensampl/db/orm.py +198 -0
- opensampl/helpers/__init__.py +1 -0
- opensampl/helpers/create_vendor.py +266 -0
- opensampl/helpers/env.py +48 -0
- opensampl/helpers/source_writer.py +62 -0
- opensampl/load_data.py +414 -0
- opensampl/server/__init__.py +21 -0
- opensampl/server/cli.py +185 -0
- opensampl/server/default.env +17 -0
- opensampl/server/docker-compose.yaml +63 -0
- opensampl/vendors/__init__.py +1 -0
- opensampl/vendors/adva.py +138 -0
- opensampl/vendors/base_probe.py +381 -0
- opensampl/vendors/constants.py +64 -0
- opensampl-0.2.0.dist-info/METADATA +222 -0
- opensampl-0.2.0.dist-info/RECORD +23 -0
- opensampl-0.2.0.dist-info/WHEEL +4 -0
- opensampl-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**beta**
|
|
3
|
+
|
|
4
|
+
Creating new vendors/clock probe types based on config files
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from pydantic import BaseModel, Field, model_validator
|
|
14
|
+
|
|
15
|
+
from opensampl.helpers.source_writer import OrmClassFormatter
|
|
16
|
+
from opensampl.vendors.constants import VendorType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MetadataField(BaseModel):
|
|
20
|
+
"""Definition for a metadata field in the vendor config"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
sqlalchemy_type: Optional[str] = Field(default="Text", alias="type")
|
|
24
|
+
primary_key: Optional[bool] = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VendorConfig(VendorType):
|
|
28
|
+
"""Configuration definition for a new vendor type"""
|
|
29
|
+
|
|
30
|
+
metadata_fields: List[MetadataField]
|
|
31
|
+
base_path: Path = Path(__file__).parent.parent
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_config_file(cls, config_path: Union[str, Path]):
|
|
35
|
+
"""Convert file config into Config object"""
|
|
36
|
+
if isinstance(config_path, str):
|
|
37
|
+
config_path = Path(config_path)
|
|
38
|
+
config = yaml.safe_load(config_path.read_text())
|
|
39
|
+
return cls(**config)
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="before")
|
|
42
|
+
@classmethod
|
|
43
|
+
def generate_default_fields(cls, data: Any) -> Any:
|
|
44
|
+
"""Generate default values for fields if they are not provided in the config"""
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
name = data.get("name")
|
|
49
|
+
if not name:
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
# Generate default values if not provided
|
|
53
|
+
if not data.get("metadata_table"):
|
|
54
|
+
data["metadata_table"] = f"{name.lower()}_metadata"
|
|
55
|
+
|
|
56
|
+
if not data.get("metadata_orm"):
|
|
57
|
+
data["metadata_orm"] = f"{name.capitalize()}Metadata"
|
|
58
|
+
|
|
59
|
+
if not data.get("parser_class"):
|
|
60
|
+
data["parser_class"] = f"{name.capitalize()}Probe"
|
|
61
|
+
|
|
62
|
+
if not data.get("parser_module"):
|
|
63
|
+
data["parser_module"] = f"{name.lower()}"
|
|
64
|
+
|
|
65
|
+
metadata_fields = data.get("metadata_fields")
|
|
66
|
+
if not metadata_fields or not isinstance(metadata_fields, Dict):
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
fields = []
|
|
70
|
+
for field, sql_type in metadata_fields.items():
|
|
71
|
+
if sql_type:
|
|
72
|
+
fields.append(MetadataField(name=field, type=sql_type))
|
|
73
|
+
else:
|
|
74
|
+
fields.append(MetadataField(name=field))
|
|
75
|
+
data["metadata_fields"] = fields
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
def create_probe_file(self) -> Path:
|
|
79
|
+
"""Create a new probe class file."""
|
|
80
|
+
# Create the probe file
|
|
81
|
+
probe_file = self.base_path / "vendors" / f"{self.parser_module}.py"
|
|
82
|
+
# TODO in write time data, optionally add value_str to df ensure maximum precision when sending through backend.
|
|
83
|
+
PROBE_TEMPLATE = f'''\
|
|
84
|
+
from opensampl.vendors.base_probe import BaseProbe
|
|
85
|
+
import pandas as pd
|
|
86
|
+
from opensampl.vendors.constants import {self.name}, ProbeKey
|
|
87
|
+
|
|
88
|
+
class {self.parser_class}(BaseProbe):
|
|
89
|
+
def __init__(self, input_file, **kwargs):
|
|
90
|
+
# TODO: define how to find probe_key (probe_id and ip_address)
|
|
91
|
+
super().__init__(input_file=input_file, **kwargs)
|
|
92
|
+
|
|
93
|
+
def process_time_data(self) -> pd.DataFrame:
|
|
94
|
+
"""
|
|
95
|
+
Process time series data from the input file.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
pd.DataFrame: DataFrame with columns:
|
|
99
|
+
- time (datetime64[ns]): timestamp for each measurement
|
|
100
|
+
- value (float64): measured value at each timestamp
|
|
101
|
+
"""
|
|
102
|
+
# TODO: Implement time series data processing logic specific to {self.name} probes
|
|
103
|
+
raise NotImplementedError("Time data processing not implemented for {self.parser_class}")
|
|
104
|
+
|
|
105
|
+
def process_metadata(self) -> dict:
|
|
106
|
+
"""
|
|
107
|
+
Process metadata from the input file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
dict: Dictionary mapping table names to ORM objects
|
|
111
|
+
"""
|
|
112
|
+
# TODO: Implement metadata processing logic specific to {self.name}
|
|
113
|
+
raise NotImplementedError("Metadata processing not implemented for {self.parser_class}")
|
|
114
|
+
'''
|
|
115
|
+
|
|
116
|
+
probe_file.write_text(PROBE_TEMPLATE)
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"Wrote {self.parser_class} to {probe_file}. Open the file, and follow TODO instructions to implement."
|
|
119
|
+
)
|
|
120
|
+
return probe_file
|
|
121
|
+
|
|
122
|
+
def create_metadata_class(self) -> ast.ClassDef:
|
|
123
|
+
"""Create the metadata class AST"""
|
|
124
|
+
class_body = [
|
|
125
|
+
ast.Assign(
|
|
126
|
+
targets=[ast.Name(id="__tablename__", ctx=ast.Store())],
|
|
127
|
+
value=ast.Constant(value=self.metadata_table),
|
|
128
|
+
),
|
|
129
|
+
ast.Assign(
|
|
130
|
+
targets=[ast.Name(id="probe_uuid", ctx=ast.Store())],
|
|
131
|
+
value=ast.Call(
|
|
132
|
+
func=ast.Name(id="Column", ctx=ast.Load()),
|
|
133
|
+
args=[
|
|
134
|
+
ast.Name(id="String", ctx=ast.Load()),
|
|
135
|
+
ast.Call(
|
|
136
|
+
func=ast.Name(id="ForeignKey", ctx=ast.Load()),
|
|
137
|
+
args=[ast.Constant(value="probe_metadata.uuid")],
|
|
138
|
+
keywords=[],
|
|
139
|
+
),
|
|
140
|
+
],
|
|
141
|
+
keywords=[ast.keyword(arg="primary_key", value=ast.Constant(value=True))],
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Add fields from config
|
|
147
|
+
for field in self.metadata_fields:
|
|
148
|
+
class_body.append(
|
|
149
|
+
ast.Assign(
|
|
150
|
+
targets=[ast.Name(id=field.name, ctx=ast.Store())],
|
|
151
|
+
value=ast.Call(
|
|
152
|
+
func=ast.Name(id="Column", ctx=ast.Load()),
|
|
153
|
+
args=[ast.Name(id=field.sqlalchemy_type, ctx=ast.Load())],
|
|
154
|
+
keywords=[],
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Add relationship
|
|
160
|
+
class_body.append(
|
|
161
|
+
ast.Assign(
|
|
162
|
+
targets=[ast.Name(id="probe", ctx=ast.Store())],
|
|
163
|
+
value=ast.Call(
|
|
164
|
+
func=ast.Name(id="relationship", ctx=ast.Load()),
|
|
165
|
+
args=[ast.Constant(value="ProbeMetadata")],
|
|
166
|
+
keywords=[
|
|
167
|
+
ast.keyword(
|
|
168
|
+
arg="back_populates",
|
|
169
|
+
value=ast.Constant(value=f"{self.name.lower()}_metadata"),
|
|
170
|
+
)
|
|
171
|
+
],
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return ast.ClassDef(
|
|
177
|
+
name=self.metadata_orm,
|
|
178
|
+
bases=[ast.Name(id="Base", ctx=ast.Load())],
|
|
179
|
+
keywords=[],
|
|
180
|
+
body=class_body,
|
|
181
|
+
decorator_list=[],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# def update_constants(self):
|
|
185
|
+
def update_orm_file(self):
|
|
186
|
+
"""Update the orm.py file with the new metadata class"""
|
|
187
|
+
orm_path = self.base_path / "db" / "orm.py"
|
|
188
|
+
|
|
189
|
+
# Read existing file
|
|
190
|
+
with open(orm_path) as f:
|
|
191
|
+
source = f.read()
|
|
192
|
+
tree = ast.parse(source)
|
|
193
|
+
|
|
194
|
+
# Add relationship to ProbeMetadata class
|
|
195
|
+
for node in tree.body:
|
|
196
|
+
if isinstance(node, ast.ClassDef) and node.name == "ProbeMetadata":
|
|
197
|
+
# Add relationship field
|
|
198
|
+
node.body.append(
|
|
199
|
+
ast.Assign(
|
|
200
|
+
targets=[ast.Name(id=f"{self.name.lower()}_metadata", ctx=ast.Store())],
|
|
201
|
+
value=ast.Call(
|
|
202
|
+
func=ast.Name(id="relationship", ctx=ast.Load()),
|
|
203
|
+
args=[ast.Constant(value=self.metadata_orm)],
|
|
204
|
+
keywords=[
|
|
205
|
+
ast.keyword(
|
|
206
|
+
arg="back_populates",
|
|
207
|
+
value=ast.Constant(value="probe"),
|
|
208
|
+
),
|
|
209
|
+
ast.keyword(arg="uselist", value=ast.Constant(value=False)),
|
|
210
|
+
],
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Create new class and add to tree
|
|
216
|
+
new_class = self.create_metadata_class()
|
|
217
|
+
tree.body.append(new_class)
|
|
218
|
+
|
|
219
|
+
module = OrmClassFormatter.format(tree=tree)
|
|
220
|
+
|
|
221
|
+
# Write back to file
|
|
222
|
+
with orm_path.open(mode="w") as f:
|
|
223
|
+
f.write(module.code)
|
|
224
|
+
|
|
225
|
+
def update_constants(self):
|
|
226
|
+
"""Update the constants.py file with the new vendor type"""
|
|
227
|
+
constants_path = self.base_path / "vendors" / "constants.py"
|
|
228
|
+
|
|
229
|
+
new_vendor_str = f"""\
|
|
230
|
+
{self.name.upper()} = VendorType(
|
|
231
|
+
name='{self.name}',
|
|
232
|
+
parser_class='{self.parser_class}',
|
|
233
|
+
parser_module='{self.parser_module}',
|
|
234
|
+
metadata_table='{self.metadata_table}',
|
|
235
|
+
metadata_orm='{self.metadata_orm}'
|
|
236
|
+
)
|
|
237
|
+
"""
|
|
238
|
+
new_map_entry = f"\t'{self.name}': {self.name.upper()},"
|
|
239
|
+
|
|
240
|
+
file_text = constants_path.read_text()
|
|
241
|
+
tree = ast.parse(file_text)
|
|
242
|
+
|
|
243
|
+
for i, node in enumerate(tree.body):
|
|
244
|
+
if (
|
|
245
|
+
isinstance(node, ast.Assign)
|
|
246
|
+
and isinstance(node.targets[0], ast.Name)
|
|
247
|
+
and node.targets[0].id == "VENDOR_MAP"
|
|
248
|
+
):
|
|
249
|
+
vm_lineno = node.lineno
|
|
250
|
+
vm_end_lineno = node.end_lineno
|
|
251
|
+
break
|
|
252
|
+
file_lines = file_text.splitlines()
|
|
253
|
+
if vm_lineno and vm_end_lineno:
|
|
254
|
+
file_lines.insert(vm_end_lineno - 1, new_map_entry)
|
|
255
|
+
file_lines.insert(vm_lineno - 1, new_vendor_str)
|
|
256
|
+
|
|
257
|
+
# constants2_path = self.base_path / 'vendors' / 'constants2.py'
|
|
258
|
+
# constants2_path.write_text('\n'.join(file_lines))
|
|
259
|
+
with constants_path.open(mode="w") as f:
|
|
260
|
+
f.write("\n".join(file_lines))
|
|
261
|
+
|
|
262
|
+
def create(self):
|
|
263
|
+
"""Create the new vendor"""
|
|
264
|
+
self.update_orm_file()
|
|
265
|
+
self.create_probe_file()
|
|
266
|
+
self.update_constants()
|
opensampl/helpers/env.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Environment functions for openSAMPL"""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import find_dotenv, set_key
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from opensampl.constants import ENV_VARS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_env(name: str, value: str, temp: bool = None):
|
|
12
|
+
"""
|
|
13
|
+
Set the value of an environment variable.
|
|
14
|
+
|
|
15
|
+
Note that this will only work if the variable is set in the .env file, if it is a true environment variable the
|
|
16
|
+
change will not persist.
|
|
17
|
+
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
opensampl config set BACKEND_URL http://localhost:8000
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
# TODO: if you have the env var actually set in your environment, and not in a dotenv, the "set" won't work.
|
|
24
|
+
# options:
|
|
25
|
+
# 1) have override in dotenv option
|
|
26
|
+
# 2) have a "run with" at the end of any opensampl command to have it just be that for that run
|
|
27
|
+
|
|
28
|
+
# Verify variable exists
|
|
29
|
+
if not any(var.name == name for var in ENV_VARS.all()):
|
|
30
|
+
logger.warning(
|
|
31
|
+
f"Environment variable '{name}' not used by openSAMPL. Will be added to .env, but will not change "
|
|
32
|
+
f"functionality."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Set for current session
|
|
36
|
+
os.environ[name] = value
|
|
37
|
+
|
|
38
|
+
env_path = find_dotenv()
|
|
39
|
+
logger.debug(f"Found env_path: {env_path}")
|
|
40
|
+
if not env_path:
|
|
41
|
+
env_path = ".env"
|
|
42
|
+
Path(env_path).touch()
|
|
43
|
+
|
|
44
|
+
# Update .env file
|
|
45
|
+
set_key(env_path, name, value)
|
|
46
|
+
logger.debug(f"Set {name}={value} in {env_path}")
|
|
47
|
+
|
|
48
|
+
logger.debug(os.getenv(name))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Helper for writing new source code based on new Vendor Configuration"""
|
|
2
|
+
import astor
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from black import FileMode, format_str
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OrmClassFormatter(cst.CSTTransformer):
|
|
8
|
+
"""Formats class definitions with proper spacing between sections"""
|
|
9
|
+
|
|
10
|
+
def _get_statement_type(self, stmt: cst.CSTNode) -> str:
|
|
11
|
+
"""Determine the type of given statement"""
|
|
12
|
+
if isinstance(stmt, cst.SimpleStatementLine):
|
|
13
|
+
if len(stmt.body) == 1:
|
|
14
|
+
if isinstance(stmt.body[0], cst.Assign):
|
|
15
|
+
if isinstance(stmt.body[0].targets[0], cst.Name):
|
|
16
|
+
name = stmt.body[0].targets[0].value
|
|
17
|
+
if name.startswith("__"):
|
|
18
|
+
return "dunder"
|
|
19
|
+
value = stmt.body[0].value
|
|
20
|
+
if isinstance(value, cst.Call):
|
|
21
|
+
func_name = str(value.func.value)
|
|
22
|
+
if "Column" in func_name:
|
|
23
|
+
return "column"
|
|
24
|
+
elif "relationship" in func_name:
|
|
25
|
+
return "relationship"
|
|
26
|
+
return "other"
|
|
27
|
+
|
|
28
|
+
def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef:
|
|
29
|
+
"""Update Class Definition"""
|
|
30
|
+
# Group statements by type
|
|
31
|
+
body = []
|
|
32
|
+
current_group = []
|
|
33
|
+
last_type = None
|
|
34
|
+
|
|
35
|
+
for stmt in updated_node.body.body:
|
|
36
|
+
curr_type = self._get_statement_type(stmt)
|
|
37
|
+
|
|
38
|
+
if last_type is not None and curr_type != last_type:
|
|
39
|
+
body.extend(current_group)
|
|
40
|
+
body.append(cst.EmptyLine())
|
|
41
|
+
current_group = []
|
|
42
|
+
|
|
43
|
+
current_group.append(stmt)
|
|
44
|
+
last_type = curr_type
|
|
45
|
+
|
|
46
|
+
if current_group:
|
|
47
|
+
body.extend(current_group)
|
|
48
|
+
|
|
49
|
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=body))
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def format(cls, tree):
|
|
53
|
+
"""Convert back to source, use black to format"""
|
|
54
|
+
source = format_str(
|
|
55
|
+
astor.to_source(tree),
|
|
56
|
+
mode=FileMode(line_length=120, magic_trailing_comma=True),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Parse with LibCST for formatting
|
|
60
|
+
module = cst.parse_module(source)
|
|
61
|
+
module = module.visit(cls())
|
|
62
|
+
return module
|