Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions scripts/validate_version_bump.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import re
import subprocess
import sys
from os import popen
from pathlib import Path

from packaging import version
Expand All @@ -19,8 +20,15 @@ def main(base_branch: str):
local_path = Path(__file__).parents[1] / version_file
local_version = extract_version(local_path.read_text())

with popen(f"git fetch origin && git show {base_branch}:{version_file}") as fh:
base_version = extract_version(fh.read())
if not re.fullmatch(r"[A-Za-z0-9._/-]+", base_branch) or base_branch.startswith("-"):
raise ValueError(f"Invalid base branch ref: {base_branch!r}")

subprocess.run(["git", "fetch", "origin"], check=True)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use subprocess instead of popen

base_contents = subprocess.check_output(
["git", "show", f"{base_branch}:{version_file}"],
text=True,
)
base_version = extract_version(base_contents)

if is_version_bump(local_version, base_version):
print(f"Version bump detected: {base_version} -> {local_version}")
Expand Down
2 changes: 1 addition & 1 deletion src/albert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

__all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"]

__version__ = "1.11.2"
__version__ = "1.12.0"
6 changes: 4 additions & 2 deletions src/albert/core/auth/_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ def do_GET(self):
status = "successful" if self.server.token else "failed (no token found)"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set CSP header in response

"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none';",
)
self.end_headers()
self.wfile.write(
f"""
<html>
<body>
<h1>Authentication {status}</h1>
<p>You can close this window now.</p>
<script>window.close()</script>
<button onclick="window.close()">Close Window</button>
</body>
</html>
""".encode()
Expand Down
62 changes: 61 additions & 1 deletion src/albert/utils/property_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

from __future__ import annotations

import ast
import math
import mimetypes
import operator
import re
import uuid
from collections.abc import Callable
Expand Down Expand Up @@ -573,6 +576,63 @@ def get_all_columns_used_in_calculations(*, first_row_data_column: list):
return used_columns


_ALLOWED_BINOPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
}
_ALLOWED_UNARYOPS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
_ALLOWED_FUNCS: dict[str, tuple[Callable[..., float], int]] = {
"log10": (math.log10, 1),
"ln": (math.log, 1),
"sqrt": (math.sqrt, 1),
"pi": (lambda: math.pi, 0),
}
_ALLOWED_NAMES = {"pi": math.pi}


def _safe_eval_math(*, expression: str) -> float:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use custom eval using ast.eval insteaf of eval()

"""Safely evaluate supported math expressions."""
parsed = ast.parse(expression, mode="eval")

def _eval(node: ast.AST) -> float:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int | float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINOPS:
return _ALLOWED_BINOPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_UNARYOPS:
return _ALLOWED_UNARYOPS[type(node.op)](_eval(node.operand))
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name):
raise ValueError("Unsupported function call.")
func_name = node.func.id
if func_name not in _ALLOWED_FUNCS:
raise ValueError("Unsupported function.")
if node.keywords:
raise ValueError("Keyword arguments are not supported.")
func, arity = _ALLOWED_FUNCS[func_name]
if len(node.args) != arity:
raise ValueError("Unsupported function arity.")
if arity == 0:
return func()
return func(_eval(node.args[0]))
if isinstance(node, ast.Name):
if node.id in _ALLOWED_NAMES:
return _ALLOWED_NAMES[node.id]
raise ValueError("Unsupported name.")
raise ValueError("Unsupported expression.")

return _eval(parsed)


def evaluate_calculation(*, calculation: str, column_values: dict) -> float | None:
"""Evaluate a calculation expression against column values."""
calculation = calculation.lstrip("=")
Expand All @@ -589,7 +649,7 @@ def repl(match: re.Match) -> str:
calculation = pattern.sub(repl, calculation)

