-
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
5 changed files
with
514 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,88 @@ | ||
# 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 WTForms 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 injected into 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. New members can be accessed as if they had been defined in the original class. All calls to existing members will be redirected to the ones defined in the patch class. 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. In some cases, like having a `patches.py` file with only patch classes, your linter may complain that the import is unused. You can safely silence this warning. | ||
|
||
### 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 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 useful 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 `v3.2.8` of the codebase. 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,128 @@ | ||
# Patching Indico classes | ||
|
||
This page of the guide explains how to generally patch Indico classes to add and override attributes, methods and properties. For more specific use cases, like patching [SQLAlchemy models](./models.md) or [WTForms forms](./forms.md), please refer to their respective pages of the guide. | ||
|
||
- [Adding and overriding attributes](#adding-and-overriding-attributes) | ||
- [Adding and overriding methods](#adding-and-overriding-methods) | ||
- [Adding and overriding properties](#adding-and-overriding-properties) | ||
|
||
## Adding and overriding attributes | ||
|
||
It is possible to add a new attribute to the original class by simply defining it in the patch class. For instance, you could add an `error_message` attribute to `RHUserBlock` that will be used in overridden methods in a section below. | ||
|
||
```python | ||
@patch(RHUserBlock) | ||
class _RHUserBlock: | ||
# Adds new attribute | ||
error_message = L_('Action not allowed on this user.') | ||
``` | ||
|
||
It is also possible to override an existing attribute in the original class by assigning a new value to it in the patch class. In this example, a plugin-defined form is assigned to be used by `LocalRegistrationHandler` instead of the original `LocalRegistrationForm`. | ||
|
||
```python | ||
@patch(LocalRegistrationHandler) | ||
class _LocalRegistrationHandler: | ||
# Overrides existing attribute | ||
form = CustomLocalRegistrationForm | ||
``` | ||
|
||
## Adding and overriding methods | ||
|
||
Similarly, it is possible to add a new method to the original class by defining it in the patch class. For instance, you could add a `_can_block_user` method to `RHUserBlock` that will be used in overridden methods. | ||
|
||
```python | ||
@patch(RHUserBlock) | ||
class _RHUserBlock: | ||
# Adds new method | ||
def _can_block_user(self): | ||
return not self.user.merged_into_user | ||
``` | ||
|
||
Defining a method on the patch class with the same name as an existing method in the original class will override the original method. Also, it's possible to call the original method from the patch class by using `super()`. | ||
|
||
In this example, the PUT and DELETE process methods of the class `RHUserBlock` are intercepted to check if the user can be blocked with the newly added `_can_block_user()`. If not, an exception is raised with `error_message`. Otherwise, the original method is called. | ||
|
||
```python | ||
@patch(RHUserBlock) | ||
class _RHUserBlock: | ||
# Adds new attribute | ||
error_message = L_('Action not allowed on this user') | ||
|
||
# Adds new method | ||
def _can_block_user(self): | ||
return not self.user.merged_into_user | ||
|
||
# Overrides existing method | ||
def _process_PUT(self): | ||
if self._can_block_user(): | ||
raise Forbidden(self.error_message) | ||
return super()._process_PUT() | ||
|
||
# Overrides existing method | ||
def _process_DELETE(self): | ||
if self._can_block_user(): | ||
raise Forbidden(self.error_message) | ||
return super()._process_PUT() | ||
``` | ||
|
||
This works as well for `@staticmethod` and `@classmethod`: | ||
|
||
```python | ||
@patch(User) | ||
class _User: | ||
# Adds new classmethod | ||
@classmethod | ||
def get_academic_users(cls): | ||
return cls.query.filter(cls.title.in_({UserTitle.dr, UserTitle.prof})) | ||
|
||
# Overrides existing staticmethod | ||
@staticmethod | ||
def get_system_user(): | ||
system_user = super().get_system_user() | ||
logger.info('System user was retrieved.') | ||
return system_user | ||
``` | ||
|
||
> [!IMPORTANT] | ||
> Overriding a method in the original class is fragile and can break in future versions of Indico if the original method is changed. A more reliable way to override a method in the original class is to intercept calls to it via the [`interceptable_function`](https://github.com/indico/indico/blob/v3.2.8/indico/core/signals/plugin.py#L121) signal. | ||
## Adding and overriding properties | ||
|
||
Adding a new property to the original class is also supported by defining it in the patch class. As an example, you could add a new `is_academic` property to `User` that will be check if a user's title is either Doc. or Prof. | ||
|
||
```python | ||
@patch(User) | ||
class _User: | ||
# Adds new property | ||
@property | ||
def is_academic(self): | ||
return self.title in {UserTitle.dr, UserTitle.prof} | ||
``` | ||
|
||
As expected, overriding a property as well as its setter and deleter descriptor methods is supported. Like in the case of methods, it's possible to call the original property descriptor method from the patch class by using `super()`. | ||
|
||
For example, here below the `data` property of `Identity` is overridden to add a new key to the dictionary returned by the original property. The setter and deleter descriptors are also overridden to raise an exception if they are called. | ||
|
||
```python | ||
@patch(Identity) | ||
class _Identity: | ||
# Overrides existing property descriptor method | ||
@property | ||
def data(self): | ||
data = super().data | ||
data.update({'tag': 'plugin'}) | ||
return data | ||
|
||
# Overrides existing property descriptor method | ||
@data.setter | ||
def data(self, value): | ||
raise RuntimeError('Cannot set data on identity') | ||
|
||
# Adds new property descriptor method | ||
@data.deleter | ||
def data(self): | ||
raise RuntimeError('Cannot delete data on identity') | ||
``` | ||
|
||
> [!NOTE] | ||
> It is not currently possible to call `super()` on the `setter` and `deleter` descriptor methods of properties. |
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,57 @@ | ||
# Patching Enums | ||
|
||
Enums in Indico are commonly used to define valid values in certain database columns and form fields. This page of the guide explains how to patch Indico Enums to add members and carry over additional attributes. | ||
|
||
- [Adding new members](#adding-new-members) | ||
- [Inject extra attributes](#inject-extra-attributes) | ||
|
||
## Adding new members | ||
|
||
```python | ||
@patch(SurveyState) | ||
class _SurveyState(IndicoEnum): | ||
# Adds a new member with value 101 | ||
disabled = 101 | ||
``` | ||
|
||
New members can be added to the original Enum by defining them in the patch Enum. In this example, the `disabled` member is added to the `SurveyState` Enum with the value `101`. | ||
|
||
> [!IMPORTANT] | ||
> Make sure to set a high-enough value that guarantees that values of new members will not conflict with present AND future values defined in Indico. This is especially important when patching Enums used in `pyIntEnum` columns. | ||
```python | ||
@patch(SurveyState, padding=100) | ||
class _SurveyState(IndicoEnum): | ||
# Also adds a new member with value 101 | ||
disabled = 1 | ||
``` | ||
|
||
A more succinct way of guaranteeing that values of new members will not conflict is to use the `padding` argument of the `@patch()` decorator. | ||
|
||
```python | ||
@patch(UserTitle, padding=100) | ||
class _UserTitle(RichIntEnum): | ||
# Adds new members and their associated human-readable titles | ||
__titles__ = [None, 'Madam', 'Sir', 'Rev.'] | ||
madam = 1 | ||
sir = 2 | ||
rev = 3 | ||
``` | ||
|
||
It's also possible to add new members to Indico-defined `RichEnum` and variants. In this example, new user titles are added. The `__titles__` attribute is used to define how they should be displayed in the user interface and it will be carried over to the original Enum. | ||
|
||
## Inject extra attributes | ||
|
||
```python | ||
# Overrides the __page_sizes__ of the original PageSize Enum | ||
@patch(PageSize, padding=100, extra_args=('__page_sizes__',)) | ||
class _PageSize: | ||
__page_sizes__ = {**PageSize.__page_sizes__, **{ | ||
'A7': pagesizes.A7, | ||
'A8': pagesizes.A8, | ||
}} | ||
a7 = 1 | ||
a8 = 2 | ||
``` | ||
|
||
Only the attributes used by `RichEnums` are carried over to the original Enum (e.g. `__titles__`, `__css_classes__`). All other attributes defined in the patch Enum are ignored unless explicitly declared in the `extra_args` argument of the `@patch` decorator. |
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,114 @@ | ||
# Patching WTForms forms | ||
|
||
Forms in Indico are defined using the [WTForms](https://wtforms.readthedocs.io/) library and most of them are rendered based on the fields defined in the form classes. Common use cases for patching Indico are adding, modifying, removing or reordering fields in forms. This page of the guide explains how to do this and handle some other cases. | ||
|
||
- [Adding new fields](#adding-new-fields) | ||
- [Modifying existing fields](#modifying-existing-fields) | ||
- [Removing existing fields](#removing-existing-fields) | ||
- [Reordering fields](#reordering-fields) | ||
- [Altering field validators](#altering-field-validators) | ||
- [Altering field choices](#altering-field-choices) | ||
|
||
## Adding new fields | ||
|
||
```python | ||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
# Adds new field (this usually requires adding a new column to the database) | ||
is_sponsored = BooleanField(_('Sponsored'), [DataRequired()], widget=SwitchWidget()) | ||
``` | ||
|
||
The field can be defined in the form as if it was defined in the original class. Custom validation `validate_<field_name>()` functions and `@genereated_data` methods can also be added as it's possible in non-patched forms. | ||
|
||
> [!NOTE] | ||
New fields will appear by default at the end of the form. For customizing the order of fields, please refer to the [reordering fields](#reordering-fields) section. | ||
|
||
## Modifying existing fields | ||
|
||
```python | ||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
# Replaces the original field with a field of a different type | ||
description = StringField(_('Description'), EventDataForm.description.validators) | ||
``` | ||
|
||
Existing fields can be redefined in the patch class. This is useful, for instance, when you want to change the type of a field. In this example, the validators are set to be the same as in the original field. | ||
|
||
## Removing existing fields | ||
|
||
```python | ||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
# Removes the original field | ||
del EventDataForm.url_shortcut | ||
``` | ||
|
||
If the field is used to populate a model object, it may be necessary to remove the corresponding column from the database, to alter the column to be nullable, or to set a default value in the column definition. | ||
|
||
> [!NOTE] | ||
> Patching for removing fields is not necessary, as you are deleting the attribute in the original class directly. It may still be a good idea to do it within a patch class as a way to keep all modifications of a WTForm under the same class. | ||
## Reordering fields | ||
|
||
```python | ||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
# Reorders new field to appear before title field | ||
def __iter__(self): | ||
for field_name in self._fields: | ||
if field_name in ('is_sponsored',): | ||
continue | ||
if field_name == 'title': | ||
yield self._fields['is_sponsored'] | ||
yield self._fields[field_name] | ||
``` | ||
|
||
The `__iter__()` method gives all the flexibility to reorder fields in a form. In this example, the new `is_sponsored` field is moved to appear before the `title` field. | ||
|
||
## Altering field validators | ||
|
||
```python | ||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
def __init__(self, *args, **kwargs): | ||
super().__init__(*args, **kwargs) | ||
# Removes all validators from title field | ||
self.title.validators = [] | ||
``` | ||
|
||
Overriding the `__init__()` method of the form class allows to alter the validators of existing fields. In this example, all validators are removed from the `title` field. | ||
|
||
```python | ||
BANNED_SHORTCUTS = {'admin', 'indico', 'event', 'official'} | ||
|
||
@patch(EventDataForm) | ||
class _EventDataForm: | ||
# Overrides validator function | ||
def validate_url_shortcut(self, field): | ||
super().validate_url_shortcut(field) | ||
if field.data in BANNED_SHORTCUTS: | ||
raise ValidationError(_('This URL shortcut is not allowed.')) | ||
``` | ||
|
||
Custom validator functions can be overridden in the patch class. In this example, the `validate_url_shortcut()` function is overridden to add a check for banned shortcuts. | ||
|
||
## Altering field choices | ||
|
||
```python | ||
@patch(EventLanguagesForm) | ||
class _EventLanguagesForm: | ||
__disabled_locales__ = {'en_US', 'fr_FR'} | ||
|
||
# Overrides field choices | ||
def __init__(self, *args, **kwargs): | ||
super().__init__(*args, **kwargs) | ||
self.default_locale.choices = [ | ||
(id_, title) for id_, title in EventLanguagesForm.default_locale.choices | ||
if id_ not in self.__disabled_locales__ | ||
] | ||
``` | ||
|
||
Similarly to validators, choices can be altered by overriding the `__init__()` method of the form class. In this example, the `default_locale` field choices are overridden to remove some locales from the list. | ||
|
||
> [!IMPORTANT] | ||
> Fields linked to Enums, like `IndicoEnumSelectField`, use Enums members as choices. Their choices can be more reliably altered by patching the Enum class. For more information, please refer to the [Patching Enums](./enums.md) page of the guide. |
Oops, something went wrong.