diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 1069b4b..92c8a6d 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -119,3 +119,7 @@ An HTTP client fetches a URL taken from user/external input with no allowlist or ### command-injection subprocess/os.system/os.popen runs a shell with concatenated or formatted user/external input (shell=True, or a single string command). Only flag when the injected value is not a constant/whitelist. + +### pointless-wrapper + +A new or changed function or method only forwards to another callable with the same arguments and return value, adding no conversion, validation, defaulting, error handling, or other logic. Only flag when call sites could invoke the inner callable directly, the wrapper does not implement an interface, protocol, or abstract method, and it is not a public re-export of a private or third-party symbol. diff --git a/.template.yaml b/.template.yaml index b945204..366f1b7 100644 --- a/.template.yaml +++ b/.template.yaml @@ -1,6 +1,6 @@ # Autogenerated - DO NOT EDIT # Parameters of the project as generated from template -_commit: 2555e1a +_commit: fcdbd3c _src_path: gh:kraysent/python-template package_name: uploader project_name: uploader diff --git a/AGENTS.md b/AGENTS.md index 29f8f46..22da2d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,3 +79,7 @@ The only exception to the above rule is for imports from the `typing` or `collec from typing import Any from collections.abc import Sequence ``` + +### File changes + +When moving files as part of a refactor of any kind prefer using `git mv` whenver possible instead of removing + adding. diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 0ba84c3..777affd 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +import astropy.constants as const import astropy.units as u import numpy as np import pytest @@ -124,6 +125,131 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=1.5, result_unit=u.dimensionless_unscaled, ), + EvalCase( + name="tan", + expression="tan(45 * deg)", + columns={}, + result_val=1.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="asin", + expression='asin(col("float_col_dimless"))', + columns=_COLUMNS, + result_val=0.5236, + result_unit=u.rad, + ), + EvalCase( + name="acos", + expression="acos(1)", + columns={}, + result_val=0.0, + result_unit=u.rad, + ), + EvalCase( + name="atan", + expression="atan(1)", + columns={}, + result_val=0.7854, + result_unit=u.rad, + ), + EvalCase( + name="atan2", + expression="atan2(0, 1)", + columns={}, + result_val=0.0, + result_unit=u.rad, + ), + EvalCase( + name="deg2rad", + expression="deg2rad(180)", + columns={}, + result_val=np.pi, + result_unit=u.rad, + ), + EvalCase( + name="rad2deg", + expression="rad2deg(pi)", + columns={}, + result_val=180.0, + result_unit=u.deg, + ), + EvalCase( + name="wrap360", + expression="wrap360(370 * deg)", + columns={}, + result_val=10.0, + result_unit=u.deg, + ), + EvalCase( + name="wrap360_negative", + expression="wrap360(-10 * deg)", + columns={}, + result_val=350.0, + result_unit=u.deg, + ), + EvalCase( + name="exp", + expression="exp(0)", + columns={}, + result_val=1.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="log10", + expression="log10(100)", + columns={}, + result_val=2.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="ln", + expression="ln(1)", + columns={}, + result_val=0.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="pow", + expression="pow(2, 3)", + columns={}, + result_val=8.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="max", + expression="max(2, 3)", + columns={}, + result_val=3.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="min", + expression="min(2, 3)", + columns={}, + result_val=2.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="max_equivalent_units", + expression="max(10 * deg, 1 * rad)", + columns={}, + result_val=57.2958, + result_unit=u.deg, + ), + EvalCase( + name="vector_max", + expression='max(col("vec_col"), 2)', + columns=_COLUMNS, + result_val=[2.0, 2.0, 3.0], + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="error_max_incompatible_units", + expression="max(1 * deg, 1)", + columns={}, + error=True, + ), EvalCase( name="vector_sqrt", expression='sqrt(col("vec_col"))', @@ -138,6 +264,34 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=3.1416, result_unit=u.dimensionless_unscaled, ), + EvalCase( + name="speed_of_light", + expression="c", + columns={}, + result_val=299792458.0, + result_unit=u.m / u.s, + ), + EvalCase( + name="solar_mass", + expression="M_sun", + columns={}, + result_val=const.M_sun.value, + result_unit=u.kg, + ), + EvalCase( + name="parsec", + expression="pc", + columns={}, + result_val=1.0, + result_unit=u.pc, + ), + EvalCase( + name="jansky", + expression="Jy", + columns={}, + result_val=1.0, + result_unit=u.Jy, + ), EvalCase(name="error_missing_column_call", expression='col("missing")', columns={}, error=True), EvalCase(name="error_incompatible_units", expression="arcsec + mag", columns={}, error=True), EvalCase( @@ -260,6 +414,59 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=1.5, result_unit=u.km / u.s, ), + EvalCase( + name="where_true", + expression='where(col("float_col") > 1, col("float_col"), 0)', + columns=_COLUMNS, + result_val=1.5, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_false", + expression='where(col("float_col") > 2, 10, col("float_col"))', + columns=_COLUMNS, + result_val=1.5, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_nested", + expression='where(col("float_col") > 2, 1, where(col("float_col") > 1, 2, 3))', + columns=_COLUMNS, + result_val=2, + ), + EvalCase( + name="where_angle", + expression='where(col("angle_col") > 180 * deg, col("angle_col") - 360 * deg, col("angle_col"))', + columns=_COLUMNS, + result_val=-170.0, + result_unit=u.deg, + ), + EvalCase( + name="where_string", + expression='where(col("string_col_2") == "M", col("string_col_1"), "x")', + columns=_COLUMNS, + result_val="NGC 123", + ), + EvalCase( + name="where_vector", + expression='where(col("vec_col") > 1.5, col("vec_col"), 0)', + columns=_COLUMNS, + result_val=[0.0, 2.0, 3.0], + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_vector_angle", + expression='where(col("vec_angle_col") > 90 * deg, col("vec_angle_col"), 0 * deg)', + columns=_COLUMNS, + result_val=[0.0, 0.0, 180.0], + result_unit=u.deg, + ), + EvalCase( + name="where_vector_string", + expression='where(col("vec_string_a") == "IC", col("vec_string_b"), "x")', + columns=_COLUMNS, + result_val=["x", "456", "x"], + ), EvalCase(name="error_unknown_unit", expression='unit("not_a_unit")', columns={}, error=True), EvalCase(name="error_unit_non_string", expression="unit(1)", columns={}, error=True), ] diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index dd9b717..1d51249 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -12,6 +12,8 @@ ('"M " + col("id")', {"id"}), ("1 + 2", set()), ("pi + deg", set()), + ("M_sun * G / c ** 2", set()), + ("max(1, min(2, 3))", set()), ("", ExpressionSyntaxError), ("1 +", ExpressionSyntaxError), ("col(", ExpressionSyntaxError), @@ -24,6 +26,9 @@ ("e_logd25 + logd25", ExpressionSyntaxError), ("logd25 + logd25", ExpressionSyntaxError), ('3 * 10 ** col("logd25") * e_logd25 * arcsec', ExpressionSyntaxError), + ("atan2(1, 0) + wrap360(10 * deg)", set()), + ('where(col("a") > 0, col("a"), 0)', {"a"}), + ('where(col("a") == "M", col("b"), where(col("a") == "N", col("c"), "x"))', {"a", "b", "c"}), ("unknown(1)", ExpressionSyntaxError), ("col", ExpressionSyntaxError), ] diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py index a9c2582..457f2df 100644 --- a/tests/test_formula_validate.py +++ b/tests/test_formula_validate.py @@ -9,7 +9,12 @@ def test_validate_expression_accepts_valid() -> None: assert validate_expression('to_deg(col("RAJ2000"))') == [] assert validate_expression("sin(pi) + 1.5 * deg") == [] assert validate_expression("sqrt(4)") == [] + assert validate_expression("atan2(1, 0) + pow(2, 3) + wrap360(10 * deg)") == [] assert validate_expression('col("dist") * unit("Mpc")') == [] + assert validate_expression("M_sun / L_sun * sigma") == [] + assert validate_expression('where(col("v") > 0, col("v"), 0)') == [] + assert validate_expression('where(col("a") == "M", 1, where(col("a") != "N", 2, 3))') == [] + assert validate_expression("max(1, min(2, 3))") == [] assert validate_expression("") == [] diff --git a/uploader/app/lib/formula/constants.py b/uploader/app/lib/formula/constants.py new file mode 100644 index 0000000..ec5b65a --- /dev/null +++ b/uploader/app/lib/formula/constants.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import final + +import astropy.constants as const +import astropy.units as u +import numpy as np + + +@final +@dataclass(frozen=True) +class ConstantDef: + name: str + value: u.Quantity + detail: str + + @property + def insert(self) -> str: + return self.name + + +NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( + ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), + ConstantDef("c", const.c, "Speed of light"), + ConstantDef("G", const.G, "Gravitational constant"), + ConstantDef("h", const.h, "Planck constant"), + ConstantDef("k_B", const.k_B, "Boltzmann constant"), + ConstantDef("sigma", const.sigma_sb, "Stefan-Boltzmann constant"), + ConstantDef("m_e", const.m_e, "Electron mass"), + ConstantDef("m_p", const.m_p, "Proton mass"), + ConstantDef("au", 1 * u.au, "Astronomical unit"), + ConstantDef("pc", 1 * u.pc, "Parsec"), + ConstantDef("ly", 1 * u.lyr, "Light year"), + ConstantDef("eV", 1 * u.eV, "Electronvolt"), + ConstantDef("Jy", 1 * u.Jy, "Jansky"), + ConstantDef("M_sun", const.M_sun, "Solar mass"), + ConstantDef("R_sun", const.R_sun, "Nominal solar radius"), + ConstantDef("L_sun", const.L_sun, "Nominal solar luminosity"), + ConstantDef("M_earth", const.M_earth, "Earth mass"), + ConstantDef("M_jup", const.M_jup, "Jupiter mass"), + ConstantDef("deg", 1 * u.deg, "Degree"), + ConstantDef("rad", 1 * u.rad, "Radian"), + ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), + ConstantDef("arcsec", 1 * u.arcsec, "Arcsecond"), + ConstantDef("mag", 1 * u.mag, "Magnitude"), +) diff --git a/uploader/app/lib/formula/functions.py b/uploader/app/lib/formula/functions.py new file mode 100644 index 0000000..481bb2e --- /dev/null +++ b/uploader/app/lib/formula/functions.py @@ -0,0 +1,178 @@ +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import final + +import astropy.units as u +import numpy as np +from astropy.coordinates import Angle + +from uploader.app.lib.formula.values import TextValue, Value + + +@final +@dataclass(frozen=True) +class FunctionDef: + name: str + detail: str + impl: object | None = None + placeholder: str = "${1:x}" + + @property + def insert(self) -> str: + return f"{self.name}({self.placeholder})" + + @property + def signature(self) -> str: + args = re.sub(r"\$\{\d+:([^}]+)\}", r"\1", self.placeholder) + return f"{self.name}({args})" + + +def _scalar_to_str(value: float | int | np.number) -> str: + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return str(numeric) + + +def _formula_str(value: Value) -> str | np.ndarray: + if isinstance(value, TextValue): + return value.data + if isinstance(value, str): + return value + if isinstance(value, u.Quantity): + scalar = value.value + if isinstance(scalar, np.ndarray): + return np.asarray([_scalar_to_str(x) for x in scalar]) + return _scalar_to_str(scalar) + return np.asarray([_scalar_to_str(x) for x in value]) + + +def _to_deg(value: object) -> u.Quantity: + if isinstance(value, TextValue): + angle = Angle(value.data, unit=u.Unit(value.unit)) if value.unit else Angle(value.data) + return angle.to(u.deg) + if isinstance(value, str): + return Angle(value).to(u.deg) + if isinstance(value, u.Quantity): + return value.to(u.deg) + if isinstance(value, np.ndarray): + return u.Quantity([_to_deg(item).value for item in value], unit=u.deg) + raise TypeError(f"to_deg() expected angle or coordinate string, got {type(value).__name__}") + + +def _unit(name: object) -> u.Quantity: + if not isinstance(name, str): + raise TypeError(f"unit() expected a unit name string, got {type(name).__name__}") + return 1 * u.Unit(name) + + +def _to_quantity(value: object) -> u.Quantity: + if isinstance(value, u.Quantity): + return value + return np.asarray(value, dtype=float) * u.dimensionless_unscaled + + +def _math(fn: Callable[..., u.Quantity]) -> Callable[..., u.Quantity]: + def impl(*args: object) -> u.Quantity: + return fn(*(_to_quantity(arg) for arg in args)) + + return impl + + +def _as_angle(value: object, default_unit: u.Unit) -> u.Quantity: + if isinstance(value, u.Quantity): + if value.unit.is_equivalent(u.rad): + return value + if value.unit.is_equivalent(u.dimensionless_unscaled): + return value.to_value(u.dimensionless_unscaled) * default_unit + raise TypeError(f"expected an angle or dimensionless value, got unit {value.unit}") + return np.asarray(value, dtype=float) * default_unit + + +def _deg2rad(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.rad) + + +def _rad2deg(value: object) -> u.Quantity: + return _as_angle(value, u.rad).to(u.deg) + + +def _wrap360(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) + + +def _extremum(fn: Callable[..., u.Quantity]) -> Callable[[object, object], u.Quantity]: + def impl(left: object, right: object) -> u.Quantity: + left_q = left if isinstance(left, u.Quantity) else _to_quantity(left) + right_q = right if isinstance(right, u.Quantity) else _to_quantity(right) + aligned = right_q.to(left_q.unit) + return fn(left_q, aligned) + + return impl + + +def _mask(cond: object) -> np.ndarray: + if isinstance(cond, u.Quantity): + return np.asarray(cond.value) + return np.asarray(cond) + + +def _where(cond: object, then: object, otherwise: object) -> object: + mask = _mask(cond) + if mask.shape == (): + return then if bool(mask) else otherwise + if isinstance(then, u.Quantity) or isinstance(otherwise, u.Quantity): + then_q = then if isinstance(then, u.Quantity) else _to_quantity(then) + else_q = otherwise if isinstance(otherwise, u.Quantity) else _to_quantity(otherwise) + aligned = else_q.to(then_q.unit) + return np.where(mask, np.asarray(then_q.value), np.asarray(aligned.value)) * then_q.unit + then_v = then.data if isinstance(then, TextValue) else then + else_v = otherwise.data if isinstance(otherwise, TextValue) else otherwise + return np.where(mask, np.asarray(then_v), np.asarray(else_v)) + + +COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') + +FUNCTIONS: tuple[FunctionDef, ...] = ( + FunctionDef("sin", "Sine (argument must be an angle)", np.sin), + FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), + FunctionDef("tan", "Tangent (argument must be an angle)", np.tan), + FunctionDef("asin", "Arcsine (returns radians)", _math(np.arcsin)), + FunctionDef("acos", "Arccosine (returns radians)", _math(np.arccos)), + FunctionDef("atan", "Arctangent (returns radians)", _math(np.arctan)), + FunctionDef( + "atan2", + "Two-argument arctangent (returns radians)", + _math(np.arctan2), + placeholder="${1:y}, ${2:x}", + ), + FunctionDef("deg2rad", "Convert degrees to radians", _deg2rad, placeholder="${1:deg}"), + FunctionDef("rad2deg", "Convert radians to degrees", _rad2deg, placeholder="${1:rad}"), + FunctionDef("wrap360", "Wrap angle to [0, 360) degrees", _wrap360, placeholder="${1:deg}"), + FunctionDef("sqrt", "Square root", _math(np.sqrt)), + FunctionDef("exp", "Exponential", _math(np.exp)), + FunctionDef("log10", "Base-10 logarithm", _math(np.log10)), + FunctionDef("ln", "Natural logarithm", _math(np.log)), + FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), + FunctionDef("max", "Larger of two values", _extremum(np.maximum), placeholder="${1:x}, ${2:y}"), + FunctionDef("min", "Smaller of two values", _extremum(np.minimum), placeholder="${1:x}, ${2:y}"), + FunctionDef("str", "Convert to text", _formula_str), + FunctionDef( + "where", + "Pick then if cond is true, otherwise the third argument; nest for extra branches", + _where, + placeholder="${1:cond}, ${2:then}, ${3:else}", + ), + FunctionDef( + "to_deg", + 'Convert to degrees; e.g. "00 02 08.4" (hourangle), "+16 35 13" (deg), "00h02m08.4s"', + _to_deg, + ), + FunctionDef( + "unit", + 'Astropy unit from a name string; e.g. "Mpc", "km/s", "Jy"', + _unit, + placeholder='"${1:name}"', + ), +) diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index 8805dd5..10483b5 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -1,131 +1,10 @@ from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Literal, TypedDict, final +from typing import Any, Literal, TypedDict -import astropy.constants as const -import astropy.units as u -import numpy as np -from astropy.coordinates import Angle - -from uploader.app.lib.formula.values import TextValue, Value - - -@final -@dataclass(frozen=True) -class FunctionDef: - name: str - detail: str - impl: object | None = None - placeholder: str = "${1:x}" - - @property - def insert(self) -> str: - return f"{self.name}({self.placeholder})" - - @property - def signature(self) -> str: - args = self.placeholder.replace("${1:", "").replace("}", "") - return f"{self.name}({args})" - - -@final -@dataclass(frozen=True) -class ConstantDef: - name: str - value: u.Quantity - detail: str - - @property - def insert(self) -> str: - return self.name - - -@final -@dataclass(frozen=True) -class OperatorDef: - name: str - detail: str - - -NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( - ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), - ConstantDef("c", const.c, "Speed of light"), - ConstantDef("deg", 1 * u.deg, "Degree"), - ConstantDef("rad", 1 * u.rad, "Radian"), - ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), - ConstantDef("arcsec", 1 * u.arcsec, "Arcsecond"), - ConstantDef("mag", 1 * u.mag, "Magnitude"), -) - - -OPERATORS: tuple[OperatorDef, ...] = ( - OperatorDef("+", "Addition; also concatenates strings"), - OperatorDef("-", "Subtraction"), - OperatorDef("*", "Multiplication"), - OperatorDef("/", "Division"), - OperatorDef("**", "Exponentiation"), - OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), -) - - -def _scalar_to_str(value: float | int | np.number) -> str: - numeric = float(value) - if numeric.is_integer(): - return str(int(numeric)) - return str(numeric) - - -def _formula_str(value: Value) -> str | np.ndarray: - if isinstance(value, TextValue): - return value.data - if isinstance(value, str): - return value - if isinstance(value, u.Quantity): - scalar = value.value - if isinstance(scalar, np.ndarray): - return np.asarray([_scalar_to_str(x) for x in scalar]) - return _scalar_to_str(scalar) - return np.asarray([_scalar_to_str(x) for x in value]) - - -def _to_deg(value: object) -> u.Quantity: - if isinstance(value, TextValue): - angle = Angle(value.data, unit=u.Unit(value.unit)) if value.unit else Angle(value.data) - return angle.to(u.deg) - if isinstance(value, str): - return Angle(value).to(u.deg) - if isinstance(value, u.Quantity): - return value.to(u.deg) - if isinstance(value, np.ndarray): - return u.Quantity([_to_deg(item).value for item in value], unit=u.deg) - raise TypeError(f"to_deg() expected angle or coordinate string, got {type(value).__name__}") - - -def _unit(name: object) -> u.Quantity: - if not isinstance(name, str): - raise TypeError(f"unit() expected a unit name string, got {type(name).__name__}") - return 1 * u.Unit(name) - - -COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') - -FUNCTIONS: tuple[FunctionDef, ...] = ( - FunctionDef("sin", "Sine (argument must be an angle)", np.sin), - FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), - FunctionDef("sqrt", "Square root", np.sqrt), - FunctionDef("str", "Convert to text", _formula_str), - FunctionDef( - "to_deg", - 'Convert to degrees; e.g. "00 02 08.4" (hourangle), "+16 35 13" (deg), "00h02m08.4s"', - _to_deg, - ), - FunctionDef( - "unit", - 'Astropy unit from a name string; e.g. "Mpc", "km/s", "Jy"', - _unit, - placeholder='"${1:name}"', - ), -) +from uploader.app.lib.formula.constants import NAMED_CONSTANTS +from uploader.app.lib.formula.functions import COL_FUNCTION, FUNCTIONS +from uploader.app.lib.formula.operators import OPERATORS +from uploader.app.lib.formula.values import Value class ExpressionToken(TypedDict): @@ -174,9 +53,9 @@ def build_namespace(columns: Mapping[str, Value]) -> dict[str, object]: def expression_syntax_help() -> str: - constants = ", ".join(f"`{c.name}` ({c.detail})" for c in NAMED_CONSTANTS) - functions = ", ".join(f"`{fn.signature}` ({fn.detail})" for fn in (COL_FUNCTION, *FUNCTIONS)) - operators = ", ".join(f"`{op.name}` ({op.detail})" for op in OPERATORS) + constants = ", ".join(f"`{c.name}`" for c in NAMED_CONSTANTS) + functions = ", ".join(f"`{fn.signature}`" for fn in (COL_FUNCTION, *FUNCTIONS)) + operators = ", ".join(f"`{op.name}`" for op in OPERATORS) return f"""\ ## Expression syntax @@ -198,4 +77,5 @@ def expression_syntax_help() -> str: - `180 * deg` - `"G"` - fills the column with a text "G" - Copy another column: `col("ra")` -- Mathematical expression: `3 * 10 ** col("logd25") * arcsec`""" +- Mathematical expression: `3 * 10 ** col("logd25") * arcsec` +- Conditional: `where(col("v") > 0, col("v"), 0)`""" diff --git a/uploader/app/lib/formula/operators.py b/uploader/app/lib/formula/operators.py new file mode 100644 index 0000000..26e088e --- /dev/null +++ b/uploader/app/lib/formula/operators.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import final + + +@final +@dataclass(frozen=True) +class OperatorDef: + name: str + detail: str + + +OPERATORS: tuple[OperatorDef, ...] = ( + OperatorDef("+", "Addition; also concatenates strings"), + OperatorDef("-", "Subtraction"), + OperatorDef("*", "Multiplication"), + OperatorDef("/", "Division"), + OperatorDef("**", "Exponentiation"), + OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), + OperatorDef("==", "Equal"), + OperatorDef("!=", "Not equal"), + OperatorDef("<", "Less than"), + OperatorDef("<=", "Less than or equal"), + OperatorDef(">", "Greater than"), + OperatorDef(">=", "Greater than or equal"), +) diff --git a/uploader/app/lib/formula/validate.py b/uploader/app/lib/formula/validate.py index f15df99..a69bf01 100644 --- a/uploader/app/lib/formula/validate.py +++ b/uploader/app/lib/formula/validate.py @@ -5,7 +5,8 @@ from pydantic import AfterValidator, ValidationError from pydantic_core import PydanticCustomError -from uploader.app.lib.formula.namespace import COL_FUNCTION, FUNCTIONS, NAMED_CONSTANTS +from uploader.app.lib.formula.constants import NAMED_CONSTANTS +from uploader.app.lib.formula.functions import COL_FUNCTION, FUNCTIONS @final