diff --git a/scripts/validate_version_bump.py b/scripts/validate_version_bump.py index 21b35fad..6a0c1cb4 100644 --- a/scripts/validate_version_bump.py +++ b/scripts/validate_version_bump.py @@ -1,6 +1,7 @@ import argparse +import re +import subprocess import sys -from os import popen from pathlib import Path from packaging import version @@ -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) + 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}") diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 5629ed49..280296be 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.11.2" +__version__ = "1.12.0" diff --git a/src/albert/core/auth/_listener.py b/src/albert/core/auth/_listener.py index 66d45a1b..4a8f8aa7 100644 --- a/src/albert/core/auth/_listener.py +++ b/src/albert/core/auth/_listener.py @@ -31,6 +31,10 @@ 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( + "Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none';", + ) self.end_headers() self.wfile.write( f""" @@ -38,8 +42,6 @@ def do_GET(self):

Authentication {status}

You can close this window now.

- - """.encode() diff --git a/src/albert/utils/property_data.py b/src/albert/utils/property_data.py index 55be04f7..c18cf561 100644 --- a/src/albert/utils/property_data.py +++ b/src/albert/utils/property_data.py @@ -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 @@ -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: + """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("=") @@ -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.", diff --git a/tests/collections/test_property_data.py b/tests/collections/test_property_data.py index 5603bf96..b25b8814 100644 --- a/tests/collections/test_property_data.py +++ b/tests/collections/test_property_data.py @@ -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, @@ -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: @@ -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)