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/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/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 8a58686..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) @@ -770,14 +771,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 +1399,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 +1475,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/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..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,30 @@ 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. + + 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, @@ -181,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 e650409..76a9a32 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, @@ -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 @@ -266,6 +253,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. @@ -277,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], @@ -375,7 +379,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 123ec70..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 @@ -109,6 +106,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( @@ -153,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. + + :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, + ) - @property - def _env(self) -> Environment: - """The OdooRPC environment object this record manager uses.""" - return self._odoo.env[self.env_name] + 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( @@ -230,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( @@ -246,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 = [ { @@ -268,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 @@ -527,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: @@ -629,7 +650,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. @@ -660,7 +681,7 @@ def create(self, **fields) -> 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, @@ -676,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], @@ -808,6 +854,26 @@ 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.execute_kw( + "update", + record.id if isinstance(record, RecordBase) else record, + self._encode_create_fields(fields), + ) + def unlink( self, *records: int | Record | Iterable[int | Record], @@ -832,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, @@ -861,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, ) @@ -870,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 @@ -964,3 +1030,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..6d0922e 100644 --- a/openstack_odooclient/managers/account_move.py +++ b/openstack_odooclient/managers/account_move.py @@ -16,20 +16,22 @@ 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 from ..base.record_manager_named import NamedRecordManagerBase +from .attachment import AttachmentMixin if TYPE_CHECKING: from collections.abc import Iterable, Mapping -class AccountMove(RecordBase["AccountMoveManager"]): - amount_total: float +class AccountMove(AttachmentMixin, RecordBase["AccountMoveManager"]): + 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)] @@ -159,7 +161,7 @@ class AccountMove(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, @@ -170,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, ) @@ -210,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, @@ -225,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 new file mode 100644 index 0000000..62f094e --- /dev/null +++ b/openstack_odooclient/managers/attachment.py @@ -0,0 +1,475 @@ +# 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._manager.execute_kw( + "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.execute_kw( + "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._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/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 d57eaae..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 @@ -27,6 +28,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.""" @@ -66,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. @@ -75,6 +82,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.""" 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 0d8a202..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", ] @@ -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 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"