-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
209 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,80 @@ | ||
# How to use Indico Patcher | ||
# Indico Patching Guide | ||
|
||
<!-- TODO --> | ||
This guide describes how to use `indico-patcher` for multiple use cases and considerations to keep in mind when patching Indico. In this page, you can read general information applicable in all the use cases described in the rest of the guide. | ||
|
||
- [Terminology](#terminology) | ||
- [Usage](#usage) | ||
- [FAQs](#faqs) | ||
- [Keep in mind](#keep-in-mind) | ||
|
||
The guide covers in detail the following use cases in dedicated pages: | ||
|
||
1. [Patching Indico classes](./classes.md) | ||
2. [Patching SQLAlchemy models](./models.md) | ||
3. [Patching WTForm forms](./forms.md) | ||
4. [Patching Enums](./enums.md) | ||
|
||
## Terminology | ||
|
||
- **Patch class**: A class that is decorated with the `@patch` decorator. It is uniquely used to patch an existing class in Indico and never to be imported and used directly. | ||
- **Original class**: The Indico class that is being patched by a patch class, passed as argument to the `@patch()` decorator. | ||
- **Patched class**: The class that results from the application of a patch class to an original class. | ||
- **Patch**: A collection of attributes, methods and properties defined in a patch class that are applied to the original class. A patch is applied when the patch class is imported. | ||
|
||
## Usage | ||
|
||
Import the `patch` decorator from the `indico_patcher` module: | ||
|
||
```python | ||
from indico_patcher import patch | ||
``` | ||
|
||
Import also the original class to be patched: | ||
|
||
```python | ||
from indico.modules.users.models.users import User | ||
from indico.modules.users.models.user_titles import UserTitle | ||
``` | ||
|
||
Define a patch class and decorate it with the `@patch()` decorator. The original class is passed as argument to the decorator. Since the patch class is not meant to be used directly, it is recommended to prefix its name with an underscore: | ||
|
||
```python | ||
@patch(User) | ||
class _User: | ||
... | ||
``` | ||
|
||
This differs a bit in the case of enums, as the patch class must inherit from `Enum` or a subclass of it: | ||
|
||
```python | ||
@patch(UserTitle) | ||
class _UserTitle(RichIntEnum): | ||
... | ||
``` | ||
|
||
Once the patch class is imported in the plugin, the original class will be patched and you can access the newly defined members as if they were part of it. | ||
|
||
> [!NOTE] | ||
> For more specific usage details, please refer to the different pages of the guide. | ||
## FAQs | ||
|
||
### When are patches applied? | ||
|
||
The patches are applied when the patch classes are imported. This means that you need to import the patch classes in your plugin's `__init__.py` file or in any other file that is imported by it. | ||
|
||
### Can the same class be patched multiple times? | ||
|
||
Yes, this is possible and it is useful or unavoidable in some cases. For instance, you may want to patch the same class in two different modules of your plugin. Or you may enable two different plugins that patch the same class. In both cases, the patches will be applied in the order in which the patch classes are imported. This means that if multiple patches are overriding the same class member, the last one will be the one that is applied. | ||
|
||
### What are some built-in tools to avoid patching Indico? | ||
|
||
Indico provides many signals that can be used to extend its functionality without patching it. You can find a list of all the available signals in [`indico/core/signals`](https://github.com/indico/indico/tree/v3.2.8/indico/core/signals). A particularly versatile one is [`interceptable_function`](https://github.com/indico/indico/blob/v3.2.8/indico/core/signals/plugin.py#L121). You may also want to check [Flask signals](https://flask.palletsprojects.com/en/2.0.x/api/#signals) and [SQLAlchemy event hooks](https://docs.sqlalchemy.org/en/14/core/event.html). | ||
|
||
## Keep in mind | ||
|
||
> [!WARNING] | ||
> Remember! With great power comes great responsibility. Patch Indico as little as possible and only when it is absolutely necessary. Always try to find a way to achieve what you want without patching Indico. | ||
> [!NOTE] | ||
> The code examples and links to APIs in this guide make reference to Indico classes and dependencies as they are defined in the `v3.2.8` of the codebase. Please note that some class names, class locations and APIs may differ if you are using a different version of Indico. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# Patching Enums |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# Patching WTForm forms |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
# Patching SQLAlchemy models | ||
|
||
This page of the guide explains how to patch SQLAlchemy models defined in Indico. This is useful, for instance, when you want to add new columns or relationships to an already existing model, or when you want to add new constraints to an already existing table. This is illustrated with some examples of common use cases. | ||
|
||
- [Adding new columns and relationships](#adding-new-columns-and-relationships) | ||
- [Adding and modifying hybrid properties](#adding-and-modifying-hybrid-properties) | ||
- [Adding, removing and replacing table constraints](#adding-removing-and-replacing-table-constraints) | ||
- [Generating Alembic migration scripts for patched models](#generating-alembic-migration-scripts-for-patched-models) | ||
|
||
## Adding new columns and relationships | ||
|
||
As an example, you can add a new `credit_card_id` column to the `User` model in Indico, and a `credit_card` relationship to a hypothetical `CreditCard` model. | ||
|
||
```python | ||
@patch(User) | ||
class _User: | ||
credit_card_id = db.Column(db.String, ForeignKey('credit_cards.id')) | ||
credit_card = db.relationship('CreditCard', backref=backref('user')) | ||
``` | ||
|
||
You can then use the new column and relationship to insert, update and query rows in the database as if they were defined in the original model. | ||
|
||
```python | ||
user = User.query.filter_by(id=1).one() | ||
user.credit_card = CreditCard('XXXX-XXXX-XXXX-XXXX') | ||
``` | ||
|
||
```python | ||
users = User.query.filter(User.credit_card == None).all() | ||
``` | ||
|
||
> [!IMPORTANT] | ||
> Please note that for this to work as expected, you will still need to add the new column to the database schema via Alembic migration script. How to do this is explained in a [section below](#generating-alembic-migration-scripts-for-patched-models). | ||
## Adding and modifying hybrid properties | ||
|
||
Adding a new hybrid property is as simple as defining a new method and decorating it with the `@hybrid_property` decorator as you would in any model. | ||
|
||
```python | ||
@patch(Event) | ||
class _Event: | ||
@hybrid_property | ||
def is_in_series(self): | ||
return self.series_id is not None | ||
|
||
@is_in_series.expression | ||
def is_in_series(self): | ||
return ~self.series_id.is_(None) | ||
``` | ||
|
||
Replacing an existing hybrid property is also simple. Just define a new method with the same name as the original property and decorate it with the `@hybrid_property` decorator. The original hybrid property will be replaced with the new one. | ||
|
||
```python | ||
@patch(Event) | ||
class _Event: | ||
@hybrid_property | ||
def event_message(self): | ||
return '' | ||
|
||
@event_message.setter | ||
def event_message(self, value): | ||
pass | ||
|
||
@event_message.expression | ||
def event_message(self): | ||
return '' | ||
``` | ||
|
||
## Adding, removing and replacing table constraints | ||
|
||
Adding new constraints is easy, as it only requires calling the `append_constraint` method of the `__table__` attribute of an original model. | ||
|
||
```python | ||
@patch(RegistrationForm) | ||
class _RegistrationForm: | ||
RegistrationForm.__table__.append_constraint( | ||
db.CheckConstraint(...) | ||
) | ||
``` | ||
|
||
Removing constraints is a bit more involved, as it requires to first find the constraint you want to remove from the set of table constraints. The easiest way to find the name of the constraint to remove is to inspect the database schema directly (e.g. via `psql` command). | ||
|
||
```python | ||
@patch(RegistrationForm) | ||
class _RegistrationForm: | ||
RegistrationForm.__table__.constraints -= { | ||
c for c in RegistrationForm.__table__.constraints | ||
if c.name == '<constraint_name>' | ||
} | ||
``` | ||
|
||
Finally, replacing a constraint requires both previous steps. First, remove the old constraint and, then, add the new one. | ||
|
||
```python | ||
@patch(RegistrationForm) | ||
class _RegistrationForm: | ||
RegistrationForm.__table__.constraints -= { | ||
c for c in RegistrationForm.__table__.constraints | ||
if c.name == '<constraint_name>' | ||
} | ||
RegistrationForm.__table__.append_constraint( | ||
db.CheckConstraint(..., '<constraint_name>') | ||
) | ||
``` | ||
|
||
> [!IMPORTANT] | ||
> Please note that for the new constraint definitions to take effect, you still need to apply them to the database schema via Alembic migration script. How to do this is explained in a [section below](#generating-alembic-migration-scripts-for-patched-models). | ||
> [!NOTE] | ||
> It's worth noting that patching for modifying table constraints is not necessary, as you are calling `__table__` directly the original model. It may still be a good idea to do it within a patch class as a way to keep all modifications of an Indico model under the same class. | ||
## Generating Alembic migration scripts for patched models | ||
|
||
Once patches are defined, you need to generate the Alembic migration script that will perform the updates to the database schema. | ||
|
||
Typically, for plugin-defined tables, this is done by invoking `indico db --plugin <plugin-name> migrate`. However, since the new columns and table constraints are defined in Indico core classes, the migration script needs to be generated with this instead: | ||
|
||
```sh | ||
indico db migrate | ||
``` | ||
|
||
This will generate a new Alembic migration script in the `indico/migrations/versions/` directory of the `indico` package. This will not be its final location, as you shouldn't add a new migration script to the Indico core. Instead, move the script to the migrations directory of the plugin and adjust its `down_revision` to point to the latest revision. | ||
|
||
Finally, once the migration script is in the plugin's migrations directory, and manually adjusted, you can apply it to the database schema with: | ||
|
||
```sh | ||
indico db --plugin <plugin-name> upgrade | ||
``` |