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.

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)

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/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)
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).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ocl_py-0.5.0.tar.gz (89.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ocl_py-0.5.0-py3-none-any.whl (56.0 kB view details)

Uploaded Python 3

File details

Details for the file ocl_py-0.5.0.tar.gz.

File metadata

  • Download URL: ocl_py-0.5.0.tar.gz
  • Upload date:
  • Size: 89.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for ocl_py-0.5.0.tar.gz
Algorithm Hash digest
SHA256 3f0b18325f48dfe531298f902a9cbf963fc7fc1fc9e7029e26c46368aef552bd
MD5 3f0de5219dc71096a77ce91b552c09d9
BLAKE2b-256 1223f997b65c65f581a85a42df856d8a306583e405da5df8be6e153b7353dd84

See more details on using hashes here.

File details

Details for the file ocl_py-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: ocl_py-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 56.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for ocl_py-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8b3655de027cf7b733e6d95ee9f5dee475395531e17d6cb08f80cd3eff917cd8
MD5 5da0a7889292f8e4af8a13655cfc5c0d
BLAKE2b-256 d3449122105c0e000a01916106466250f0dbaaf5e4bb5dfc4fd8f5b7764fd8d2

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 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