calculation = calculation.replace("^", "**")
return eval(calculation)
return _safe_eval_math(expression=calculation)
except Exception as e:
logger.info(
"Error evaluating calculation '%s': %s. Likely do not have all values needed.",
Expand Down
153 changes: 152 additions & 1 deletion tests/collections/test_property_data.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from contextlib import suppress

import pytest

from albert import Albert
from albert.exceptions import NotFoundError
from albert.resources.data_columns import DataColumn
from albert.resources.data_templates import DataColumnValue, DataTemplate
from albert.resources.property_data import (
BulkPropertyData,
BulkPropertyDataColumn,
Expand All @@ -10,7 +17,13 @@
TaskPropertyCreate,
TaskPropertyData,
)
from albert.resources.tasks import BaseTask, PropertyTask
from albert.resources.tasks import (
BaseTask,
Block,
PropertyTask,
TaskCategory,
TaskInventoryInformation,
)


def _get_latest_row(task_properties: TaskPropertyData) -> int:
Expand Down Expand Up @@ -221,3 +234,141 @@ def test_add_and_update_property_data_on_inventory(
assert r[0].inventory_id == inv.id
assert r[0].data_columns[0].data_column_id == seeded_data_columns[0].id
assert r[0].data_columns[0].value == "55.5"


def test_task_property_calculation_evaluation(
client: Albert,
seed_prefix: str,
seeded_inventory,
seeded_lots,
seeded_locations,
seeded_projects,
seeded_workflows,
):
calc_task_id = None
calc_dt_id = None
calc_dc_ids = []

try:
dc_one = client.data_columns.create(
data_column=DataColumn(name=f"{seed_prefix} - calc col one")
)
dc_two = client.data_columns.create(
data_column=DataColumn(name=f"{seed_prefix} - calc col two")
)
dc_calc = client.data_columns.create(
data_column=DataColumn(name=f"{seed_prefix} - calc col result")
)
calc_dc_ids = [dc_one.id, dc_two.id, dc_calc.id]

calc_dt = client.data_templates.create(
data_template=DataTemplate(
name=f"{seed_prefix} - calc dt",
description="Integration test template for calculated columns.",
data_column_values=[
DataColumnValue(
data_column=dc_one,
),
DataColumnValue(
data_column=dc_two,
),
],
)
)
calc_dt_id = calc_dt.id
sequence_by_id = {col.data_column_id: col.sequence for col in calc_dt.data_column_values}
seq_one = sequence_by_id[dc_one.id]
seq_two = sequence_by_id[dc_two.id]
calc_dt = client.data_templates.add_data_columns(
data_template_id=calc_dt.id,
data_columns=[
DataColumnValue(
data_column=dc_calc,
calculation=f"={seq_one}+sqrt({seq_two})",
)
],
)
sequence_by_id = {col.data_column_id: col.sequence for col in calc_dt.data_column_values}
seq_calc = sequence_by_id[dc_calc.id]

lot = next(
(l for l in seeded_lots if l.inventory_id == seeded_inventory[0].id),
None,
)
workflow = seeded_workflows[0]
interval_id = (
workflow.interval_combinations[0].interval_id
if workflow.interval_combinations
else "default"
)

calc_task = client.tasks.create(
task=PropertyTask(
name=f"{seed_prefix} - calc task",
category=TaskCategory.PROPERTY,
inventory_information=[
TaskInventoryInformation(
inventory_id=seeded_inventory[0].id,
lot_id=lot.id if lot else None,
)
],
parent_id=seeded_inventory[0].id,
location=seeded_locations[0],
project=seeded_projects[0],
blocks=[
Block(
workflow=[workflow],
data_template=[calc_dt],
)
],
)
)
calc_task_id = calc_task.id
calc_task = client.tasks.get_by_id(id=calc_task_id)
block_id = calc_task.blocks[0].id

result = client.property_data.add_properties_to_task(
task_id=calc_task_id,
inventory_id=seeded_inventory[0].id,
block_id=block_id,
lot_id=lot.id if lot else None,
properties=[
TaskPropertyCreate(
interval_combination=interval_id,
data_template=calc_dt,
data_column=TaskDataColumn(
data_column_id=dc_one.id,
column_sequence=seq_one,
),
value="5",
),
TaskPropertyCreate(
interval_combination=interval_id,
data_template=calc_dt,
data_column=TaskDataColumn(
data_column_id=dc_two.id,
column_sequence=seq_two,
),
value="16",
),
],
return_scope="block",
)

trial = max(
(t for t in result[0].data[0].trials if t.data_columns[0].property_data is not None),
key=lambda t: t.trial_number,
)
calc_column = next(c for c in trial.data_columns if c.sequence == seq_calc)
assert calc_column.property_data is not None
assert float(calc_column.property_data.value) == pytest.approx(9.0)
finally:
if calc_task_id:
with suppress(NotFoundError):
client.tasks.delete(id=calc_task_id)
if calc_dt_id:
with suppress(NotFoundError):
client.data_templates.delete(id=calc_dt_id)
for dc_id in calc_dc_ids:
with suppress(NotFoundError):
client.data_columns.delete(id=dc_id)