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 ADDED
@@ -0,0 +1,4 @@
1
+ """
2
+ Adding this to ensure backwards compatibility with older versions of opensampl where orm import was:
3
+ from opensampl import orm
4
+ """
opensampl/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ The openSAMPL CLI package is a click based command line interface for the openSAMPL package. It provides a way to
3
+ interact with the database and load data into it.
4
+ """
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Dict, List, Union
10
+
11
+ import click
12
+ import yaml
13
+ from dotenv import find_dotenv, load_dotenv
14
+ from loguru import logger
15
+
16
+ from opensampl.constants import ENV_VARS
17
+ from opensampl.db.orm import Base
18
+ from opensampl.helpers.env import set_env
19
+ from opensampl.load_data import create_new_tables, write_to_table
20
+ from opensampl.vendors.constants import VENDOR_MAP, get_vendor_parser
21
+
22
+ BANNER=r"""
23
+
24
+ ____ _ __ __ ____ _
25
+ ___ _ __ ___ _ __ / ___| / \ | \/ | _ \| |
26
+ / _ \| '_ \ / _ \ '_ \\___ \ / _ \ | |\/| | |_) | |
27
+ | (_) | |_) | __/ | | |___) / ___ \| | | | __/| |___
28
+ \___/| .__/ \___|_| |_|____/_/ \_\_| |_|_| |_____|
29
+ |_|
30
+ tools for processing clock data
31
+ """
32
+
33
+ env_file = find_dotenv()
34
+ load_dotenv()
35
+ level = ENV_VARS.LOG_LEVEL.get_value()
36
+ logger.configure(handlers=[{"sink": sys.stderr, "level": level.upper()}])
37
+
38
+ class CaseInsensitiveGroup(click.Group):
39
+ """Defines Click group options as case-insensitive. By default, click groups are case-sensitive."""
40
+
41
+ def get_command(self, ctx, cmd_name):
42
+ """Normalize command name to lower case"""
43
+ cmd_name = cmd_name.lower()
44
+ # Match against lowercased command names
45
+ for name, cmd in self.commands.items():
46
+ if name.lower() == cmd_name:
47
+ return cmd
48
+ return None
49
+
50
+ def get_table_names():
51
+ """Get all table names from the ORM in opensampl.db.orm"""
52
+ return [table.name for table in Base.metadata.sorted_tables]
53
+
54
+
55
+ @click.group()
56
+ def cli():
57
+ """CLI utility for openSAMPL"""
58
+
59
+
60
+ @cli.command(name="init")
61
+ def init():
62
+ """
63
+ Initialize the database.
64
+
65
+ Creates all tables as defined in the opensampl.db.orm file.
66
+ This is not required if you are using `opensampl-server`, as that is done as part of that initialization of the db.
67
+ """
68
+ logger.debug("Initializing database")
69
+ create_new_tables()
70
+
71
+
72
+ @cli.group()
73
+ def config():
74
+ """View and manage environment variables used by openSAMPL"""
75
+
76
+ @config.command()
77
+ def file():
78
+ """Show the path to the env file used by openSAMPL"""
79
+ click.echo(env_file)
80
+
81
+
82
+ @config.command()
83
+ @click.option("--explain", "-e", is_flag=True, help="Include descriptions of the variables")
84
+ @click.option("--var", "-v", help="Specify a single variable to display")
85
+ def show(explain: bool, var: str):
86
+ """
87
+ Display current environment variable configurations.
88
+
89
+ Examples
90
+ --------
91
+ opensampl config show # Show all variables and their values
92
+ opensampl config show --explain # Show all variables with descriptions
93
+ opensampl config show --var BACKEND_URL # Show specific variable
94
+ opensampl config show -e -v BACKEND_URL # Show specific variable with description
95
+
96
+ """
97
+ logger.debug(f"loaded env_file: {env_file}")
98
+ if var:
99
+ # Filter to specific variable if requested
100
+ vars_to_show = [v for v in ENV_VARS.all() if v.name == var]
101
+ if not vars_to_show:
102
+ click.echo(f"Error: Environment variable '{var}' not found", err=True)
103
+ return
104
+ else:
105
+ vars_to_show = ENV_VARS.all()
106
+
107
+ from tabulate import tabulate
108
+
109
+ # Create a list of dictionaries for the DataFrame
110
+ data = []
111
+ for env_var in vars_to_show:
112
+ row = {
113
+ "Variable": env_var.name,
114
+ "Value": str(env_var.get_value()),
115
+ }
116
+ if explain:
117
+ row.update({"Description": env_var.description})
118
+ data.append(row)
119
+ maxcolwidths = [None, None, 40] if explain else [None, None]
120
+ click.echo(tabulate(data, headers="keys", tablefmt="simple", maxcolwidths=maxcolwidths))
121
+
122
+
123
+ @config.command("set")
124
+ @click.argument("name")
125
+ @click.argument("value")
126
+ def config_set(name: str, value: str, temp: bool = None):
127
+ """
128
+ Set the value of an environment variable.
129
+
130
+ Note that this will only work if the variable is set in the .env file, if it is a true environment variable the
131
+ change will not persist.
132
+
133
+ Examples
134
+ --------
135
+ opensampl config set BACKEND_URL http://localhost:8000
136
+
137
+ """
138
+ set_env(name=name, value=value, temp=temp)
139
+
140
+ @cli.group(cls=CaseInsensitiveGroup)
141
+ def load():
142
+ """Load data into database"""
143
+
144
+
145
+ for probe_name, vendor_type in VENDOR_MAP.items():
146
+ load.add_command(get_vendor_parser(probe_name).get_cli_command(), name=probe_name)
147
+
148
+
149
+ def path_or_string(value):
150
+ """Get content from a file or use the string directly"""
151
+ # Get content - either from file or use the string directly
152
+ content = value
153
+ try:
154
+ path = Path(value)
155
+ if path.exists() and path.is_file():
156
+ with open(path, "r") as f:
157
+ content = f.read()
158
+ except Exception:
159
+ # If any error occurs during path handling, treat as raw string
160
+ pass
161
+
162
+ # Try parsing as YAML
163
+ try:
164
+ return yaml.safe_load(content)
165
+ except yaml.YAMLError as yaml_err:
166
+ # If YAML parsing fails, try JSON
167
+ try:
168
+ return json.loads(content)
169
+ except json.JSONDecodeError as json_err:
170
+ # If both parsing attempts fail, raise an error
171
+ raise click.BadParameter(
172
+ f"Could not parse input as YAML or JSON.\n" f"YAML error: {yaml_err}\n" f"JSON error: {json_err}"
173
+ )
174
+
175
+
176
+ @load.command("table")
177
+ @click.option(
178
+ "--if-exists",
179
+ "-i",
180
+ type=click.Choice(["update", "error", "replace", "ignore"]),
181
+ default="update",
182
+ help="How to handle conflicts with existing entries",
183
+ )
184
+ @click.argument("table_name", type=click.Choice(get_table_names()))
185
+ @click.argument("filepath", type=path_or_string)
186
+ def table_load(filepath: Union[Dict, List], table_name: str, if_exists: str):
187
+ r"""
188
+ Load data directly into a database table. Format can be yaml or json. Can be a list of dictionaries or a single
189
+ dictionary.
190
+
191
+ You do not have to specify schema, is assumed to be castdb.
192
+ \n\n
193
+ The --if-exists option controls how to handle conflicts:\n
194
+ - update: Only update fields that are provided and non-default (default)\n
195
+ - error: Raise an error if entry exists\n
196
+ - replace: Replace all non-primary-key fields with new values\n
197
+ - ignore: Skip if entry exists\n
198
+
199
+ Example:\n
200
+ cli.py table load locations data.json\n
201
+ cli.py table load probe_metadata metadata.yaml\n
202
+ """
203
+ try:
204
+ if isinstance(filepath, list):
205
+ for row in filepath:
206
+ write_to_table(table=table_name, data=row, if_exists=if_exists)
207
+ else:
208
+ write_to_table(table_name, filepath, if_exists=if_exists)
209
+ click.echo(f"Successfully wrote data to table {table_name}")
210
+ except Exception as e:
211
+ click.echo(f"Error writing to table: {str(e)}", err=True)
212
+ raise click.Abort()
213
+
214
+
215
+ @cli.command(name="create")
216
+ @click.argument("config_path", type=click.Path(exists=True, path_type=Path))
217
+ @click.option(
218
+ "--update-db",
219
+ "-u",
220
+ is_flag=True,
221
+ help="Update the database with the new probe type",
222
+ )
223
+ def create_probe_command(config_path: Path, update_db: bool):
224
+ """
225
+ ** beta **
226
+ Create a new probe type with scaffolding, based on a config file.
227
+ """
228
+ from opensampl.helpers.create_vendor import VendorConfig
229
+
230
+ vendor_config = VendorConfig.from_config_file(config_path)
231
+ vendor_config.create()
232
+ if update_db:
233
+ create_new_tables()
234
+
235
+
236
+ if __name__ == "__main__":
237
+ cli()
opensampl/constants.py ADDED
@@ -0,0 +1,84 @@
1
+ """Constants for accessing environment configurations"""
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any, Optional, Type
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class EnvVar(BaseModel):
10
+ """Defines an environment variable and its properties"""
11
+
12
+ name: str
13
+ description: str
14
+ type: Type = str
15
+ default: Optional[Any] = None
16
+
17
+ def get_value(self):
18
+ """Get value of var in environment"""
19
+ default = self.resolve_default()
20
+ if self.type is bool:
21
+ return os.getenv(self.name, default).lower() == 'true'
22
+ elif self.type is Path:
23
+ return Path(os.getenv(self.name, default))
24
+ return os.getenv(self.name, default)
25
+
26
+ def resolve_default(self):
27
+ """Resolve default value for env var based on type"""
28
+ if self.type is bool:
29
+ return self.default or 'false'
30
+ if self.type is Path and self.default is not None:
31
+ return Path(self.default).resolve()
32
+ return self.default
33
+
34
+
35
+ class ENV_VARS:
36
+ """Variables referenced by openSAMPL"""
37
+
38
+ ROUTE_TO_BACKEND = EnvVar(
39
+ name="ROUTE_TO_BACKEND",
40
+ description=("Route all database operations through BACKEND_URL rather "
41
+ "than applying directly using DATABASE_URL"),
42
+ type=bool
43
+ )
44
+ BACKEND_URL = EnvVar(
45
+ name="BACKEND_URL",
46
+ description="URL of the backend service when routing is enabled",
47
+ )
48
+ DATABASE_URL = EnvVar(
49
+ name="DATABASE_URL",
50
+ description="URL for direct database connections",
51
+ )
52
+ ARCHIVE_PATH = EnvVar(
53
+ name="ARCHIVE_PATH",
54
+ description="Default path that files are moved to after they have been processed",
55
+ type=Path,
56
+ default="archive",
57
+ )
58
+ LOG_LEVEL = EnvVar(
59
+ name="LOG_LEVEL",
60
+ description="Log level for opensampl cli",
61
+ default="INFO",
62
+ )
63
+ API_KEY = EnvVar(
64
+ name="API_KEY",
65
+ description="Access key for interacting with the backend",
66
+ )
67
+
68
+ @classmethod
69
+ def __iter__(cls):
70
+ """Get all EnvVar objects as iterable"""
71
+ yield from (value for key, value in cls.__dict__.items() if isinstance(value, EnvVar))
72
+
73
+ @classmethod
74
+ def get(cls, name: str):
75
+ """Get EnvVar object by name"""
76
+ var = getattr(cls, name, None)
77
+ if isinstance(var, EnvVar):
78
+ return var.get_value()
79
+ return os.getenv(name)
80
+
81
+ @classmethod
82
+ def all(cls) -> list[EnvVar]:
83
+ """Get all EnvVar objects as list"""
84
+ return [value for key, value in cls.__dict__.items() if isinstance(value, EnvVar)]
@@ -0,0 +1 @@
1
+ """Database files used by openSAMPL"""
@@ -0,0 +1,128 @@
1
+ """ORM for managing access to openSAMPL database and endpoints"""
2
+ import secrets
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import List, Union
6
+
7
+ from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
8
+ from sqlalchemy.orm import declarative_base, relationship
9
+ from sqlalchemy.orm.exc import NoResultFound
10
+ from sqlalchemy.schema import MetaData
11
+
12
+ Base = declarative_base(metadata=MetaData(schema="access"))
13
+
14
+
15
+ class APIAccessKey(Base):
16
+ """Table for recording and managing API access keys."""
17
+
18
+ __tablename__ = "api_access_keys"
19
+
20
+ id = Column(Integer, primary_key=True, index=True)
21
+ key = Column(String(64), unique=True, nullable=False, index=True)
22
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
23
+ expires_at = Column(DateTime, nullable=True)
24
+
25
+ def generate_key(self):
26
+ """Generate the API access key."""
27
+ self.key = secrets.token_urlsafe(48)
28
+ return self.key
29
+
30
+ def is_expired(self):
31
+ """Check if API access key is expired."""
32
+ return self.expires_at is not None and datetime.utcnow() > self.expires_at
33
+
34
+
35
+ class Views(Base):
36
+ """Table for recording and managing views."""
37
+
38
+ __tablename__ = "views"
39
+ view_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
40
+ name = Column(Text)
41
+
42
+ @staticmethod
43
+ def get_view_by_name(session, name):
44
+ """Get view by name"""
45
+ try:
46
+ return session.query(Views).filter_by(name=name).one()
47
+ except NoResultFound:
48
+ print(f"View with name {name} not found")
49
+ return None
50
+
51
+
52
+ class Roles(Base):
53
+ """Table for recording and managing roles."""
54
+
55
+ __tablename__ = "roles"
56
+ role_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
57
+ name = Column(Text)
58
+ view_id = Column(Text, ForeignKey("views.view_id"))
59
+
60
+ @staticmethod
61
+ def get_role_by_name(session, name):
62
+ """Get role by name"""
63
+ try:
64
+ return session.query(Roles).filter_by(name=name).one()
65
+ except NoResultFound:
66
+ print(f"Role with name {name} not found")
67
+ return None
68
+
69
+
70
+ class Users(Base):
71
+ """Table for recording and managing users."""
72
+
73
+ __tablename__ = "users"
74
+ user_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
75
+ email = Column(Text)
76
+
77
+ @staticmethod
78
+ def get_user_by_email(session, email):
79
+ """Get user by email"""
80
+ try:
81
+ return session.query(Users).filter_by(email=email).one()
82
+ except NoResultFound:
83
+ print(f"User with email {email} not found")
84
+ return None
85
+
86
+
87
+ class UserRole(Base):
88
+ """Table for recording and managing user roles."""
89
+
90
+ __tablename__ = "user_role"
91
+ user_id = Column(Text, ForeignKey("users.user_id"), primary_key=True)
92
+ role_id = Column(Text, ForeignKey("roles.role_id"), primary_key=True)
93
+
94
+
95
+ def add_user_role(emails: Union[str, List[str]], role_name: str, session):
96
+ """Add user role to the database."""
97
+ if isinstance(emails, str):
98
+ emails = [emails]
99
+
100
+ role = Roles.get_role_by_name(name=role_name)
101
+ if role is None:
102
+ print(f"Role with name {role_name} not found")
103
+ return
104
+
105
+ for email in emails:
106
+ user = Users.get_user_by_email(email=email)
107
+ if user is None:
108
+ # Create a new user if not found
109
+ user = Users(email=email)
110
+ session.add(user)
111
+ session.flush() # Flush to get the generated user_id
112
+ print(f"New user created with email {email}")
113
+
114
+ # Check if user already has the specified role
115
+ if any(ur.role_id == role.role_id for ur in user.user_role):
116
+ print(f"User with email {email} already has role {role_name}")
117
+ continue
118
+
119
+ # Create a new entry in user_role table
120
+ user_role = UserRole(user_id=user.user_id, role_id=role.role_id)
121
+ session.add(user_role)
122
+ print(f"User with email {email} assigned role {role_name}")
123
+ session.commit()
124
+
125
+
126
+ Views.roles = relationship("Roles")
127
+ Roles.user_role = relationship("UserRole")
128
+ Users.user_role = relationship("UserRole")
opensampl/db/orm.py ADDED
@@ -0,0 +1,198 @@
1
+ """Main openSAMPL database ORM"""
2
+ import uuid
3
+ from datetime import datetime
4
+
5
+ from geoalchemy2 import Geometry, WKTElement
6
+ from geoalchemy2.shape import to_shape
7
+ from loguru import logger
8
+ from sqlalchemy import NUMERIC, TIMESTAMP, Boolean, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
9
+ from sqlalchemy.event import listens_for
10
+ from sqlalchemy.orm import Session, declarative_base, relationship
11
+ from sqlalchemy.schema import MetaData
12
+
13
+ SCHEMA_NAME = "castdb"
14
+
15
+
16
+ class BaseHelpers:
17
+ """Mixin for Base class that adds some helper methods"""
18
+
19
+ def to_dict(self):
20
+ """Convert to dictionary, including changes to make it serializable"""
21
+ def convert_value(value):
22
+ if isinstance(value, datetime):
23
+ return value.isoformat()
24
+ elif hasattr(value, "__geo_interface__"):
25
+ return to_shape(value).__geo_interface__
26
+ return value
27
+
28
+ return {c.name: convert_value(getattr(self, c.name)) for c in self.__table__.columns}
29
+
30
+
31
+ Base = declarative_base(cls=BaseHelpers, metadata=MetaData(schema=SCHEMA_NAME))
32
+
33
+
34
+ class Locations(Base):
35
+ """
36
+ Table for storing locations
37
+
38
+ Automatically parses lat, lon, and z into point
39
+ """
40
+
41
+ __tablename__ = "locations"
42
+
43
+ uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
44
+ name = Column(Text, unique=True, nullable=False)
45
+ geom = Column(Geometry(geometry_type="GEOMETRY", srid=4326))
46
+ public = Column(Boolean, nullable=True)
47
+
48
+ probe_metadata = relationship("ProbeMetadata")
49
+
50
+ def __init__(self, **kwargs):
51
+ """Initialize Location object, including converting lat, lon, and z into point"""
52
+ if "lat" in kwargs and "lon" in kwargs:
53
+ lat = kwargs.pop("lat")
54
+ lon = kwargs.pop("lon")
55
+ z = kwargs.pop("z", None)
56
+ projection = kwargs.pop("projection", 4326)
57
+ if z is not None:
58
+ point_str = f"POINT({lon} {lat} {z})"
59
+ else:
60
+ point_str = f"POINT({lon} {lat})"
61
+ kwargs["geom"] = WKTElement(point_str, srid=projection)
62
+ super().__init__(**kwargs)
63
+
64
+
65
+ class TestMetadata(Base):
66
+ """TestMetadata table for storing name, start and end of tests"""
67
+
68
+ __tablename__ = "test_metadata"
69
+
70
+ uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
71
+ name = Column(Text, unique=True, nullable=False)
72
+ start_date = Column(TIMESTAMP)
73
+ end_date = Column(TIMESTAMP)
74
+
75
+ probe_metadata = relationship("ProbeMetadata")
76
+
77
+
78
+ class ProbeMetadata(Base):
79
+ """
80
+ Stores the basic information about clock probes.
81
+
82
+ A unique probe is identified by its ip address and probe_id.
83
+
84
+ Can be associated with an existing location or test by its name by providing location_name/test_name
85
+ """
86
+
87
+ __tablename__ = "probe_metadata"
88
+
89
+ uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
90
+ probe_id = Column(Text)
91
+ ip_address = Column(Text)
92
+ vendor = Column(Text)
93
+ model = Column(Text)
94
+ name = Column(Text, unique=True)
95
+ public = Column(Boolean, nullable=True)
96
+ location_uuid = Column(String(36), ForeignKey("locations.uuid"))
97
+ test_uuid = Column(String(36), ForeignKey("test_metadata.uuid"))
98
+
99
+ __table_args__ = (UniqueConstraint("probe_id", "ip_address", name="uq_probe_metadata_ipaddress_probeid"),)
100
+
101
+ probe_data = relationship("ProbeData")
102
+ adva_metadata = relationship("AdvaMetadata", back_populates="probe", uselist=False)
103
+
104
+ def __init__(self, **kwargs):
105
+ """Initialize Probe Metadata object, dealing with converting location name into uuid"""
106
+ location_name = kwargs.pop("location_name", None)
107
+ test_name = kwargs.pop("test_name", None)
108
+ super().__init__(**kwargs)
109
+
110
+ if location_name:
111
+ self._location_name = location_name # Store it temporarily until we have a session
112
+ if test_name:
113
+ self._test_name = test_name
114
+
115
+ def resolve_references(self, session=None):
116
+ """
117
+ Resolve the references to location and/or test entries when given just the name, and
118
+ provides the correct foreign uuid key.
119
+
120
+ :param session: sqlalchemy session, used to query for the location/test
121
+ """
122
+ # If no session, attempt to figure it out from the object
123
+ if not session:
124
+ session = Session.object_session(self)
125
+ if not session:
126
+ logger.warning(
127
+ "No session provided and one could not be found. Not resolving references for probe metadata."
128
+ )
129
+ return
130
+
131
+ if hasattr(self, "_location_name"):
132
+ location = session.query(Locations).filter_by(name=self._location_name).first()
133
+ if not location:
134
+ logger.warning(
135
+ f"Could not find location with name {self._location_name}, leaving location reference null."
136
+ )
137
+ self.location_uuid = location.uuid if location else None
138
+ delattr(self, "_location_name") # Clean up after resolving
139
+
140
+ if hasattr(self, "_test_name"):
141
+ test_meta = session.query(TestMetadata).filter_by(name=self._test_name).first()
142
+ if not test_meta:
143
+ logger.warning(
144
+ f"Could not find test metadata with name {self._test_name}, leaving test reference null."
145
+ )
146
+ self.test_uuid = test_meta.uuid if test_meta else None
147
+ delattr(self, "_test_name")
148
+
149
+
150
+ class ProbeData(Base):
151
+ """
152
+ Table for storing actual time data from the probes.
153
+
154
+ Each entry has a reference to the probe's uuid, timestamp for the measurement, and value for the measurement.
155
+ """
156
+
157
+ __tablename__ = "probe_data"
158
+
159
+ time = Column(TIMESTAMP, primary_key=True)
160
+ probe_uuid = Column(String(36), ForeignKey("probe_metadata.uuid"), primary_key=True)
161
+ value = Column(NUMERIC)
162
+
163
+
164
+ class AdvaMetadata(Base):
165
+ """
166
+ ADVA Clock Probe specific metadata
167
+
168
+ This is metadata that is specifically provided by ADVA probes in their text file exports.
169
+ """
170
+
171
+ __tablename__ = "adva_metadata"
172
+
173
+ probe_uuid = Column(String, ForeignKey("probe_metadata.uuid"), primary_key=True)
174
+ type = Column(Text)
175
+ start = Column(TIMESTAMP)
176
+ frequency = Column(Integer)
177
+ timemultiplier = Column(Integer)
178
+ multiplier = Column(Integer)
179
+ title = Column(Text)
180
+ adva_probe = Column(Text)
181
+ adva_reference = Column(Text)
182
+ adva_reference_expected_ql = Column(Text)
183
+ adva_source = Column(Text)
184
+ adva_direction = Column(Text)
185
+ adva_version = Column(Float)
186
+ adva_status = Column(Text)
187
+ adva_mtie_mask = Column(Text)
188
+ adva_mask_margin = Column(Integer)
189
+
190
+ probe = relationship("ProbeMetadata", back_populates="adva_metadata")
191
+
192
+
193
+ @listens_for(ProbeMetadata, "before_insert")
194
+ def resolve_uuid(mapper, connection, target):
195
+ """Resolve the location_uuid and test_uuid entries for a probe before the object is inserted into the database."""
196
+ session = Session.object_session(target)
197
+ if session:
198
+ target.resolve_references(session)
@@ -0,0 +1 @@
1
+ """Helper functions for the openSAMPL package"""