Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 1.0.0

- Accept an `evaluate` function in `resolve_template` instead of `fp_options`, so that the caller owns
compilation and its caching #37 (@ruscoder)
- Move the cache of compiled expressions out of the library into the caller, `ExpressionCache` is no
longer exported #37 (@ruscoder)
- Make `strict` and `evaluate` keyword-only #37 (@ruscoder)

## 0.3.1

- Make `ExpressionCache` thread-safe, it raised a spurious `FPMLValidationError` when an entry was evicted mid-lookup
Expand Down
38 changes: 27 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,30 +685,46 @@ result = resolve_template(



#### Cache
#### Custom evaluator

Expressions are compiled on every evaluation by default. Pass an `evaluate` function to reuse compiled expressions, cached the way your application needs, see [details](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md#using-a-custom-evaluator).

There's no cache by default, expressions are compiled on every evaluation. Pass an `ExpressionCache` through `fp_options` to reuse compiled expressions, see [details](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md#caching-compiled-expressions).
An evaluator takes a resource, an expression and a context, and returns the list of results. It is
responsible for the model and the user-defined functions itself.

Example:

```python
from fpml import ExpressionCache, resolve_template
from functools import lru_cache

result = resolve_template(
resource,
template,
context,
fp_options={'cache': ExpressionCache(max_size=1024)}
)
from fhirpathpy import compile
from fhirpathpy.models import models

from fpml import resolve_template


@lru_cache(maxsize=2**14)
def cached_compile(expression, model_name):
return compile(expression, models.get(model_name))


def evaluate(resource, expression, context):
return cached_compile(expression, "r4")(resource, context)


result = resolve_template(resource, template, context, evaluate=evaluate)
```

#### User-defined functions

There's an ability to pass user-defined functions through fp_options
There's an ability to pass user-defined functions to the evaluator. A custom evaluator applies them
itself, see [custom evaluator](#custom-evaluator).

Example:

```python
from fpml import make_evaluator, resolve_template

user_invocation_table = {
"pow": {
"fn": lambda inputs, exp=2: [i**exp for i in inputs],
Expand All @@ -720,7 +736,7 @@ result = resolve_template(
resource,
template,
context,
fp_options={'userInvocationTable': user_invocation_table}
evaluate=make_evaluator(options={"userInvocationTable": user_invocation_table})
)
```

65 changes: 38 additions & 27 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ result = resolve_template(
resource,
template,
context=None,
fp_options=None,
strict=False
strict=False,
evaluate=None
)
```

Expand All @@ -35,8 +35,8 @@ result = resolve_template(
- resource (Resource): The input FHIR resource to process.
- template (Any): The template describing the transformation.
- context (Optional[Context], optional): Additional context data. Defaults to None.
- fp_options (Optional[FPOptions], optional): Options for controlling FHIRPath evaluation. Defaults to None.
- strict (bool, optional): Whether to enforce strict mode. Defaults to False. See more details on [strict mode](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#strict-mode).
- evaluate (Optional[Evaluate], optional): Evaluates one FHIRPath expression against a resource and a context. Defaults to `make_evaluator()`, which compiles every expression on every evaluation.

### Returns:

Expand Down Expand Up @@ -97,9 +97,10 @@ Output:
### Using FHIR data-model

```python
from fpml import resolve_template
from fhirpathpy.models import models

from fpml import make_evaluator, resolve_template


template = {
"resourceType": "Patient",
Expand All @@ -112,11 +113,9 @@ template = {

context = {}

fp_options = {
"model": models["r4"]
}
evaluate = make_evaluator(models["r4"])

result = resolve_template(resource, template, context, fp_options)
result = resolve_template(resource, template, context, evaluate=evaluate)
print(result)
```

Expand All @@ -128,7 +127,7 @@ Output:
### Using user-defined functions

```python
from fpml import resolve_template
from fpml import make_evaluator, resolve_template


template = {
Expand All @@ -149,11 +148,9 @@ user_invocation_table = {
}
}

fp_options = {
"userInvocationTable": user_invocation_table
}
evaluate = make_evaluator(options={"userInvocationTable": user_invocation_table})

result = resolve_template(resource, template, context, fp_options)
result = resolve_template(resource, template, context, evaluate=evaluate)
print(result)
```

Expand All @@ -162,31 +159,45 @@ Output:
{'resourceType': 'Patient', 'name': [{'text': 'Name'}]}
```

### Caching compiled expressions

Parsing FHIRPath expressions is expensive, so expressions can be compiled once and reused via
`ExpressionCache` passed through `fp_options`. The cache size is the number of compiled expressions
kept in memory, zero disables caching.
### Using a custom evaluator

Entries are keyed by the expression only, while compilation binds the model and the user-defined
functions, so keep one long-living cache per `fp_options`. A cache is safe to share between threads.
By default every expression is compiled on every evaluation, which is expensive. Pass an `evaluate`
function to reuse compiled expressions, cached the way your application needs. It takes a resource,
an expression and a context, and returns the list of results.

```python
from functools import lru_cache

from fhirpathpy import compile
from fhirpathpy.models import models

from fpml import ExpressionCache, resolve_template
from fpml import resolve_template


# 1024 long expressions take up to 100mb
fp_options = {
"model": models["r4"],
"cache": ExpressionCache(max_size=1024),
}
@lru_cache(maxsize=1024)
def cached_compile(expression, model_name):
return compile(expression, models.get(model_name))


def evaluate(resource, expression, context):
return cached_compile(expression, "r4")(resource, context)


for resource in resources:
resolve_template(resource, template, context, fp_options)
resolve_template(resource, template, context, evaluate=evaluate)
```

Compilation binds the model and the user-defined functions, so cache entries are only reusable for
the same pair. Keep them in the key, like `model_name` above, when the application evaluates against
more than one model, otherwise expressions compiled for one silently resolve against the other.

A compiled expression retains its parsed AST, so the memory cost grows with the expression length:
`1024` of them take about 11mb for short expressions and up to 290mb for 1kb ones. Size the cache for
the number of distinct expressions your templates and questionnaires actually contain.

`make_evaluator` builds the default evaluator from a model and a user-defined function table, as the
examples above do. A custom evaluator applies both itself.

### Handling validation errors

```python
Expand Down
7 changes: 5 additions & 2 deletions python/fpml/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import importlib.metadata

from .core.cache import ExpressionCache
from .core.core_exceptions import FPMLValidationError
from .core.core_types import Evaluate
from .core.evaluator import FPOptions, make_evaluator
from .core.extract import resolve_template

__title__ = "fpml"
Expand All @@ -11,7 +12,9 @@
__copyright__ = "Copyright 2025 beda.software"

__all__ = [
"ExpressionCache",
"Evaluate",
"FPMLValidationError",
"FPOptions",
"make_evaluator",
"resolve_template",
]
58 changes: 0 additions & 58 deletions python/fpml/core/cache.py

This file was deleted.

32 changes: 3 additions & 29 deletions python/fpml/core/core_types.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
from typing import TYPE_CHECKING, Any, Callable, Optional, TypedDict, Union
from typing import Any, Callable, Optional, TypedDict, Union

from typing_extensions import NotRequired

if TYPE_CHECKING:
from .cache import ExpressionCache

Resource = dict[str, Any]
Node = Any
DictNode = dict[str, Any]
Expand All @@ -30,30 +27,7 @@ class UserFnDefinition(TypedDict):
UserInvocationTable = dict[str, UserFnDefinition]


class FPOptions(TypedDict):
"""
Optional parameters for controlling FHIRPath evaluation.

Attributes:
model (Optional[Model]):
An optional "model" data object specific to a domain, e.g. R4.
See https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#using-data-models
userInvocationTable (Optional[UserInvocationTable]):
A table of user-defined functions that
can be used in FHIRPath expressions during template processing.
See https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#user-defined-functions
cache (Optional[ExpressionCache]):
A cache of compiled expressions, e.g. ExpressionCache(max_size=1024).
Expressions are compiled on every evaluation when it's not passed.

See Also:
FHIRPath py Documentation:
https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#fhirpathpy
"""

model: NotRequired[Model]
userInvocationTable: NotRequired[UserInvocationTable]
cache: NotRequired["ExpressionCache"]
Evaluate = Callable[[Resource, str, Context], list[Any]]


class MatcherResult(TypedDict):
Expand All @@ -66,7 +40,7 @@ class MatcherResult(TypedDict):
Resource,
DictNode,
Context,
Optional[FPOptions],
Evaluate,
],
Optional[MatcherResult],
]
Expand Down
23 changes: 23 additions & 0 deletions python/fpml/core/evaluator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any, Optional, TypedDict

from fhirpathpy import evaluate as fhirpath_evaluate # type: ignore
from typing_extensions import NotRequired

from .core_types import Context, Evaluate, Model, Resource, UserInvocationTable


class FPOptions(TypedDict):
"""Options passed to fhirpathpy, see
https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#user-defined-functions
"""

userInvocationTable: NotRequired[UserInvocationTable]


def make_evaluator(model: Optional[Model] = None, options: Optional[FPOptions] = None) -> Evaluate:
"""Builds the default evaluator, which compiles every expression on every evaluation."""

def evaluate(resource: Resource, expression: str, context: Context) -> list[Any]:
return fhirpath_evaluate(resource, expression, context, model, options)

return evaluate
Loading
Loading