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
- Quick start
- Python classes as the data model
- Syntax
- Semantics
- Type checking
- Compiling expressions
- Known limitations and risks
- Development
- License
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) andinvalid(error) are distinct internally; partial operations (division by zero, out-of-bounds access, navigation fromnull, ...) yieldinvalidinstead of raising. At theeval_oclboundary both are returned as PythonNone; useoclIsUndefined()/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), theninvalid, thennull.forAll/existsare the corresponding folds;select/rejectrequire a defined boolean body. - Equality.
=/<>are strict ininvalidonly (null = nullistrue). 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), so9007199254740993.0 = 9007199254740993holds; Real results are coerced tofloatat theeval_oclboundary. - The
invalidliteral is accepted in expressions (the standard's abstract syntax has no such literal). div/moduse floor division (Python//), so-7 div 2 = -4and-7 mod 2 = 1; OCL 2.4 truncates toward zero. The identitya = (a div b)*b + (a mod b)holds in both.collectdoes not flatten nested results (it is a plain map), socollectNestedcoincides withcollectand 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'slist,setanddictby the forbiddenfruit package, which rewrites the builtin types' method tables throughctypeswhile an expression is being evaluated (theocl_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.countorlist.appendon 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
BagandSequenceshare thelistrepresentation. - The deviations from the OCL standard listed under
Semantics (0-based indexing, exact reals, floor
div/mod, non-flatteningcollect) 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, aModelInterfacederived from Python classes (annotations and live objects)ocl/parser/- generated by ANTLR fromOclExpression.g4(not committed;make grammars, needs Java 11+ andpip 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)
| File | Size | Uploaded | |
|---|---|---|---|
| ocl_py-0.6.0.tar.gz | 109.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|