Skip to main content

OCL for Python

ocl-py parses, evaluates and type-checks 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

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 for why. No Java is needed to install or use the library; the parser is shipped pre-generated.

Quick start

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:

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.

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.

Syntax

The accepted language is the expression part of OCL 2.4 (no context, inv, pre/post or def clauses: an expression is checked and evaluated on its own, with free variables such as self supplied by the caller). Spaces and newlines are insignificant; comments are not supported.

expr  ::= literal | name | 'self'
        | expr '.' name                         -- attribute or association end
        | expr '.' name '(' [expr {',' expr}] ')' -- operation on an object or a String
        | expr '->' op '(' [expr {',' expr}] ')'  -- collection operation
        | expr '->' iterator '(' name {',' name} '|' expr ')'
        | expr '->' 'iterate' '(' name ';' name '=' expr '|' expr ')'
        | ('-' | 'not') expr
        | expr binop expr
        | '(' expr ')'
        | 'if' expr 'then' expr 'else' expr 'endif'
        | 'let' name [':' type] '=' expr {',' name [':' type] '=' expr} 'in' expr
        | Class '::' LITERAL                    -- enum literal, e.g. Color::RED
        | [module '::'] Class                   -- a class, e.g. Person.allInstances()

literal ::= Integer | Real | 'text' | 'true' | 'false' | 'null' | 'invalid'
        | Kind '{' [item {',' item}] '}'        -- Kind: Set | Bag | Sequence | OrderedSet
        | 'Tuple' '{' name [':' type] '=' expr {',' name [':' type] '=' expr} '}'
item    ::= expr | expr '..' expr               -- an Integer interval, e.g. Set{1..5}

binop, loosest first:  implies | xor | or | and | = <> == | < <= > >= | + - | * / div mod
                       ('-' between two Sets is set difference)

The three spellings are not interchangeable: . navigates and calls operations of the object or String at hand (self.name, s.size(), s.concat(t), p.oclIsKindOf(Person), p.oclAsType(Student), x.oclIsUndefined(), Person.allInstances()), -> is reserved for the pre-defined collection operations and iterators listed below, and :: qualifies names (Color::RED, model::Person). x == y is the structural-equality extension described under Semantics.

Collection operations from iterate

All collection operations can be understood through one primitive. c->iterate(x; acc = init | body) sets acc to init, then for each element x of c (in order for Sequence and OrderedSet) replaces acc by the value of body, and yields the final acc. Together with the collection literals and ->including(o) (which adds o: duplicates are dropped by Set and OrderedSet, appended by Bag and Sequence), it defines the rest. In the table c and c2 are collections, o an element, b a Boolean and e an arbitrary expression over x, K the kind of c, and KB the kind collect produces (Bag for a Set or Bag source, Sequence for a Sequence or OrderedSet). Each row was checked against the built-in operation on sample collections of every kind; the built-ins are the reference, the definitions explain them.

Operation Collections Definition
c->size() all c->iterate(x; acc = 0 | acc + 1)
c->isEmpty() all c->size() = 0
c->notEmpty() all c->size() > 0
c->sum() all c->iterate(x; acc = 0 | acc + x)
c->count(o) all c->iterate(x; acc = 0 | if x = o then acc + 1 else acc endif)
c->includes(o) all c->iterate(x; acc = false | acc or x = o)
c->excludes(o) all not c->includes(o)
c->includesAll(c2) all c2->forAll(y | c->includes(y))
c->excludesAll(c2) all c2->forAll(y | c->excludes(y))
c->select(x | b) all c->iterate(x; acc = K{} | if b then acc->including(x) else acc endif)
c->reject(x | b) all c->select(x | not (b))
c->collect(x | e) all c->iterate(x; acc = KB{} | acc->including(e))
c->collectNested(x | e) all c->collect(x | e) (collect does not flatten here, see Semantics)
c->forAll(x | b) all c->iterate(x; acc = true | acc and b)
c->exists(x | b) all c->iterate(x; acc = false | acc or b)
c->one(x | b) all c->select(x | b)->size() = 1
c->any(x | b) all c->select(x | b)->asSequence()->first() (invalid when nothing matches)
c->isUnique(x | e) all c->collect(x | e)->asSet()->size() = c->size()
c->excluding(o) all c->select(x | x <> o)
c->union(c2) Set, Bag, Sequence c2->iterate(x; acc = c | acc->including(x)) (Sequence: concatenation)
c->intersection(c2) Set c->select(x | c2->includes(x)) (on Bags the built-in keeps the smaller multiplicity)
c->symmetricDifference(c2) Set c->select(x | c2->excludes(x))->union(c2->select(x | c->excludes(x)))
c->flatten() all c->iterate(s; acc = KB{} | acc->union(s)) per nesting level
c->asSet() all c->iterate(x; acc = Set{} | acc->including(x))
c->asBag() all c->iterate(x; acc = Bag{} | acc->including(x))
c->asSequence() all c->iterate(x; acc = Sequence{} | acc->including(x))
c->asOrderedSet() all c->iterate(x; acc = OrderedSet{} | acc->including(x))
c->product(c2) all c->iterate(x; acc = Set{} | acc->union(c2->collect(y | Tuple{first = x, second = y})->asSet()))
c->at(i) Sequence, OrderedSet c->iterate(x; acc = Tuple{n = 0, r = null} | Tuple{n = acc.n + 1, r = if acc.n = i then x else acc.r endif}).r (0-based)
c->first() Sequence, OrderedSet c->at(0)
c->last() Sequence, OrderedSet c->at(c->size() - 1)
c->indexOf(o) Sequence, OrderedSet c->iterate(x; acc = Tuple{n = 0, r = null} | Tuple{n = acc.n + 1, r = if acc.r = null and x = o then acc.n else acc.r endif}).r
c->append(o) Sequence, OrderedSet c->including(o)
c->prepend(o) Sequence, OrderedSet c->iterate(x; acc = K{o} | acc->including(x))
c->subSequence(i, j), c->subOrderedSet(i, j) Sequence, OrderedSet c->iterate(x; acc = Tuple{n = 0, r = K{}} | Tuple{n = acc.n + 1, r = if acc.n >= i and acc.n < j then acc.r->append(x) else acc.r endif}).r (positions i up to j, exclusive)
c->insertAt(i, o) Sequence c->subSequence(0, i)->append(o)->union(c->subSequence(i, c->size()))
c->sortedBy(x | e) all c->iterate(x; acc = Sequence{} | acc->select(y | e(y) <= e(x))->append(x)->union(acc->select(y | e(y) > e(x)))), followed by ->asOrderedSet() for a Set or OrderedSet source (e(y) is e with y for x; stable insertion sort)
c - c2 Set c->select(x | c2->excludes(x)) (set difference, OCL 2.4 section 11.7.2)

