diff --git a/.github/wordlist.txt b/.github/wordlist.txt index bcd53463..609425a9 100644 --- a/.github/wordlist.txt +++ b/.github/wordlist.txt @@ -125,3 +125,8 @@ UUID lexicographically Lexicographically backport +TTL +queryable +substring +NotFoundError +QueryNotSupportedError diff --git a/Makefile b/Makefile index fb72a650..b3406e8b 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ help: @echo " redis start a Redis instance with Docker" @echo " sync generate modules redis_om, tests_sync from aredis_om, tests respectively" @echo " dist build a redis-om package" + @echo " docs start the docs server locally for preview" @echo " all equivalent to \"make lint format test\"" @echo "" @echo "Check the Makefile to know exactly what each target is doing." @@ -49,17 +50,16 @@ sync: $(INSTALL_STAMP) $(UV) run python make_sync.py .PHONY: lint -lint: $(INSTALL_STAMP) dist - $(UV) run isort --profile=black --lines-after-imports=2 ./tests/ $(NAME) $(SYNC_NAME) - $(UV) run black ./tests/ $(NAME) - $(UV) run flake8 --ignore=E231,E501,E712,E731,F401,W503 ./tests/ $(NAME) $(SYNC_NAME) +lint: $(INSTALL_STAMP) sync + $(UV) run ruff check ./tests/ $(NAME) $(SYNC_NAME) + $(UV) run ruff format --check ./tests/ $(NAME) $(SYNC_NAME) $(UV) run mypy ./tests/ --ignore-missing-imports --exclude migrate.py --exclude _compat\.py$$ $(UV) run bandit -r $(NAME) $(SYNC_NAME) -s B608 .PHONY: format format: $(INSTALL_STAMP) sync - $(UV) run isort --profile=black --lines-after-imports=2 ./tests/ $(NAME) $(SYNC_NAME) - $(UV) run black ./tests/ $(NAME) $(SYNC_NAME) + $(UV) run ruff check --fix ./tests/ $(NAME) $(SYNC_NAME) + $(UV) run ruff format ./tests/ $(NAME) $(SYNC_NAME) .PHONY: test test: $(INSTALL_STAMP) sync redis @@ -77,5 +77,9 @@ test_oss: $(INSTALL_STAMP) sync redis redis: docker compose up -d +.PHONY: docs +docs: $(INSTALL_STAMP) + $(UV) run mkdocs serve + .PHONY: all all: lint format test diff --git a/aredis_om/__init__.py b/aredis_om/__init__.py index e2193505..0f3dfa92 100644 --- a/aredis_om/__init__.py +++ b/aredis_om/__init__.py @@ -19,6 +19,7 @@ ) from .model.types import Coordinates, GeoFilter + # Backward compatibility alias - deprecated, use SchemaDetector or SchemaMigrator Migrator = SchemaDetector diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index cce9d42a..b0586280 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -24,7 +24,9 @@ Union, ) from typing import get_args as typing_get_args -from typing import no_type_check +from typing import ( + no_type_check, +) from more_itertools import ichunked from pydantic import BaseModel diff --git a/docs/migrations.md b/docs/migrations.md index f1bf4fb7..5a39e065 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -18,12 +18,8 @@ If you're upgrading from Redis OM Python 0.x to 1.0, see the **[0.x to 1.0 Migra ## CLI Commands ```bash -# Schema migrations (recommended) om migrate # File-based schema migrations with rollback support om migrate-data # Data migrations and transformations - -# Legacy command (deprecated) -migrate # Automatic schema migrations (use om migrate instead) ``` ## Schema Migrations @@ -57,24 +53,6 @@ om migrate run om migrate run --migrations-dir myapp/schema-migrations ``` -> **Note**: The legacy `migrate` command performs automatic migrations without file tracking and is deprecated. Use `om migrate` for production deployments. - -### Migration Approaches - -Redis OM provides two approaches to schema migrations: - -#### File-based Migrations (`om migrate`) - Recommended -- **Controlled**: Migrations are saved as versioned files -- **Rollback**: Previous schemas can be restored -- **Team-friendly**: Migration files can be committed to git -- **Production-safe**: Explicit migration approval workflow - -#### Automatic Migrations (`migrate`) - Deprecated -- **Immediate**: Detects and applies changes instantly -- **No rollback**: Cannot undo schema changes -- **Development-only**: Suitable for rapid prototyping -- **⚠️ Deprecated**: Use `om migrate` for production - ### How File-based Migration Works 1. **Detection**: Auto-migrator detects index changes from your models diff --git a/docs/models.md b/docs/models.md index 78c76e6b..45a6e5f2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,11 +1,10 @@ # Models and Fields -The heart of Redis OM's object mapping, validation, and querying features is a -pair of declarative models: `HashModel` and `JsonModel`. Both models work -provide roughly the same API, but they store data in Redis differently. +The heart of Redis OM's object mapping, validation, and persistence features is a +pair of declarative models: `HashModel` and `JsonModel`. Both models provide +roughly the same API, but they store data in Redis differently. -This page will explain how to create your Redis OM model by subclassing one of -these classes. +This page explains how to define Redis OM models. For querying models, see [Making Queries](querying.md). ## HashModel vs. JsonModel @@ -31,280 +30,403 @@ class Customer(HashModel): last_name: str ``` -## Configuring Models +## Fields -There are several Redis OM-specific settings you can configure in models. You -configure these settings using a special object called the _Meta object_. +You define fields on a Redis OM model using Python _type annotations_. If you +aren't familiar with type annotations, check out this +[tutorial](https://towardsdatascience.com/type-annotations-in-python-d90990b172dc). -Here is an example of using the Meta object to set a global key prefix: +This works exactly the same way as it does with Pydantic. Check out the [Pydantic documentation on field types](https://pydantic-docs.helpmanual.io/usage/types/) for guidance. + +### With HashModel + +`HashModel` stores data in Redis Hashes, which are flat. This means that a Redis Hash can't contain a Redis Set, List, or Hash. Because of this requirement, `HashModel` also does not currently support container types, such as: + +* Sets +* Lists +* Dictionaries and other "mapping" types +* Other Redis OM models +* Pydantic models + +**NOTE**: In the future, we may serialize these values as JSON strings, the same way we do for `JsonModel`. The difference would be that in the case of `HashModel`, you wouldn't be able to index these fields, just get and save them with the model. With `JsonModel`, you can index list fields and embedded `JsonModel`s. + +So, in short, if you want to use container types, use `JsonModel`. + +### With JsonModel + +Good news! Container types _are_ supported with `JsonModel`. + +We will use Pydantic's JSON serialization and encoding to serialize your `JsonModel` and save it in Redis. + +### Default Values + +Fields can have default values. You set them by assigning a value to a field. ```python +import datetime +from typing import Optional + from redis_om import HashModel class Customer(HashModel): first_name: str last_name: str + email: str + join_date: datetime.date + age: int + bio: Optional[str] = "Super dope" # <- We added a default here +``` - class Meta: - global_key_prefix = "customer-dashboard" +Now, if we create a `Customer` object without a `bio` field, it will use the default value. + +```python +andrew = Customer( + first_name="Andrew", + last_name="Brookins", + email="andrew.brookins@example.com", + join_date=datetime.date.today(), + age=38) # <- Notice, we didn't give a bio! + +print(andrew.bio) # <- So we got the default value. +# > 'Super Dope' ``` -## Abstract Models +The model will then save this default value to Redis the next time you call `save()`. -You can create abstract Redis OM models by subclassing `ABC` in addition to -either `HashModel` or `JsonModel`. Abstract models exist only to gather shared -configuration for subclasses -- you can't instantiate them. +### Optional Fields -One use of abstract models is to configure a Redis key prefix that all models in -your application will use. This is a good best practice with Redis. Here's how -you'd do it with an abstract model: +Fields without default values are required. To make a field optional, use `Optional`: ```python -from abc import ABC - +from typing import Optional from redis_om import HashModel -class BaseModel(HashModel, ABC): - class Meta: - global_key_prefix = "your-application" +class Customer(HashModel): + first_name: str + last_name: str + bio: Optional[str] = None # Optional with None default ``` -### The Meta Object Is "Special" +## Validation -The Meta object has a special property: if you create a model subclass from a base class that has a Meta object, Redis OM copies the parent's fields into the Meta object in the child class. +Redis OM uses [Pydantic](https://docs.pydantic.dev/) behind the scenes to validate data at runtime based on the model's type annotations. -Because of this, a subclass can override a single field in its parent's Meta class without having to redefine all fields. +Every Redis OM model is also a Pydantic model, so you can use Pydantic validators like `EmailStr`, `Pattern`, and many more for complex validation. -An example will make this clearer: +### Basic Type Validation + +Validation works for basic type annotations like `str`: ```python -from abc import ABC +import datetime +from typing import Optional -from redis_om import HashModel, get_redis_connection +from pydantic import EmailStr +from redis_om import HashModel -redis = get_redis_connection(port=6380) -other_redis = get_redis_connection(port=6381) +class Customer(HashModel): + first_name: str + last_name: str + email: EmailStr + join_date: datetime.date + age: int + bio: Optional[str] +``` -class BaseModel(HashModel, ABC): - class Meta: - global_key_prefix = "customer-dashboard" - database = redis +Redis OM will ensure that `first_name` is always a string, `age` is always an integer, and so on. +### Complex Validation -class Customer(BaseModel): - first_name: str - last_name: str +Let's see what happens if we try to create a `Customer` object with an invalid email address: - class Meta: - database = other_redis +```python +from pydantic import ValidationError +try: + Customer( + first_name="Andrew", + last_name="Brookins", + email="Not an email address!", + join_date=datetime.date.today(), + age=38, + bio="Python developer, works at Redis, Inc." + ) +except ValidationError as e: + print(e) + """ + 1 validation error for Customer + email + value is not a valid email address: An email address must have an @-sign. + """ +``` -print(Customer.global_key_prefix) -# > "customer-dashboard" +You'll also get a validation error if you change a field on a model instance to an invalid value and then try to save: + +```python +andrew = Customer( + first_name="Andrew", + last_name="Brookins", + email="andrew.brookins@example.com", + join_date=datetime.date.today(), + age=38, + bio="Python developer" +) + +andrew.email = "Not valid" + +try: + andrew.save() +except ValidationError as e: + print(e) # ValidationError: email is not a valid email address ``` -In this example, we created an abstract base model called `BaseModel` and gave it a Meta object containing a database connection and a global key prefix. +### Constrained Values -Then we created a subclass `BaseModel` called `Customer` and gave it a second Meta object, but only defined `database`. `Customer` _also gets the global key prefix_ that `BaseModel` defined ("customer-dashboard"). +Pydantic includes many type annotations to introduce constraints to your model field values: -While this is not how object inheritance usually works in Python, we think it is helpful to make abstract models more useful, especially as a way to group shared model settings. +* Strings that are always lowercase +* Strings that must match a regular expression +* Integers within a range +* Integers that are a specific multiple +* And many more... -### All Settings Supported by the Meta Object +All of these constraint types work with Redis OM models. Read the [Pydantic documentation on constrained types](https://docs.pydantic.dev/latest/concepts/fields/#constrained-types) to learn more. -Here is a table of the settings available in the Meta object and what they control. +## Saving and Loading Models -| Setting | Description | Default | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| global_key_prefix | A string prefix applied to every Redis key that the model manages. This could be something like your application's name. | "" | -| model_key_prefix | A string prefix applied to the Redis key representing every model. For example, the Redis Hash key for a HashModel. This prefix is also added to the redisearch index created for every model with indexed fields. | f"{new_class.__module__}.{new_class.__name__}" | -| primary_key_pattern | A format string producing the base string for a Redis key representing this model. This string should accept a "pk" format argument. **Note:** This is a "new style" format string, which will be called with `.format()`. | "{pk}" | -| database | A redis.asyncio.Redis or redis.Redis client instance that the model will use to communicate with Redis. | A new instance created with connections.get_redis_connection(). | -| primary_key_creator_cls | A class that adheres to the PrimaryKeyCreator protocol, which Redis OM will use to create a primary key for a new model instance. | UlidPrimaryKey | -| index_name | The RediSearch index name to use for this model. Only used if the model is indexed (`index=True` on the model class). | "{global_key_prefix}:{model_key_prefix}:index" | -| embedded | Whether or not this model is "embedded." Embedded models are not included in migrations that create and destroy indexes. Instead, their indexed fields are included in the index for the parent model. **Note**: Only `JsonModel` can have embedded models. | False | -| encoding | The default encoding to use for strings. This encoding is given to redis-py at the connection level. In both cases, Redis OM will decode binary strings from Redis using your chosen encoding. | "utf-8" | +### Saving Models -### Custom Primary Key Creators +Save a model to Redis by calling `save()`: -By default, Redis OM uses ULID (Universally Unique Lexicographically Sortable Identifier) for primary keys. ULIDs are 128-bit identifiers that are lexicographically sortable and contain a timestamp component. +```python +andrew = Customer( + first_name="Andrew", + last_name="Brookins", + email="andrew.brookins@example.com", + join_date=datetime.date.today(), + age=38) -You can customize how primary keys are generated by providing a custom `primary_key_creator_cls` in the Meta object. This is useful when you need a specific ID format, such as UUID v7. +await andrew.save() # Async +# andrew.save() # Sync +``` -#### Using UUID v7 +### Conditional Saves -UUID v7 is a time-ordered UUID that provides similar benefits to ULID with better compatibility with existing UUID infrastructure. Here's how to use it: +Use `nx` (only if not exists) or `xx` (only if exists) for conditional saves: ```python -import uuid -from redis_om import HashModel +# Only save if the key does NOT exist (insert-only) +result = await andrew.save(nx=True) -class UUIDv7PrimaryKey: - @staticmethod - def create_pk(*args, **kwargs) -> str: - return str(uuid.uuid7()) +# Only save if the key already exists (update-only) +result = await andrew.save(xx=True) +``` -class MyModel(HashModel): - name: str +Returns `None` if the condition was not met, otherwise returns the model. - class Meta: - primary_key_creator_cls = UUIDv7PrimaryKey +### Getting a Model by Primary Key + +If you have the primary key of a model, you can call the `get()` method: + +```python +customer = await Customer.get(andrew.pk) ``` -**Note:** `uuid.uuid7()` requires Python 3.11+ or a backport library like `uuid6`. +### Automatic Primary Keys + +Models generate a globally unique primary key automatically without needing to talk to Redis: + +```python +andrew = Customer( + first_name="Andrew", + last_name="Brookins", + email="andrew.brookins@example.com", + join_date=datetime.date.today(), + age=38) + +print(andrew.pk) +# > '01FJM6PH661HCNNRC884H6K30C' +``` -#### Custom Primary Key Protocol +The ID is available *before* you save the model. The default ID generation function creates [ULIDs](https://github.com/ulid/spec). -Your custom primary key creator class must implement a `create_pk` static method that returns a string: +### Updating Models + +Update a model instance with specific field values: ```python -class CustomPrimaryKey: - @staticmethod - def create_pk(*args, **kwargs) -> str: - # Return your custom primary key as a string - return generate_my_custom_id() +# Update specific fields on an instance +await andrew.update(age=39, bio="Updated bio") ``` -#### Sharing Primary Key Creators +### Deleting Models -You can use an abstract base model to share a custom primary key creator across multiple models: +Delete a model by primary key: ```python -from abc import ABC -import uuid -from redis_om import HashModel +await Customer.delete(andrew.pk) +``` -class UUIDv7PrimaryKey: - @staticmethod - def create_pk(*args, **kwargs) -> str: - return str(uuid.uuid7()) +Or delete multiple models: -class BaseModel(HashModel, ABC): - class Meta: - primary_key_creator_cls = UUIDv7PrimaryKey +```python +await Customer.delete_many([customer1, customer2, customer3]) +``` -class Customer(BaseModel): - name: str - email: str +### Expiring Models -class Order(BaseModel): - product: str - quantity: int +Set a TTL (time to live) on a model instance: -# Both Customer and Order will use UUID v7 for primary keys +```python +# Expire Andrew in 2 minutes (120 seconds) +andrew.expire(120) ``` -## Configuring Pydantic +### Listing All Primary Keys + +Get all primary keys for a model: -Every Redis OM model is also a Pydantic model, so in addition to configuring Redis OM behavior with the Meta object, you can control Pydantic configuration via the Config object within a model class. +```python +async for pk in Customer.all_pks(): + print(pk) +``` -See the [Pydantic documentation for details](https://pydantic-docs.helpmanual.io/usage/model_config/) on how this object works and the settings that are available. +## Configuring Models + +There are several Redis OM-specific settings you can configure in models. You +configure these settings using a special object called the _Meta object_. -The default Pydantic configuration for models, which Redis OM sets for you, is equivalent to the following (demonstrated on an actual model): +Here is an example of using the Meta object to set a global key prefix: ```python from redis_om import HashModel class Customer(HashModel): - # ... Fields ... + first_name: str + last_name: str - model_config = ConfigDict( - from_attributes=True, - arbitrary_types_allowed=True, - extra="allow", - ) + class Meta: + global_key_prefix = "customer-dashboard" ``` -Some features may not work correctly if you change these settings. +### All Settings Supported by the Meta Object -## Fields +| Setting | Description | Default | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| global_key_prefix | A string prefix applied to every Redis key that the model manages. This could be something like your application's name. | "" | +| model_key_prefix | A string prefix applied to the Redis key representing every model. For example, the Redis Hash key for a HashModel. This prefix is also added to the redisearch index created for every model with indexed fields. | f"{new_class.__module__}.{new_class.__name__}" | +| primary_key_pattern | A format string producing the base string for a Redis key representing this model. This string should accept a "pk" format argument. **Note:** This is a "new style" format string, which will be called with `.format()`. | "{pk}" | +| database | A redis.asyncio.Redis or redis.Redis client instance that the model will use to communicate with Redis. | A new instance created with connections.get_redis_connection(). | +| primary_key_creator_cls | A class that adheres to the PrimaryKeyCreator protocol, which Redis OM will use to create a primary key for a new model instance. | UlidPrimaryKey | +| index_name | The RediSearch index name to use for this model. Only used if the model is indexed (`index=True` on the model class). | "{global_key_prefix}:{model_key_prefix}:index" | +| embedded | Whether or not this model is "embedded." Embedded models are not included in migrations that create and destroy indexes. Instead, their indexed fields are included in the index for the parent model. **Note**: Only `JsonModel` can have embedded models. | False | +| encoding | The default encoding to use for strings. This encoding is given to redis-py at the connection level. In both cases, Redis OM will decode binary strings from Redis using your chosen encoding. | "utf-8" | -You define fields on a Redis OM model using Python _type annotations_. If you -aren't familiar with type annotations, check out this -[tutorial](https://towardsdatascience.com/type-annotations-in-python-d90990b172dc). +## Abstract Models -This works exactly the same way as it does with Pydantic. Check out the [Pydantic documentation on field types](https://pydantic-docs.helpmanual.io/usage/types/) for guidance. +You can create abstract Redis OM models by subclassing `ABC` in addition to +either `HashModel` or `JsonModel`. Abstract models exist only to gather shared +configuration for subclasses -- you can't instantiate them. -### With HashModel +One use of abstract models is to configure a Redis key prefix that all models in +your application will use: -`HashModel` stores data in Redis Hashes, which are flat. This means that a Redis Hash can't contain a Redis Set, List, or Hash. Because of this requirement, `HashModel` also does not currently support container types, such as: +```python +from abc import ABC +from redis_om import HashModel -* Sets -* Lists -* Dictionaries and other "mapping" types -* Other Redis OM models -* Pydantic models -**NOTE**: In the future, we may serialize these values as JSON strings, the same way we do for `JsonModel`. The difference would be that in the case of `HashModel`, you wouldn't be able to index these fields, just get and save them with the model. With `JsonModel`, you can index list fields and embedded `JsonModel`s. +class BaseModel(HashModel, ABC): + class Meta: + global_key_prefix = "your-application" +``` -So, in short, if you want to use container types, use `JsonModel`. +### Meta Object Inheritance -### With JsonModel +The Meta object has a special property: if you create a model subclass from a base class that has a Meta object, Redis OM copies the parent's fields into the Meta object in the child class. -Good news! Container types _are_ supported with `JsonModel`. +A subclass can override a single field in its parent's Meta class without having to redefine all fields: -We will use Pydantic's JSON serialization and encoding to serialize your `JsonModel` and save it in Redis. +```python +from abc import ABC +from redis_om import HashModel, get_redis_connection -### Default Values -Fields can have default values. You set them by assigning a value to a field. +redis = get_redis_connection(port=6380) +other_redis = get_redis_connection(port=6381) -```python -import datetime -from typing import Optional -from redis_om import HashModel +class BaseModel(HashModel, ABC): + class Meta: + global_key_prefix = "customer-dashboard" + database = redis -class Customer(HashModel): +class Customer(BaseModel): first_name: str last_name: str - email: str - join_date: datetime.date - age: int - bio: Optional[str] = "Super dope" # <- We added a default here + + class Meta: + database = other_redis + + +print(Customer.global_key_prefix) +# > "customer-dashboard" # Inherited from BaseModel ``` -Now, if we create a `Customer` object without a `bio` field, it will use the default value. +### Custom Primary Key Creators -```python -import datetime -from typing import Optional +By default, Redis OM uses ULID (Universally Unique Lexicographically Sortable Identifier) for primary keys. You can customize this: +```python +import uuid from redis_om import HashModel -class Customer(HashModel): - first_name: str - last_name: str - email: str - join_date: datetime.date - age: int - bio: Optional[str] = "Super dope" +class UUIDv7PrimaryKey: + @staticmethod + def create_pk(*args, **kwargs) -> str: + return str(uuid.uuid7()) -andrew = Customer( - first_name="Andrew", - last_name="Brookins", - email="andrew.brookins@example.com", - join_date=datetime.date.today(), - age=38) # <- Notice, we didn't give a bio! +class MyModel(HashModel): + name: str -print(andrew.bio) # <- So we got the default value. -# > 'Super Dope' + class Meta: + primary_key_creator_cls = UUIDv7PrimaryKey ``` -The model will then save this default value to Redis the next time you call `save()`. +**Note:** `uuid.uuid7()` requires Python 3.11+ or a backport library like `uuid6`. -## Model-Level Indexing +## Configuring Pydantic + +Every Redis OM model is also a Pydantic model, so you can control Pydantic configuration via `model_config`: + +```python +from pydantic import ConfigDict +from redis_om import HashModel -If you're using the RediSearch module in your Redis instance, you can make your entire model indexed by adding `index=True` to the model class declaration. This automatically creates and manages a secondary index for the model, allowing you to query on any field. -To make a model indexed, add `index=True` to your model class: +class Customer(HashModel): + # ... Fields ... + + model_config = ConfigDict( + from_attributes=True, + arbitrary_types_allowed=True, + extra="allow", + ) +``` + +See the [Pydantic documentation](https://pydantic-docs.helpmanual.io/usage/model_config/) for available settings. + +## Model-Level Indexing + +If you're using Redis with the Search capability, you can make your model indexed by adding `index=True` to the model class declaration: ```python from redis_om import HashModel @@ -317,11 +439,11 @@ class Customer(HashModel, index=True): age: int ``` -In this example, all fields in the `Customer` model will be indexed automatically. +In this example, all fields in the `Customer` model will be indexed automatically, enabling queries with `find()`. ### Excluding Fields from Indexing -By default, all fields in an indexed model are indexed. You can exclude specific fields from indexing using `Field(index=False)`: +You can exclude specific fields from indexing using `Field(index=False)`: ```python from redis_om import HashModel, Field @@ -330,13 +452,13 @@ from redis_om import HashModel, Field class Customer(HashModel, index=True): first_name: str = Field(index=False) # Not indexed last_name: str # Indexed (default) - email: str # Indexed (default) - age: int # Indexed (default) + email: str # Indexed (default) + age: int # Indexed (default) ``` ### Field-Specific Index Options -While you no longer need to specify `index=True` on individual fields (since the model is indexed), you can still use field-specific options to control indexing behavior: +Control indexing behavior with field-specific options: ```python from redis_om import HashModel, Field @@ -350,81 +472,42 @@ class Customer(HashModel, index=True): category: str = Field(case_sensitive=False) # Indexed as TAG, case-insensitive ``` -### Migration from Field-Level Indexing - -**Redis OM 1.0+ uses model-level indexing.** If you're upgrading from an earlier version, you'll need to update your models: - -```python -# Old way (0.x) - field-by-field indexing -class Customer(HashModel): - first_name: str = Field(index=True) - last_name: str = Field(index=True) - email: str = Field(index=True) - age: int = Field(index=True, sortable=True) - -# New way (1.0+) - model-level indexing -class Customer(HashModel, index=True): - first_name: str - last_name: str - email: str - age: int = Field(sortable=True) -``` - -For detailed migration instructions, see the [0.x to 1.0 Migration Guide](migration_guide_0x_to_1x.md). - ### Field Index Types -Redis OM automatically chooses the appropriate RediSearch field type based on the Python field type and options: +Redis OM automatically chooses the appropriate RediSearch field type based on the Python field type: -- **String fields** → **TAG fields** by default (exact matching), or **TEXT fields** if `full_text_search=True` -- **Numeric fields** (int, float) → **NUMERIC fields** (range queries and sorting) -- **Boolean fields** → **TAG fields** -- **Datetime fields** → **NUMERIC fields** (stored as Unix timestamps) -- **Geographic fields** → **GEO fields** +| Python Type | RediSearch Field Type | Notes | +|-------------|----------------------|-------| +| `str` | TAG | Exact matching (default) | +| `str` with `full_text_search=True` | TEXT | Full-text search | +| `int`, `float` | NUMERIC | Range queries and sorting | +| `bool` | TAG | Boolean fields | +| `datetime` | NUMERIC | Stored as Unix timestamps | +| Geographic types | GEO | Location queries | -All field types (TAG, TEXT, NUMERIC, and GEO) support sorting when marked with `sortable=True`. +All field types support sorting when marked with `sortable=True`. -### Making String Fields Sortable +### Running Migrations -String fields can be made sortable as either TAG or TEXT fields: - -```python -class Customer(HashModel, index=True): - # TAG field - exact matching with sorting - category: str = Field(sortable=True) +To create the indexes for indexed models, use the `om migrate` CLI command: - # TEXT field - full-text search with sorting - name: str = Field(sortable=True, full_text_search=True) +```bash +om migrate ``` -**TAG fields** are best for exact matching and categorical data, while **TEXT fields** support full-text search queries. Both can be sorted. - -To create the indexes for any models that are indexed (have `index=True`), use the `om migrate` CLI command that Redis OM installs in your Python environment. - -This command detects any `JsonModel` or `HashModel` instances in your project and does the following for each model that isn't abstract or embedded: - -* If no index exists yet for the model: - * The migrator creates an index - * The migrator stores a hash of the index definition -* If an index exists for the model: - * The migrator checks if the stored hash for the index is out of date - * If the stored hash is out of date, the migrator drops the index (not your data!) and rebuilds it with the new index definition - -You can also run the `Migrator` yourself with code: +Or run the `Migrator` programmatically: ```python -from redis_om import ( - get_redis_connection, - Migrator -) +from redis_om import Migrator -redis = get_redis_connection() Migrator().run() ``` +For detailed migration instructions, see [Migrations](migrations.md). + ## Vector Fields -Redis OM supports vector fields for similarity search, enabling AI and machine learning applications. Vector fields store embeddings (arrays of floats) and can be searched using K-Nearest Neighbors (KNN) queries. +Redis OM supports vector fields for similarity search, enabling AI and machine learning applications. ### Defining Vector Fields @@ -433,6 +516,7 @@ Use `VectorFieldOptions` to configure vector fields: ```python from redis_om import JsonModel, Field, VectorFieldOptions + class Document(JsonModel, index=True): title: str = Field(index=True) content: str = Field(full_text_search=True) @@ -447,8 +531,6 @@ class Document(JsonModel, index=True): ### Vector Algorithm Options -Redis OM supports two vector indexing algorithms: - **FLAT** - Brute-force search, best for smaller datasets: ```python @@ -457,7 +539,6 @@ vector_options = VectorFieldOptions.flat( dimension=768, distance_metric=VectorFieldOptions.DISTANCE_METRIC.COSINE, initial_cap=1000, # Optional: pre-allocate space - block_size=1000, # Optional: memory block size ) ``` @@ -468,11 +549,9 @@ vector_options = VectorFieldOptions.hnsw( type=VectorFieldOptions.TYPE.FLOAT32, dimension=768, distance_metric=VectorFieldOptions.DISTANCE_METRIC.COSINE, - initial_cap=1000, # Optional: pre-allocate space m=16, # Optional: max outgoing edges per node ef_construction=200, # Optional: construction-time search width ef_runtime=10, # Optional: query-time search width - epsilon=0.01, # Optional: relative factor for range queries ) ``` @@ -487,336 +566,40 @@ vector_options = VectorFieldOptions.hnsw( - `FLOAT32` - 32-bit floating point (most common) - `FLOAT64` - 64-bit floating point -### Querying Vector Fields - -Use `KNNExpression` to perform similarity searches: - -```python -from redis_om import KNNExpression - -# Create a query vector (from your embedding model) -query_embedding = get_embedding("search query") - -# Find the 10 most similar documents -results = await Document.find( - KNNExpression( - k=10, - vector_field_name="embedding", - reference_vector=query_embedding, - ) -).all() -``` - -### Hybrid Queries - -Combine vector search with filters: - -```python -# Find similar documents within a category -results = await Document.find( - (Document.category == "technology") & - KNNExpression( - k=10, - vector_field_name="embedding", - reference_vector=query_embedding, - ) -).all() -``` - -### Advanced Vector Search with RedisVL - -For advanced vector search capabilities, Redis OM integrates with [RedisVL](https://github.com/redis/redis-vl-python). This gives you access to: - -- VectorQuery with hybrid policies (BATCHES, ADHOC_BF) -- VectorRangeQuery for epsilon-based searches -- Advanced filter expressions -- EF_RUNTIME tuning for HNSW indexes - -#### Converting Models to RedisVL Schema - -Use `to_redisvl_schema()` to convert your Redis OM model to a RedisVL `IndexSchema`: - -```python -from aredis_om.redisvl import to_redisvl_schema -from redisvl.index import SearchIndex - -# Convert your model to a RedisVL schema -schema = to_redisvl_schema(Document) - -# Use with RedisVL's SearchIndex -index = SearchIndex(schema=schema, redis_client=redis) -``` - -#### Getting a Ready-to-Use SearchIndex - -Use `get_redisvl_index()` to get a RedisVL `SearchIndex` connected to your model's database: - -```python -from aredis_om.redisvl import get_redisvl_index -from redisvl.query import VectorQuery - -# Get a RedisVL index for your model -index = get_redisvl_index(Document) - -# Use RedisVL's advanced query features -results = await index.query(VectorQuery( - vector=query_embedding, - vector_field_name="embedding", - num_results=10, - return_fields=["title", "content"], -)) -``` - -#### When to Use RedisVL Integration - -Use the RedisVL integration when you need: - -- **Hybrid search policies**: Control how filters and vector search interact -- **Range queries**: Find all vectors within a distance threshold -- **Runtime tuning**: Adjust HNSW `ef_runtime` per query -- **Advanced filters**: Complex filter expressions beyond Redis OM's query DSL - -For most use cases, Redis OM's built-in `KNNExpression` is sufficient. The RedisVL integration is an escape hatch for advanced scenarios. - -## Field Projection - -Redis OM supports field projection, which allows you to retrieve only specific fields from your models rather than loading all fields. This can improve performance and reduce memory usage, especially for models with many fields. - -There are two main methods for field projection: - -### `.values()` - Dictionary Results - -The `.values()` method returns query results as dictionaries instead of model instances: - -```python -from redis_om import HashModel, Field - -class Customer(HashModel, index=True): - first_name: str - last_name: str - email: str - age: int - bio: str - -# Get all fields as dictionaries -customers = Customer.find().values() -# Returns: [{"first_name": "John", "last_name": "Doe", "email": "john@example.com", "age": 30, "bio": "..."}] - -# Get only specific fields as dictionaries -customers = Customer.find().values("first_name", "email") -# Returns: [{"first_name": "John", "email": "john@example.com"}] -``` - -### `.only()` - Partial Model Instances - -The `.only()` method returns partial model instances that contain only the specified fields. Accessing fields that weren't loaded will raise an `AttributeError`: - -```python -# Get partial model instances with only specific fields -customers = Customer.find().only("first_name", "email") - -for customer in customers: - print(customer.first_name) # ✓ Works - field was loaded - print(customer.email) # ✓ Works - field was loaded - print(customer.age) # ✗ Raises AttributeError - field not loaded -``` - -### Performance Benefits - -Both methods use Redis's `RETURN` clause for efficient field projection at the database level, which means: -- Only requested fields are transferred over the network -- Less memory usage on both Redis and client side -- Faster query execution for large models -- Automatic type conversion for returned fields - -### Type Conversion - -Redis OM automatically converts field values to their proper Python types based on your model field definitions: - -```python -class Product(HashModel, index=True): - name: str - price: float - in_stock: bool - created_at: datetime.datetime - -# Values are automatically converted to correct types -products = Product.find().values("name", "price", "in_stock") -# Returns: [{"name": "Widget", "price": 19.99, "in_stock": True}] -# Note: price is float, in_stock is bool (not strings) -``` - -### Combining with Other Query Methods - -Field projection works seamlessly with other query methods: - -```python -# Combine with filtering and sorting -expensive_products = Product.find( - Product.price > 100 -).sort_by("price").only("name", "price") - -# Combine with pagination -first_page = Product.find().values("name", "price").page(0, 10) - -# Use with async queries (for async models) -products = await AsyncProduct.find().values("name", "price").all() -``` - -### Deep Field Projection +For querying vector fields, see [Making Queries: Vector Search](querying.md#vector-similarity-search). -Redis OM supports Django-like deep field projection using double underscore (`__`) syntax to access nested fields in embedded models and dictionaries. This allows you to extract specific values from complex nested structures without loading the entire object. +## Embedded Models (JsonModel Only) -#### Embedded Model Fields - -Extract fields from embedded models using the `field__subfield` syntax: +`JsonModel` supports embedding models within other models: ```python from redis_om import JsonModel, Field + class Address(JsonModel): street: str - city: str - zipcode: str = Field(index=True) # Specific field indexing for embedded model + city: str = Field(index=True) + zipcode: str country: str = "USA" class Meta: embedded = True + class Customer(JsonModel, index=True): name: str age: int address: Address - metadata: dict = Field(default_factory=dict) - -# Extract nested fields from embedded models -customers = Customer.find().values("name", "address__city", "address__zipcode") -# Returns: [{"name": "John Doe", "address__city": "Anytown", "address__zipcode": "12345"}] - -# Works with .only() method too -customer = Customer.find().only("name", "address__street").first() -print(customer.name) # ✓ Works -print(getattr(customer, "address__street")) # ✓ Works - returns "123 Main St" -print(customer.age) # ✗ Raises AttributeError - not loaded ``` -#### Dictionary Field Access - -Access nested dictionary values using the same syntax: - -```python -# Sample data with nested dictionary -customer_data = { - "name": "John Doe", - "metadata": { - "role": "admin", - "preferences": { - "theme": "dark", - "notifications": True, - "settings": { - "language": "en" - } - } - } -} - -# Extract values at any nesting level -result = Customer.find().values( - "name", - "metadata__role", - "metadata__preferences__theme", - "metadata__preferences__settings__language" -) -# Returns: [{ -# "name": "John Doe", -# "metadata__role": "admin", -# "metadata__preferences__theme": "dark", -# "metadata__preferences__settings__language": "en" -# }] -``` +Embedded models: -#### Mixed Deep Fields +- Must have `embedded = True` in their Meta class +- Are stored as nested JSON within the parent document +- Can have their own indexed fields (included in parent's index) +- Are not separately queryable -- query through the parent model -Combine regular fields, embedded model fields, and dictionary fields in a single query: +## Next Steps -```python -# Mix all types of field projection -customers = Customer.find().values( - "name", # Regular field - "age", # Regular field - "address__city", # Embedded model field - "address__country", # Embedded model field - "metadata__role", # Dictionary field - "metadata__preferences__theme" # Nested dictionary field -) -``` - -#### Validation and Error Handling - -Deep field paths are fully validated to ensure they exist in your model hierarchy: - -```python -# ✓ Valid - address is an embedded model with a city field -Customer.find().values("name", "address__city") - -# ✗ Invalid - nonexistent root field -Customer.find().values("name", "nonexistent__field") -# Raises: QueryNotSupportedError - -# ✗ Invalid - city is not a complex field -Customer.find().values("name", "address__city__invalid") -# Raises: QueryNotSupportedError - -# ✗ Invalid - address exists but zipcode_invalid doesn't -Customer.find().values("name", "address__zipcode_invalid") -# Raises: QueryNotSupportedError -``` - -#### Performance Considerations - -Deep field projection automatically uses the full document fallback strategy for optimal data access: - -- **Simple fields only**: Uses efficient Redis `RETURN` clause -- **Deep fields present**: Queries full documents and extracts requested fields -- **Automatic detection**: No manual configuration needed -- **Type preservation**: All nested values maintain their proper Python types - -```python -# This query uses RETURN clause (efficient) -Customer.find().values("name", "age") - -# This query uses fallback (still efficient, but queries full documents) -Customer.find().values("name", "address__city") -``` - -### Limitations - -Field projection has some limitations to be aware of: - -#### Complex Field Types (JsonModel only) - -For `JsonModel`, complex field types (embedded models, dictionaries, lists) cannot be projected using Redis's `RETURN` clause. Redis OM automatically falls back to querying full documents and manually extracting the requested fields, but this means: - -- **HashModel**: All simple field types work with efficient projection -- **JsonModel**: Simple fields use efficient projection, complex fields use fallback -- **Performance**: Fallback is still fast but transfers more data - -#### Supported vs Unsupported Field Types - -```python -# ✓ Supported for efficient projection (all model types) -class Product(HashModel, index=True): # or JsonModel - name: str # ✓ String fields - price: float # ✓ Numeric fields - active: bool # ✓ Boolean fields - created: datetime # ✓ DateTime fields - -# JsonModel: These use fallback strategy (still supported) -class Customer(JsonModel): - profile: UserProfile # Uses fallback (embedded model) - settings: dict # Uses fallback (dictionary) - tags: List[str] # Uses fallback (list) - - # Deep field access works for all complex types - result = Customer.find().values("name", "profile__email", "settings__theme") -``` +- Learn how to query your models in [Making Queries](querying.md) +- Configure index migrations in [Migrations](migrations.md) diff --git a/docs/querying.md b/docs/querying.md new file mode 100644 index 00000000..184b02c4 --- /dev/null +++ b/docs/querying.md @@ -0,0 +1,526 @@ +# Making Queries + +Redis OM provides a powerful query language that allows you to query Redis with Python expressions. This page covers everything you need to know about querying models. + +## Prerequisites + +Before you can query models, you need: + +1. **An indexed model**: Add `index=True` to your model class +2. **Run migrations**: Execute `om migrate` to create the RediSearch index + +```python +from redis_om import HashModel, Field + + +class Customer(HashModel, index=True): + first_name: str + last_name: str = Field(index=True) + email: str + age: int = Field(index=True, sortable=True) +``` + +```bash +om migrate +``` + +## The `find()` Method + +The `find()` method is the entry point for all queries. It returns a `FindQuery` object that you can chain with other methods: + +```python +# Find all customers +customers = await Customer.find().all() + +# Find with a filter expression +customers = await Customer.find(Customer.last_name == "Brookins").all() + +# Find with multiple expressions (AND) +customers = await Customer.find( + Customer.last_name == "Brookins", + Customer.age > 30 +).all() +``` + +## Expression Operators + +Redis OM supports a variety of operators for building query expressions. + +### Comparison Operators + +| Operator | Meaning | Example | +|----------|---------|---------| +| `==` | Equal | `Customer.name == "John"` | +| `!=` | Not equal | `Customer.name != "John"` | +| `<` | Less than | `Customer.age < 30` | +| `<=` | Less than or equal | `Customer.age <= 30` | +| `>` | Greater than | `Customer.age > 30` | +| `>=` | Greater than or equal | `Customer.age >= 30` | + +```python +# Numeric comparisons +young_customers = await Customer.find(Customer.age < 30).all() +seniors = await Customer.find(Customer.age >= 65).all() + +# String equality +johns = await Customer.find(Customer.first_name == "John").all() +``` + +### String Operators + +| Operator | Meaning | Example | +|----------|---------|---------| +| `%` | LIKE (pattern matching) | `Customer.name % "John*"` | +| `.startswith()` | Starts with | `Customer.name.startswith("Jo")` | +| `.endswith()` | Ends with | `Customer.name.endswith("son")` | +| `.contains()` | Contains substring | `Customer.email.contains("@gmail")` | + +```python +# Pattern matching with % (LIKE) +customers = await Customer.find(Customer.last_name % "Brook*").all() + +# String methods +customers = await Customer.find(Customer.email.startswith("andrew")).all() +customers = await Customer.find(Customer.email.contains("@example.com")).all() +``` + +### Collection Operators + +| Operator | Meaning | Example | +|----------|---------|---------| +| `<<` | IN (value in list) | `Customer.status << ["active", "pending"]` | +| `>>` | NOT IN | `Customer.status >> ["banned", "deleted"]` | + +```python +# Find customers with specific statuses +active_customers = await Customer.find( + Customer.status << ["active", "pending"] +).all() + +# Exclude certain statuses +good_standing = await Customer.find( + Customer.status >> ["banned", "suspended"] +).all() +``` + +## Combining Expressions + +Use logical operators to combine multiple expressions: + +### AND Operator (`&`) + +```python +# Find young customers named John +customers = await Customer.find( + (Customer.first_name == "John") & (Customer.age < 30) +).all() +``` + +### OR Operator (`|`) + +```python +# Find customers who are either young or named John +customers = await Customer.find( + (Customer.age < 30) | (Customer.first_name == "John") +).all() +``` + +### NOT Operator (`~`) + +```python +# Find customers who are NOT named John +customers = await Customer.find( + ~(Customer.first_name == "John") +).all() +``` + +### Complex Expressions + +Use parentheses to build complex queries: + +```python +# Find customers who are: +# - NOT named Andrew AND +# - (have last name Brookins OR Smith) +customers = await Customer.find( + ~(Customer.first_name == "Andrew") & + ((Customer.last_name == "Brookins") | (Customer.last_name == "Smith")) +).all() +``` + +### Visualizing Expression Trees + +Use the `tree` property to visualize how Redis OM interprets your query: + +```python +query = Customer.find( + ~(Customer.first_name == "Andrew") & + ((Customer.last_name == "Brookins") | (Customer.last_name == "Smith")) +) +print(query.expression.tree) +""" + ┌first_name +┌NOT EQ┤ +| └Andrew + AND┤ + | ┌last_name + | ┌EQ┤ + | | └Brookins + └OR┤ + | ┌last_name + └EQ┤ + └Smith +""" +``` + +## Terminal Methods + +Terminal methods execute the query and return results: + +### `.all()` - Get All Results + +Returns all matching models: + +```python +customers = await Customer.find(Customer.age > 30).all() +``` + +### `.first()` - Get First Result + +Returns the first matching model or raises `NotFoundError`: + +```python +from redis_om import NotFoundError + +try: + customer = await Customer.find(Customer.email == "john@example.com").first() +except NotFoundError: + print("No customer found") +``` + +### `.count()` - Count Results + +Returns the number of matching models without loading them: + +```python +count = await Customer.find(Customer.age > 30).count() +print(f"Found {count} customers over 30") +``` + +### `.page()` - Paginated Results + +Returns a specific page of results: + +```python +# Get first 10 results (page 0) +first_page = await Customer.find().sort_by("age").page(offset=0, limit=10) + +# Get next 10 results +second_page = await Customer.find().sort_by("age").page(offset=10, limit=10) +``` + +**Important**: Always use `.sort_by()` before `.page()` for stable pagination. Without explicit sorting, Redis doesn't guarantee consistent ordering between pages, which can cause results to shift or duplicate across pages. + +## Sorting Results + +Use `.sort_by()` to order results. Prefix field names with `-` for descending order: + +```python +# Sort by age ascending +customers = await Customer.find().sort_by("age").all() + +# Sort by age descending +customers = await Customer.find().sort_by("-age").all() + +# Sort by multiple fields +customers = await Customer.find().sort_by("last_name", "-age").all() +``` + +**Note**: Fields must be marked as `sortable=True` in the model definition: + +```python +class Customer(HashModel, index=True): + name: str + age: int = Field(sortable=True) # Can be sorted +``` + +## Field Projection + +Field projection allows you to retrieve only specific fields, improving performance for models with many fields. + +### `.values()` - Dictionary Results + +Returns query results as dictionaries instead of model instances: + +```python +# Get all fields as dictionaries +customers = await Customer.find().values().all() +# Returns: [{"first_name": "John", "last_name": "Doe", ...}] + +# Get only specific fields +customers = await Customer.find().values("first_name", "email").all() +# Returns: [{"first_name": "John", "email": "john@example.com"}] +``` + +### `.only()` - Partial Model Instances + +Returns partial model instances with only the specified fields. Accessing unloaded fields raises `AttributeError`: + +```python +customers = await Customer.find().only("first_name", "email").all() + +for customer in customers: + print(customer.first_name) # ✓ Works + print(customer.email) # ✓ Works + print(customer.age) # ✗ Raises AttributeError +``` + +### Deep Field Projection (JsonModel) + +Access nested fields in embedded models using double underscore syntax: + +```python +class Address(JsonModel): + street: str + city: str + + class Meta: + embedded = True + + +class Customer(JsonModel, index=True): + name: str + address: Address + + +# Extract nested fields +customers = await Customer.find().values("name", "address__city").all() +# Returns: [{"name": "John", "address__city": "New York"}] +``` + +## Bulk Operations + +### `.update()` - Update Multiple Records + +Update all matching records with new field values: + +```python +# Give everyone in the "premium" tier a discount +await Customer.find( + Customer.tier == "premium" +).update(discount_percent=20) +``` + +### `.delete()` - Delete Multiple Records + +Delete all matching records: + +```python +# Delete all inactive customers +deleted_count = await Customer.find( + Customer.status == "inactive" +).delete() +``` + +## Vector Similarity Search + +Redis OM supports vector similarity search using `KNNExpression`: + +```python +from redis_om import KNNExpression + +# Create a query vector (from your embedding model) +query_embedding = get_embedding("search query") + +# Find the 10 most similar documents +results = await Document.find( + KNNExpression( + k=10, + vector_field_name="embedding", + reference_vector=query_embedding, + ) +).all() +``` + +### Hybrid Vector + Filter Queries + +Combine vector search with traditional filters: + +```python +# Find similar documents within a specific category +results = await Document.find( + (Document.category == "technology") & + KNNExpression( + k=10, + vector_field_name="embedding", + reference_vector=query_embedding, + ) +).all() +``` + +### Advanced Vector Search with RedisVL + +For advanced vector search capabilities, Redis OM integrates with [RedisVL](https://github.com/redis/redis-vl-python): + +```python +from aredis_om.redisvl import get_redisvl_index +from redisvl.query import VectorQuery + +# Get a RedisVL index for your model +index = get_redisvl_index(Document) + +# Use RedisVL's advanced query features +results = await index.query(VectorQuery( + vector=query_embedding, + vector_field_name="embedding", + num_results=10, + return_fields=["title", "content"], +)) +``` + +RedisVL provides: + +- VectorQuery with hybrid policies (BATCHES, ADHOC_BF) +- VectorRangeQuery for epsilon-based searches +- EF_RUNTIME tuning for HNSW indexes +- Advanced filter expressions + +## Async Iteration + +`FindQuery` objects support async iteration: + +```python +async for customer in Customer.find(Customer.age > 30): + print(customer.name) +``` + +## Index Access + +Access specific results by index: + +```python +query = Customer.find(Customer.age > 30) + +# Get the 5th result (0-indexed) +fifth_customer = await query[4] +``` + +## Boolean Queries (JsonModel Only) + +`JsonModel` supports querying on boolean fields: + +```python +class Product(JsonModel, index=True): + name: str + active: bool = Field(index=True) + + +# Find all active products +active_products = await Product.find(Product.active == True).all() +``` + +**Note**: Boolean queries are not supported with `HashModel` due to how Redis Hashes store data. + +## Calling Raw Redis Commands + +Sometimes you'll need to run a Redis command directly. Use the `db()` method to get a connected Redis client: + +```python +from redis_om import HashModel + + +class Demo(HashModel): + some_field: str + + +redis_conn = Demo.db() + +# Run any Redis command +redis_conn.sadd("myset", "a", "b", "c", "d") +redis_conn.sismember("myset", "b") # Returns True +``` + +Or use `get_redis_connection()`: + +```python +from redis_om import get_redis_connection + +redis_conn = get_redis_connection() +redis_conn.set("hello", "world") +``` + +## Query Debugging + +### Getting the Raw Query + +Get the RediSearch query string that will be executed: + +```python +query = Customer.find(Customer.age > 30) +print(query.query) # Shows the RediSearch query string +``` + +### Getting Query Arguments + +Get the full FT.SEARCH arguments: + +```python +args = await query.execute(return_query_args=True) +print(args) # Shows all FT.SEARCH arguments +``` + +## Performance Tips + +1. **Use field projection** when you don't need all fields: + ```python + await Customer.find().values("name", "email").all() + ``` + +2. **Use `.count()` instead of `.all()` for counting**: + ```python + count = await Customer.find(Customer.active == True).count() + ``` + +3. **Use pagination for large result sets**: + ```python + page = await Customer.find().page(offset=0, limit=100) + ``` + +4. **Mark fields as `sortable=True`** only when needed, as it increases memory usage. + +5. **Use appropriate indexes**: TEXT fields for full-text search, TAG fields for exact matching. + +## Error Handling + +### NotFoundError + +Raised when `.first()` finds no results: + +```python +from redis_om import NotFoundError + +try: + customer = await Customer.find(Customer.pk == "nonexistent").first() +except NotFoundError: + print("Customer not found") +``` + +### QueryNotSupportedError + +Raised when a query is not supported: + +```python +from redis_om import QueryNotSupportedError + +try: + # Invalid: trying to access non-existent nested field + await Customer.find().values("nonexistent__field").all() +except QueryNotSupportedError as e: + print(f"Query error: {e}") +``` + +## Next Steps + +- Learn about [Models and Fields](models.md) for defining your data structures +- Check [Errors](errors.md) for a complete list of error codes + diff --git a/docs/validation.md b/docs/validation.md deleted file mode 100644 index 90861908..00000000 --- a/docs/validation.md +++ /dev/null @@ -1,135 +0,0 @@ -# Validation - -Redis OM uses [Pydantic][pydantic-url] behind the scenes to validate data at runtime, based on the model's type annotations. - -## Basic Type Validation - -Validation works for basic type annotations like `str`. Thus, given the following model: - -```python -import datetime -from typing import Optional - -from pydantic import EmailStr - -from redis_om import HashModel - - -class Customer(HashModel): - first_name: str - last_name: str - email: EmailStr - join_date: datetime.date - age: int - bio: Optional[str] -``` - -... Redis OM will ensure that `first_name` is always a string. - -But every Redis OM model is also a Pydantic model, so you can use existing Pydantic validators like `EmailStr`, `Pattern`, and many more for complex validation! - -## Complex Validation - -Let's see what happens if we try to create a `Customer` object with an invalid email address. - -```python -import datetime -from typing import Optional - -from pydantic import EmailStr, ValidationError - -from redis_om import HashModel - - -class Customer(HashModel): - first_name: str - last_name: str - email: EmailStr - join_date: datetime.date - age: int - bio: Optional[str] - - -# We'll get a validation error if we try to use an invalid email address! -try: - Customer( - first_name="Andrew", - last_name="Brookins", - email="Not an email address!", - join_date=datetime.date.today(), - age=38, - bio="Python developer, works at Redis, Inc." - ) -except ValidationError as e: - print(e) - """ - 1 validation error for Customer - email - value is not a valid email address: An email address must have an @-sign. [type=value_error, ...] - """ -``` - -As you can see, creating the `Customer` object generated a validation error indicating that the email address is invalid. - -We'll also get a validation error if we change a field on a model instance to an invalid value and then try to save the model: - -```python -import datetime -from typing import Optional - -from pydantic import EmailStr, ValidationError - -from redis_om import HashModel - - -class Customer(HashModel): - first_name: str - last_name: str - email: EmailStr - join_date: datetime.date - age: int - bio: Optional[str] - - -andrew = Customer( - first_name="Andrew", - last_name="Brookins", - email="andrew.brookins@example.com", - join_date=datetime.date.today(), - age=38, - bio="Python developer, works at Redis, Inc." -) - -andrew.email = "Not valid" - -try: - andrew.save() -except ValidationError as e: - print(e) - """ - 1 validation error for Customer - email - value is not a valid email address: An email address must have an @-sign. [type=value_error, ...] - """ -``` - -Once again, we get a validation error indicating the email address is invalid. - -## Constrained Values - -If you want to use any of the constraints. - -Pydantic includes many type annotations to introduce constraints to your model field values. - -The concept of "constraints" includes quite a few possibilities: - -* Strings that are always lowercase -* Strings that must match a regular expression -* Integers within a range -* Integers that are a specific multiple -* And many more... - -All of these constraint types work with Redis OM models. Read the [Pydantic documentation on constrained types](https://pydantic-docs.helpmanual.io/usage/types/#constrained-types) to learn more. - - -[pydantic-url]: https://github.com/samuelcolvin/pydantic diff --git a/mkdocs.yml b/mkdocs.yml index 34ee74a3..8ce85fa2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,7 @@ nav: - Redis Setup: redis_setup.md - User Guide: - Models: models.md - - Validation: validation.md + - Making Queries: querying.md - Migrations: migrations.md - FastAPI Integration: fastapi_integration.md - Reference: diff --git a/pyproject.toml b/pyproject.toml index a1578743..542f4fd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,9 +56,7 @@ dev = [ "mypy>=1.9.0; platform_python_implementation == 'CPython'", "pytest>=8.0.2,<10.0.0", "ipdb>=0.13.9", - "black>=24.2,<27.0", - "isort>=5.9.3,<8.0.0", - "flake8>=5.0.4,<8.0.0", + "ruff>=0.9.0", "bandit>=1.7.4", "coverage>=7.1", "pytest-cov>=5,<8", @@ -99,5 +97,32 @@ artifacts = [ "tests_sync/**/*", ] +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort +] +ignore = [ + "E231", # missing whitespace after ',' + "E501", # line too long (handled by formatter) + "E712", # comparison to True/False + "E731", # do not assign a lambda expression + "F401", # imported but unused (needed for re-exports) +] + +[tool.ruff.lint.isort] +known-first-party = ["aredis_om", "redis_om"] +lines-after-imports = 2 + +[tool.mypy] +ignore_missing_imports = true +disable_error_code = ["annotation-unchecked"] + diff --git a/tests/test_oss_redis_features.py b/tests/test_oss_redis_features.py index 645b31ff..89630201 100644 --- a/tests/test_oss_redis_features.py +++ b/tests/test_oss_redis_features.py @@ -136,8 +136,8 @@ async def test_saves_model_and_creates_pk(m): assert member2 == member -def test_raises_error_with_embedded_models(m): - class Address(m.BaseHashModel, index=True): +def test_raises_error_with_embedded_models(m) -> None: + class Address(m.BaseHashModel, index=True): # type: ignore[call-arg] address_line_1: str address_line_2: Optional[str] city: str diff --git a/tests/test_pydantic_integrations.py b/tests/test_pydantic_integrations.py index 87224a0b..fdd42db7 100644 --- a/tests/test_pydantic_integrations.py +++ b/tests/test_pydantic_integrations.py @@ -59,7 +59,8 @@ class ModelWithValidator(HashModel): field: Optional[str] = Field(default=None, index=True) @field_validator("field", mode="after") - def set_field(cls, v): + @classmethod + def set_field(cls, v: Optional[str]) -> str: return value m = ModelWithValidator(field="foo") diff --git a/tests/test_tag_separator.py b/tests/test_tag_separator.py index 0520b875..16ae34db 100644 --- a/tests/test_tag_separator.py +++ b/tests/test_tag_separator.py @@ -10,12 +10,13 @@ import pytest import pytest_asyncio -# We need to run this check as sync code (during tests) even in async mode -from redis_om import has_redisearch from aredis_om import Field, HashModel, JsonModel, Migrator from aredis_om.model.model import SINGLE_VALUE_TAG_FIELD_SEPARATOR +# We need to run this check as sync code (during tests) even in async mode +from redis_om import has_redisearch + from .conftest import py_test_mark_asyncio if not has_redisearch(): diff --git a/uv.lock b/uv.lock index 8b8bd016..5304ffe7 100644 --- a/uv.lock +++ b/uv.lock @@ -117,50 +117,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/0b/8bdc52111c83e2dc2f97403dc87c0830b8989d9ae45732b34b686326fb2c/bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a", size = 134451, upload-time = "2026-01-19T04:05:20.938Z" }, ] -[[package]] -name = "black" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/1b/523329e713f965ad0ea2b7a047eeb003007792a0353622ac7a8cb2ee6fef/black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168", size = 1849661, upload-time = "2026-01-18T04:59:12.425Z" }, - { url = "https://files.pythonhosted.org/packages/14/82/94c0640f7285fa71c2f32879f23e609dd2aa39ba2641f395487f24a578e7/black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d", size = 1689065, upload-time = "2026-01-18T04:59:13.993Z" }, - { url = "https://files.pythonhosted.org/packages/f0/78/474373cbd798f9291ed8f7107056e343fd39fef42de4a51c7fd0d360840c/black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0", size = 1751502, upload-time = "2026-01-18T04:59:15.971Z" }, - { url = "https://files.pythonhosted.org/packages/29/89/59d0e350123f97bc32c27c4d79563432d7f3530dca2bff64d855c178af8b/black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24", size = 1400102, upload-time = "2026-01-18T04:59:17.8Z" }, - { url = "https://files.pythonhosted.org/packages/e1/bc/5d866c7ae1c9d67d308f83af5462ca7046760158bbf142502bad8f22b3a1/black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89", size = 1207038, upload-time = "2026-01-18T04:59:19.543Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/f05f22ff13756e1a8ce7891db517dbc06200796a16326258268f4658a745/black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", size = 1831956, upload-time = "2026-01-18T04:59:21.38Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f2/b2c570550e39bedc157715e43927360312d6dd677eed2cc149a802577491/black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", size = 1672499, upload-time = "2026-01-18T04:59:23.257Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d7/990d6a94dc9e169f61374b1c3d4f4dd3037e93c2cc12b6f3b12bc663aa7b/black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14", size = 1735431, upload-time = "2026-01-18T04:59:24.729Z" }, - { url = "https://files.pythonhosted.org/packages/36/1c/cbd7bae7dd3cb315dfe6eeca802bb56662cc92b89af272e014d98c1f2286/black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", size = 1400468, upload-time = "2026-01-18T04:59:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/59/b1/9fe6132bb2d0d1f7094613320b56297a108ae19ecf3041d9678aec381b37/black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", size = 1207332, upload-time = "2026-01-18T04:59:28.711Z" }, - { url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" }, - { url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" }, - { url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" }, - { url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" }, - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -670,20 +626,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] -[[package]] -name = "flake8" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, -] - [[package]] name = "flask" version = "3.1.2" @@ -942,15 +884,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] -[[package]] -name = "isort" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, -] - [[package]] name = "itsdangerous" version = "2.2.0" @@ -1187,15 +1120,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1681,15 +1605,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1845,15 +1760,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, ] -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1982,45 +1888,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, ] -[[package]] -name = "pytokens" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/c5/c20818fef16c4ab5f9fd7bad699268ba21bf24f655711df4e33bb7a9ab47/pytokens-0.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:af0c3166aea367a9e755a283171befb92dd3043858b94ae9b3b7efbe9def26a3", size = 160682, upload-time = "2026-01-19T07:58:51.583Z" }, - { url = "https://files.pythonhosted.org/packages/46/c4/ad03e4abe05c6af57c4d7f8f031fafe80f0074796d09ab5a73bf2fac895f/pytokens-0.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae524ed14ca459932cbf51d74325bea643701ba8a8b0cc2d10f7cd4b3e2b63", size = 245748, upload-time = "2026-01-19T07:58:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b9/4a7ee0a692603b16d8fdfbc5c44e0f6910d45eec6b2c2188daa4670f179d/pytokens-0.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e95cb158c44d642ed62f555bf8136bbe780dbd64d2fb0b9169e11ffb944664c3", size = 258671, upload-time = "2026-01-19T07:58:55.667Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/02bb29dc4985fb8d759d9c96f189c3a828e74f0879fdb843e9fb7a1db637/pytokens-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:df58d44630eaf25f587540e94bdf1fc50b4e6d5f212c786de0fb024bfcb8753a", size = 261749, upload-time = "2026-01-19T07:58:57.442Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/9a8bdcc5444d85d4dba4aa1b530d81af3edc4a9ab76bf1d53ea8bfe8479d/pytokens-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55efcc36f9a2e0e930cfba0ce7f83445306b02f8326745585ed5551864eba73a", size = 102805, upload-time = "2026-01-19T07:58:59.068Z" }, - { url = "https://files.pythonhosted.org/packages/b4/05/3196399a353dd4cd99138a88f662810979ee2f1a1cdb0b417cb2f4507836/pytokens-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:92eb3ef88f27c22dc9dbab966ace4d61f6826e02ba04dac8e2d65ea31df56c8e", size = 160075, upload-time = "2026-01-19T07:59:00.316Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/c8fc4ed0a1c4f660391b201cda00b1d5bbcc00e2998e8bcd48b15eefd708/pytokens-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4b77858a680635ee9904306f54b0ee4781effb89e211ba0a773d76539537165", size = 247318, upload-time = "2026-01-19T07:59:01.636Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0e/53e55ba01f3e858d229cd84b02481542f42ba59050483a78bf2447ee1af7/pytokens-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25cacc20c2ad90acb56f3739d87905473c54ca1fa5967ffcd675463fe965865e", size = 259752, upload-time = "2026-01-19T07:59:04.229Z" }, - { url = "https://files.pythonhosted.org/packages/dc/56/2d930d7f899e3f21868ca6e8ec739ac31e8fc532f66e09cbe45d3df0a84f/pytokens-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628fab535ebc9079e4db35cd63cb401901c7ce8720a9834f9ad44b9eb4e0f1d4", size = 262842, upload-time = "2026-01-19T07:59:06.14Z" }, - { url = "https://files.pythonhosted.org/packages/42/dd/4e7e6920d23deffaf66e6f40d45f7610dcbc132ca5d90ab4faccef22f624/pytokens-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d0f568d7e82b7e96be56d03b5081de40e43c904eb6492bf09aaca47cd55f35b", size = 102620, upload-time = "2026-01-19T07:59:07.839Z" }, - { url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" }, - { url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" }, - { url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" }, - { url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" }, - { url = "https://files.pythonhosted.org/packages/98/63/627b7e71d557383da5a97f473ad50f8d9c2c1f55c7d3c2531a120c796f6e/pytokens-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73eff3bdd8ad08da679867992782568db0529b887bed4c85694f84cdf35eafc6", size = 159744, upload-time = "2026-01-19T07:59:16.88Z" }, - { url = "https://files.pythonhosted.org/packages/28/d7/16f434c37ec3824eba6bcb6e798e5381a8dc83af7a1eda0f95c16fe3ade5/pytokens-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d97cc1f91b1a8e8ebccf31c367f28225699bea26592df27141deade771ed0afb", size = 253207, upload-time = "2026-01-19T07:59:18.069Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/04102856b9527701ae57d74a6393d1aca5bad18a1b1ca48ccffb3c93b392/pytokens-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c8952c537cb73a1a74369501a83b7f9d208c3cf92c41dd88a17814e68d48ce", size = 267452, upload-time = "2026-01-19T07:59:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/0e/ef/0936eb472b89ab2d2c2c24bb81c50417e803fa89c731930d9fb01176fe9f/pytokens-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dbf56f3c748aed9310b310d5b8b14e2c96d3ad682ad5a943f381bdbbdddf753", size = 265965, upload-time = "2026-01-19T07:59:20.613Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/64f3d6f7df4a9e92ebda35ee85061f6260e16eac82df9396020eebbca775/pytokens-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e131804513597f2dff2b18f9911d9b6276e21ef3699abeffc1c087c65a3d975e", size = 102813, upload-time = "2026-01-19T07:59:22.012Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f1/d07e6209f18ef378fc2ae9dee8d1dfe91fd2447c2e2dbfa32867b6dd30cf/pytokens-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d7374c917197106d3c4761374718bc55ea2e9ac0fb94171588ef5840ee1f016", size = 159968, upload-time = "2026-01-19T07:59:23.07Z" }, - { url = "https://files.pythonhosted.org/packages/0a/73/0eb111400abd382a04f253b269819db9fcc748aa40748441cebdcb6d068f/pytokens-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd3fa1caf9e47a72ee134a29ca6b5bea84712724bba165d6628baa190c6ea5b", size = 253373, upload-time = "2026-01-19T07:59:24.381Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8d/9e4e2fdb5bcaba679e54afcc304e9f13f488eb4d626e6b613f9553e03dbd/pytokens-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6986576b7b07fe9791854caa5347923005a80b079d45b63b0be70d50cce5f1", size = 267024, upload-time = "2026-01-19T07:59:25.74Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b7/e0a370321af2deb772cff14ff337e1140d1eac2c29a8876bfee995f486f0/pytokens-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9940f7c2e2f54fb1cb5fe17d0803c54da7a2bf62222704eb4217433664a186a7", size = 270912, upload-time = "2026-01-19T07:59:27.072Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/4348f916c440d4c3e68b53b4ed0e66b292d119e799fa07afa159566dcc86/pytokens-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:54691cf8f299e7efabcc25adb4ce715d3cef1491e1c930eaf555182f898ef66a", size = 103836, upload-time = "2026-01-19T07:59:28.112Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/a693c0cfa9c783a2a8c4500b7b2a8bab420f8ca4f2d496153226bf1c12e3/pytokens-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94ff5db97a0d3cd7248a5b07ba2167bd3edc1db92f76c6db00137bbaf068ddf8", size = 167643, upload-time = "2026-01-19T07:59:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/c0/dd/a64eb1e9f3ec277b69b33ef1b40ffbcc8f0a3bafcde120997efc7bdefebf/pytokens-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dd6261cd9cc95fae1227b1b6ebee023a5fd4a4b6330b071c73a516f5f59b63", size = 289553, upload-time = "2026-01-19T07:59:30.537Z" }, - { url = "https://files.pythonhosted.org/packages/df/22/06c1079d93dbc3bca5d013e1795f3d8b9ed6c87290acd6913c1c526a6bb2/pytokens-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdca8159df407dbd669145af4171a0d967006e0be25f3b520896bc7068f02c4", size = 302490, upload-time = "2026-01-19T07:59:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/8d/de/a6f5e43115b4fbf4b93aa87d6c83c79932cdb084f9711daae04549e1e4ad/pytokens-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b5770abeb2a24347380a1164a558f0ebe06e98aedbd54c45f7929527a5fb26e", size = 305652, upload-time = "2026-01-19T07:59:33.685Z" }, - { url = "https://files.pythonhosted.org/packages/ab/3d/c136e057cb622e36e0c3ff7a8aaa19ff9720050c4078235691da885fe6ee/pytokens-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:74500d72c561dad14c037a9e86a657afd63e277dd5a3bb7570932ab7a3b12551", size = 115472, upload-time = "2026-01-19T07:59:34.734Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2111,7 +1978,7 @@ wheels = [ [[package]] name = "redis-om" -version = "1.0.4b0" +version = "1.0.6" source = { editable = "." } dependencies = [ { name = "click" }, @@ -2139,13 +2006,10 @@ examples = [ [package.dev-dependencies] dev = [ { name = "bandit" }, - { name = "black" }, { name = "codespell" }, { name = "coverage" }, { name = "email-validator" }, - { name = "flake8" }, { name = "ipdb" }, - { name = "isort" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mypy", marker = "platform_python_implementation == 'CPython'" }, @@ -2155,6 +2019,7 @@ dev = [ { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "ruff" }, { name = "tox" }, { name = "tox-pyenv" }, { name = "unasync" }, @@ -2184,13 +2049,10 @@ provides-extras = ["examples"] [package.metadata.requires-dev] dev = [ { name = "bandit", specifier = ">=1.7.4" }, - { name = "black", specifier = ">=24.2,<27.0" }, { name = "codespell", specifier = ">=2.2.0" }, { name = "coverage", specifier = ">=7.1" }, { name = "email-validator", specifier = ">=2.0.0" }, - { name = "flake8", specifier = ">=5.0.4,<8.0.0" }, { name = "ipdb", specifier = ">=0.13.9" }, - { name = "isort", specifier = ">=5.9.3,<8.0.0" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-material", specifier = ">=9.7.1" }, { name = "mypy", marker = "platform_python_implementation == 'CPython'", specifier = ">=1.9.0" }, @@ -2200,6 +2062,7 @@ dev = [ { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "pytest-cov", specifier = ">=5,<8" }, { name = "pytest-xdist", specifier = ">=3.1.0" }, + { name = "ruff", specifier = ">=0.9.0" }, { name = "tox", specifier = ">=4.14.1" }, { name = "tox-pyenv", specifier = ">=1.1.0" }, { name = "unasync", specifier = ">=0.6.0" }, @@ -2253,6 +2116,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + [[package]] name = "setuptools" version = "80.10.1"