orchestrata_tools.test_generator.testgen.conditions

Reference › orchestrata_tools › test_generator › testgen › conditions

This module derives concrete input values from the text of a condition, without executing anything. Given a condition such as not tag, b == 0, value is None, user_id < 0 or tag in self.tags, it returns a ConditionInputs record with true_inputs (values that make the condition true), false_inputs, any setup_code lines needed before the call, and a notes string. analyse(condition, args_info) is the entry point: it parses the condition and uses the function's argument information to choose values. It handles the common guard patterns of real Python code. The module is self-contained, using the standard ast module and its sibling types, and is imported only by branch. Tests are tests/test_orchestrata_tools_testgen_conditions_coverage.py and ..._units.py.

At a glance

Source src/orchestrata_tools/test_generator/testgen/conditions.py
Lines of code 382
Classes 1
Functions 11
Imports (internal) orchestrata_tools.test_generator.testgen.types
Imported by orchestrata_tools.test_generator.testgen.branch
Tests that import it tests/test_orchestrata_tools_testgen_conditions_coverage.py, tests/test_orchestrata_tools_testgen_units.py

Class diagram

Class diagram orchestrata_tools.test_generator.testgen.conditions

Dependency diagram

Dependency diagram orchestrata_tools.test_generator.testgen.conditions

API

Constants

Name Type Value
_EMPTY ConditionInputs ConditionInputs({}, {}, [], '')

Classes

ConditionInputs (dataclass)

Decorators: dataclass · Line 32

Concrete values derived from one condition expression.

Attribute Type Default
true_inputs dict[str, str]
false_inputs dict[str, str]
setup_code list[str]
notes str

Functions

Function Signature Summary
analyse analyse(condition: str, args_info: dict) -> ConditionInputs Parse condition and return ConditionInputs for the true and false paths.
_analyse_node _analyse_node(node: ast.expr, ann_map: dict[str, str], raw: str) -> ConditionInputs
_build_ann_map _build_ann_map(args_info: dict) -> dict[str, str] name → annotation string from the args dict.
_name _name(node: ast.expr) -> str Extract a simple name string from a Name or Attribute node.
_const_repr _const_repr(node: ast.expr) -> str Return the source repr of a constant node.
_typed_value _typed_value(name: str, ann_map: dict[str, str], override_type: str | None = None) -> str Return a valid sentinel value for a parameter, respecting annotation.
_other_value _other_value(val: str, name: str, ann_map: dict[str, str]) -> str Return a value that is NOT equal to val.
_comparison_values _comparison_values(op: ast.cmpop, val_repr: str) -> tuple[str, str] Given a comparison operator and the RHS value, return (true_value, false_value) for the LHS variable.
_op_str _op_str(op: ast.cmpop) -> str
_truthy _truthy(ann: str | None) -> str A truthy value for the given annotation.
_falsy _falsy(ann: str | None) -> str A falsy value for the given annotation.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_tools/test_generator/testgen/conditions.py

"""conditions.py — Derives concrete input values from AST condition expressions.

Given a condition string like "not tag", "b == 0", "value is None",
"user_id < 0", "tag in self.tags" — returns:

    ConditionInputs(
        true_inputs  = {"tag": '""'},        # inputs that make condition True
        false_inputs = {"tag": '"hello"'},   # inputs that make condition False
        setup_code   = [],                   # extra lines needed before the call
        notes        = "empty string triggers the branch"
    )

Handles the most common guard patterns in real Python code without
needing to execute anything.

Restored 2026-08-19 from ``scripts/ast_serializer/testgen`` (deleted
2026-07-09, commit 6a186c9d), unchanged in substance: this module was
already self-contained (stdlib ``ast`` + its own sibling ``.types``), with
no dependency on the deleted parser's ``StatementRecord``/``ModuleRecord``
tree — only its imports moved to absolute paths.
"""

from __future__ import annotations

import ast
from dataclasses import dataclass

from orchestrata_tools.test_generator.testgen.types import sentinel


@dataclass
class ConditionInputs:
    """Concrete values derived from one condition expression."""

    true_inputs: dict[str, str]  # arg_name → value_repr (makes condition True)
    false_inputs: dict[str, str]  # arg_name → value_repr (makes condition False)
    setup_code: list[str]  # lines to emit before the call (e.g. pre-populate a list)
    notes: str  # human-readable explanation


_EMPTY: ConditionInputs = ConditionInputs({}, {}, [], "")


def analyse(condition: str, args_info: dict) -> ConditionInputs:
    """
    Parse `condition` and return ConditionInputs for the true and false paths.

    `args_info` is the ``args`` dict built by
    ``orchestrata_tools.test_generator.testgen.adapter.args_info_from_ast`` —
    used to map parameter names to their type annotations.
    """
    if not condition:
        return _EMPTY

    ann_map = _build_ann_map(args_info)

    try:
        tree = ast.parse(condition, mode="eval")
        node = tree.body
    except SyntaxError:
        return _EMPTY

    return _analyse_node(node, ann_map, condition)


# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------


def _analyse_node(node: ast.expr, ann_map: dict[str, str], raw: str) -> ConditionInputs:
    # not X
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
        inner = _analyse_node(node.operand, ann_map, raw)
        # Flip true/false
        return ConditionInputs(
            true_inputs=inner.false_inputs,
            false_inputs=inner.true_inputs,
            setup_code=inner.setup_code,
            notes=f"negation of: {inner.notes}",
        )

    # X is None  /  X is not None
    if isinstance(node, ast.Compare) and len(node.ops) == 1:
        if isinstance(node.ops[0], ast.Is):
            if (
                isinstance(node.comparators[0], ast.Constant)
                and node.comparators[0].value is None
            ):
                name = _name(node.left)
                return ConditionInputs(
                    true_inputs={name: "None"} if name else {},
                    false_inputs={name: _typed_value(name, ann_map)} if name else {},
                    setup_code=[],
                    notes=f"{name} is None",
                )

        if isinstance(node.ops[0], ast.IsNot):
            if (
                isinstance(node.comparators[0], ast.Constant)
                and node.comparators[0].value is None
            ):
                name = _name(node.left)
                return ConditionInputs(
                    true_inputs={name: _typed_value(name, ann_map)} if name else {},
                    false_inputs={name: "None"} if name else {},
                    setup_code=[],
                    notes=f"{name} is not None",
                )

        # X == value  /  X != value
        if isinstance(node.ops[0], (ast.Eq, ast.NotEq)):
            name = _name(node.left)
            val = _const_repr(node.comparators[0])
            other = _other_value(val, name, ann_map)
            if isinstance(node.ops[0], ast.Eq):
                return ConditionInputs(
                    true_inputs={name: val} if name else {},
                    false_inputs={name: other} if name else {},
                    setup_code=[],
                    notes=f"{name} equals {val}",
                )
            else:
                return ConditionInputs(
                    true_inputs={name: other} if name else {},
                    false_inputs={name: val} if name else {},
                    setup_code=[],
                    notes=f"{name} not equal to {val}",
                )

        # X < val  /  X <= val  /  X > val  /  X >= val
        if isinstance(node.ops[0], (ast.Lt, ast.LtE, ast.Gt, ast.GtE)):
            name = _name(node.left)
            val = _const_repr(node.comparators[0])
            op = node.ops[0]
            true_v, false_v = _comparison_values(op, val)
            return ConditionInputs(
                true_inputs={name: true_v} if name else {},
                false_inputs={name: false_v} if name else {},
                setup_code=[],
                notes=f"{name} {_op_str(op)} {val}",
            )

        # X in Y  /  X not in Y
        if isinstance(node.ops[0], (ast.In, ast.NotIn)):
            item = _name(node.left)
            coll = _name(node.comparators[0])
            item_val = _typed_value(item, ann_map) if item else '"item"'
            is_in = isinstance(node.ops[0], ast.In)

            if is_in:
                # true: collection contains item
                # false: collection does not contain item
                setup_true = [f"# pre-populate {coll} so it contains {item_val}"]
                return ConditionInputs(
                    true_inputs={item: item_val} if item else {},
                    false_inputs={item: item_val} if item else {},
                    setup_code=setup_true,
                    notes=f"{item} found in {coll} (true) vs not found (false)",
                )
            else:
                return ConditionInputs(
                    true_inputs={item: item_val} if item else {},
                    false_inputs={item: item_val} if item else {},
                    setup_code=[f"# ensure {coll} does NOT contain {item_val}"],
                    notes=f"{item} not in {coll}",
                )

    # isinstance(x, T)
    if isinstance(node, ast.Call):
        func = ast.unparse(node.func)
        if func == "isinstance" and len(node.args) == 2:
            name = _name(node.args[0])
            tp = ast.unparse(node.args[1])
            tv = _typed_value(name, ann_map, override_type=tp)
            return ConditionInputs(
                true_inputs={name: tv} if name else {},
                false_inputs={name: '"wrong_type"'} if name else {},
                setup_code=[],
                notes=f"isinstance({name}, {tp})",
            )

    # hasattr(obj, 'attr')
    if isinstance(node, ast.Call):
        func = ast.unparse(node.func)
        if func == "hasattr" and len(node.args) == 2:
            return ConditionInputs(
                true_inputs={},
                false_inputs={},
                setup_code=["# ensure obj has/lacks the attribute"],
                notes=f"hasattr({ast.unparse(node.args[0])}, {ast.unparse(node.args[1])})",
            )

    # bool(x) / truthiness:  just a Name node  e.g. "if value:"
    if isinstance(node, ast.Name):
        name = node.id
        ann = ann_map.get(name)
        return ConditionInputs(
            true_inputs={name: _truthy(ann)},
            false_inputs={name: _falsy(ann)},
            setup_code=[],
            notes=f"{name} is truthy / falsy",
        )

    # Attribute access: self.flag, obj.active, etc.
    if isinstance(node, ast.Attribute):
        attr = ast.unparse(node)
        return ConditionInputs(
            true_inputs={},
            false_inputs={},
            setup_code=[f"# arrange so that {attr} is True / False"],
            notes=f"attribute condition: {attr}",
        )

    # Boolean AND / OR — analyse each operand independently
    if isinstance(node, ast.BoolOp):
        parts = [_analyse_node(v, ann_map, raw) for v in node.values]
        if isinstance(node.op, ast.And):
            # All must be true for true path; any false for false path
            merged_true: dict[str, str] = {}
            merged_false: dict[str, str] = {}
            setup: list[str] = []
            for p in parts:
                merged_true.update(p.true_inputs)
                if not merged_false:
                    merged_false.update(p.false_inputs)
                setup.extend(p.setup_code)
            return ConditionInputs(
                merged_true, merged_false, setup, f"AND condition: {raw}"
            )
        else:
            # Any true for true path; all false for false path
            merged_true = {}
            merged_false = {}
            setup = []
            for p in parts:
                if not merged_true:
                    merged_true.update(p.true_inputs)
                merged_false.update(p.false_inputs)
                setup.extend(p.setup_code)
            return ConditionInputs(
                merged_true, merged_false, setup, f"OR condition: {raw}"
            )

    return _EMPTY


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _build_ann_map(args_info: dict) -> dict[str, str]:
    """name → annotation string from the args dict."""
    m: dict[str, str] = {}
    for group in ("posonlyargs", "args", "kwonlyargs"):
        for a in args_info.get(group, []):
            if a.get("annotation"):
                m[a["name"]] = a["annotation"]
    return m