Because and and or are the OCL three-valued connectives, the forAll and exists definitions reproduce the standard's treatment of undefined bodies (false and invalid = false, true or invalid = true). Index positions are 0-based throughout, so first() is at(0).

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) 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:

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.

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)

Deriving the model automatically

For plain OCLTerm classes the model can be reflected instead of written by hand. reflect takes modules and/or classes and reads class annotations (pages: set[Webpage], nick: str | None) and, for attributes that are only assigned in __init__, the values of the objects that exist at that moment:

from ocl import reflect, check_ocl, OCLTerm

class Web(OCLTerm):
    def __init__(self, domain):
        self.domain = domain
        self.pages = set()

class Webpage(OCLTerm):
    def __init__(self, name, web):
        self.name = name
        self.web = web
        web.pages.add(self)

d = Web("example.org")
pages = [Webpage("home", d), Webpage("news", d)]   # keep the objects referenced

m = reflect(Web)                     # Webpage is found through Web.pages
print(m.describe())
# Web.domain : String[1]
# Web.pages : Set(<Webpage>[1])
# Webpage.name : String[1]
# Webpage.web : <Web>[1]
print(check_ocl("self.pages.name->asSet()", m, {"self": m.object_type("Web")}))   # Set(String[1])

Precedence is overrides (an explicit {(class, property): Type} mapping), then annotations, then observed values. Observations over several objects are merged with the OCL least upper bound: an attribute that is None somewhere becomes [?], an always-empty collection has element type OclVoid, and a Python list is a Sequence unless list_kind="Bag". Methods are reported only when fully annotated, enums are picked up from enum.Enum classes, and everything skipped is listed in m.warnings. The result is observational ([1] means "no None seen"), so use annotations or overrides where a declared multiplicity matters.

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:

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 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 (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/reflect.py - reflect, a ModelInterface derived from Python classes (annotations and live objects)
  • 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). It builds with Isabelle2025: isabelle build -d Safe_OCL Safe_OCL (if an AFP copy of Safe_OCL is registered as a component, build with a user home that omits it, e.g. USER_HOME=/tmp/isa isabelle build -d Safe_OCL Safe_OCL)
  • docs/ - LaTeX/PDF documentation of the semantics and the type checking rules (repository only)
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).

Metadata

Release files for ocl-py 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ocl-py 0.6.0
File Size Uploaded
ocl_py-0.6.0.tar.gz 109.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ocl-py 0.6.0
File Interpreter ABI Platform
ocl_py-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 174.4 kB

Release files / ocl_py-0.6.0.tar.gz

Download URL ocl_py-0.6.0.tar.gz
Size 109.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a61ce4bdd3471420c83f4e163eb4d42263de53b5b8874b11360252492f82b75b
BLAKE2b-256 checksum
How to use checksums
bdd1100f83e99d524c3ecc99be8fdcb75ee37aaaa5069c91094736361d39e5ba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / ocl_py-0.6.0-py3-none-any.whl

Download URL ocl_py-0.6.0-py3-none-any.whl
Size 65.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
48d1dcc9d2d0fd9bba0b6449aa375a65bba79a105efe42c9fdbdd5d02122e08c
BLAKE2b-256 checksum
How to use checksums
45019d6e36f4cdb75d725d821c6474f5ceb2056d38d5399d609946315c0f0120
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 release files

0.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page