oarepo-model 0.1.0.dev51
Add your description here
Requires Python: <3.15,>=3.13

# OARepo Model

Model customizations and builders for [Invenio](https://inveniosoftware.org/) framework.

## Overview

This package provides a way of building an Invenio model with user customizations. It allows you to add mixins, classes to components, routes, and other customizations to the model while ensuring that the model remains consistent, functional and upgradable.

## Installation

```bash
pip install oarepo-model
```

### Requirements

- Python 3.13+
- Invenio 14.x

## High-level API

First, create a model using the `model` function from `oarepo_model.api` and include the necessary presets.

```python
# mymodel.py

from oarepo_model.api import model
from oarepo_model.presets.records_resources import records_resources_preset
from oarepo_model.presets.drafts import drafts_preset


my_model = model(
    "my_model",
    version="1.0.0",
    presets=[
        records_resources_preset,
        drafts_preset,
    ],
    customizations=[
    ],
)
```

Then you need to register the model before Invenio is initialized. The best way is
to do it in the `invenio.cfg` file:

```python
# invenio.cfg

from mymodel import my_model

my_model.register()
```

## Adding customizations

You can add customizations to the model by using the `customizations` parameter
of the `model` function. The following customizations are available, importable from
`oarepo_model.customizations`:

| Name | Description |
| ---- | ----------- |
| **classes** |      |
| `AddClass(name)` | Adds a new class to the model. |
| `AddBaseClass(name, base_class)` | Adds a single base class to a model class. Call multiple times to add multiple base classes in order. |
| `AddClassField(name, field_name, field_value)` | Adds a field (attribute, method, or property) to an existing class. |
| `PrependMixin(name, mixin)` | Prepends a single mixin to a model class (adds it as the first parent). Call multiple times in reverse order for multiple mixins. |
| `ReplaceBaseClass(name, old_base, new_base)` | Replaces one base class with another in a model class. |
| **modules** |      |
| `AddModule(name, exists_ok=False)` | Adds a module to the model. |
| `AddToModule(module_name, property_name, value, exists_ok=False)` | Adds a property to a module in the model. |
| `AddFileToModule(symbolic_name, module_name, file_path, payload, exists_ok=False)` | Adds a file to the module with specified content. |
| `AddJSONFile(symbolic_name, module_name, file_path, payload, exists_ok=False)` | Adds a JSON file to the module (automatically serializes dictionary to JSON). |
| `CopyFile(source_symbolic_name, target_symbolic_name, target_module_name, target_file_path, exists_ok=False)` | Copies content from one symbolic file location to another. |
| `PatchJSONFile(symbolic_name, payload)` | Patches/modifies an existing JSON file by merging new data with existing content. |
| **lists** |      |
| `AddList(name, exists_ok=False)` | Adds a new list to the model. |
| `AddClassList(name, exists_ok=False)` | Adds a new class list to the model. A class list keeps an MRO-consistent order of classes and can be used later as bases for a generated class. If this ordering functionality is not required, use `AddList`. |
| `AddToList(list_name, value, exists_ok=False)` | Appends a value to an existing list in the model. Set `exists_ok=True` to allow duplicates. |
| **dicts** |      |
| `AddDictionary(name, default=None, exists_ok=False)` | Adds a dictionary to the model. |
| `AddToDictionary(name, {..}...)` or `AddToDictionary(name, key=..., value=..., patch=False)` | Adds entries to a dictionary in the model (optionally merge with `patch=True`). |
| **entry points** |      |
| `AddEntryPoint(group, name, module_path)` | Adds an entry point to the model. |
| **facets** |      |
| `AddFacetGroup(name, facets, exists_ok=False)` | Adds a facet group to the model for search result filtering. |
| **high-level** |       |
| `AddMetadataExport(code, name, mimetype, serializer, ...)` | Adds a serializer for metadata exports. |
| `AddMetadataImport(code, name, mimetype, deserializer, ...)` | Adds a deserializer for metadata imports. |
| `AddPIDRelation(name, path, keys, pid_field, ...)` | Declares a PID relation system field based on a path (supports list and nested-list relations). |
| `SetDefaultSearchFields(*search_fields)` | Specifies a set of default search fields for the index. |
| `PatchIndexSettings(settings)` | Patches/modifies OpenSearch/Elasticsearch index settings. |
| `SetIndexTotalFieldsLimit(limit)` | Sets the `index.mapping.total_fields.limit` setting. |
| `SetIndexNestedFieldsLimit(limit)` | Sets the `index.mapping.nested_fields.limit` setting. |
| `SetPermissionPolicy(policy_class)` | Sets the permission policy for the model. |

### Extending class with a mixin

To add a mixin to a class in the model, you can use the `PrependMixin` customization.
Mixins are prepended to the class, so they take precedence in the MRO. If the resulting
MRO would be inconsistent, it is automatically reordered to a consistent order.

```python
from oarepo_model.customizations import PrependMixin
from my_mixins import BaseMixin

my_model = model(
    "my_model",
    version="1.0.0",
    presets=[
        records_resources_preset,
        drafts_preset,
    ],
    customizations=[
        PrependMixin("Record", BaseMixin),
    ],
)
```

### Adding a new service component

```python
from oarepo_model.customizations import AddToList

class MyComponent:
    ...

my_model = model(
    "my_model",
    version="1.0.0",
    presets=[
        records_resources_preset,
        drafts_preset,
    ],
    customizations=[
        AddToList("record_service_components", MyComponent),
    ],
)
```

### Generating metadata schema via data types

To generate `RecordSchema`/`MetadataSchema` from a type definition, pass `types` and set `metadata_type` to the name of the root type:

```python
from oarepo_model.api import model
from oarepo_model.presets.records_resources import records_resources_preset

my_model = model(
    "my_model",
    version="1.0.0",
    presets=[records_resources_preset],
    types=[
        {
            "RecordMetadata": {
                "properties": {
                    "title": {"type": "fulltext+keyword", "required": True},
                },
            }
        }
    ],
    metadata_type="RecordMetadata",
)
```



## Behind the scenes

When the model is created, the following steps are performed:

1. An instance of `InvenioModel` is created. This instance holds the basic configuration
   of the model, such as its name, version, api and ui slugs.

2. An instance of an `InvenioModelBuilder` is created.

3. All presets are collected and sorted according to their dependencies.

4. For each preset:
   1. Dependencies of the preset are collected, including those that were passed
      as `customizations` to the model. If the dependency has not yet been built,
      it is built at this moment.
   2. The `apply` method is called with the builder and the model. The method returns
      a list of customizations that are applied to the model.

5. If there are any unapplied customizations, they are applied to the model.

6. During the application of the customizations, instances of `Partial` are created
   within the builder, such as `BuilderClass`, `BuilderList`, `BuilderModule`. These
   instances provide a recipe for a part of the final model. The part is built either
   if it is needed by a preset/customization or at the end of the model building process.

7. The result of the model building process is transformed into a `SimpleNamespace`
   and returned to the caller. The returned object also provides helpers:
   - `register()` / `unregister()` — to register the in-memory model for import/entry points
   - `get_resources()` — to retrieve the in-memory files as a `{path: content}` mapping

## Registering the model

Invenio needs some parts of the model to be registered via entry points. We provide
a `register` method on the model instance that automatically adds the model to the
entry points via registering a new importer to `sys.meta_path`. This allows
Invenio to find model components in the entry points and use them during the
initialization process.

The call needs to be done before Invenio is initialized, which is why the best place
to do it is in the `invenio.cfg` file.

## Design decisions

### Late binding

The classes within the model should be as loosely coupled as possible. This is implemented
by using dependency injection wherever possible.

#### Dependency descriptor

A dependency descriptor makes sure that the class is loaded from the model during runtime.
This allows adding circular dependencies between classes, for example.

**Note:** This does not work with Invenio's system fields, as these are handled in
a special way by Invenio and are skipped. For example, a `pid` field on a record might
not be created in this way.

```python

class A:
    b = Dependency("B")
```

#### `builder.get_runtime_dependencies()`

This call returns an object that can return resolved dependencies during runtime via
its `get` method. This is useful, for example, when you want to access a model artifact from
within a function or a method. This cannot be used in static initialization.

```python

class MyPreset:
    def apply(self, builder, model, ...):
        runtime_deps = builder.get_runtime_dependencies()
        class A:
            def __init__(self):
                self.b = runtime_deps.get("B")
        yield AddClass("A", A)
```

#### Injected properties

`oarepo_model` and `oarepo_model_namespace` are injected into every generated class. Imported
modules created by the model expose whatever attributes you added via customizations, but do not
receive special injections automatically.

### Early binding (for system fields and similar)

In system fields, due to the way they are initialized in Invenio, we cannot use
late binding. This means that we need to use the classes directly in the system fields,
and they have to be built before the system fields are declared. This is done via reordering
the presets and customizations so that the system fields' classes are built before the classes
that use them.

Each preset has two properties: `provides` and `depends_on`. The `provides` property
is a list of classes that the preset provides or modifies, while the `depends_on`
property is a list of classes that the preset depends on. The presets are sorted
by their dependencies, so that the dependencies are built before the preset itself.

You can then get the built dependencies from the 3rd argument of the `apply` method
of the preset. This is a dictionary of classes that were built during the model building process.

Example:

```python

class MyPreset(Preset):
    provides = ["MyClass"]
    depends_on = ["Record"]

    def apply(self, builder, model, dependencies):
        # dependencies is a dict of classes that were built during the model building process
        class MyClass(metaclass=MetaThatNeedsToHaveBProperty):
            b = dependencies["Record"]  # The Record has been built at this point and is a valid class
        yield AddClass("MyClass", MyClass)
```

## Development

### Setup

```bash
# Clone repository
git clone https://github.com/oarepo/oarepo-model.git
cd oarepo-model

./run.sh venv
```

### Running Tests

```bash
./run.sh test
```

## License

Copyright (c) 2025 CESNET z.s.p.o.

OARepo Model is free software; you can redistribute it and/or modify it under the terms of the MIT License. See [LICENSE](LICENSE) file for more details.

## Links

- Documentation: <https://github.com/oarepo/oarepo-model>
- PyPI: <https://pypi.org/project/oarepo-model/>
- Issues: <https://github.com/oarepo/oarepo-model/issues>
- OARepo Project: <https://github.com/oarepo>

## Acknowledgments

This project builds upon [Invenio Framework](https://inveniosoftware.org/) and is developed as part of the OARepo ecosystem.