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
- 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.
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)
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/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)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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3f0b18325f48dfe531298f902a9cbf963fc7fc1fc9e7029e26c46368aef552bd
|
|
| MD5 |
3f0de5219dc71096a77ce91b552c09d9
|
|
| BLAKE2b-256 |
1223f997b65c65f581a85a42df856d8a306583e405da5df8be6e153b7353dd84
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b3655de027cf7b733e6d95ee9f5dee475395531e17d6cb08f80cd3eff917cd8
|
|
| MD5 |
5da0a7889292f8e4af8a13655cfc5c0d
|
|
| BLAKE2b-256 |
d3449122105c0e000a01916106466250f0dbaaf5e4bb5dfc4fd8f5b7764fd8d2
|