odata1c-client 0.1.5
A Python client for interacting with 1C systems via OData v3, providing ORM-like functionality, data validation, and query construction.
License: MIT
Requires Python: <4.0,>=3.12

# OData1C - A Python OData Client for 1C Systems

OData1C is a Python client designed to interact with 1C systems via their OData v3 REST endpoints.  
This client aims to simplify and streamline common tasks such as querying entities, applying filters and expansions, 
creating and updating data, and handling related entities. 
It uses **Pydantic** for data validation and **Requests** for HTTP communication.

While originally inspired by [PyOData1C](https://github.com/kr-aleksey/PyOData1C) (with only essential OData capabilities for 1C), OData1C has been refined 
with clearer structure, comprehensive English docstrings, improved maintainability, and extensibility.

## Key Features

- **OData Integration**: Communicates with 1C OData endpoints (primarily tested with OData v3).
- **Data Validation**: Leverages Pydantic for serialization, deserialization, and validation of model data.
- **ORM-Like Interface**: Define your entity models as Pydantic classes inheriting from `ODataModel`, and use `OData` and `EntityManager` classes to interact with the server.
- **Complex Query Building**:  
  - **Filtering**: Apply filters using Django-style lookups or `Q` objects.
  - **Expansion**: Fetch related nested entities via `$expand`.
  - **Pagination**: Limit and skip results using `$top` and `$skip`.
- **Chaining API**: Methods like `filter()`, `expand()`, `top()`, and `skip()` return the manager instance, allowing method chaining and cleaner query construction.
- **Error Handling**: Provides domain-specific exceptions, validation error accumulation, and flexible error handling strategies.
- **Context Manager Support**: Use `Connection` as a context manager (`with ... as ...`) to ensure proper resource cleanup.

## Installation

```bash
pip install odata1c-client
```

## Dependencies
- Python = 3.12
- Pydantic = 2.7.0
- Requests = 2.32.0

## Usage

Below is a sample usage example showing how to define models and query data:

```python
from uuid import UUID

from requests.auth import HTTPBasicAuth
from pydantic import Field

from OData1C.connection import Connection
from OData1C.models import ODataModel
from OData1C.odata.entity_manager import OData


class MeasureUnitModel(ODataModel):
  uid: UUID = Field(alias='Ref_Key', exclude=True)
  name: str = Field(alias='Description', max_length=6)


class NomenclatureModel(ODataModel):
  uid: UUID = Field(alias='Ref_Key', exclude=True)
  code: str = Field(alias='Code', max_length=12)
  name: str = Field(alias='Description', max_length=200)
  measure_unit: MeasureUnitModel = Field(alias='Measure_Unit')

  nested_models = {
    'measure_unit': MeasureUnitModel
  }


class NomenclatureOdata(OData):
  database = 'database_name'
  entity_model = NomenclatureModel
  entity_name = 'Catalog_Nomenclature'


with Connection('ODATA_HOST',
                'ODATA_PROTOCOL',
                HTTPBasicAuth('ODATA_USERNAME', 'ODATA_PASSWORD')) as conn:
  nomenclatures = (
    NomenclatureOdata.manager(conn)
    .expand('measure_unit')
    .filter(code__in=['00-123', '00-456'])
    .all(ignore_invalid=True)
  )

  for item in nomenclatures:
    print(item.name, item.measure_unit.name)
```

You can find more examples in OData1C/example.

## Connection Class
The `Connection` class provides an interface for sending HTTP requests to the 1C OData server.
It can be instantiated directly or used as a context manager (`with ... as ...`) to handle session lifecycle automatically.
Its constructor requires parameters such as `host` (the domain or IP of the 1C server), `protocol` (e.g. http or https), and `authentication` (e.g. HTTPBasicAuth).
You can also specify `connection_timeout` and `read_timeout` to control network timings. Internally, `Connection` uses the `Requests` library.

Example usage:
```python
with Connection(
        host='my1c.domain.ru',
        protocol='http',
        authentication=HTTPBasicAuth('user', 'pass')) as conn:
    # Perform OData operations here
```
Or without a context manager:
```python
conn = Connection(
  host='my1c.domain.ru',
  protocol='http',
  authentication=HTTPBasicAuth('user', 'pass'))
# Perform OData operations here
```

## Defining Models

Models must inherit from `ODataModel` (a Pydantic BaseModel subclass).
Use `nested_models` to specify related models for `$expand` queries:

```python
class MyNestedModel(ODataModel):
    # Define fields and aliases as needed

class MyModel(ODataModel):
    # Define fields and aliases
    nested_models = {
        'some_related_field': MyNestedModel
    }
```

## Working with OData Entities

Create a subclass of OData and define:
- **database**: The service root or database name.
- **entity_model**: The Pydantic model class for data validation.
- **entity_name**: The OData entity set name.

```python
class FooOdata(OData):
    database = 'my1cdb'
    entity_model = MyModel
    entity_name = 'bar'
```

## EntityManager

Obtain a manager instance and perform operations:

```python
manager = FooOdata.manager(conn)
items = manager.all()
```

### Common Methods in EntityManager:
- **all(ignore_invalid=False)**: Executes a GET request and returns validated entities. If `ignore_invalid=True`, skips invalid objects and accumulates errors in `validation_errors`.
- **create(data)**: Sends a POST request to create a new entity. `data` can be either a dictionary or an instance of the `entity_model` (for example, `MyModel`) representing the new entity.
- **get(guid):** Fetches a single entity by `GUID`.
- **update(guid, data)**: Updates an entity identified by `GUID` using PATCH. `data` can be either a dictionary or an instance of the `entity_model` (for example, `MyModel`) containing the fields to update.
- **post_document(guid, operational_mode=False)**: Posts (commits) a document by `GUID`.
- **unpost_document(guid)**: Unposts (reverts) a previously posted document by `GUID`.
- **expand(*fields)**: Specifies which related entities to `$expand`. Accepts positional string arguments - field names for which related entities should be retrieved. The passed field names must be declared in the `entity_model.nested_models` (for example, `MyModel.nested_models`) dictionary.
- **filter(...)**: Applies `$filter` conditions. It accepts keyword arguments as conditions (lookups in Django style) or positional `Q` objects.
Lookup format: `field__operator__annotation` where:
  - **field** is the model field name;
  - **operator** is one of `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in` (defaults to `eq` if omitted);
  - **annotation (optional)** can be `GUID` or `datetime`.

  ```python
  manager.filter(foo='abc')
  manager.filter(bar__gt=100)
  manager.filter(uid_1c__in__guid=[...])
  ```

- **skip(n), top(n)**: Applies `$skip` and `$top` for pagination.

## Filtering with Q

For complex filters, use `Q` objects and combine them with logical operators:

```python
from OData1C.odata.query import Q

manager.filter(Q(name='Ivanov') & Q(age__gt=30))
```

## Working with Metadata

OData1C also provides a convenient way to fetch and parse 1C OData `$metadata` descriptions, caching them for faster 
subsequent access. This helps if you need to inspect available Catalogs, Documents, or any other entity types and their 
fields without repeatedly issuing costly HTTP requests.

```python
from OData1C.connection import Connection
from OData1C.odata.metadata_manager import MetadataManager
from requests.auth import HTTPBasicAuth

with Connection(
        host='1c.dev.evola.ru',
        protocol='https',
        authentication=HTTPBasicAuth('username', 'password')
) as conn:
    mdm = MetadataManager(connection=conn, database='zup-demo')

    # Retrieve all entity types
    entity_types = mdm.list_entity_types()
    print(entity_types)

    # Retrieve all entity sets
    entity_sets = mdm.list_entity_sets()
    print(entity_sets)

    # Inspect specific entity fields
    fields = mdm.get_properties_for_entity_type("Catalog_ФизическиеЛица")
    print(fields)

    # Force reloading metadata if necessary
    mdm.reload_metadata()
```

This approach caches the metadata in memory on the first load, and any subsequent calls (like `list_entity_sets()` or 
`get_properties_for_entity_type(...)`) retrieve information from an in-memory structure rather than re-parsing XML each 
time. This can significantly reduce the overhead if you repeatedly inspect the metadata in your application.


## Debugging

The `EntityManager` object has request and response attributes after executing a request. These hold instances of `ODataRequest` and `requests.Response`, respectively. You can inspect them for debugging:

```python
with Connection(
        host='my1c.domain.ru',
        protocol='http',
        authentication=HTTPBasicAuth('user', 'pass')) as conn:
    manager = FooOdata.manager(conn)
    bars = manager.top(3).all()
    pprint(manager.request)
    pprint(manager.response.json())
 ```

## Contributing

Contributions are welcome! Please open issues or submit pull requests for improvements or bug fixes.