Metadata-Version: 2.4
Name: ocl-py
Version: 0.5.0
Summary: Object Constraint Language (OCL 2.4) for Python: parser, multi-valued evaluator over Python objects, and type checker
Author-email: Srdjan Krstic <srdan.krstic@inf.ethz.ch>
License-Expression: Apache-2.0
Project-URL: Homepage, https://gitlab.inf.ethz.ch/honguyen/nuactiongui-project/-/tree/main/packages/ocl-py
Project-URL: Source, https://gitlab.inf.ethz.ch/honguyen/nuactiongui-project
Project-URL: Changelog, https://gitlab.inf.ethz.ch/honguyen/nuactiongui-project/-/blob/main/CHANGELOG.md
Keywords: ocl,object-constraint-language,constraints,type-checker,evaluator,antlr
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Software Development :: Compilers
Classifier: Topic :: Software Development :: Interpreters
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: antlr4-python3-runtime==4.13.2
Requires-Dist: forbiddenfruit==0.1.4
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Dynamic: license-file

# OCL for Python

`ocl-py` parses, evaluates and type-checks [OCL 2.4](https://www.omg.org/spec/OCL/2.4)
expressions in Python. Ordinary Python objects form the data model: a
class that derives from `OCLTerm` becomes an OCL class, its attributes
become OCL properties, and `allInstances()` works over the objects you
have created. The evaluator implements the multi-valued semantics of the
standard (`null` and `invalid` are distinct, boolean connectives follow
the OCL truth tables), and the type checker validates expressions against
a class model you describe through a small protocol.

- [Installation](#installation)
- [Quick start](#quick-start)
- [Python classes as the data model](#python-classes-as-the-data-model)
- [Semantics](#semantics)
- [Type checking](#type-checking)
- [Compiling expressions](#compiling-expressions)
- [Known limitations and risks](#known-limitations-and-risks)
- [Development](#development)
- [License](#license)

## Installation

```bash
pip install ocl-py
pip install "ocl-py[test]"   # adds pytest, for running the test suite
```

Requirements: **CPython 3.10 to 3.14**. Other implementations (PyPy,
Jython, IronPython) are not supported; see
[Known limitations and risks](#known-limitations-and-risks) for why.
No Java is needed to install or use the library; the parser is shipped
pre-generated.

## Quick start

```python
from ocl import eval_ocl

result = eval_ocl("let x = Set{0, 1..5} in x->select(e | e > 0)->collect(e | e + 4)")
assert result == [5, 6, 7, 8, 9]
```

`eval_ocl` takes an OCL expression as a string and returns its value as a
plain Python value: `Set` becomes `set`, `Bag` and `Sequence` become
`list`, `OrderedSet` becomes an insertion-ordered `dict` view, `Real`
becomes `float`, and both undefined values (`null`, `invalid`) become
`None`. Free variables of the expression are supplied as keyword
arguments:

```python
from ocl import eval_ocl

eval_ocl("x + y", x=2, y=3)          # 5
eval_ocl("s->includes(3)", s={1, 2})  # False
```

## Python classes as the data model

Classes that inherit from `OCLTerm` are OCL classes. Every instance is
registered (by weak reference) so that `Class.allInstances()` works, `=`
on objects is reference equality, and `oclIsTypeOf`/`oclIsKindOf` follow
the OCL rules. Attributes are read by name; collection-valued attributes
are ordinary Python collections.

```python
from ocl import eval_ocl, OCLTerm

class Researcher(OCLTerm):
    def __init__(self, name):
        self.name = name
        self.advisers = []
        self.papers = []

class Paper(OCLTerm):
    def __init__(self, title, year, published):
        self.title = title
        self.year = year
        self.published = published
        self.authors = []

p1 = Paper("P1", 2020, True)
p2 = Paper("P2", 2021, False)
r1, r2 = Researcher("R1"), Researcher("R2")
r2.advisers.append(r1)
r1.papers = [p1]; p1.authors.append(r1)
r2.papers = [p2]; p2.authors.append(r2)

# A reviewer may see a paper if it is published, or if no author is
# one of the reviewer's advisees with a recent joint paper.
rule = ("self.published or self.authors->forAll(a | "
        "caller.advisers->excludes(a) and a.papers->forAll(p | "
        "(p <> self and 2023 - p.year < 2) implies p.authors->excludes(caller)))")

assert eval_ocl(rule, self=p1, caller=r2) is True
assert eval_ocl("Paper.allInstances()->select(p | p.published)->size()",
                Paper=Paper) == 1
```

A class named in an expression (`Paper.allInstances()`) is a free
variable like any other, so pass the class itself as a keyword argument
(`Paper=Paper`). Methods defined on your classes can be called from OCL
with the usual dot syntax (`self.summary()`). To compile an expression
once and evaluate it where the classes are in scope, see
[Compiling expressions](#compiling-expressions).

## Semantics

The evaluator implements the multi-valued semantics of OCL 2.4. The
authoritative definition is an Isabelle/HOL formalization (an extension
of [Safe_OCL](https://www.isa-afp.org/entries/Safe_OCL.html)) kept in the
source repository; its lemmas mirror the test suite one-to-one.

- **Undefined values.** `null` (no value) and `invalid` (error) are
  distinct internally; partial operations (division by zero,
  out-of-bounds access, navigation from `null`, ...) yield `invalid`
  instead of raising. At the `eval_ocl` boundary both are returned as
  Python `None`; use `oclIsUndefined()` / `oclIsInvalid()` inside the
  expression to distinguish them.
- **Boolean connectives** (`and`, `or`, `xor`, `implies`, `not`) follow
  the OCL/Kleene truth tables: a definite value dominates (`false and
  invalid = false`), then `invalid`, then `null`. `forAll`/`exists` are
  the corresponding folds; `select`/`reject` require a defined boolean
  body.
- **Equality.** `=`/`<>` are strict in `invalid` only (`null = null` is
  `true`). On objects (`OCLTerm`) `=` is **reference equality**; on
  tuples it is structural (tuples are values).
- **`==` (language extension, not in the OCL standard).** Shallow
  structural equality: same class and equal attribute dictionaries,
  attribute values compared with `=` semantics. On non-objects `==`
  coincides with `=`.

Documented deviations from the OCL specification, kept deliberately:

- **Indexing is 0-based** (`at`, `indexOf`, `insertAt`, `subOrderedSet`,
  `subSequence`), with Python-style negative indices; the standard is
  1-based with inclusive ranges.
- **Real literals are exact rationals** (`fractions.Fraction`), so
  `9007199254740993.0 = 9007199254740993` holds; Real results are coerced
  to `float` at the `eval_ocl` boundary.
- The `invalid` literal is accepted in expressions (the standard's
  abstract syntax has no such literal).
- **`div`/`mod` use floor division** (Python `//`), so `-7 div 2 = -4` and
  `-7 mod 2 = 1`; OCL 2.4 truncates toward zero. The identity
  `a = (a div b)*b + (a mod b)` holds in both.
- **`collect` does not flatten** nested results (it is a plain map), so
  `collectNested` coincides with `collect` and an explicit `->flatten()`
  is needed where OCL 2.4 would flatten implicitly. The *type checker*
  types `->collect(...)` with the flattened element type, following
  Safe_OCL.

## Type checking

`check_ocl(expression, model=None, env=None)` returns the type of an
expression or raises `OclTypeError`. `env` maps free-variable names to
types from `ocl.types`; `model` describes your classes. Without a model,
pure OCL expressions still type-check:

```python
from ocl import check_ocl
from ocl.types import Required, INTEGER

print(check_ocl("Set{1, 2}->size() > 1"))                 # Boolean[1]
print(check_ocl("x + 1", env={"x": Required(INTEGER)}))    # Integer[1]
```

A class model is any object implementing the `ModelInterface` protocol
(six methods, no base class to inherit). Types are built from
`ocl.types`: `Required(t)` / `Optional(t)` for the `[1]` / `[?]`
multiplicities, `ObjectType(name)`, `EnumType(name)`, the primitives
`STRING`, `INTEGER`, `REAL`, `BOOLEAN`, and `SetOf`, `BagOf`,
`SequenceOf`, `OrderedSetOf` for collections.

```python
from ocl import check_ocl, OclTypeError
from ocl.types import (Required, Optional, ObjectType, EnumType,
                       STRING, INTEGER, BOOLEAN, SetOf, BagOf)

PERSON, THOUGHT = ObjectType("Person"), ObjectType("Thought")

class Model:
    _classes = {"Person", "Thought"}
    _enums = {"Color": {"RED", "GREEN"}}
    _props = {
        ("Person", "name"): Required(STRING),
        ("Person", "age"): Required(INTEGER),
        ("Person", "created"): SetOf(Required(THOUGHT)),
        ("Thought", "content"): Required(STRING),
        ("Thought", "color"): Required(EnumType("Color")),
        ("Thought", "createdBy"): Optional(PERSON),
    }
    _methods = {("Person", "recent"): [([Required(INTEGER)], SetOf(Required(THOUGHT)))]}

    def is_class(self, name):              return name in self._classes
    def is_enum(self, name):               return name in self._enums
    def has_literal(self, enum, literal):  return literal in self._enums.get(enum, ())
    def subclass_rel(self, child, parent): return False   # strict subclassing, if any
    def property_type(self, cls, prop):    return self._props.get((cls, prop))
    def method_signatures(self, cls, name): return self._methods.get((cls, name), [])

env = {"self": Required(PERSON)}
assert check_ocl("self.created->forAll(t | t.color = Color::RED)", Model(), env) == Required(BOOLEAN)
# collect over a Set yields a Bag; navigating through the optional end
# createdBy weakens the result to String[?]
assert check_ocl("self.recent(2)->collect(t | t.createdBy.name)", Model(), env) == BagOf(Optional(STRING))
try:
    check_ocl("self.age + self.name", Model(), env)
except OclTypeError as e:
    print(e)   # '+' requires Integer[1]/Real[1] operands, got Integer[1] and String[1] (in: self.age+self.name)
```

The typing rules follow Safe_OCL; the deviations (D1-D8, e.g. `null`
comparisons are well-typed for every operand type, empty collection
literals are accepted) are listed in the module docstring of
`ocl.typecheck` and in the repository's `docs/type-checking-rules.pdf`.

## Compiling expressions

`eval_ocl` is a thin wrapper around `ocl.compile`, which translates an
expression into the source of a Python lambda whose parameters are the
expression's free variables:

```python
from ocl import compile
source, free_vars = compile("self.age > limit")
# source == "(lambda self= None, limit= None: ocl_cmp('>', (lambda: ocl_dot((lambda: self), 'age')), (lambda: limit)))"
# free_vars == {"self", "limit"}
```

Two options matter when the lambda is evaluated somewhere other than
where the model classes are defined: `type_names` lists the names that
denote classes or enums (so they are never mistaken for free variables),
and `type_prefix` qualifies them with a module name in the emitted code
(`Color::RED` becomes `model.Color.RED` with `type_prefix="model"`).
`eval_python(func, **args)` evaluates such a compiled lambda with the OCL
runtime active.

## Known limitations and risks

- **Runtime patching of CPython builtins.** The OCL collection
  operations (`->select`, `->forAll`, `->including`, ...) are made
  available on Python's `list`, `set` and `dict` by the
  [forbiddenfruit](https://github.com/clarete/forbiddenfruit) package,
  which rewrites the builtin types' method tables through `ctypes` while
  an expression is being evaluated (the `ocl_extensions()` context
  manager installs them and removes them afterwards). Consequences:
  - it works on **CPython only**;
  - forbiddenfruit has had no release since 2021 and depends on CPython
    internals, so a future CPython version may break evaluation; the
    dependency is pinned and the test suite is run on every supported
    interpreter before a release, but there is no upstream to fix a
    breakage;
  - the patches are **process-global** for the duration of an
    evaluation: other threads that call, e.g., `list.count` or
    `list.append` on their own lists during that window see the OCL
    versions. Do not evaluate OCL concurrently with unrelated code that
    relies on the exact behaviour of those builtin methods, or serialize
    evaluations behind a lock.
- **Bag equality is order-sensitive**, because `Bag` and `Sequence` share
  the `list` representation.
- The deviations from the OCL standard listed under
  [Semantics](#semantics) (0-based indexing, exact reals, floor
  `div`/`mod`, non-flattening `collect`) are intentional and stable.

## Development

Source layout (repository, not the installed package):

- `ocl/compiler.py` - ANTLR front end and the OCL-to-Python-lambda
  compiler (`compile`, `LambdaVisitor`)
- `ocl/ocl.py` - the multi-valued runtime and evaluator (`eval_ocl`,
  `eval_python`, `OCLTerm`, `OCLTuple`)
- `ocl/types.py`, `ocl/typecheck.py` - the type system and the type
  checker (`check_ocl`, `ModelInterface`)
- `ocl/parser/` - generated by ANTLR from `OclExpression.g4` (not
  committed; `make grammars`, needs Java 11+ and `pip install antlr4-tools`)
- `tests/` - the unit tests (`make test`; the suite is also shipped in
  the source distribution)
- `Safe_OCL/` - the Isabelle/HOL formalization of the evaluation
  semantics and typing rules (LGPL 2.1, following Safe_OCL; repository
  only, not part of the distribution)
- `docs/` - LaTeX/PDF documentation of the semantics and the type
  checking rules (repository only)

```bash
pip install -r requirements.txt
make grammars        # regenerate ocl/parser with the pinned ANTLR version
make test
make dist            # sdist + wheel into dist/ (see RELEASING.md)
```

## License

Apache License 2.0; see `LICENSE` and `NOTICE`. Dependencies:
`antlr4-python3-runtime` (BSD-3-Clause) and `forbiddenfruit` (MIT).
