From 763759ff10106dcd99cc0159891c7b3dbb463fa1 Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Thu, 16 Oct 2025 17:09:50 +1300 Subject: [PATCH 1/3] Add support for updating records This adds support for updating records in place, using new `update` methods added to the record and record manager base classes. The `update` method has the same smart evaluation of field names based on record classes that the `create` method has. This allows ID or object field names to be used for model refs, and field aliases. --- changelog.d/8.added.md | 1 + docs/managers/account-move.md | 2 +- docs/managers/index.md | 86 ++++++++++++++++++++- openstack_odooclient/base/record.py | 17 ++++ openstack_odooclient/base/record_manager.py | 21 ++++- pyproject.toml | 1 + 6 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 changelog.d/8.added.md diff --git a/changelog.d/8.added.md b/changelog.d/8.added.md new file mode 100644 index 0000000..f2e5008 --- /dev/null +++ b/changelog.d/8.added.md @@ -0,0 +1 @@ +Add support for updating records diff --git a/docs/managers/account-move.md b/docs/managers/account-move.md index 893fca5..3e2ac57 100644 --- a/docs/managers/account-move.md +++ b/docs/managers/account-move.md @@ -265,7 +265,7 @@ Values: * ``reversed`` - Reversed * ``invoicing_legacy`` - Invoicing App Legacy -### state +### `state` ```python state: Literal["draft", "posted", "cancel"] diff --git a/docs/managers/index.md b/docs/managers/index.md index 8a58686..f0fb759 100644 --- a/docs/managers/index.md +++ b/docs/managers/index.md @@ -770,14 +770,55 @@ pass the returned IDs to the [``list``](#list) method. |-------------|--------------------------------------| | `list[int]` | The IDs of the newly created records | +### `update` + +```python +def update(record: int | Record, **fields: Any) -> None +``` + +Update one or more fields on this record in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.users.get(1234) +User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +>>> odoo_client.users.update(1234, name="New Name") +>>> odoo_client.users.get(1234) +User(record={'id': 1234, 'name': 'New Name', ...}, fields=None) +``` + +Field names are passed as keyword arguments. +This method has the same flexibility with regards to what +field names are used as when [creating records](#create); +for example, when updating a model ref, either its ID +(e.g. ``user_id``) or object (e.g. ``user``) field names +can be used. + +*Added in version 0.2.0.* + +#### Parameters + +| Name | Type | Description | Default | +|------------|----------------|-----------------------------------------|------------| +| `record` | `int | Record` | The record to update (object or ID) | (required) | +| `**fields` | `Any` | Record field values (keyword arguments) | (required) | + ### `unlink`/`delete` ```python -unlink(*records: Record | int | Iterable[Record | int]) -> None +unlink(*records: int | Record | Iterable[int | Record]) -> None ``` ```python -delete(*records: Record | int | Iterable[Record | int]) -> None +delete(*records: int | Record | Iterable[int | Record]) -> None ``` Delete one or more records from Odoo. @@ -1357,6 +1398,44 @@ User(record={'id': 1234, ...}, fields=None) |------------------|-------------------| | `dict[str, Any]` | Record dictionary | +#### `update` + +```python +def update(**fields: Any) -> None +``` + +Update one or more fields on this record in place. + +```python +>>> user +User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +>>> user.update(name="New Name") +>>> user.refresh() +User(record={'id': 1234, 'name': 'New Name', ...}, fields=None) +``` + +Field names are passed as keyword arguments. +This method has the same flexibility with regards to what +field names are used as when [creating records](#create); +for example, when updating a model ref, either its ID +(e.g. ``user_id``) or object (e.g. ``user``) field names +can be used. + +!!! note + + This record object is not updated in place by this method. + + If you need an updated version of the record object, + use the [`refresh`](#refresh) method to fetch the latest version. + +*Added in version 0.2.0.* + +##### Parameters + +| Name | Type | Description | Default | +|------------|-------|-----------------------------------------|------------| +| `**fields` | `Any` | Record field values (keyword arguments) | (required) | + #### `refresh` ```python @@ -1395,9 +1474,10 @@ Delete this record from Odoo. ```python >>> user -User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +User(record={'id': 1234, ...}, fields=None) >>> user.unlink() >>> user.refresh() +Traceback (most recent call last): ... openstack_odooclient.exceptions.RecordNotFoundError: User record not found with ID: 1234 ``` diff --git a/openstack_odooclient/base/record.py b/openstack_odooclient/base/record.py index e650409..b97fa79 100644 --- a/openstack_odooclient/base/record.py +++ b/openstack_odooclient/base/record.py @@ -266,6 +266,23 @@ def as_dict(self, raw: bool = False) -> dict[str, Any]: } ) + def update(self, **fields: Any) -> None: + """Update one or more fields on this record in place. + + Field names are passed as keyword arguments. + This method has the same flexibility with regards to what + field names are used as when creating records; for example, + when updating a model ref, either its ID (e.g. ``user_id``) + or object (e.g. ``user``) field names can be used. + + Note that this record object is not updated in place by + this method. If you need an updated version of the record + object, use the `refresh` method to fetch the latest version. + + *Added in version 0.2.0.* + """ + self._manager.update(self.id, **fields) + def refresh(self) -> Self: """Fetch the latest version of this record from Odoo. diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index 123ec70..b2afded 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -629,7 +629,7 @@ def _encode_filter_field(self, field: str) -> tuple[Any, str]: type_hint = self._record_type_hints[local_field] return (type_hint, remote_field) - def create(self, **fields) -> int: + def create(self, **fields: Any) -> int: """Create a new record, using the specified keyword arguments as input fields. @@ -808,6 +808,25 @@ def _encode_create_field( self._encode_value(type_hint=type_hint, value=value), ) + def update(self, record: int | Record, **fields: Any) -> None: + """Update one or more fields on the given record in place. + + Field names are passed as keyword arguments. + This method has the same flexibility with regards to what + field names are used as when creating records; for example, + when updating a model ref, either its ID (e.g. ``user_id``) + or object (e.g. ``user``) field names can be used. + + *Added in version 0.2.0.* + + :param record: The record to update (object or ID) + :type record: int | Record + """ + self._env.update( + record.id if isinstance(record, RecordBase) else record, + self._encode_create_fields(fields), + ) + def unlink( self, *records: int | Record | Iterable[int | Record], diff --git a/pyproject.toml b/pyproject.toml index 0d8a202..d8a9394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ packages = ["openstack_odooclient"] [tool.poe.tasks] lint = "ruff check" format = "ruff format" +type-check = "mypy openstack_odooclient" [tool.ruff] fix = true From 2f9cd30e540d5fd6c094d355eb42eed55e2a2dfd Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Wed, 15 Oct 2025 15:19:01 +1300 Subject: [PATCH 2/3] Add support for managing attachments Add a new manager for attachment records (`ir.attachment`) in Odoo. These are intended to be used for uploading/downloading attachments to/from invoices. Invoice attachments will then be attached to invoice emails sent out to customers. The contents of the attachments won't be fetched when querying them from Odoo by default; instead it is intended that the separate `download` method be used to download the attachment contents separately. An `upload` method is also available, to provide an easier to use interface for uploading attachments. --- _typos.toml | 2 + changelog.d/7.added.md | 1 + docs/managers/attachment.md | 526 ++++++++++++++++++ docs/managers/index.md | 1 + mkdocs.yml | 1 + openstack_odooclient/__init__.py | 8 + openstack_odooclient/base/client.py | 8 + openstack_odooclient/base/record.py | 5 +- openstack_odooclient/base/record_manager.py | 4 + openstack_odooclient/client.py | 4 + openstack_odooclient/managers/account_move.py | 3 +- openstack_odooclient/managers/attachment.py | 470 ++++++++++++++++ openstack_odooclient/managers/product.py | 12 + 13 files changed, 1042 insertions(+), 3 deletions(-) create mode 100644 _typos.toml create mode 100644 changelog.d/7.added.md create mode 100644 docs/managers/attachment.md create mode 100644 openstack_odooclient/managers/attachment.py diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..02d3ec1 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +datas = "datas" diff --git a/changelog.d/7.added.md b/changelog.d/7.added.md new file mode 100644 index 0000000..ef6fcb1 --- /dev/null +++ b/changelog.d/7.added.md @@ -0,0 +1 @@ +Add support for managing attachments diff --git a/docs/managers/attachment.md b/docs/managers/attachment.md new file mode 100644 index 0000000..57f7c8b --- /dev/null +++ b/docs/managers/attachment.md @@ -0,0 +1,526 @@ +# Attachments + +*Added in version 0.2.0.* + +This page documents how to use the manager and record objects +for attachments. + +## Details + +| Name | Value | +|-----------------|-----------------| +| Odoo Modules | Base, Mail | +| Odoo Model Name | `ir.attachment` | +| Manager | `attachments` | +| Record Type | `Attachment` | + +## Manager + +The attachment manager is available as the `attachments` +attribute on the Odoo client object. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, ...}, fields=None) +``` + +For more information on how to use managers, refer to [Managers](index.md). + +The following manager methods are also available, in addition to the standard methods. + +### `upload` + +```python +def upload( + name: str, + data: bytes, + *, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + type: str = "binary", + **fields: Any, +) -> int +``` + +Upload an attachment and associate it with the given record. + +One of ``record`` or ``res_id`` must be set to specify the record +to link the attachment to. When ``res_id`` is used, ``res_model`` +must also be specified to define the model of the record. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.upload( +... "example.txt", +... b"Hello, world!", +... res_id=1234, +... res_model="account.move", +... res_field="message_main_attachment_id", +... ) +5678 +``` + +When ``record`` is used, this is not necessary. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> account_move = odoo_client.account_moves.get(1234) +>>> odoo_client.attachments.upload( +... "example.txt", +... b"Hello, world!", +... record=account_move, +... res_field="message_main_attachment_id", +... ) +5678 +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +#### Parameters + +| Name | Type | Description | Default | +|-------------|---------------------|----------------------------------------------------------------|------------| +| `name` | `str` | The name of the attachment | (required) | +| `data` | `bytes` | The contents of the attachment | (required) | +| `record` | `RecordBase | None` | The linked record (if referencing by object) | `None` | +| `res_id` | `int | None` | The ID of the linked record (if referencing by ID) | `None` | +| `res_model` | `str | None` | The model of the linked record (if referencing by ID) | `None` | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +#### Returns + +| Type | Description | +|-------|------------------------------------------------| +| `int` | The record ID of the newly uploaded attachment | + +### `download` + +```python +def download( + attachment: int | Attachment, +) -> bytes +``` + +Download a given attachment, and return the contents as bytes. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|---------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | + +#### Returns + +| Type | Description | +|---------|---------------------| +| `bytes` | Attachment contents | + +### `reupload` + +```python +def reupload( + attachment: int | Attachment, + data: bytes, + **fields: Any, +) -> None +``` + +Reupload a new version of the contents of the given attachment, +and update the attachment in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.download(1234) +b'Goodbye, world!' +>>> odoo_client.attachments.reupload( +... 1234, +... b"Hello, world!", +... ) +>>> odoo_client.attachments.download(1234) +b'Hello, world!' +``` + +Other fields can be updated at the same time by passing them +as keyword arguments. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, 'name': 'hello.txt', ...}, fields=None) +>>> odoo_client.attachments.download(1234) +b'Goodbye, world!' +>>> odoo_client.attachments.reupload( +... 1234, +... b"Hello, world!", +... name="example.txt", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, 'name': 'example.txt', ...}, fields=None) +>>> odoo_client.attachments.download(1234) +b'Hello, world!' +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|----------------------------------------------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | +| `data` | `bytes` | The contents of the attachment | (required) | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +### `register_as_main_attachment` + +```python +def register_as_main_attachment( + attachment: int | Attachment, + force: bool = True, +) -> None +``` + +Register the given attachment as the main attachment +of the record it is attached to. + +The model of the attached record must have the +``message_main_attachment_id`` field defined. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|---------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | +| `force` | `bool` | Overwrite if already set | `True` | + +## Record + +The attachment manager returns `Attachment` record objects. + +To import the record class for type hinting purposes: + +```python +from openstack_odooclient import Attachment +``` + +The record class currently implements the following fields and methods. + +For more information on attributes and methods common to all record types, +see [Record Attributes and Methods](index.md#attributes-and-methods). + +### `checksum` + +```python +checksum: str +``` + +A SHA1 checksum of the attachment contents. + +### `company_id` + +```python +company_id: int | None +``` + +The ID for the [company](company.md) that owns this attachment, if set. + +### `company_name` + +```python +company_name: str | None +``` + +The name of the [company](company.md) that owns this attachment, if set. + +### `company` + +```python +company: Company | None +``` + +The [company](company.md) that owns this attachment, if set. + +This fetches the full record from Odoo once, +and caches it for subsequent accesses. + +### `datas` + +```python +datas: str | Literal[False] +``` + +The contents of the attachment, encoded in base64. + +Only applies when [``type``](#type) is set to ``binary``. + +**This field is not fetched by default.** To make this field available, +use the ``fields`` parameter on the [``get``](index.md#get) or +[``list``](index.md#list) methods to select the ``datas`` field. + +### `description` + +```python +description: str | Literal[False] +``` + +A description of the file, if defined. + +### `index_content` + +```python +index_content: str +``` + +The index content value computed from the attachment contents. + +**This field is not fetched by default.** To make this field available, +use the ``fields`` parameter on the [``get``](index.md#get) or +[``list``](index.md#list) methods to select the ``index_content`` field. + +### `mimetype` + +```python +mimetype: str +``` + +MIME type of the attached file. + +### `name` + +```python +name: str +``` + +The name of the attachment. + +Usually matches the filename of the attached file. + +### `public` + +```python +public: bool +``` + +Whether or not the attachment is publicly accessible. + +### `res_field` + +```python +res_field: str | Literal[False] +``` + +The name of the field used to refer to this attachment +on the linked record's model, if set. + +### `res_id` + +```python +res_id: int | Literal[False] +``` + +The ID of the record this attachment is linked to, if set. + +### `res_model` + +```python +res_model: str | Literal[False] +``` + +The name of the model of the record this attachment +is linked to, if set. + +### `res_name` + +```python +res_name: str | Literal[False] +``` + +The name of the record this attachment is linked to, if set. + +### `store_fname` + +```python +store_fname: str | Literal[False] +``` + +The stored filename for this attachment, if set. + +### `type` + +```python +type: Literal["binary", "url"] +``` + +The type of the attachment. + +When set to ``binary``, the contents of the attachment are available +using the ``datas`` field. When set to ``url``, the attachment can be +downloaded from the URL configured in the ``url`` field. + +Values: + +* ``binary`` - Stored internally as binary data +* ``url`` - Stored externally, accessible using a URL + +### `url` + +```python +url: str | Literal[False] +``` + +The URL the contents of the attachment are available from. + +Only applies when ``type`` is set to ``url``. + +### `download` + +```python +def download() -> bytes +``` + +Download this attachment, and return the contents as bytes. + +#### Returns + +| Type | Description | +|---------|---------------------| +| `bytes` | Attachment contents | + +### `reupload` + +```python +def reupload( + data: bytes, + **fields: Any, +) -> None +``` + +Reupload a new version of the contents of this attachment, +and update the attachment in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> attachment = odoo_client.attachments.get(1234) +>>> attachment.download() +b'Goodbye, world!' +>>> attachment.reupload(b"Hello, world!") +>>> attachment.download() +b'Hello, world!' +``` + +Other fields can be updated at the same time by passing them +as keyword arguments. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> attachment = odoo_client.attachments.get(1234) +>>> attachment +Attachment(record={'id': 1234, 'name': 'hello.txt', ...}, fields=None) +>>> attachment.download() +b'Goodbye, world!' +>>> attachment.reupload( +... b"Hello, world!", +... name="example.txt", +... ) +>>> attachment = attachment.refresh() +>>> attachment +Attachment(record={'id': 1234, 'name': 'example.txt', ...}, fields=None) +>>> attachment.download() +b'Hello, world!' +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +!!! note + + This attachment object not updated in place by this method. + + If you need an updated version of the attachment object, + use the [`refresh`](index.md#refresh) method to fetch the latest version. + +#### Parameters + +| Name | Type | Description | Default | +|------------|---------|----------------------------------------------------------------|------------| +| `data` | `bytes` | The contents of the attachment | (required) | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +### `register_as_main_attachment` + +```python +def register_as_main_attachment( + force: bool = True, +) -> None +``` + +Register this attachment as the main attachment +of the record it is attached to. + +The model of the attached record must have the +``message_main_attachment_id`` field defined. + +#### Parameters + +| Name | Type | Description | Default | +|---------|--------|--------------------------|---------| +| `force` | `bool` | Overwrite if already set | `True` | diff --git a/docs/managers/index.md b/docs/managers/index.md index f0fb759..7b906ec 100644 --- a/docs/managers/index.md +++ b/docs/managers/index.md @@ -23,6 +23,7 @@ For example, performing a simple search query would look something like this: * [Account Moves (Invoices)](account-move.md) * [Account Move (Invoice) Lines](account-move-line.md) +* [Attachments](attachment.md) * [Companies](company.md) * [OpenStack Credits](credit.md) * [OpenStack Credit Transactions](credit-transaction.md) diff --git a/mkdocs.yml b/mkdocs.yml index f2ecb21..30fcc52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - managers/index.md - managers/account-move.md - managers/account-move-line.md + - managers/attachment.md - managers/company.md - managers/credit.md - managers/credit-type.md diff --git a/openstack_odooclient/__init__.py b/openstack_odooclient/__init__.py index 97cd8aa..eb02493 100644 --- a/openstack_odooclient/__init__.py +++ b/openstack_odooclient/__init__.py @@ -34,6 +34,11 @@ AccountMoveLine, AccountMoveLineManager, ) +from .managers.attachment import ( + Attachment, + AttachmentManager, + AttachmentMixin, +) from .managers.company import Company, CompanyManager from .managers.credit import Credit, CreditManager from .managers.credit_transaction import ( @@ -83,6 +88,9 @@ "AccountMoveLine", "AccountMoveLineManager", "AccountMoveManager", + "Attachment", + "AttachmentManager", + "AttachmentMixin", "Client", "ClientBase", "ClientError", diff --git a/openstack_odooclient/base/client.py b/openstack_odooclient/base/client.py index 04bd4c2..dc9d314 100644 --- a/openstack_odooclient/base/client.py +++ b/openstack_odooclient/base/client.py @@ -167,6 +167,14 @@ def __init__( opener=opener, ) self._odoo.login(database, username, password) + self._env_manager_mapping: dict[str, RecordManagerBase] = {} + """An internal mapping between env (model) names and their managers. + + This is populated by the manager classes themselves when created, + and used by the ``Attachment.res_model_manager`` field. + + *Added in version 0.2.0.* + """ self._record_manager_mapping: dict[ Type[RecordBase], RecordManagerBase, diff --git a/openstack_odooclient/base/record.py b/openstack_odooclient/base/record.py index b97fa79..8be05c4 100644 --- a/openstack_odooclient/base/record.py +++ b/openstack_odooclient/base/record.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import date, datetime -from types import MappingProxyType +from types import MappingProxyType, UnionType from typing import ( TYPE_CHECKING, Annotated, @@ -392,7 +392,8 @@ def _getattr_model_ref( # The following is for decoding a singular model ref value. # Check if the model ref is optional, and if it is, # return the desired value for when the value is empty. - if get_type_origin(attr_type) is Union: + attr_type_origin = get_type_origin(attr_type) + if attr_type_origin is Union or attr_type_origin is UnionType: unsupported_union = ( "Only unions of the format Optional[T], " "Union[T, type(None)] or Union[T, Literal[False]] " diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index b2afded..3088d99 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -109,6 +109,7 @@ def __init__(self, client: ClientBase) -> None: """The Odoo client object the manager uses.""" # Assign this record manager object as the manager # responsible for the configured record class in the client. + self._client._env_manager_mapping[self.env_name] = self self._client._record_manager_mapping[self.record_class] = self self._record_type_hints = MappingProxyType( get_type_hints( @@ -983,3 +984,6 @@ def _encode_value(self, type_hint: Any, value: Any) -> Any: v_type = get_type_args(type_hint)[0] return [self._encode_value(v_type, v) for v in value] return value + + +RecordManager = RecordManagerBase[RecordBase["RecordManager"]] diff --git a/openstack_odooclient/client.py b/openstack_odooclient/client.py index 00ea202..c68149e 100644 --- a/openstack_odooclient/client.py +++ b/openstack_odooclient/client.py @@ -18,6 +18,7 @@ from .base.client import ClientBase from .managers.account_move import AccountMoveManager from .managers.account_move_line import AccountMoveLineManager +from .managers.attachment import AttachmentManager from .managers.company import CompanyManager from .managers.credit import CreditManager from .managers.credit_transaction import CreditTransactionManager @@ -90,6 +91,9 @@ class Client(ClientBase): account_move_lines: AccountMoveLineManager """Account move (invoice) line manager.""" + attachments: AttachmentManager + """Attachment manager.""" + companies: CompanyManager """Company manager.""" diff --git a/openstack_odooclient/managers/account_move.py b/openstack_odooclient/managers/account_move.py index c401485..7df3d01 100644 --- a/openstack_odooclient/managers/account_move.py +++ b/openstack_odooclient/managers/account_move.py @@ -20,12 +20,13 @@ from ..base.record import ModelRef, RecordBase from ..base.record_manager_named import NamedRecordManagerBase +from .attachment import AttachmentMixin if TYPE_CHECKING: from collections.abc import Iterable, Mapping -class AccountMove(RecordBase["AccountMoveManager"]): +class AccountMove(AttachmentMixin, RecordBase["AccountMoveManager"]): amount_total: float """Total (taxed) amount charged on the account move (invoice).""" diff --git a/openstack_odooclient/managers/attachment.py b/openstack_odooclient/managers/attachment.py new file mode 100644 index 0000000..26f69b5 --- /dev/null +++ b/openstack_odooclient/managers/attachment.py @@ -0,0 +1,470 @@ +# Copyright (C) 2025 Catalyst Cloud Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import base64 + +from typing import TYPE_CHECKING, Annotated, Any, Literal + +from ..base.record import ModelRef, RecordBase +from ..base.record_manager import RecordManager, RecordManagerBase + +if TYPE_CHECKING: + from ..base.client import ClientBase + + +class Attachment(RecordBase["AttachmentManager"]): + access_token: str | Literal[False] + """An access token that can be used to + fetch the attachment, if defined. + """ + + checksum: str + """A SHA1 checksum of the attachment contents.""" + + company_id: Annotated[int | None, ModelRef("company_id", Company)] + """The ID for the company that owns this attachment, if set.""" + + company_name: Annotated[str | None, ModelRef("company_id", Company)] + """The name of the company that owns this attachment, if set.""" + + company: Annotated[Company | None, ModelRef("company_id", Company)] + """The company that owns this attachment, if set. + + This fetches the full record from Odoo once, + and caches it for subsequent accesses. + """ + + datas: str | Literal[False] + """The contents of the attachment, encoded in base64. + + Only applies when ``type`` is set to ``binary``. + + **This field is not fetched by default.** To make this field available, + use the ``fields`` parameter on the ``get`` or ``list`` methods to select + the ``datas`` field. + """ + + description: str | Literal[False] + """A description of the file, if defined.""" + + index_content: str + """The index content value computed from the attachment contents. + + **This field is not fetched by default.** To make this field available, + use the ``fields`` parameter on the ``get`` or ``list`` methods to select + the ``index_content`` field. + """ + + mimetype: str + """MIME type of the attached file.""" + + name: str + """The name of the attachment. + + Usually matches the filename of the attached file. + """ + + public: bool + """Whether or not the attachment is publicly accessible.""" + + res_field: str | Literal[False] + """The name of the field used to refer to this attachment + on the linked record's model, if set. + """ + + res_id: int | Literal[False] + """The ID of the record this attachment is linked to, if set.""" + + res_model: str | Literal[False] + """The name of the model of the record this attachment + is linked to, if set. + """ + + res_name: str | Literal[False] + """The name of the record this attachment is linked to, if set.""" + + store_fname: str | Literal[False] + """The stored filename for this attachment, if set.""" + + type: Literal["binary", "url"] + """The type of the attachment. + + When set to ``binary``, the contents of the attachment are available + using the ``datas`` field. When set to ``url``, the attachment can be + downloaded from the URL configured in the ``url`` field. + + Values: + + * ``binary`` - Stored internally as binary data + * ``url`` - Stored externally, accessible using a URL + """ + + url: str | Literal[False] + """The URL the contents of the attachment are available from. + + Only applies when ``type`` is set to ``url``. + """ + + @property + def res_model_manager(self) -> RecordManager | None: + """The manager for the model of the record + this attachment is linked to. + """ + if not self.res_model: + return None + return get_res_model_manager( + client=self._client, + res_model=self.res_model, + ) + + def download(self) -> bytes: + """Download this attachment, and return the contents as bytes. + + :return: Attachment contents + :rtype: bytes + """ + return download(manager=self._manager, attachment_id=self.id) + + def reupload(self, data: bytes, **fields: Any) -> None: + """Reupload a new version of the contents of this attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + Note that this attachment object not updated in place by + this method. If you need an updated version of the attachment + object, use the `refresh` method to fetch the latest version. + + :param data: Contents of the attachment + :type data: bytes + """ + reupload( + manager=self._manager, + attachment_id=self.id, + data=data, + **fields, + ) + + def register_as_main_attachment(self, force: bool = True) -> None: + """Register this attachment as the main attachment + of the record it is attached to. + + The model of the attached record must have the + ``message_main_attachment_id`` field defined. + + :param force: Overwrite if already set, defaults to True + :type force: bool, optional + """ + self._env.register_as_main_attachment(self.id, force=force) + + +class AttachmentManager(RecordManagerBase[Attachment]): + env_name = "ir.attachment" + record_class = Attachment + default_fields = ( + "access_token", + "checksum", + "company_id", + # datas not fetched by default + "description", + # index_content not fetched by default + "mimetype", + "name", + "public", + "res_field", + "res_id", + "res_model", + "res_name", + "store_fname", + "type", + "url", + ) + + def upload( + self, + name: str, + data: bytes, + *, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + **fields: Any, + ) -> int: + """Upload an attachment and associate it with the given record. + + One of ``record`` or ``res_id`` must be set to specify the record + to link the attachment to. When ``res_id`` is used, ``res_model`` + must also be specified to define the model of the record. + + When ``record`` is used, this is not necessary. + + Any keyword arguments passed to this method are passed to + the attachment record as fields. + + :param name: The name of the attachment + :type name: str + :param data: The contents of the attachment + :type data: bytes + :param record: The linked record, defaults to None + :type record: RecordBase | None, optional + :param res_id: The ID of the linked record, defaults to None + :type res_id: int | None, optional + :param res_model: The model of the linked record, defaults to None + :type res_model: str | None, optional + :return: The record ID of the newly uploaded attachment + :rtype: int + """ + return upload( + manager=self, + name=name, + data=data, + record=record, + res_id=res_id, + res_model=res_model, + **fields, + ) + + def download(self, attachment: int | Attachment) -> bytes: + """Download a given attachment, and return the contents as bytes. + + :param attachment: Attachment (ID or object) + :type attachment: int | Attachment + :return: Attachment contents + :rtype: bytes + """ + return download( + manager=self, + attachment_id=( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + ) + + def reupload( + self, + attachment: int | Attachment, + data: bytes, + **fields: Any, + ) -> None: + """Reupload a new version of the contents of the given attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + :param data: Contents of the attachment + :type data: bytes + """ + reupload( + manager=self, + attachment_id=( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + data=data, + **fields, + ) + + def register_as_main_attachment( + self, + attachment: int | Attachment, + force: bool = True, + ) -> None: + """Register the given attachment as the main attachment + of the record it is attached to. + + The model of the attached record must have the + ``message_main_attachment_id`` field defined. + + :param attachment: Attachment (ID or object) + :type attachment: int | Attachment + :param force: Overwrite if already set, defaults to True + :type force: bool, optional + """ + self._env.register_as_main_attachment( + ( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + force=force, + ) + + +class AttachmentMixin: + message_main_attachment_id: Annotated[ + int | None, + ModelRef("message_main_attachment_id", Attachment), + ] + """The ID of the main attachment on the record, if there is one.""" + + message_main_attachment_name: Annotated[ + str | None, + ModelRef("message_main_attachment_name", Attachment), + ] + """The name of the main attachment on the record, if there is one.""" + + message_main_attachment: Annotated[ + Attachment | None, + ModelRef("message_main_attachment", Attachment), + ] + """The main attachment on the record, if there is one. + + This fetches the full record from Odoo once, + and caches it for subsequent accesses. + """ + + +def get_res_model_manager( + client: ClientBase, + res_model: str, +) -> RecordManager: + """Return the manager for the given model. + + :param client: Odoo client object + :type client: ClientBase + :param res_model: Model name + :type res_model: str + :return: Model manager + :rtype: RecordManager + """ + + return client._env_manager_mapping[res_model] + + +def upload( + *, + manager: AttachmentManager, + name: str, + data: bytes, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + **fields: Any, +) -> int: + """Upload an attachment and associate it with the given record. + + One of ``record`` or ``res_id`` must be set to specify the record + to link the attachment to. When ``res_id`` is used, ``res_model`` + must also be specified to define the model of the record. + + When ``record`` is used, this is not necessary. + + Any keyword arguments passed to this method are passed to + the attachment record as fields. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param name: The name of the attachment + :type name: str + :param data: The contents of the attachment + :type data: bytes + :param record: The linked record, defaults to None + :type record: RecordBase | None, optional + :param res_id: The ID of the linked record, defaults to None + :type res_id: int | None, optional + :param res_model: The model of the linked record, defaults to None + :type res_model: str | None, optional + :return: The record ID of the newly uploaded attachment + :rtype: int + """ + + if record: + res_id = record.id + res_model = record._manager.env_name + elif not res_id: + raise ValueError( + ( + "Either record or res_id must be specified " + f"when uploading attachment: {name}" + ), + ) + + if not res_model: + raise ValueError( + ( + "res_model must be specified for a record reference using " + f"res_id {res_id} when uploading attachment: {name}" + ), + ) + + fields["type"] = "binary" + fields.pop("datas", None) + fields.pop("url", None) + + return manager.create( + name=name, + res_id=res_id, + res_model=res_model, + datas=base64.b64encode(data).decode(encoding="ascii"), + **fields, + ) + + +def download(manager: AttachmentManager, attachment_id: int) -> bytes: + """Download an attachment by ID, and return the contents as bytes. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param attachment_id: ID of the attachment to download + :type attachment_id: int + :return: Attachment contents + :rtype: bytes + """ + + return base64.b64decode( + manager._env.read(attachment_id, fields=["datas"])[0]["datas"], + ) + + +def reupload( + *, + manager: AttachmentManager, + attachment_id: int, + data: bytes, + **fields: Any, +) -> None: + """Reupload a new version of the contents of the given attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param attachment_id: Attachment ID + :type attachment_id: int + :param data: The contents of the attachment + :type data: bytes + """ + + fields.pop("type", None) + fields.pop("datas", None) + fields.pop("url", None) + + return manager.update( + attachment_id, + datas=base64.b64encode(data).decode(encoding="ascii"), + **fields, + ) + + +# NOTE(callumdickinson): Import here to make sure circular imports work. +from .company import Company # noqa: E402 diff --git a/openstack_odooclient/managers/product.py b/openstack_odooclient/managers/product.py index d57eaae..86c97f0 100644 --- a/openstack_odooclient/managers/product.py +++ b/openstack_odooclient/managers/product.py @@ -27,6 +27,12 @@ class Product(RecordBase["ProductManager"]): + active: bool + """Whether or not this product is active (enabled). + + *Added in version 0.2.0.* + """ + categ_id: Annotated[int, ModelRef("categ_id", ProductCategory)] """The ID for the category this product is under.""" @@ -75,6 +81,12 @@ class Product(RecordBase["ProductManager"]): name: str """The name of the product.""" + sale_ok: bool + """Whether or not this product is sellable. + + *Added in version 0.2.0.* + """ + uom_id: Annotated[int, ModelRef("uom_id", Uom)] """The ID for the Unit of Measure for this product.""" From 85140b4c6e5d05afaf388edcfe5c9949c39dccbb Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Fri, 31 Oct 2025 09:56:59 +1300 Subject: [PATCH 3/3] Replace OdooRPC with direct requests Remove the OpenStack Odoo Client for Python's usage of the OdooRPC library, and replace it with direct JSON RPC requests made internally using the `httpx` library. Functionally, the most important change here is that we now do our own JSON encoding and decoding. This allows us to decode non-integer numbers as `Decimal` objects instead of floats, ensuring that number handling is as accurate as possible (on the client side anyway, Odoo still stores them as floats unfortunately). There are other benefits, such as performance improvements from avoiding OdooRPC's ORM abstractions and other additional processing we don't need. --- openstack_odooclient/base/client.py | 284 +++++++++++------- openstack_odooclient/base/record.py | 15 +- openstack_odooclient/base/record_manager.py | 106 +++++-- openstack_odooclient/managers/account_move.py | 15 +- .../managers/account_move_line.py | 9 +- openstack_odooclient/managers/attachment.py | 11 +- openstack_odooclient/managers/credit.py | 5 +- .../managers/credit_transaction.py | 3 +- openstack_odooclient/managers/currency.py | 5 +- openstack_odooclient/managers/grant.py | 3 +- openstack_odooclient/managers/pricelist.py | 26 +- openstack_odooclient/managers/product.py | 3 +- .../managers/referral_code.py | 7 +- .../managers/reseller_tier.py | 7 +- openstack_odooclient/managers/sale_order.py | 17 +- .../managers/sale_order_line.py | 27 +- .../managers/support_subscription_type.py | 3 +- openstack_odooclient/managers/tax.py | 3 +- .../managers/term_discount.py | 5 +- openstack_odooclient/managers/uom.py | 5 +- .../managers/volume_discount_range.py | 11 +- openstack_odooclient/managers/voucher_code.py | 5 +- openstack_odooclient/util.py | 16 + pyproject.toml | 2 +- uv.lock | 237 ++++++++++----- 25 files changed, 526 insertions(+), 304 deletions(-) diff --git a/openstack_odooclient/base/client.py b/openstack_odooclient/base/client.py index dc9d314..085d58a 100644 --- a/openstack_odooclient/base/client.py +++ b/openstack_odooclient/base/client.py @@ -15,24 +15,23 @@ from __future__ import annotations -import ssl -import urllib.request +import json +import random from pathlib import Path -from typing import TYPE_CHECKING, Literal, Type, overload +from typing import TYPE_CHECKING, Any, Type + +import httpx -from odoorpc import ODOO # type: ignore[import] from packaging.version import Version from typing_extensions import get_type_hints # 3.11 and later -from ..util import is_subclass +from ..util import JSONDecoder, JSONEncoder, is_subclass from .record import RecordBase from .record_manager import RecordManagerBase if TYPE_CHECKING: - from odoorpc.db import DB # type: ignore[import] - from odoorpc.env import Environment # type: ignore[import] - from odoorpc.report import Report # type: ignore[import] + from collections.abc import Mapping class ClientBase: @@ -81,92 +80,22 @@ class ClientBase: :type version: str | None, optional """ - @overload def __init__( self, *, - hostname: str | None = ..., - database: str | None = ..., - username: str | None = ..., - password: str | None = ..., - protocol: str = "jsonrpc", - port: int = 8069, - verify: bool | str | Path = ..., - version: str | None = ..., - odoo: ODOO, - ) -> None: ... - - @overload - def __init__( - self, - *, - hostname: str, + base_url: str, database: str, username: str, password: str, - protocol: str = "jsonrpc", - port: int = 8069, - verify: bool | str | Path = ..., - version: str | None = ..., - odoo: Literal[None] = ..., - ) -> None: ... - - @overload - def __init__( - self, - *, - hostname: str | None = ..., - database: str | None = ..., - username: str | None = ..., - password: str | None = ..., - protocol: str = "jsonrpc", - port: int = 8069, - verify: bool | str | Path = ..., - version: str | None = ..., - odoo: ODOO | None = ..., - ) -> None: ... - - def __init__( - self, - *, - hostname: str | None = None, - database: str | None = None, - username: str | None = None, - password: str | None = None, - protocol: str = "jsonrpc", - port: int = 8069, verify: bool | str | Path = True, - version: str | None = None, - odoo: ODOO | None = None, + timeout: int | None = None, ) -> None: - # If an OdooRPC object is provided, use that directly. - # Otherwise, make a new one with the provided settings. - if odoo: - self._odoo = odoo - else: - opener = None - if protocol.endswith("+ssl"): - ssl_verify = verify is not False - ssl_cafile = ( - str(verify) if isinstance(verify, (Path, str)) else None - ) - if not ssl_verify or ssl_cafile: - ssl_context = ssl.create_default_context(cafile=ssl_cafile) - if not ssl_verify: - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - opener = urllib.request.build_opener( - urllib.request.HTTPSHandler(context=ssl_context), - urllib.request.HTTPCookieProcessor(), - ) - self._odoo = ODOO( - protocol=protocol, - host=hostname, - port=port, - version=version, - opener=opener, - ) - self._odoo.login(database, username, password) + self._base_url = base_url + self._database = database + self._username = username + self._password = password + self._verify = str(verify) if isinstance(verify, Path) else verify + self._timeout = timeout self._env_manager_mapping: dict[str, RecordManagerBase] = {} """An internal mapping between env (model) names and their managers. @@ -189,47 +118,170 @@ def __init__( for attr_name, attr_type in get_type_hints(type(self)).items(): if is_subclass(attr_type, RecordManagerBase): setattr(self, attr_name, attr_type(self)) - - @property - def odoo(self) -> ODOO: - """The OdooRPC connection object currently being used - by this client. - """ - return self._odoo - - @property - def db(self) -> DB: - """The database management service.""" - return self._odoo.db - - @property - def report(self) -> Report: - """The report management service.""" - return self._odoo.report - - @property - def env(self) -> Environment: - """The OdooRPC environment wrapper object. - - This allows interacting with models that do not have managers - within this Odoo client. - Usage is the same as on a native ``odoorpc.ODOO`` object. - """ - return self._odoo.env + self.login() @property def user_id(self) -> int: """The ID for the currently logged in user.""" - return self._odoo.env.uid + return self._user_id @property def version(self) -> Version: """The version of the server, as a comparable ``packaging.version.Version`` object. """ - return Version(self._odoo.version) + return self._odoo_version @property def version_str(self) -> str: """The version of the server, as a string.""" - return self._odoo.version + return self._odoo_version_str + + def _jsonrpc( + self, + *, + params: Mapping[str, Any] | None = None, + url: str = "/jsonrpc", + ) -> httpx.Response: + return self._http_client.post( + url, + headers={"Content-Type": "application/json"}, + content=json.dumps( + { + "jsonrpc": "2.0", + "method": "call", + "params": dict(params) if params else {}, + "id": random.randint(0, 1000000000), # noqa: S311 + }, + cls=JSONEncoder, + separators=(",", ":"), + ), + ) + + def login(self) -> None: + """Login to the Odoo database with the configured + username and password. + + Fetches and stores a new session cookie, usable until + it expires after a pre-determined amount of time. + + If a request is made to Odoo after the session cookie + has expired, the Odoo client will automatically run this + method to refresh the session cookie. + """ + # Set up the HTTP client session that will be used + # for all requests. + self._http_client = httpx.Client( + base_url=self._base_url, + verify=self._verify, + timeout=self._timeout, + ) + # Login, and set up the user context. + response = self._jsonrpc( + params={ + "service": "common", + "method": "login", + "args": [self._database, self._username, self._password], + }, + ) + # TODO(callumdickinson): Handle HTTP 401. + user_id: int | None = response.json()["result"] + if not user_id: + # TODO(callumdickinson): Custom exception class. + raise ValueError("Incorrect username or password") + response = self._jsonrpc( + params={ + "service": "object", + "method": "execute", + "args": [ + self._database, + self._username, + self._password, + "res.users", + "context_get", + ], + }, + ) + context: dict[str, Any] = response.json()["result"] + context["uid"] = user_id + self._user_id = user_id + self._context = context + # Discover the Odoo server's version. + response = self._jsonrpc(url="/web/webclient/version_info") + self._odoo_version_str: str = response.json()["server_version"] + self._odoo_version = Version(self._odoo_version_str) + + def close(self) -> None: + self._http_client.close() + + def execute( + self, + model: str, + method: str, + /, + *args: Any, + ) -> Any: + """Invoke a method on the given model, + passing all other positional arguments + as parameters, and return the result. + + :param model: The model to run the method on + :type model: str + :param method: The method to invoke + :type method: str + :return: The return value of the method + :rtype: Any + """ + response = self._jsonrpc( + params={ + "service": "object", + "method": "execute", + "args": [ + self._database, + self._user_id, + self._password, + model, + method, + *args, + ], + }, + ) + data: dict[str, Any] = json.loads(response.text, cls=JSONDecoder) + return data.get("result") + + def execute_kw( + self, + model: str, + method: str, + /, + *args: Any, + **kwargs: Any, + ) -> Any: + """Invoke a method on the given model, + passing all other positional arguments + and all keyword arguments as parameters, + and return the result. + + :param model: The model to run the method on + :type model: str + :param method: The method to invoke + :type method: str + :return: The return value of the method + :rtype: Any + """ + response = self._jsonrpc( + params={ + "service": "object", + "method": "execute_kw", + "args": [ + self._database, + self._user_id, + self._password, + model, + method, + [args, kwargs], + ], + }, + ) + data: dict[str, Any] = json.loads(response.text, cls=JSONDecoder) + return data.get("result") diff --git a/openstack_odooclient/base/record.py b/openstack_odooclient/base/record.py index 8be05c4..76a9a32 100644 --- a/openstack_odooclient/base/record.py +++ b/openstack_odooclient/base/record.py @@ -42,9 +42,6 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence - from odoorpc import ODOO # type: ignore[import] - from odoorpc.env import Environment # type: ignore[import] - from .client import ClientBase RecordManager = TypeVar("RecordManager", bound="RecordManagerBase") @@ -206,16 +203,6 @@ def _manager(self) -> RecordManager: mapping = self._client._record_manager_mapping return mapping[type(self)] # type: ignore[return-value] - @property - def _odoo(self) -> ODOO: - """The OdooRPC connection object this record was created from.""" - return self._client._odoo - - @property - def _env(self) -> Environment: - """The OdooRPC environment object this record was created from.""" - return self._manager._env - @property def _type_hints(self) -> MappingProxyType[str, Any]: return self._manager._record_type_hints @@ -294,7 +281,7 @@ def refresh(self) -> Self: """ return type(self)( client=self._client, - record=self._env.read( + record=self._manager._read( self.id, fields=self._fields, )[0], diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index 3088d99..f517fb0 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -49,9 +49,6 @@ if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence - from odoorpc import ODOO # type: ignore[import] - from odoorpc.env import Environment # type: ignore[import] - from .client import ClientBase FilterCriterion = tuple[str, str, Any] | Sequence[Any] | str @@ -154,15 +151,45 @@ def __init__(self, client: ClientBase) -> None: except IndexError: pass - @property - def _odoo(self) -> ODOO: - """The OdooRPC connection object this record manager uses.""" - return self._client._odoo + def execute(self, method: str, /, *args: Any) -> Any: + """Invoke a method on the record model, + passing all other positional arguments + as parameters, and return the result. - @property - def _env(self) -> Environment: - """The OdooRPC environment object this record manager uses.""" - return self._odoo.env[self.env_name] + :param method: The method to invoke + :type method: str + :return: The return value of the method + :rtype: Any + """ + return self._client.execute( + self.env_name, + method, + *args, + ) + + def execute_kw( + self, + method: str, + /, + *args: Any, + **kwargs: Any, + ) -> Any: + """Invoke a method on the record model, + passing all other positional arguments + and all keyword arguments as parameters, + and return the result. + + :param method: The method to invoke + :type method: str + :return: The return value of the method + :rtype: Any + """ + return self._client.execute_kw( + self.env_name, + method, + *args, + **kwargs, + ) @overload def list( @@ -231,12 +258,7 @@ def list( :return: List of records :rtype: list[Record] | list[dict[str, Any]] """ - if isinstance(ids, int): - _ids: int | list[int] = ids - else: - _ids = list(ids) - if not _ids: - return [] # type: ignore[return-value] + _ids = [ids] if isinstance(ids, int) else list(ids) fields = fields or self.default_fields or None _fields = ( list( @@ -247,10 +269,7 @@ def list( if fields is not None else None ) - records: Iterable[dict[str, Any]] = self._env.read( - _ids, - fields=_fields, - ) + records: Iterable[dict[str, Any]] = self._read(ids, fields=_fields) if as_dict: res_dicts = [ { @@ -269,7 +288,7 @@ def list( for record in records ] if not optional: - required_ids = {_ids} if isinstance(_ids, int) else set(_ids) + required_ids = set(_ids) found_ids: set[int] = ( set(record["id"] for record in res_dicts) if as_dict @@ -528,8 +547,9 @@ def search( :return: List of records :rtype: list[Record] | list[int] | list[dict[str, Any]] """ - ids: list[int] = self._env.search( - (self._encode_filters(filters) if filters else []), + ids: list[int] = self.execute_kw( + "search", + self._encode_filters(filters) if filters else [], order=order, ) if as_id: @@ -661,7 +681,7 @@ def create(self, **fields: Any) -> int: :return: The ID of the newly created record :rtype: int """ - return self._env.create(self._encode_create_fields(fields)) + return self.execute_kw("create", self._encode_create_fields(fields)) def create_multi(self, *records: Mapping[str, Any]) -> builtins.list[int]: """Create one or more new records in a single request, @@ -677,13 +697,38 @@ def create_multi(self, *records: Mapping[str, Any]) -> builtins.list[int]: :return: The IDs of the newly created records :rtype: list[int] """ - res: int | list[int] = self._env.create( + res: int | list[int] = self.execute_kw( + "create", [self._encode_create_fields(record) for record in records], ) if isinstance(res, int): return [res] return res + def _read( + self, + ids: int | Iterable[int], + *, + fields: Iterable[str] | None = None, + ) -> builtins.list[dict[str, Any]]: + """Read raw record objects with the given IDs. + + :param ids: IDs of the records to read + :type ids: Iterable[int] + :param fields: Fields to select, defaults to None + :type fields: Iterable[str] | None, optional + :return: Raw record objects + :rtype: list[dict[str, Any]] + """ + kwargs = {} + if fields: + kwargs["fields"] = list(fields) + return self.execute_kw( + "read", + [ids] if isinstance(ids, int) else list(ids), + **kwargs, + ) + def _encode_create_fields( self, fields: Mapping[str, Any], @@ -823,7 +868,8 @@ def update(self, record: int | Record, **fields: Any) -> None: :param record: The record to update (object or ID) :type record: int | Record """ - self._env.update( + self.execute_kw( + "update", record.id if isinstance(record, RecordBase) else record, self._encode_create_fields(fields), ) @@ -852,7 +898,7 @@ def unlink( _ids.extend( ((i.id if isinstance(i, RecordBase) else i) for i in ids), ) - self._env.unlink(_ids) + self.execute_kw("unlink", _ids) def delete( self, @@ -881,7 +927,7 @@ def _get_remote_field(self, field: str) -> str: # based on the version of the Odoo server. return get_mapped_field( field_mapping=self.record_class._field_mapping, - odoo_version=self._odoo.version, + odoo_version=self._client.version_str, field=field, ) @@ -890,7 +936,7 @@ def _get_local_field(self, field: str) -> str: # based on the version of the Odoo server. local_field = get_mapped_field( field_mapping=self._field_mapping_reverse, - odoo_version=self._odoo.version, + odoo_version=self._client.version_str, field=field, ) # If the field is a model ref, find the local field diff --git a/openstack_odooclient/managers/account_move.py b/openstack_odooclient/managers/account_move.py index 7df3d01..6d0922e 100644 --- a/openstack_odooclient/managers/account_move.py +++ b/openstack_odooclient/managers/account_move.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from typing import TYPE_CHECKING, Annotated, Any, Literal from ..base.record import ModelRef, RecordBase @@ -27,10 +28,10 @@ class AccountMove(AttachmentMixin, RecordBase["AccountMoveManager"]): - amount_total: float + amount_total: Decimal """Total (taxed) amount charged on the account move (invoice).""" - amount_untaxed: float + amount_untaxed: Decimal """Total (untaxed) amount charged on the account move (invoice).""" currency_id: Annotated[int, ModelRef("currency_id", Currency)] @@ -160,7 +161,7 @@ class AccountMove(AttachmentMixin, RecordBase["AccountMoveManager"]): def action_post(self) -> None: """Change this draft account move (invoice) into "posted" state.""" - self._env.action_post(self.id) + self._manager.execute_kw("action_post", self.id) def send_openstack_invoice_email( self, @@ -171,7 +172,8 @@ def send_openstack_invoice_email( :param email_ctx: Optional email context, defaults to None :type email_ctx: Mapping[str, Any] | None, optional """ - self._env.send_openstack_invoice_email( + self._manager.execute_kw( + "send_openstack_invoice_email", self.id, email_ctx=dict(email_ctx) if email_ctx else None, ) @@ -211,7 +213,7 @@ def action_post( for i in ids # type: ignore[union-attr] ), ) - self._env.action_post(_ids) + self.execute_kw("action_post", _ids) def send_openstack_invoice_email( self, @@ -226,7 +228,8 @@ def send_openstack_invoice_email( :param email_ctx: Optional email context, defaults to None :type email_ctx: Mapping[str, Any] | None, optional """ - self._env.send_openstack_invoice_email( + self.execute_kw( + "send_openstack_invoice_email", ( account_move.id if isinstance(account_move, AccountMove) diff --git a/openstack_odooclient/managers/account_move_line.py b/openstack_odooclient/managers/account_move_line.py index c738389..503fd93 100644 --- a/openstack_odooclient/managers/account_move_line.py +++ b/openstack_odooclient/managers/account_move_line.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -40,7 +41,7 @@ class AccountMoveLine(RecordBase["AccountMoveLineManager"]): and caches it for subsequent accesses. """ - line_tax_amount: float + line_tax_amount: Decimal """Amount charged in tax on the account move (invoice) line.""" move_id: Annotated[int, ModelRef("move_id", AccountMove)] @@ -100,12 +101,12 @@ class AccountMoveLine(RecordBase["AccountMoveLineManager"]): by this account move (invoice) line. """ - price_subtotal: float + price_subtotal: Decimal """Amount charged for the product (untaxed) on the account move (invoice) line. """ - price_unit: float + price_unit: Decimal """Unit price for the product used on the account move (invoice) line.""" product_id: Annotated[int, ModelRef("product_id", Product)] @@ -126,7 +127,7 @@ class AccountMoveLine(RecordBase["AccountMoveLineManager"]): and caches it for subsequent accesses. """ - quantity: float + quantity: Decimal """Quantity of product charged on the account move (invoice) line.""" diff --git a/openstack_odooclient/managers/attachment.py b/openstack_odooclient/managers/attachment.py index 26f69b5..62f094e 100644 --- a/openstack_odooclient/managers/attachment.py +++ b/openstack_odooclient/managers/attachment.py @@ -170,7 +170,11 @@ def register_as_main_attachment(self, force: bool = True) -> None: :param force: Overwrite if already set, defaults to True :type force: bool, optional """ - self._env.register_as_main_attachment(self.id, force=force) + self._manager.execute_kw( + "register_as_main_attachment", + self.id, + force=force, + ) class AttachmentManager(RecordManagerBase[Attachment]): @@ -298,7 +302,8 @@ def register_as_main_attachment( :param force: Overwrite if already set, defaults to True :type force: bool, optional """ - self._env.register_as_main_attachment( + self.execute_kw( + "register_as_main_attachment", ( attachment.id if isinstance(attachment, Attachment) @@ -430,7 +435,7 @@ def download(manager: AttachmentManager, attachment_id: int) -> bytes: """ return base64.b64decode( - manager._env.read(attachment_id, fields=["datas"])[0]["datas"], + manager._read(attachment_id, fields=("datas",))[0]["datas"], ) diff --git a/openstack_odooclient/managers/credit.py b/openstack_odooclient/managers/credit.py index ea55c7e..28cb378 100644 --- a/openstack_odooclient/managers/credit.py +++ b/openstack_odooclient/managers/credit.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -36,13 +37,13 @@ class Credit(RecordBase["CreditManager"]): and caches it for subsequent accesses. """ - current_balance: float + current_balance: Decimal """The current remaining balance on the credit.""" expiry_date: date """The date the credit expires.""" - initial_balance: float + initial_balance: Decimal """The initial balance this credit started off with.""" name: str diff --git a/openstack_odooclient/managers/credit_transaction.py b/openstack_odooclient/managers/credit_transaction.py index 7f60b51..4d82883 100644 --- a/openstack_odooclient/managers/credit_transaction.py +++ b/openstack_odooclient/managers/credit_transaction.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -38,7 +39,7 @@ class CreditTransaction(RecordBase["CreditTransactionManager"]): description: str """A description of this credit transaction.""" - value: float + value: Decimal """The value of the credit transaction.""" diff --git a/openstack_odooclient/managers/currency.py b/openstack_odooclient/managers/currency.py index b8d4d2e..fdf0dca 100644 --- a/openstack_odooclient/managers/currency.py +++ b/openstack_odooclient/managers/currency.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date as datetime_date +from decimal import Decimal from typing import Literal from ..base.record import RecordBase @@ -54,10 +55,10 @@ class Currency(RecordBase["CurrencyManager"]): * ``after`` - Place the unit after the amount """ - rate: float + rate: Decimal """The rate of the currency to the currency of rate 1.""" - rounding: float + rounding: Decimal """The rounding factor configured for this currency.""" symbol: str diff --git a/openstack_odooclient/managers/grant.py b/openstack_odooclient/managers/grant.py index 5b4d33c..51b0cee 100644 --- a/openstack_odooclient/managers/grant.py +++ b/openstack_odooclient/managers/grant.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -45,7 +46,7 @@ class Grant(RecordBase["GrantManager"]): start_date: date """The start date of the grant.""" - value: float + value: Decimal """The value of the grant.""" voucher_code_id: Annotated[ diff --git a/openstack_odooclient/managers/pricelist.py b/openstack_odooclient/managers/pricelist.py index bb8d2b1..74b7ca0 100644 --- a/openstack_odooclient/managers/pricelist.py +++ b/openstack_odooclient/managers/pricelist.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -63,15 +64,19 @@ class Pricelist(RecordBase["PricelistManager"]): name: str """The name of this pricelist.""" - def get_price(self, product: int | Product, qty: float) -> float: + def get_price( + self, + product: int | Product, + qty: float | Decimal, + ) -> Decimal: """Get the price to charge for a given product and quantity. :param product: Product to get the price for (ID or object) :type product: int | Product :param qty: Quantity to charge for - :type qty: float + :type qty: float | Decimal :return: Price to charge - :rtype: float + :rtype: Decimal """ return get_price( manager=self._manager, @@ -89,8 +94,8 @@ def get_price( self, pricelist: int | Pricelist, product: int | Product, - qty: float, - ) -> float: + qty: float | Decimal, + ) -> Decimal: """Get the price to charge for a given pricelist, product and quantity. @@ -99,9 +104,9 @@ def get_price( :param product: Product to get the price for (ID or object) :type product: int | Product :param qty: Quantity to charge for - :type qty: float + :type qty: float | Decimal :return: Price to charge - :rtype: float + :rtype: Decimal """ return get_price( manager=self, @@ -115,12 +120,13 @@ def get_price( manager: PricelistManager, pricelist: int | Pricelist, product: int | Product, - qty: float, -) -> float: + qty: float | Decimal, +) -> Decimal: pricelist_id = ( pricelist.id if isinstance(pricelist, Pricelist) else pricelist ) - price = manager._env.price_get( + price = manager.execute_kw( + "price_get", pricelist_id, (product.id if isinstance(product, Product) else product), max(qty, 0), diff --git a/openstack_odooclient/managers/product.py b/openstack_odooclient/managers/product.py index 86c97f0..8f63834 100644 --- a/openstack_odooclient/managers/product.py +++ b/openstack_odooclient/managers/product.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Annotated, Any, Literal, overload from ..base.record import ModelRef, RecordBase @@ -72,7 +73,7 @@ class Product(RecordBase["ProductManager"]): display_name: str """The name of this product in OpenStack, and on invoices.""" - list_price: float + list_price: Decimal """The list price of the product. This becomes the unit price of the product on invoices. diff --git a/openstack_odooclient/managers/referral_code.py b/openstack_odooclient/managers/referral_code.py index ea17dad..60a6fe4 100644 --- a/openstack_odooclient/managers/referral_code.py +++ b/openstack_odooclient/managers/referral_code.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -28,7 +29,7 @@ class ReferralCode(RecordBase["ReferralCodeManager"]): Set to ``-1`` for unlimited uses. """ - before_reward_usage_threshold: float + before_reward_usage_threshold: Decimal """The amount of usage that must be recorded by the new sign-up before the reward credit is awarded to the referrer. """ @@ -51,7 +52,7 @@ class ReferralCode(RecordBase["ReferralCodeManager"]): and caches them for subsequent accesses. """ - referral_credit_amount: float + referral_credit_amount: Decimal """Initial balance for the referral credit.""" referral_credit_duration: int @@ -79,7 +80,7 @@ class ReferralCode(RecordBase["ReferralCodeManager"]): and caches it for subsequent accesses. """ - reward_credit_amount: float + reward_credit_amount: Decimal """Initial balance for the reward credit.""" reward_credit_duration: int diff --git a/openstack_odooclient/managers/reseller_tier.py b/openstack_odooclient/managers/reseller_tier.py index 41f54f0..4a2541d 100644 --- a/openstack_odooclient/managers/reseller_tier.py +++ b/openstack_odooclient/managers/reseller_tier.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -22,7 +23,7 @@ class ResellerTier(RecordBase["ResellerTierManager"]): - discount_percent: float + discount_percent: Decimal """The maximum discount percentage for this reseller tier (0-100).""" discount_product_id: Annotated[ @@ -47,7 +48,7 @@ class ResellerTier(RecordBase["ResellerTierManager"]): and caches it for subsequent accesses. """ - free_monthly_credit: float + free_monthly_credit: Decimal """The amount the reseller gets monthly in credit for demo projects.""" free_monthly_credit_product_id: Annotated[ @@ -85,7 +86,7 @@ class ResellerTier(RecordBase["ResellerTierManager"]): name: str """Reseller tier name.""" - min_usage_threshold: float + min_usage_threshold: Decimal """The minimum required usage amount for the reseller tier.""" diff --git a/openstack_odooclient/managers/sale_order.py b/openstack_odooclient/managers/sale_order.py index a2ee550..164aba1 100644 --- a/openstack_odooclient/managers/sale_order.py +++ b/openstack_odooclient/managers/sale_order.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date, datetime +from decimal import Decimal from typing import Annotated, Literal from ..base.record import FieldAlias, ModelRef, RecordBase @@ -23,13 +24,13 @@ class SaleOrder(RecordBase["SaleOrderManager"]): - amount_untaxed: float + amount_untaxed: Decimal """The untaxed total cost of the sale order.""" - amount_tax: float + amount_tax: Decimal """The amount in taxes on this sale order.""" - amount_total: float + amount_total: Decimal """The taxed total cost of the sale order.""" client_order_ref: str | Literal[False] @@ -147,11 +148,11 @@ class SaleOrder(RecordBase["SaleOrderManager"]): def action_confirm(self) -> None: """Confirm this sale order.""" - self._env.action_confirm(self.id) + self._manager.execute_kw("action_confirm", self.id) def create_invoices(self) -> None: """Create invoices from this sale order.""" - self._env.create_invoices(self.id) + self._manager.execute_kw("create_invoices", self.id) class SaleOrderManager(NamedRecordManagerBase[SaleOrder]): @@ -164,7 +165,8 @@ def action_confirm(self, sale_order: int | SaleOrder) -> None: :param sale_order: The sale order to confirm :type sale_order: int | SaleOrder """ - self._env.action_confirm( + self.execute_kw( + "action_confirm", ( sale_order.id if isinstance(sale_order, SaleOrder) @@ -178,7 +180,8 @@ def create_invoices(self, sale_order: int | SaleOrder) -> None: :param sale_order: The sale order to create invoices from :type sale_order: int | SaleOrder """ - self._env.create_invoices( + self.execute_kw( + "create_invoices", ( sale_order.id if isinstance(sale_order, SaleOrder) diff --git a/openstack_odooclient/managers/sale_order_line.py b/openstack_odooclient/managers/sale_order_line.py index ade8a5a..0eae89e 100644 --- a/openstack_odooclient/managers/sale_order_line.py +++ b/openstack_odooclient/managers/sale_order_line.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -53,7 +54,7 @@ class SaleOrderLine(RecordBase["SaleOrderLineManager"]): and caches it for subsequent accesses. """ - discount: float + discount: Decimal """Discount percentage on the sale order line (0-100).""" display_name: str @@ -168,25 +169,25 @@ class SaleOrderLine(RecordBase["SaleOrderLineManager"]): by this sale order line. """ - price_reduce: float + price_reduce: Decimal """Base unit price, less discount (see the ``discount`` field).""" - price_reduce_taxexcl: float + price_reduce_taxexcl: Decimal """Actual unit price, excluding tax.""" - price_reduce_taxinc: float + price_reduce_taxinc: Decimal """Actual unit price, including tax.""" - price_subtotal: float + price_subtotal: Decimal """Subtotal price for the sale order line, excluding tax.""" - price_tax: float + price_tax: Decimal """Tax charged on the sale order line.""" - price_total: float + price_total: Decimal """Total price for the sale order line, including tax.""" - price_unit: float + price_unit: Decimal """Base unit price, excluding tax, before any discounts.""" product_id: Annotated[int, ModelRef("product_id", Product)] @@ -220,7 +221,7 @@ class SaleOrderLine(RecordBase["SaleOrderLineManager"]): and caches it for subsequent accesses. """ - product_uom_qty: float + product_uom_qty: Decimal """The product quantity on the sale order line.""" product_uom_readonly: bool @@ -231,10 +232,10 @@ class SaleOrderLine(RecordBase["SaleOrderLineManager"]): product_updatable: bool """Whether or not the product can be edited on this sale order line.""" - qty_invoiced: float + qty_invoiced: Decimal """The product quantity that has already been invoiced.""" - qty_to_invoice: float + qty_to_invoice: Decimal """The product quantity that still needs to be invoiced.""" salesman_id: Annotated[int, ModelRef("salesman_id", Partner)] @@ -279,12 +280,12 @@ class SaleOrderLine(RecordBase["SaleOrderLineManager"]): and caches it for subsequent accesses. """ - untaxed_amount_invoiced: float + untaxed_amount_invoiced: Decimal """The balance, excluding tax, on the sale order line that has already been invoiced. """ - untaxed_amount_to_invoice: float + untaxed_amount_to_invoice: Decimal """The balance, excluding tax, on the sale order line that still needs to be invoiced. """ diff --git a/openstack_odooclient/managers/support_subscription_type.py b/openstack_odooclient/managers/support_subscription_type.py index d05b1a0..63b0bfe 100644 --- a/openstack_odooclient/managers/support_subscription_type.py +++ b/openstack_odooclient/managers/support_subscription_type.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -46,7 +47,7 @@ class SupportSubscriptionType(RecordBase["SupportSubscriptionTypeManager"]): and caches it for subsequent accesses. """ - usage_percent: float + usage_percent: Decimal """Percentage of usage compared to price (0-100).""" support_subscription_ids: Annotated[ diff --git a/openstack_odooclient/managers/tax.py b/openstack_odooclient/managers/tax.py index 03c5fdc..4a1f56c 100644 --- a/openstack_odooclient/managers/tax.py +++ b/openstack_odooclient/managers/tax.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -25,7 +26,7 @@ class Tax(RecordBase["TaxManager"]): active: bool """Whether or not this tax is active (enabled).""" - amount: float + amount: Decimal """The amount of tax to apply.""" amount_type: Literal["group", "fixed", "percent", "division"] diff --git a/openstack_odooclient/managers/term_discount.py b/openstack_odooclient/managers/term_discount.py index 6117925..967b691 100644 --- a/openstack_odooclient/managers/term_discount.py +++ b/openstack_odooclient/managers/term_discount.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from typing import Annotated from typing_extensions import Self @@ -25,7 +26,7 @@ class TermDiscount(RecordBase["TermDiscountManager"]): - discount_percent: float + discount_percent: Decimal """The maximum discount percentage for this term discount (0-100).""" early_termination_date: date | None @@ -34,7 +35,7 @@ class TermDiscount(RecordBase["TermDiscountManager"]): end_date: date """The date that the term discount expires on.""" - min_commit: float + min_commit: Decimal """The minimum commitment for this term discount to apply.""" partner_id: Annotated[int, ModelRef("partner", Partner)] diff --git a/openstack_odooclient/managers/uom.py b/openstack_odooclient/managers/uom.py index 8e42a7e..cd30173 100644 --- a/openstack_odooclient/managers/uom.py +++ b/openstack_odooclient/managers/uom.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -38,12 +39,12 @@ class Uom(RecordBase["UomManager"]): and caches it for subsequent accesses. """ - factor: float + factor: Decimal """How much bigger or smaller this unit is compared to the reference Unit of Measure (UoM) for the classified category. """ - factor_inv: float + factor_inv: Decimal """How many times this Unit of Measure is bigger than the reference Unit of Measure (UoM) for the classified category. """ diff --git a/openstack_odooclient/managers/volume_discount_range.py b/openstack_odooclient/managers/volume_discount_range.py index f6d68b6..1fcf159 100644 --- a/openstack_odooclient/managers/volume_discount_range.py +++ b/openstack_odooclient/managers/volume_discount_range.py @@ -15,6 +15,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Annotated from ..base.record import ModelRef, RecordBase @@ -58,7 +59,7 @@ class VolumeDiscountRange(RecordBase["VolumeDiscountRangeManager"]): and caches it for subsequent accesses. """ - discount_percent: float + discount_percent: Decimal """Discount percentage of this volume discount range (0-100).""" name: str @@ -66,13 +67,13 @@ class VolumeDiscountRange(RecordBase["VolumeDiscountRangeManager"]): this volume discount range. """ - max: float | None + max: Decimal | None """Optional maximum charge for this volume discount range. Intended to be used when creating tiered volume discounts for customers. """ - min: float + min: Decimal """Minimum charge for this volume discount range.""" use_max: bool @@ -85,7 +86,7 @@ class VolumeDiscountRangeManager(RecordManagerBase[VolumeDiscountRange]): def get_for_charge( self, - charge: float, + charge: Decimal, customer_group: int | CustomerGroup | None = None, ) -> VolumeDiscountRange | None: """Return the volume discount range to apply to a given charge. @@ -100,7 +101,7 @@ def get_for_charge( ``None`` is returned. :param charge: The charge to find the applicable discount range for - :type charge: float + :type charge: Decimal :param customer_group: Get discount for a specific customer group :type customer_group: int | CustomerGroup | None, optional :return: Highest percentage applicable discount range (if found) diff --git a/openstack_odooclient/managers/voucher_code.py b/openstack_odooclient/managers/voucher_code.py index 8db78f4..4774d86 100644 --- a/openstack_odooclient/managers/voucher_code.py +++ b/openstack_odooclient/managers/voucher_code.py @@ -16,6 +16,7 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from typing import Annotated, Literal from ..base.record import ModelRef, RecordBase @@ -29,7 +30,7 @@ class VoucherCode(RecordBase["VoucherCodeManager"]): code: str """The code string for this voucher code.""" - credit_amount: float | Literal[False] + credit_amount: Decimal | Literal[False] """The initial credit balance for the voucher code, if a credit is to be created by the voucher code. """ @@ -117,7 +118,7 @@ class VoucherCode(RecordBase["VoucherCodeManager"]): and caches it for subsequent accesses. """ - grant_value: float | Literal[False] + grant_value: Decimal | Literal[False] """The value of the grant, if a grant is to be created by the voucher code. """ diff --git a/openstack_odooclient/util.py b/openstack_odooclient/util.py index 9b870f3..f3ece89 100644 --- a/openstack_odooclient/util.py +++ b/openstack_odooclient/util.py @@ -15,6 +15,9 @@ from __future__ import annotations +import json + +from decimal import Decimal from typing import TYPE_CHECKING, Any, Type, TypeGuard, TypeVar if TYPE_CHECKING: @@ -30,6 +33,19 @@ T = TypeVar("T") +class JSONDecoder(json.JSONDecoder): + def __init__(self, **kwargs: Any) -> None: + kwargs.pop("parse_float", None) + super().__init__(parse_float=Decimal, **kwargs) + + +class JSONEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: + if isinstance(o, Decimal): + return str(o) + return super().default(o) + + def get_mapped_field( field_mapping: Mapping[str | None, Mapping[str, str]], odoo_version: str, diff --git a/pyproject.toml b/pyproject.toml index d8a9394..854ed8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "OdooRPC>=0.9.0", + "httpx>=0.28.1", "packaging", "typing-extensions>=4.15.0", ] diff --git a/uv.lock b/uv.lock index 607fc0b..c5d12db 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -36,66 +51,91 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -119,6 +159,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -131,6 +183,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -335,7 +424,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.6.21" +version = "9.6.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -350,9 +439,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/d5/ab83ca9aa314954b0a9e8849780bdd01866a3cfcb15ffb7e3a61ca06ff0b/mkdocs_material-9.6.21.tar.gz", hash = "sha256:b01aa6d2731322438056f360f0e623d3faae981f8f2d8c68b1b973f4f2657870", size = 4043097, upload-time = "2025-09-30T19:11:27.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/5d/317e37b6c43325cb376a1d6439df9cc743b8ee41c84603c2faf7286afc82/mkdocs_material-9.6.22.tar.gz", hash = "sha256:87c158b0642e1ada6da0cbd798a3389b0bc5516b90e5ece4a0fb939f00bacd1c", size = 4044968, upload-time = "2025-10-15T09:21:15.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/4f/98681c2030375fe9b057dbfb9008b68f46c07dddf583f4df09bf8075e37f/mkdocs_material-9.6.21-py3-none-any.whl", hash = "sha256:aa6a5ab6fb4f6d381588ac51da8782a4d3757cb3d1b174f81a2ec126e1f22c92", size = 9203097, upload-time = "2025-09-30T19:11:24.063Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6fdb9a7a04fb222f4849ffec1006f891a0280825a20314d11f3ccdee14eb/mkdocs_material-9.6.22-py3-none-any.whl", hash = "sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84", size = 9206252, upload-time = "2025-10-15T09:21:12.175Z" }, ] [[package]] @@ -418,20 +507,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "odoorpc" -version = "0.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/0a/9f907fbfefd2486bb4a3faab03f094f03b300b413004f348a3583ecc1898/OdooRPC-0.10.1.tar.gz", hash = "sha256:d0bc524c5b960781165575bad9c13d032d6f968c3c09276271045ddbbb483aa5", size = 58086, upload-time = "2023-08-16T09:13:42.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/60/8c5ea2a63151d6c1215e127909eeee3c16e792bbae92ab596dd921d6669d/OdooRPC-0.10.1-py2.py3-none-any.whl", hash = "sha256:a0900bdd5c989c414b1ef40dafccd9363f179312d9166d9486cf70c7c2f0dd44", size = 38482, upload-time = "2023-08-16T09:13:40.8Z" }, -] - [[package]] name = "openstack-odooclient" source = { editable = "." } dependencies = [ - { name = "odoorpc" }, + { name = "httpx" }, { name = "packaging" }, { name = "typing-extensions" }, ] @@ -448,7 +528,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "odoorpc", specifier = ">=0.9.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "packaging" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] @@ -691,6 +771,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.3.0"