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.
@@ -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()
@@ -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