simstack 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.
- simstack/__init__.py +0 -0
- simstack/_version.py +34 -0
- simstack/core/__init__.py +0 -0
- simstack/core/artifacts.py +447 -0
- simstack/core/asnyc_helper.py +14 -0
- simstack/core/config_file.py +60 -0
- simstack/core/context.py +167 -0
- simstack/core/data.py +139 -0
- simstack/core/definitions.py +21 -0
- simstack/core/engine.py +116 -0
- simstack/core/find_simstack_modules.py +43 -0
- simstack/core/hash.py +177 -0
- simstack/core/model_table.py +199 -0
- simstack/core/node.py +911 -0
- simstack/core/node_runner.py +254 -0
- simstack/core/node_table.py +380 -0
- simstack/core/recompute_artifacts.py +120 -0
- simstack/core/reset_db.py +12 -0
- simstack/core/resources.py +101 -0
- simstack/core/run_node.py +69 -0
- simstack/core/runner.py +435 -0
- simstack/core/simstack_result.py +41 -0
- simstack/core/submit_node.py +143 -0
- simstack/core/task_id.py +22 -0
- simstack/data/__init__.py +0 -0
- simstack/methods/__init__.py +0 -0
- simstack/methods/get_file.py +10 -0
- simstack/methods/multiple_executor.py +307 -0
- simstack/models/__init__.py +37 -0
- simstack/models/array_list.py +48 -0
- simstack/models/array_storage.py +54 -0
- simstack/models/artifact_models.py +127 -0
- simstack/models/base_types.py +208 -0
- simstack/models/charts_artifact.py +411 -0
- simstack/models/dataset.py +432 -0
- simstack/models/dataset_metadata.py +303 -0
- simstack/models/file_instance.py +77 -0
- simstack/models/file_list.py +321 -0
- simstack/models/files.py +229 -0
- simstack/models/log_entry_model.py +37 -0
- simstack/models/models.py +40 -0
- simstack/models/node_registry.py +105 -0
- simstack/models/pandas_model.py +210 -0
- simstack/models/parameters.py +388 -0
- simstack/models/pickle_models.py +311 -0
- simstack/models/runner_model.py +39 -0
- simstack/models/simple_table.py +32 -0
- simstack/models/simstack_model.py +97 -0
- simstack/models/slurm_info.py +17 -0
- simstack/models/table_artifact.py +87 -0
- simstack/util/__init__.py +0 -0
- simstack/util/async_zip_utils.py +18 -0
- simstack/util/cleaned_json_schema.py +20 -0
- simstack/util/config_reader.py +273 -0
- simstack/util/custom_model_dump.py +75 -0
- simstack/util/db.py +457 -0
- simstack/util/db_logger.py +117 -0
- simstack/util/default_from_dict.py +57 -0
- simstack/util/directory_iterator.py +216 -0
- simstack/util/file_hashing.py +68 -0
- simstack/util/files.py +396 -0
- simstack/util/find_python_files.py +33 -0
- simstack/util/generate_ui_schema.py +80 -0
- simstack/util/geometry_utils.py +118 -0
- simstack/util/get_module_path.py +26 -0
- simstack/util/git_pull.py +65 -0
- simstack/util/git_repository_status.py +93 -0
- simstack/util/import_module.py +65 -0
- simstack/util/importer.py +163 -0
- simstack/util/make_graph.py +107 -0
- simstack/util/make_table.py +827 -0
- simstack/util/minimal_route_finder.py +72 -0
- simstack/util/mock_db.py +144 -0
- simstack/util/module_path_checker.py +38 -0
- simstack/util/path_manager.py +234 -0
- simstack/util/project_root_finder.py +36 -0
- simstack/util/queue_watcher.py +262 -0
- simstack/util/route_finder.py +74 -0
- simstack/util/runner_util.py +162 -0
- simstack/util/safe_code_executor.py +194 -0
- simstack/util/sample_config.py +98 -0
- simstack/util/sensitive_content_filter.py +48 -0
- simstack/util/setup_logging.py +49 -0
- simstack/util/submit_to_watchdog.py +106 -0
- simstack/util/ui_tools.py +213 -0
- simstack-0.2.0.dist-info/METADATA +361 -0
- simstack-0.2.0.dist-info/RECORD +91 -0
- simstack-0.2.0.dist-info/WHEEL +5 -0
- simstack-0.2.0.dist-info/entry_points.txt +10 -0
- simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- simstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def find_python_files(directory: str) -> List[str]:
|
|
6
|
+
"""
|
|
7
|
+
Returns full paths of all .py files in the given directory and its subdirectories,
|
|
8
|
+
excluding __init__.py files.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
directory: The root directory to search from
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
List of absolute file paths to all .py files that aren't __init__.py
|
|
15
|
+
"""
|
|
16
|
+
python_files = []
|
|
17
|
+
|
|
18
|
+
# Ensure the directory exists
|
|
19
|
+
if not os.path.isdir(directory):
|
|
20
|
+
raise ValueError(f"'{directory}' is not a valid directory")
|
|
21
|
+
|
|
22
|
+
# Convert to absolute path
|
|
23
|
+
directory = os.path.abspath(directory)
|
|
24
|
+
|
|
25
|
+
# Walk through directory structure
|
|
26
|
+
for root, dirs, files in os.walk(directory):
|
|
27
|
+
for file in files:
|
|
28
|
+
# Check if it's a Python file but not __init__.py
|
|
29
|
+
if file.endswith(".py") and file != "__init__.py":
|
|
30
|
+
full_path = os.path.join(root, file)
|
|
31
|
+
python_files.append(full_path)
|
|
32
|
+
|
|
33
|
+
return python_files
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def generate_ui_schema(cls):
|
|
5
|
+
"""
|
|
6
|
+
Generates a UI schema that uses GenericForm for fields with ui_schema function.
|
|
7
|
+
Also preserves any existing UI schema configurations from the class.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
cls: The model class to generate UI schema for
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
dict: The generated UI schema
|
|
14
|
+
"""
|
|
15
|
+
# Get base UI schema from the class if it exists
|
|
16
|
+
|
|
17
|
+
base_schema = cls.ui_base_schema() if hasattr(cls, "ui_base_schema") else {}
|
|
18
|
+
|
|
19
|
+
# Process each field in the model
|
|
20
|
+
for field_name, field in cls.model_fields.items():
|
|
21
|
+
field_type = field.annotation
|
|
22
|
+
|
|
23
|
+
# Unwrap Optional type to get the underlying type
|
|
24
|
+
if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
|
|
25
|
+
# Optional[X] is implemented as Union[X, None]
|
|
26
|
+
args = field_type.__args__
|
|
27
|
+
if len(args) == 2 and args[1] is type(None):
|
|
28
|
+
field_type = args[0]
|
|
29
|
+
|
|
30
|
+
# Handle Lists by checking their inner type
|
|
31
|
+
is_list = hasattr(field_type, "__origin__") and field_type.__origin__ is list
|
|
32
|
+
if is_list:
|
|
33
|
+
field_type = field_type.__args__[0]
|
|
34
|
+
|
|
35
|
+
# Check for Optional inside List
|
|
36
|
+
if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
|
|
37
|
+
args = field_type.__args__
|
|
38
|
+
if len(args) == 2 and args[1] is type(None):
|
|
39
|
+
field_type = args[0]
|
|
40
|
+
|
|
41
|
+
# Check if field type has ui_schema method (this is true for all simstack_model classes)
|
|
42
|
+
if hasattr(field_type, "ui_schema"):
|
|
43
|
+
module_path = field_type.__module__
|
|
44
|
+
full_path = f"{module_path}.{field_type.__name__}"
|
|
45
|
+
# if simstack is in the module path, skip all elements after the last simstack occurrence
|
|
46
|
+
if "simstack" in module_path:
|
|
47
|
+
full_path = ".".join(
|
|
48
|
+
module_path[module_path.rfind("simstack") :].split(".")
|
|
49
|
+
+ [field_type.__name__]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
field_schema = field_type.ui_schema() # get the field schema for the field
|
|
53
|
+
# Don't override if there's already a UI configuration for this field
|
|
54
|
+
if field_name not in base_schema:
|
|
55
|
+
if "ui:field" in field_schema:
|
|
56
|
+
# If the field schema already has a ui:field, use it
|
|
57
|
+
if is_list:
|
|
58
|
+
base_schema[field_name] = {"items": field_schema}
|
|
59
|
+
else:
|
|
60
|
+
base_schema[field_name] = field_schema
|
|
61
|
+
else:
|
|
62
|
+
# Check if field type is a list
|
|
63
|
+
ui_ref = {
|
|
64
|
+
"ui:field": "GenericFormField",
|
|
65
|
+
"ui:options": {
|
|
66
|
+
"model": full_path,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
# in rjsf the ui_scheme for list element must be in "items" key
|
|
70
|
+
if is_list:
|
|
71
|
+
base_schema[field_name] = {"items": ui_ref}
|
|
72
|
+
else:
|
|
73
|
+
ui_ref["ui:options"]["accordion"] = "true"
|
|
74
|
+
base_schema[field_name] = ui_ref
|
|
75
|
+
|
|
76
|
+
# Always hide the id field if it exists
|
|
77
|
+
if "id" not in base_schema:
|
|
78
|
+
base_schema["id"] = {"ui:widget": "hidden"}
|
|
79
|
+
|
|
80
|
+
return base_schema
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _normalize_vector(v: List[float]) -> List[float]:
|
|
6
|
+
"""Normalize a vector to unit length."""
|
|
7
|
+
norm = math.sqrt(sum(x * x for x in v))
|
|
8
|
+
if norm < 1e-10:
|
|
9
|
+
return [1.0, 0.0, 0.0] # Default direction for zero vector
|
|
10
|
+
return [x / norm for x in v]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cross_product(a: List[float], b: List[float]) -> List[float]:
|
|
14
|
+
"""Calculate cross product of two 3D vectors."""
|
|
15
|
+
return [
|
|
16
|
+
a[1] * b[2] - a[2] * b[1],
|
|
17
|
+
a[2] * b[0] - a[0] * b[2],
|
|
18
|
+
a[0] * b[1] - a[1] * b[0],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_perpendicular_direction(v1: List[float]) -> List[float]:
|
|
23
|
+
"""Get a direction perpendicular to v1."""
|
|
24
|
+
# Find the component with smallest absolute value
|
|
25
|
+
min_idx = min(range(3), key=lambda i: abs(v1[i]))
|
|
26
|
+
|
|
27
|
+
# Create perpendicular vector
|
|
28
|
+
v2 = [0.0, 0.0, 0.0]
|
|
29
|
+
v2[min_idx] = 1.0
|
|
30
|
+
|
|
31
|
+
# Make it perpendicular using Gram-Schmidt
|
|
32
|
+
dot_product = sum(v1[i] * v2[i] for i in range(3))
|
|
33
|
+
for i in range(3):
|
|
34
|
+
v2[i] -= dot_product * v1[i]
|
|
35
|
+
|
|
36
|
+
return v2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_principal_direction(matrix: List[List[float]]) -> List[float]:
|
|
40
|
+
"""Get the principal direction (largest eigenvalue direction) using power iteration."""
|
|
41
|
+
# Start with random vector
|
|
42
|
+
v = [1.0, 1.0, 1.0]
|
|
43
|
+
|
|
44
|
+
# Power iteration
|
|
45
|
+
for _ in range(10): # Usually converges quickly
|
|
46
|
+
# v = matrix * v
|
|
47
|
+
new_v = [0.0, 0.0, 0.0]
|
|
48
|
+
for i in range(3):
|
|
49
|
+
for j in range(3):
|
|
50
|
+
new_v[i] += matrix[i][j] * v[j]
|
|
51
|
+
|
|
52
|
+
# Normalize
|
|
53
|
+
norm = math.sqrt(sum(x * x for x in new_v))
|
|
54
|
+
if norm > 1e-10:
|
|
55
|
+
v = [x / norm for x in new_v]
|
|
56
|
+
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_eigenvectors_3x3(matrix: List[List[float]]) -> List[List[float]]:
|
|
61
|
+
"""
|
|
62
|
+
Get eigenvectors of a 3x3 matrix using simplified approach.
|
|
63
|
+
For the inertia tensor, we'll use a simplified canonical form.
|
|
64
|
+
"""
|
|
65
|
+
# For molecular hashing, we can use a simplified approach
|
|
66
|
+
# that still provides rotation invariance
|
|
67
|
+
|
|
68
|
+
# Create an orthonormal basis from the inertia tensor
|
|
69
|
+
# Start with the direction of maximum variance
|
|
70
|
+
v1 = _get_principal_direction(matrix)
|
|
71
|
+
|
|
72
|
+
# Get a perpendicular direction
|
|
73
|
+
v2 = _get_perpendicular_direction(v1)
|
|
74
|
+
|
|
75
|
+
# Third direction is cross product
|
|
76
|
+
v3 = _cross_product(v1, v2)
|
|
77
|
+
|
|
78
|
+
# Normalize all vectors
|
|
79
|
+
v1 = _normalize_vector(v1)
|
|
80
|
+
v2 = _normalize_vector(v2)
|
|
81
|
+
v3 = _normalize_vector(v3)
|
|
82
|
+
|
|
83
|
+
# Return as columns of transformation matrix
|
|
84
|
+
return [[v1[0], v2[0], v3[0]], [v1[1], v2[1], v3[1]], [v1[2], v2[2], v3[2]]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_canonical_orientation(coords: List[List[float]]) -> List[List[float]]:
|
|
88
|
+
"""
|
|
89
|
+
Get coordinates in a canonical orientation using the inertia tensor.
|
|
90
|
+
|
|
91
|
+
:param coords: Centered coordinates of atoms
|
|
92
|
+
:return: Coordinates in canonical orientation
|
|
93
|
+
"""
|
|
94
|
+
if len(coords) < 2:
|
|
95
|
+
return coords
|
|
96
|
+
|
|
97
|
+
# Calculate inertia tensor (3x3 matrix)
|
|
98
|
+
inertia_tensor = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]
|
|
99
|
+
|
|
100
|
+
for pos in coords:
|
|
101
|
+
# I_ij = sum(r_k * r_l) where k,l are coordinate indices
|
|
102
|
+
for i in range(3):
|
|
103
|
+
for j in range(3):
|
|
104
|
+
inertia_tensor[i][j] += pos[i] * pos[j]
|
|
105
|
+
|
|
106
|
+
# Get eigenvalues and eigenvectors using power iteration method
|
|
107
|
+
eigenvectors = _get_eigenvectors_3x3(inertia_tensor)
|
|
108
|
+
|
|
109
|
+
# Transform coordinates to canonical orientation
|
|
110
|
+
canonical_coords = []
|
|
111
|
+
for coord in coords:
|
|
112
|
+
new_coord = [0.0, 0.0, 0.0]
|
|
113
|
+
for i in range(3):
|
|
114
|
+
for j in range(3):
|
|
115
|
+
new_coord[i] += coord[j] * eigenvectors[j][i]
|
|
116
|
+
canonical_coords.append(new_coord)
|
|
117
|
+
|
|
118
|
+
return canonical_coords
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from simstack.util.project_root_finder import find_project_root
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Get the module name from the current file path
|
|
7
|
+
def get_module_path(file_path: str):
|
|
8
|
+
# Get the absolute path of the current file
|
|
9
|
+
file_path = os.path.abspath(file_path)
|
|
10
|
+
|
|
11
|
+
# # Get all directories in the Python path
|
|
12
|
+
# for path in sys.path:
|
|
13
|
+
# if path and file_path.startswith(path):
|
|
14
|
+
# # Remove the path prefix and the .py extension
|
|
15
|
+
# relative_path = file_path[len(path):].lstrip(os.sep)
|
|
16
|
+
# module_path = os.path.splitext(relative_path)[0].replace(os.sep, '.')
|
|
17
|
+
# return module_path
|
|
18
|
+
|
|
19
|
+
# If not found in sys.path, use an alternative approach
|
|
20
|
+
project_root = find_project_root() # Assumes running from project root
|
|
21
|
+
if file_path.startswith(project_root):
|
|
22
|
+
relative_path = file_path[len(project_root) :].lstrip(os.sep)
|
|
23
|
+
module_path = os.path.splitext(relative_path)[0].replace(os.sep, ".")
|
|
24
|
+
return module_path
|
|
25
|
+
|
|
26
|
+
return None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("git_pull")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def git_pull_periodically(repo_path, interval_minutes=1, log_file=None):
|
|
11
|
+
"""
|
|
12
|
+
Performs git pull in the specified repository at regular intervals.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
repo_path (str): Path to the Git repository
|
|
16
|
+
interval_minutes (int): Time interval between pulls in minutes
|
|
17
|
+
log_file (str, optional): Path to log file. If None, logs to console.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
None
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Convert minutes to seconds
|
|
24
|
+
interval_seconds = interval_minutes * 60
|
|
25
|
+
logger.info("git_pull " + repo_path)
|
|
26
|
+
# Check if the path is a valid git repository
|
|
27
|
+
if not os.path.exists(os.path.join(repo_path, ".git")):
|
|
28
|
+
logging.error(f"The directory {repo_path} is not a valid Git repository.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
logging.info(
|
|
32
|
+
f"Starting periodic git pull every {interval_minutes} minutes for {repo_path}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
# Change to the repository directory
|
|
39
|
+
os.chdir(repo_path)
|
|
40
|
+
|
|
41
|
+
# Log the start time
|
|
42
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
43
|
+
logging.info(f"Executing git pull at {now}")
|
|
44
|
+
|
|
45
|
+
# Execute git pull
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git", "pull"], capture_output=True, text=True, check=True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Log the output
|
|
51
|
+
logging.info(f"Git pull output: {result.stdout.strip()}")
|
|
52
|
+
if result.stderr:
|
|
53
|
+
logging.warning(f"Git pull stderr: {result.stderr.strip()}")
|
|
54
|
+
|
|
55
|
+
except subprocess.CalledProcessError as e:
|
|
56
|
+
logging.error(f"Git pull failed: {e.stderr.strip()}")
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logging.error(f"Error during git pull: {str(e)}")
|
|
59
|
+
|
|
60
|
+
# Wait for the next interval
|
|
61
|
+
logging.info(f"Waiting {interval_minutes} minutes until next pull...")
|
|
62
|
+
time.sleep(interval_seconds)
|
|
63
|
+
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
logging.info("Git pull process stopped by user.")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_git_status(repo_path: Path) -> Dict[str, Optional[object]]:
|
|
8
|
+
"""
|
|
9
|
+
Get the current Git short commit hash and whether the branch is up to date with its upstream.
|
|
10
|
+
|
|
11
|
+
Returns a dict:
|
|
12
|
+
{
|
|
13
|
+
"short_hash": Optional[str], # e.g., "a1b2c3d", None if not a git repo
|
|
14
|
+
"branch": Optional[str], # current branch name or "HEAD" if detached, None if not a repo
|
|
15
|
+
"up_to_date": Optional[bool], # True/False if upstream exists, None if unknown/no upstream
|
|
16
|
+
"ahead": Optional[int], # commits ahead of upstream (0 if up to date), None if unknown
|
|
17
|
+
"behind": Optional[int], # commits behind upstream (0 if up to date), None if unknown
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def run_git(args, check=False):
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
["git", "-C", str(repo_path), *args],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=check,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
result: Dict[str, Optional[object]] = {
|
|
30
|
+
"short_hash": None,
|
|
31
|
+
"branch": None,
|
|
32
|
+
"up_to_date": None,
|
|
33
|
+
"ahead": None,
|
|
34
|
+
"behind": None,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Verify we're in a git repository
|
|
38
|
+
try:
|
|
39
|
+
inside = run_git(["rev-parse", "--is-inside-work-tree"])
|
|
40
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
41
|
+
return result # Not a repository
|
|
42
|
+
except Exception:
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
# Get short hash
|
|
46
|
+
try:
|
|
47
|
+
hash_res = run_git(["rev-parse", "--short", "HEAD"], check=True)
|
|
48
|
+
result["short_hash"] = hash_res.stdout.strip()
|
|
49
|
+
except subprocess.CalledProcessError:
|
|
50
|
+
# Detached or other state where HEAD isn't resolvable
|
|
51
|
+
result["short_hash"] = None
|
|
52
|
+
|
|
53
|
+
# Get branch (or "HEAD" if detached)
|
|
54
|
+
try:
|
|
55
|
+
branch_res = run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=True)
|
|
56
|
+
result["branch"] = branch_res.stdout.strip()
|
|
57
|
+
except subprocess.CalledProcessError:
|
|
58
|
+
result["branch"] = None
|
|
59
|
+
|
|
60
|
+
# Find upstream; if none, we can't determine up-to-date status against a remote
|
|
61
|
+
try:
|
|
62
|
+
upstream_res = run_git(
|
|
63
|
+
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
|
|
64
|
+
)
|
|
65
|
+
if upstream_res.returncode != 0:
|
|
66
|
+
# No upstream configured
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
upstream_ref = upstream_res.stdout.strip()
|
|
70
|
+
|
|
71
|
+
# Ensure we compare against the latest remote state
|
|
72
|
+
run_git(["fetch", "--quiet"])
|
|
73
|
+
|
|
74
|
+
# Compute ahead/behind relative to upstream
|
|
75
|
+
# Format: "<behind> <ahead>" because left is upstream, right is HEAD
|
|
76
|
+
counts_res = run_git(
|
|
77
|
+
["rev-list", "--left-right", "--count", f"{upstream_ref}...HEAD"],
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
behind_str, ahead_str = counts_res.stdout.strip().split()
|
|
81
|
+
behind, ahead = int(behind_str), int(ahead_str)
|
|
82
|
+
|
|
83
|
+
result["behind"] = behind
|
|
84
|
+
result["ahead"] = ahead
|
|
85
|
+
result["up_to_date"] = behind == 0 and ahead == 0
|
|
86
|
+
except subprocess.CalledProcessError:
|
|
87
|
+
# If comparison fails, leave up_to_date/ahead/behind as None
|
|
88
|
+
pass
|
|
89
|
+
except ValueError:
|
|
90
|
+
# Unexpected parsing format
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return result
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from simstack.util.project_root_finder import find_project_root
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("import_module_from_file")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def import_module_from_file(file_path: Path):
|
|
13
|
+
"""
|
|
14
|
+
Import a Python file as a module.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
file_path: Path object pointing to the Python file to import
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Imported module or None if import failed
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
logger.debug(f"Attempting to import module from: {file_path}")
|
|
24
|
+
if not file_path.exists():
|
|
25
|
+
print(f"File not found: {file_path}")
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
root_dir = find_project_root()
|
|
29
|
+
relative_path = file_path.relative_to(root_dir)
|
|
30
|
+
directory, filename = os.path.split(str(relative_path))
|
|
31
|
+
basename = filename.split(".")[0]
|
|
32
|
+
|
|
33
|
+
module_name = ".".join(relative_path.parts[:-1]) + "." + basename
|
|
34
|
+
|
|
35
|
+
if root_dir not in sys.path:
|
|
36
|
+
sys.path.insert(0, root_dir)
|
|
37
|
+
logger.debug(f"Added {root_dir} to sys.path")
|
|
38
|
+
# Try simple import first
|
|
39
|
+
try:
|
|
40
|
+
module = importlib.import_module(module_name)
|
|
41
|
+
return module
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
logger.error(f"Direct import failed: {e}")
|
|
44
|
+
|
|
45
|
+
# Fall back to spec-based import
|
|
46
|
+
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
|
47
|
+
if spec is None or spec.loader is None:
|
|
48
|
+
print(f"Failed to create spec for {file_path}")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
sys.modules[module_name] = module
|
|
53
|
+
try:
|
|
54
|
+
spec.loader.exec_module(module)
|
|
55
|
+
return module
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Error processing module: {file_path} {e}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error(f"Error importing module from {file_path}: {e}")
|
|
62
|
+
import traceback
|
|
63
|
+
|
|
64
|
+
traceback.print_exc()
|
|
65
|
+
return None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable, Optional, Type
|
|
4
|
+
|
|
5
|
+
from odmantic import Model, AIOEngine, ObjectId
|
|
6
|
+
|
|
7
|
+
from simstack.core.context import context
|
|
8
|
+
from simstack.core.engine import current_engine_context
|
|
9
|
+
from simstack.models.models import ModelMapping, NodeModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("importer")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def function_from_model(model, task_id: ObjectId) -> Optional[Callable]:
|
|
15
|
+
function_path = model.function_mapping
|
|
16
|
+
module_path, function_name = function_path.rsplit(".", 1)
|
|
17
|
+
|
|
18
|
+
# if model.pickle_function is not None:
|
|
19
|
+
# logger.info(f"task_id: {task_id} found pickle_function for {function_path}")
|
|
20
|
+
# # The pickle_function is a reference to the FunctionPickle
|
|
21
|
+
# function_pickle = model.pickle_function
|
|
22
|
+
# logger.info(f"task_id: {task_id} loading function {function_path} from database")
|
|
23
|
+
# # Load the function from the FunctionPickle
|
|
24
|
+
# func = function_pickle.load_function()
|
|
25
|
+
# logger.info(f"task_id: {task_id} Signature: {inspect.signature(func)} _inner: {hasattr(func, "_inner")}")
|
|
26
|
+
#
|
|
27
|
+
# # Safe source code retrieval for pickled functions
|
|
28
|
+
# try:
|
|
29
|
+
# source_code = inspect.getsource(func)
|
|
30
|
+
# logger.info(f"Source: {source_code}")
|
|
31
|
+
# except (OSError, TypeError):
|
|
32
|
+
# logger.info("Source code not available (function loaded from pickle)")
|
|
33
|
+
#
|
|
34
|
+
# return func
|
|
35
|
+
# else:
|
|
36
|
+
|
|
37
|
+
logger.info(
|
|
38
|
+
f"task_id: {task_id} loading function {function_path} using regular import"
|
|
39
|
+
)
|
|
40
|
+
# Import the module
|
|
41
|
+
module = importlib.import_module(module_path)
|
|
42
|
+
# Get the function from the module
|
|
43
|
+
function = getattr(module, function_name)
|
|
44
|
+
return function
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def import_function(
|
|
48
|
+
function_path: str, task_id: ObjectId = None
|
|
49
|
+
) -> Optional[Callable]:
|
|
50
|
+
"""
|
|
51
|
+
Dynamically import a function from a module using its full path.
|
|
52
|
+
load the function information using NodeModel
|
|
53
|
+
load the pickled version if it exists
|
|
54
|
+
if there is no pickled version, use regular import.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
function_path: Dot notation path to the function (e.g. 'methods.submodule.function_name')
|
|
58
|
+
task_id: Optional task Id
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The imported function object or None if import fails
|
|
62
|
+
"""
|
|
63
|
+
engine = current_engine_context.get()
|
|
64
|
+
|
|
65
|
+
node_model = await engine.find_one(
|
|
66
|
+
NodeModel, NodeModel.function_mapping == function_path
|
|
67
|
+
)
|
|
68
|
+
if node_model is None:
|
|
69
|
+
raise LookupError(
|
|
70
|
+
f"task_id: {task_id} Function {function_path} not found in the NodeModel Table"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return await function_from_model(node_model, task_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def import_function_by_name(
|
|
77
|
+
function_name: str, task_id: ObjectId, engine: AIOEngine = None
|
|
78
|
+
) -> Optional[Callable]:
|
|
79
|
+
if not engine:
|
|
80
|
+
engine = context.db.engine
|
|
81
|
+
|
|
82
|
+
node_model = await engine.find_one(NodeModel, NodeModel.name == function_name)
|
|
83
|
+
if node_model is None:
|
|
84
|
+
logger.error(f"Could not find function mapping for name: {function_name}")
|
|
85
|
+
raise ValueError(f"Could not find function mapping for name: {function_name}")
|
|
86
|
+
|
|
87
|
+
return await function_from_model(node_model, task_id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def import_class(class_path: str) -> Type[Model] | None:
|
|
91
|
+
"""
|
|
92
|
+
Dynamically import a class from a module using its full path.
|
|
93
|
+
First tries to load the class from the database using ModelMapping
|
|
94
|
+
|
|
95
|
+
A pickled version of the class is used primarily
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
class_path: Dot notation path to the class (e.g. 'models.submodule.ClassName')
|
|
100
|
+
:param class_path:
|
|
101
|
+
Returns:
|
|
102
|
+
The imported class object or None if import fails
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
engine = current_engine_context.get()
|
|
107
|
+
# Split the path into module path and class name
|
|
108
|
+
module_path, class_name = class_path.rsplit(".", 1)
|
|
109
|
+
model_mapping = await engine.find_one(
|
|
110
|
+
ModelMapping, ModelMapping.name == class_name
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# If not found by name, try by mapping
|
|
114
|
+
if not model_mapping:
|
|
115
|
+
model_mapping = await engine.find_one(
|
|
116
|
+
ModelMapping, ModelMapping.mapping == class_path
|
|
117
|
+
)
|
|
118
|
+
else: # when searching by name the path may have changed
|
|
119
|
+
module_path, class_name = model_mapping.mapping.rsplit(".", 1)
|
|
120
|
+
|
|
121
|
+
if not model_mapping:
|
|
122
|
+
logger.error(f"Error finding ModelMapping for {class_name}")
|
|
123
|
+
raise LookupError(f"Error finding ModelMapping for {class_name}")
|
|
124
|
+
|
|
125
|
+
# TODO where do picke classes come from?
|
|
126
|
+
# If we found a ModelMapping with a pickle_class reference, try to load from the database
|
|
127
|
+
# if model_mapping.pickle_class:
|
|
128
|
+
# logger.info(f"Found ModelMapping for {class_name} with pickle_class")
|
|
129
|
+
# try:
|
|
130
|
+
# # The pickle_class is a reference to the ClassPickle
|
|
131
|
+
# class_pickle = model_mapping.pickle_class
|
|
132
|
+
# if class_pickle:
|
|
133
|
+
# logger.info(f"Loading class {class_name} from database")
|
|
134
|
+
# # Load the class from the ClassPickle
|
|
135
|
+
# pickle_result = cast(Type[Model], class_pickle.load_class())
|
|
136
|
+
# return pickle_result
|
|
137
|
+
# else:
|
|
138
|
+
# logger.warning(f"ClassPickle not found for {class_name}")
|
|
139
|
+
# raise LookupError(f"ClassPickle not found for {class_name}")
|
|
140
|
+
# except Exception as e:
|
|
141
|
+
# logger.error(f"Error loading class {class_name} from database: {e}")
|
|
142
|
+
# raise e
|
|
143
|
+
# else:
|
|
144
|
+
|
|
145
|
+
# Import the module
|
|
146
|
+
module = importlib.import_module(module_path)
|
|
147
|
+
|
|
148
|
+
# Get the class from the module
|
|
149
|
+
return getattr(module, class_name)
|
|
150
|
+
except (ImportError, AttributeError, ValueError) as e:
|
|
151
|
+
logger.error(f"Error importing class {class_path}: {e}")
|
|
152
|
+
raise e
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def import_class_by_name(class_name: str) -> Type[Model]:
|
|
156
|
+
engine = current_engine_context.get()
|
|
157
|
+
model_mapping = await engine.find_one(ModelMapping, ModelMapping.name == class_name)
|
|
158
|
+
|
|
159
|
+
if not model_mapping:
|
|
160
|
+
logger.error(f"Error finding ModelMapping for {class_name}")
|
|
161
|
+
raise LookupError(f"Error finding ModelMapping for {class_name}")
|
|
162
|
+
|
|
163
|
+
return await import_class(model_mapping.mapping)
|