def _name(node: ast.expr) -> str:
    """Extract a simple name string from a Name or Attribute node."""
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        # e.g. self.value → skip self-attributes for input mapping
        return ""
    return ""


def _const_repr(node: ast.expr) -> str:
    """Return the source repr of a constant node."""
    if isinstance(node, ast.Constant):
        return repr(node.value)
    try:
        return ast.unparse(node)
    except Exception:
        return "..."


def _typed_value(
    name: str, ann_map: dict[str, str], override_type: str | None = None
) -> str:
    """Return a valid sentinel value for a parameter, respecting annotation."""
    ann = override_type or ann_map.get(name)
    return sentinel(ann)


def _other_value(val: str, name: str, ann_map: dict[str, str]) -> str:
    """Return a value that is NOT equal to val."""
    ann = ann_map.get(name)
    # Simple heuristics
    if val == "0":
        return "1"
    if val == "True":
        return "False"
    if val == "False":
        return "True"
    if val == "None":
        return sentinel(ann)
    if val.startswith('"') or val.startswith("'"):
        return '"different_value"'
    try:
        n = int(val)
        return str(n + 1)
    except ValueError:
        pass
    return sentinel(ann)


def _comparison_values(op: ast.cmpop, val_repr: str) -> tuple[str, str]:
    """
    Given a comparison operator and the RHS value, return
    (true_value, false_value) for the LHS variable.
    """
    # Annotated `float` so the fallback below is a widening, not a retype:
    # `int` is acceptable wherever `float` is, and the value stays an int at
    # runtime when the first parse succeeds — so `str(n - 1)` still renders
    # "4", never "4.0".
    n: float
    try:
        n = int(val_repr)
    except ValueError, TypeError:
        try:
            n = float(val_repr)
        except ValueError, TypeError:
            return (val_repr, f"not_{val_repr}")

    if isinstance(op, ast.Lt):
        return (str(n - 1), str(n))
    if isinstance(op, ast.LtE):
        return (str(n), str(n + 1))
    if isinstance(op, ast.Gt):
        return (str(n + 1), str(n))
    if isinstance(op, ast.GtE):
        return (str(n), str(n - 1))
    return (val_repr, str(n + 1))


def _op_str(op: ast.cmpop) -> str:
    return {
        ast.Lt: "<",
        ast.LtE: "<=",
        ast.Gt: ">",
        ast.GtE: ">=",
        ast.Eq: "==",
        ast.NotEq: "!=",
    }.get(type(op), "?")


def _truthy(ann: str | None) -> str:
    """A truthy value for the given annotation."""
    if ann == "str":
        return '"non_empty"'
    if ann == "int":
        return "1"
    if ann == "float":
        return "1.0"
    if ann and ann.startswith(("list", "List")):
        return '["item"]'
    if ann and ann.startswith(("dict", "Dict")):
        return '{"key": "value"}'
    return sentinel(ann) or "True"


def _falsy(ann: str | None) -> str:
    """A falsy value for the given annotation."""
    if ann == "str":
        return '""'
    if ann == "int":
        return "0"
    if ann == "float":
        return "0.0"
    if ann and ann.startswith(("list", "List")):
        return "[]"
    if ann and ann.startswith(("dict", "Dict")):
        return "{}"
    if ann and "Optional" in ann:
        return "None"
    return "None"