Skip to content

API reference

Generata automaticamente dai docstring tramite mkdocstrings.

Configurazione

odoo_analysis.config

Configuration loading and validation for Odoo instances.

This module is the single source of truth for how instances.json (versioned) and .env (NOT versioned) are parsed and combined into a list of typed InstanceConfig objects ready to be consumed by ConnectionManager.

Responsibilities
  • Read and JSON-parse instances.json.
  • Validate the schema with pydantic, including version-specific rules (see _validate_auth_fields).
  • Resolve secrets from environment variables (password_env, api_key_env) after loading .env via python-dotenv.
  • Detect duplicate instance names.
Out of scope
  • Performing any RPC call (that lives in client.py).
  • Caching or session management (that lives in manager.py).

InstanceConfig

Bases: BaseModel

Validated configuration for a single Odoo instance.

Combines metadata from instances.json with secrets resolved from the environment. Secrets are wrapped in pydantic.SecretStr so they do not leak into logs or repr() output.

Attributes:

Name Type Description
name str

Unique identifier of the instance (used as CLI --instance).

url str

Full URL of the Odoo instance, including scheme.

database str

Name of the Odoo database.

version Literal[16, 17, 18, 19]

Supported Odoo major version (16, 17, 18 or 19).

username str | None

Odoo user login. Required for v16/17, optional for v18/19.

password_env str | None

Env var name holding the password (NOT the password itself). Required for v16/17, optional for v18/19.

api_key_env str | None

Env var name holding the API key. Allowed only for v18/19.

password SecretStr | None

Resolved password (set by load_instances, never read from JSON).

api_key SecretStr | None

Resolved API key (set by load_instances, never read from JSON).

Source code in src/odoo_analysis/config.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class InstanceConfig(BaseModel):
    """Validated configuration for a single Odoo instance.

    Combines metadata from `instances.json` with secrets resolved from the
    environment. Secrets are wrapped in `pydantic.SecretStr` so they do not
    leak into logs or `repr()` output.

    Attributes:
        name: Unique identifier of the instance (used as CLI `--instance`).
        url: Full URL of the Odoo instance, including scheme.
        database: Name of the Odoo database.
        version: Supported Odoo major version (16, 17, 18 or 19).
        username: Odoo user login. Required for v16/17, optional for v18/19.
        password_env: Env var name holding the password (NOT the password
            itself). Required for v16/17, optional for v18/19.
        api_key_env: Env var name holding the API key. Allowed only for v18/19.
        password: Resolved password (set by `load_instances`, never read from
            JSON).
        api_key: Resolved API key (set by `load_instances`, never read from
            JSON).
    """

    name: str = Field(min_length=1)
    url: str = Field(min_length=1)
    database: str = Field(min_length=1)
    version: Literal[16, 17, 18, 19]
    username: str | None = None
    password_env: str | None = None
    api_key_env: str | None = None
    password: SecretStr | None = None
    api_key: SecretStr | None = None

    @model_validator(mode="after")
    def validate_auth_config(self) -> InstanceConfig:
        """Ensure auth fields are coherent with the declared Odoo version."""
        _validate_auth_fields(self.version, self.username, self.password_env, self.api_key_env)
        return self

validate_auth_config()

Ensure auth fields are coherent with the declared Odoo version.

Source code in src/odoo_analysis/config.py
75
76
77
78
79
@model_validator(mode="after")
def validate_auth_config(self) -> InstanceConfig:
    """Ensure auth fields are coherent with the declared Odoo version."""
    _validate_auth_fields(self.version, self.username, self.password_env, self.api_key_env)
    return self

load_instances(json_path=DEFAULT_INSTANCES_PATH, env_path=DEFAULT_ENV_PATH)

Load and validate Odoo instance configurations.

Reads instances.json, loads .env (if present), validates the schema with pydantic, resolves secret env vars, and returns a list of InstanceConfig. Existing environment variables already set in the process are preserved (override=False).

Parameters:

Name Type Description Default
json_path Path

Path to instances.json. Defaults to the project root.

DEFAULT_INSTANCES_PATH
env_path Path

Path to the .env file. Defaults to the project root.

DEFAULT_ENV_PATH

Returns:

Type Description
list[InstanceConfig]

One InstanceConfig per entry in instances.json, in the same order.

Raises:

Type Description
ConfigError

If the file is missing, malformed, the schema is invalid, an env var referenced by password_env/api_key_env is missing, or two instances share the same name.

Example
from odoo_analysis.config import load_instances

instances = load_instances()
names = [i.name for i in instances]
Source code in src/odoo_analysis/config.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def load_instances(
    json_path: Path = DEFAULT_INSTANCES_PATH,
    env_path: Path = DEFAULT_ENV_PATH,
) -> list[InstanceConfig]:
    """Load and validate Odoo instance configurations.

    Reads `instances.json`, loads `.env` (if present), validates the schema
    with pydantic, resolves secret env vars, and returns a list of
    `InstanceConfig`. Existing environment variables already set in the
    process are preserved (`override=False`).

    Args:
        json_path: Path to `instances.json`. Defaults to the project root.
        env_path: Path to the `.env` file. Defaults to the project root.

    Returns:
        One `InstanceConfig` per entry in `instances.json`, in the same order.

    Raises:
        ConfigError: If the file is missing, malformed, the schema is invalid,
            an env var referenced by `password_env`/`api_key_env` is missing,
            or two instances share the same name.

    Example:
        ```python
        from odoo_analysis.config import load_instances

        instances = load_instances()
        names = [i.name for i in instances]
        ```
    """
    if not json_path.exists():
        raise ConfigError(f"Configuration file not found: {json_path}")

    if env_path.exists():
        load_dotenv(env_path, override=False)

    try:
        raw = json.loads(json_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ConfigError(f"Invalid JSON in {json_path}: {exc}") from exc

    try:
        parsed = _RawConfig.model_validate(raw)
    except ValidationError as exc:
        raise ConfigError(f"Invalid schema in {json_path}: {exc}") from exc

    names = [i.name for i in parsed.instances]
    duplicates = {n for n in names if names.count(n) > 1}
    if duplicates:
        raise ConfigError(f"Duplicate instance names: {sorted(duplicates)}")

    instances: list[InstanceConfig] = []
    for raw_inst in parsed.instances:
        api_key: SecretStr | None = None
        if raw_inst.api_key_env:
            val = os.environ.get(raw_inst.api_key_env)
            if not val:
                raise ConfigError(
                    f"Environment variable '{raw_inst.api_key_env}' missing or empty "
                    f"for instance '{raw_inst.name}'."
                )
            api_key = SecretStr(val)

        password: SecretStr | None = None
        if raw_inst.password_env:
            val = os.environ.get(raw_inst.password_env)
            if not val:
                raise ConfigError(
                    f"Environment variable '{raw_inst.password_env}' missing or empty "
                    f"for instance '{raw_inst.name}'."
                )
            password = SecretStr(val)

        instances.append(
            InstanceConfig(
                **raw_inst.model_dump(),
                api_key=api_key,
                password=password,
            )
        )

    return instances

Client RPC

odoo_analysis.client

Low-level wrapper around odooly.Client with version-aware authentication.

OdooClient is the only place in the package that talks to odooly directly. Higher-level callers (CLI, export scripts, notebooks) MUST go through ConnectionManager (see manager.py) or instantiate this wrapper, never odooly.Client directly: this guarantees consistent error handling (via AuthenticationError) and correct authentication for each Odoo version.

Authentication strategy
  • v16/17: classic username + password.
  • v18: API key passed as the password field (legacy auth path).
  • v19: API key passed as a Bearer token via user="__api_key__".
Out of scope
  • Loading or validating configuration (see config.py).
  • Multi-instance orchestration (see manager.py).
  • Any domain-specific data extraction (see tasks.py, excel.py).

OdooClient

Wrapper around odooly.Client with API Key or password authentication.

Lazily connects on connect() (or via the context manager). After a successful connection, the underlying odooly.Client is reachable via the client property.

Attributes:

Name Type Description
config

The InstanceConfig this client was built from.

Example

from odoo_analysis import ConnectionManager manager = ConnectionManager() with manager.get("prod") as oc: ... partners = oc.client.env["res.partner"].search_read([], ["name"])

Source code in src/odoo_analysis/client.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
class OdooClient:
    """Wrapper around `odooly.Client` with API Key or password authentication.

    Lazily connects on `connect()` (or via the context manager). After a
    successful connection, the underlying `odooly.Client` is reachable via
    the `client` property.

    Attributes:
        config: The `InstanceConfig` this client was built from.

    Example:
        >>> from odoo_analysis import ConnectionManager
        >>> manager = ConnectionManager()
        >>> with manager.get("prod") as oc:
        ...     partners = oc.client.env["res.partner"].search_read([], ["name"])
    """

    def __init__(self, config: InstanceConfig) -> None:
        """Initialize the client (does NOT open the connection).

        Args:
            config: Validated configuration produced by `load_instances`.
        """
        self.config = config
        self._client: odooly.Client | None = None

    @property
    def client(self) -> odooly.Client:
        """Return the underlying `odooly.Client`.

        Returns:
            The connected `odooly.Client` instance.

        Raises:
            RuntimeError: If `connect()` has not been called yet.
        """
        if self._client is None:
            raise RuntimeError(
                f"Instance '{self.config.name}' not connected yet. "
                "Call connect() or use the context manager."
            )
        return self._client

    def connect(self) -> odooly.Client:
        """Open the RPC session if not already open.

        Idempotent: calling `connect()` twice returns the same client.

        Returns:
            The connected `odooly.Client` instance.

        Raises:
            AuthenticationError: If the connection or login is rejected.
        """
        if self._client is not None:
            return self._client

        auth_kwargs = self._build_auth_kwargs()
        try:
            self._client = odooly.Client(
                server=self.config.url,
                db=self.config.database,
                **auth_kwargs,
            )
        except Exception as exc:
            raise AuthenticationError(
                f"Connection/authentication failed for '{self.config.name}' "
                f"({self.config.url}): {exc}"
            ) from exc

        if self._client.env.uid is None:
            raise AuthenticationError(
                f"Login rejected for '{self.config.name}': "
                "check credentials and database."
            )

        return self._client

    def _build_auth_kwargs(self) -> dict:
        """Translate `InstanceConfig` into the kwargs expected by `odooly.Client`.

        Returns:
            A dict with the appropriate authentication parameters for the
            instance's Odoo version (see module docstring for the strategy).
        """
        cfg = self.config
        if cfg.api_key and cfg.version == 19:
            # odooly v19: JSON-RPC v2 authentication via Bearer token
            return {"user": "__api_key__", "api_key": cfg.api_key.get_secret_value()}
        if cfg.api_key:
            # v18: the API key is accepted as the password during session auth
            return {"user": cfg.username, "password": cfg.api_key.get_secret_value()}
        # v16/17 (and v18/19 with password): classic username/password auth
        return {"user": cfg.username, "password": cfg.password.get_secret_value()}  # type: ignore[union-attr]

    def health_check(self) -> dict[str, Any]:
        """Connect and return a summary of the live session.

        Returns:
            Dict with keys `name`, `url`, `database`, `configured_version`,
            `server_version`, `user`, `user_login`. Used by the CLI `check`
            command to render one OK line per instance.

        Raises:
            AuthenticationError: If the connection or login fails.
        """
        client = self.connect()
        return {
            "name": self.config.name,
            "url": self.config.url,
            "database": self.config.database,
            "configured_version": self.config.version,
            "server_version": getattr(client, "version", None),
            "user": getattr(client.env.user, "name", None),
            "user_login": getattr(client.env.user, "login", None),
        }

    def close(self) -> None:
        """Drop the cached `odooly.Client` reference.

        `odooly` does not expose an explicit close primitive; this method
        simply forgets the session so a subsequent `connect()` reconnects
        from scratch.
        """
        self._client = None

    def __enter__(self) -> OdooClient:
        """Context manager entry: connect and return self."""
        self.connect()
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Context manager exit: drop the session reference."""
        self.close()

client property

Return the underlying odooly.Client.

Returns:

Type Description
Client

The connected odooly.Client instance.

Raises:

Type Description
RuntimeError

If connect() has not been called yet.

__enter__()

Context manager entry: connect and return self.

Source code in src/odoo_analysis/client.py
159
160
161
162
def __enter__(self) -> OdooClient:
    """Context manager entry: connect and return self."""
    self.connect()
    return self

__exit__(exc_type, exc, tb)

Context manager exit: drop the session reference.

Source code in src/odoo_analysis/client.py
164
165
166
167
168
169
170
171
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Context manager exit: drop the session reference."""
    self.close()

__init__(config)

Initialize the client (does NOT open the connection).

Parameters:

Name Type Description Default
config InstanceConfig

Validated configuration produced by load_instances.

required
Source code in src/odoo_analysis/client.py
50
51
52
53
54
55
56
57
def __init__(self, config: InstanceConfig) -> None:
    """Initialize the client (does NOT open the connection).

    Args:
        config: Validated configuration produced by `load_instances`.
    """
    self.config = config
    self._client: odooly.Client | None = None

close()

Drop the cached odooly.Client reference.

odooly does not expose an explicit close primitive; this method simply forgets the session so a subsequent connect() reconnects from scratch.

Source code in src/odoo_analysis/client.py
150
151
152
153
154
155
156
157
def close(self) -> None:
    """Drop the cached `odooly.Client` reference.

    `odooly` does not expose an explicit close primitive; this method
    simply forgets the session so a subsequent `connect()` reconnects
    from scratch.
    """
    self._client = None

connect()

Open the RPC session if not already open.

Idempotent: calling connect() twice returns the same client.

Returns:

Type Description
Client

The connected odooly.Client instance.

Raises:

Type Description
AuthenticationError

If the connection or login is rejected.

Source code in src/odoo_analysis/client.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def connect(self) -> odooly.Client:
    """Open the RPC session if not already open.

    Idempotent: calling `connect()` twice returns the same client.

    Returns:
        The connected `odooly.Client` instance.

    Raises:
        AuthenticationError: If the connection or login is rejected.
    """
    if self._client is not None:
        return self._client

    auth_kwargs = self._build_auth_kwargs()
    try:
        self._client = odooly.Client(
            server=self.config.url,
            db=self.config.database,
            **auth_kwargs,
        )
    except Exception as exc:
        raise AuthenticationError(
            f"Connection/authentication failed for '{self.config.name}' "
            f"({self.config.url}): {exc}"
        ) from exc

    if self._client.env.uid is None:
        raise AuthenticationError(
            f"Login rejected for '{self.config.name}': "
            "check credentials and database."
        )

    return self._client

health_check()

Connect and return a summary of the live session.

Returns:

Type Description
dict[str, Any]

Dict with keys name, url, database, configured_version,

dict[str, Any]

server_version, user, user_login. Used by the CLI check

dict[str, Any]

command to render one OK line per instance.

Raises:

Type Description
AuthenticationError

If the connection or login fails.

Source code in src/odoo_analysis/client.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def health_check(self) -> dict[str, Any]:
    """Connect and return a summary of the live session.

    Returns:
        Dict with keys `name`, `url`, `database`, `configured_version`,
        `server_version`, `user`, `user_login`. Used by the CLI `check`
        command to render one OK line per instance.

    Raises:
        AuthenticationError: If the connection or login fails.
    """
    client = self.connect()
    return {
        "name": self.config.name,
        "url": self.config.url,
        "database": self.config.database,
        "configured_version": self.config.version,
        "server_version": getattr(client, "version", None),
        "user": getattr(client.env.user, "name", None),
        "user_login": getattr(client.env.user, "login", None),
    }

Connection manager

odoo_analysis.manager

Multi-instance orchestration on top of OdooClient.

ConnectionManager is the recommended entry point for any code that needs to talk to one or more Odoo instances. It:

- Loads `instances.json` + `.env` once at construction time.
- Lazily creates a single `OdooClient` per instance name (the same name
  always returns the same client, so caching at the call site is rarely
  needed).
- Iterates over all configured instances for batch operations
  (e.g. `odoo-analysis check`).
Reuse rule

Higher-level scripts (CLI commands, notebooks, batch jobs) MUST use ConnectionManager rather than constructing OdooClient directly: this keeps configuration, error handling and lifecycle consistent across the codebase.

ConnectionManager

Manage multiple Odoo instances defined in instances.json.

The manager is cheap to construct (it only parses configuration), but each call to get(name) returns the same OdooClient for the lifetime of the manager — connections are opened lazily inside OdooClient.

Example

manager = ConnectionManager() for client in manager.iter_clients(): ... print(client.health_check()["name"]) manager.close_all()

Source code in src/odoo_analysis/manager.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class ConnectionManager:
    """Manage multiple Odoo instances defined in `instances.json`.

    The manager is cheap to construct (it only parses configuration), but
    each call to `get(name)` returns the same `OdooClient` for the lifetime
    of the manager — connections are opened lazily inside `OdooClient`.

    Example:
        >>> manager = ConnectionManager()
        >>> for client in manager.iter_clients():
        ...     print(client.health_check()["name"])
        >>> manager.close_all()
    """

    def __init__(
        self,
        json_path: Path = DEFAULT_INSTANCES_PATH,
        env_path: Path = DEFAULT_ENV_PATH,
    ) -> None:
        """Load configuration and prepare the per-instance client cache.

        Args:
            json_path: Path to `instances.json`. Defaults to project root.
            env_path: Path to `.env`. Defaults to project root.

        Raises:
            ConfigError: If configuration loading fails (see `load_instances`).
        """
        self._configs: dict[str, InstanceConfig] = {
            cfg.name: cfg for cfg in load_instances(json_path, env_path)
        }
        self._clients: dict[str, OdooClient] = {}

    @property
    def names(self) -> list[str]:
        """List of configured instance names, in declaration order."""
        return list(self._configs.keys())

    def get(self, name: str) -> OdooClient:
        """Return the `OdooClient` for the given instance name (cached).

        Args:
            name: Instance name as declared in `instances.json`.

        Returns:
            The `OdooClient` for that instance. The same name always returns
            the same client object.

        Raises:
            ConfigError: If `name` is not a known instance.
        """
        if name not in self._configs:
            raise ConfigError(
                f"Instance '{name}' is not defined. "
                f"Available: {sorted(self._configs)}"
            )
        if name not in self._clients:
            self._clients[name] = OdooClient(self._configs[name])
        return self._clients[name]

    def iter_clients(self) -> Iterator[OdooClient]:
        """Yield one `OdooClient` per configured instance, in declaration order."""
        for name in self._configs:
            yield self.get(name)

    def close_all(self) -> None:
        """Close every cached client and drop the cache.

        Subsequent `get(name)` calls will produce fresh `OdooClient` instances.
        """
        for client in self._clients.values():
            client.close()
        self._clients.clear()

names property

List of configured instance names, in declaration order.

__init__(json_path=DEFAULT_INSTANCES_PATH, env_path=DEFAULT_ENV_PATH)

Load configuration and prepare the per-instance client cache.

Parameters:

Name Type Description Default
json_path Path

Path to instances.json. Defaults to project root.

DEFAULT_INSTANCES_PATH
env_path Path

Path to .env. Defaults to project root.

DEFAULT_ENV_PATH

Raises:

Type Description
ConfigError

If configuration loading fails (see load_instances).

Source code in src/odoo_analysis/manager.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    json_path: Path = DEFAULT_INSTANCES_PATH,
    env_path: Path = DEFAULT_ENV_PATH,
) -> None:
    """Load configuration and prepare the per-instance client cache.

    Args:
        json_path: Path to `instances.json`. Defaults to project root.
        env_path: Path to `.env`. Defaults to project root.

    Raises:
        ConfigError: If configuration loading fails (see `load_instances`).
    """
    self._configs: dict[str, InstanceConfig] = {
        cfg.name: cfg for cfg in load_instances(json_path, env_path)
    }
    self._clients: dict[str, OdooClient] = {}

close_all()

Close every cached client and drop the cache.

Subsequent get(name) calls will produce fresh OdooClient instances.

Source code in src/odoo_analysis/manager.py
102
103
104
105
106
107
108
109
def close_all(self) -> None:
    """Close every cached client and drop the cache.

    Subsequent `get(name)` calls will produce fresh `OdooClient` instances.
    """
    for client in self._clients.values():
        client.close()
    self._clients.clear()

get(name)

Return the OdooClient for the given instance name (cached).

Parameters:

Name Type Description Default
name str

Instance name as declared in instances.json.

required

Returns:

Type Description
OdooClient

The OdooClient for that instance. The same name always returns

OdooClient

the same client object.

Raises:

Type Description
ConfigError

If name is not a known instance.

Source code in src/odoo_analysis/manager.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def get(self, name: str) -> OdooClient:
    """Return the `OdooClient` for the given instance name (cached).

    Args:
        name: Instance name as declared in `instances.json`.

    Returns:
        The `OdooClient` for that instance. The same name always returns
        the same client object.

    Raises:
        ConfigError: If `name` is not a known instance.
    """
    if name not in self._configs:
        raise ConfigError(
            f"Instance '{name}' is not defined. "
            f"Available: {sorted(self._configs)}"
        )
    if name not in self._clients:
        self._clients[name] = OdooClient(self._configs[name])
    return self._clients[name]

iter_clients()

Yield one OdooClient per configured instance, in declaration order.

Source code in src/odoo_analysis/manager.py
 97
 98
 99
100
def iter_clients(self) -> Iterator[OdooClient]:
    """Yield one `OdooClient` per configured instance, in declaration order."""
    for name in self._configs:
        yield self.get(name)

Domain helpers — Task

odoo_analysis.tasks

Domain helpers to extract project.task records from Odoo.

This module hosts the canonical query used to retrieve tasks across the rest of the package (CLI tasks and tasks-excel commands, plus any future export). New consumers SHOULD import fetch_tasks from here instead of re-implementing the domain or the field list.

Out of scope
  • Connection management (see manager.py).
  • File rendering (see excel.py).

fetch_tasks(client, *, assignee=None, open_only=False)

Retrieve project.task records from a connected Odoo client.

Parameters:

Name Type Description Default
client Client

A connected odooly.Client (typically obtained via ConnectionManager.get(name).connect()).

required
assignee str | None

Optional login (email) used to filter tasks by assignee. When None, all assignees are returned.

None
open_only bool

If True, exclude tasks whose stage is folded in the kanban view (stage_id.fold = True), which Odoo treats as "closed".

False

Returns:

Type Description
list[dict]

A list of dicts containing the fields listed in TASK_FIELDS,

list[dict]

in the order returned by Odoo's search_read.

Example
tasks = fetch_tasks(client, assignee="alice@example.com", open_only=True)
names = [t["name"] for t in tasks]
Source code in src/odoo_analysis/tasks.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def fetch_tasks(
    client: odooly.Client,
    *,
    assignee: str | None = None,
    open_only: bool = False,
) -> list[dict]:
    """Retrieve `project.task` records from a connected Odoo client.

    Args:
        client: A connected `odooly.Client` (typically obtained via
            `ConnectionManager.get(name).connect()`).
        assignee: Optional login (email) used to filter tasks by assignee.
            When None, all assignees are returned.
        open_only: If True, exclude tasks whose stage is folded in the kanban
            view (`stage_id.fold = True`), which Odoo treats as "closed".

    Returns:
        A list of dicts containing the fields listed in `TASK_FIELDS`,
        in the order returned by Odoo's `search_read`.

    Example:
        ```python
        tasks = fetch_tasks(client, assignee="alice@example.com", open_only=True)
        names = [t["name"] for t in tasks]
        ```
    """
    domain: list = []
    if open_only:
        # Le fasi con fold=True corrispondono a "minimized in kanban" → task chiusi
        domain.append(("stage_id.fold", "=", False))
    if assignee:
        domain.append(("user_ids.login", "=", assignee))
    return client.env["project.task"].search_read(domain, TASK_FIELDS)

Excel export

odoo_analysis.excel

Excel rendering for open project.task records.

This module is the single place where openpyxl is used. It depends on fetch_tasks for the domain query and is consumed by the CLI command tasks-excel. New Excel exports for other Odoo models SHOULD live in this module (or a sibling) and reuse the same styling helpers.

Out of scope
  • Connection management (see manager.py).
  • Domain/query logic (see tasks.py).

export_tasks_to_excel(client, instance_name, *, output_dir=None, filename=None, assignee=None)

Export open tasks to an .xlsx file.

Fetches open tasks via fetch_tasks(open_only=True), resolves assignee names and the latest chatter activity, and writes a styled spreadsheet.

Parameters:

Name Type Description Default
client Client

Connected Odoo client.

required
instance_name str

Instance name, used in the default filename.

required
output_dir Path | None

Destination directory. Defaults to the current working directory.

None
filename str | None

Output filename. When None, defaults to YYYY-MM-DD-open-tasks-<instance_name>.xlsx.

None
assignee str | None

Optional login (email) to filter tasks by assignee.

None

Returns:

Type Description
Path

The path to the saved .xlsx file.

Example

from odoo_analysis import ConnectionManager, export_tasks_to_excel manager = ConnectionManager() client = manager.get("prod").connect() path = export_tasks_to_excel(client, "prod")

Source code in src/odoo_analysis/excel.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def export_tasks_to_excel(
    client: odooly.Client,
    instance_name: str,
    *,
    output_dir: Path | None = None,
    filename: str | None = None,
    assignee: str | None = None,
) -> Path:
    """Export open tasks to an `.xlsx` file.

    Fetches open tasks via `fetch_tasks(open_only=True)`, resolves assignee
    names and the latest chatter activity, and writes a styled spreadsheet.

    Args:
        client: Connected Odoo client.
        instance_name: Instance name, used in the default filename.
        output_dir: Destination directory. Defaults to the current working
            directory.
        filename: Output filename. When None, defaults to
            `YYYY-MM-DD-open-tasks-<instance_name>.xlsx`.
        assignee: Optional login (email) to filter tasks by assignee.

    Returns:
        The path to the saved `.xlsx` file.

    Example:
        >>> from odoo_analysis import ConnectionManager, export_tasks_to_excel
        >>> manager = ConnectionManager()
        >>> client = manager.get("prod").connect()
        >>> path = export_tasks_to_excel(client, "prod")
    """
    tasks = fetch_tasks(client, assignee=assignee, open_only=True)

    task_ids = [t["id"] for t in tasks]
    base_url = client._server.rstrip("/").removesuffix("/web")

    # Batch: nomi assegnatari
    all_user_ids: set[int] = set()
    for task in tasks:
        all_user_ids.update(task.get("user_ids") or [])
    user_map = _resolve_users(client, all_user_ids)

    # Batch: ultima data per task (mail.message)
    msg_latest = _last_activity_per_task(client, task_ids)

    wb = openpyxl.Workbook()
    ws = wb.active
    assert ws is not None  # noqa: S101 - openpyxl always provides an active sheet on a fresh workbook
    ws.title = "Task aperti"

    # Intestazioni
    for col, header in enumerate(HEADERS, start=1):
        cell = ws.cell(row=1, column=col, value=header)
        cell.font = HEADER_FONT
        cell.fill = HEADER_FILL

    # Righe dati
    for row, task in enumerate(tasks, start=2):
        tid = task["id"]
        assignees = ", ".join(
            user_map.get(uid, str(uid)) for uid in (task.get("user_ids") or [])
        )

        # Ultima attività: max tra write_date del task e ultimo messaggio in chatter
        write_date = task.get("write_date") or ""
        last_msg = msg_latest.get(tid, "")
        last_activity = max(write_date, last_msg) or ""

        ws.cell(row=row, column=1, value=_many2one_name(task.get("project_id")))
        ws.cell(row=row, column=2, value=task.get("name", ""))
        ws.cell(row=row, column=3, value=_many2one_name(task.get("stage_id")))
        ws.cell(row=row, column=4, value=assignees)
        ws.cell(row=row, column=5, value=task.get("date_deadline") or "")
        ws.cell(row=row, column=6, value=PRIORITY_LABEL.get(task.get("priority", "0"), ""))
        url = f"{base_url}/web#id={tid}&model=project.task&view_type=form"
        ws.cell(row=row, column=7, value=last_activity)
        ws.cell(row=row, column=8, value=url)

    # Larghezza colonne automatica
    for col in range(1, len(HEADERS) + 1):
        max_len = max(
            (len(str(ws.cell(row=r, column=col).value or "")) for r in range(1, ws.max_row + 1)),
            default=0,
        )
        ws.column_dimensions[get_column_letter(col)].width = max_len + 4

    # Percorso output
    dest_dir = output_dir or Path.cwd()
    if not filename:
        today = datetime.date.today().strftime("%Y-%m-%d")
        filename = f"{today}-open-tasks-{instance_name}.xlsx"
    dest = dest_dir / filename
    wb.save(dest)
    return dest

CLI

odoo_analysis.cli

odoo-analysis command-line interface.

Defines the argparse subcommands exposed by the odoo-analysis script (registered in pyproject.toml [project.scripts]):

- `check`: verify connectivity and authentication for one or all instances.
- `tasks`: print open/all tasks as JSON.
- `tasks-excel`: export open tasks to an `.xlsx` file.
- `project-task-phases`: print task stages per project.
- `project-task-phases-matrix`: export a project × stage matrix to Excel.
Convention

Every subcommand uses ConnectionManager to obtain its OdooClient, catches OdooAnalysisError to render [ERROR] lines on stderr, and closes the manager in a finally block. Add new commands following the same pattern (_cmd_* function + add_parser block in _build_parser).

main(argv=None)

Entry point for the odoo-analysis script.

Parameters:

Name Type Description Default
argv Sequence[str] | None

Optional argument vector (excluding the program name). When None, argparse reads from sys.argv.

None

Returns:

Type Description
int

The exit code returned by the matched subcommand, or 1 on a top-level

int

OdooAnalysisError.

Source code in src/odoo_analysis/cli.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def main(argv: Sequence[str] | None = None) -> int:
    """Entry point for the `odoo-analysis` script.

    Args:
        argv: Optional argument vector (excluding the program name). When None,
            argparse reads from `sys.argv`.

    Returns:
        The exit code returned by the matched subcommand, or 1 on a top-level
        `OdooAnalysisError`.
    """
    parser = _build_parser()
    args = parser.parse_args(argv)
    try:
        return int(args.func(args))
    except OdooAnalysisError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

Eccezioni

odoo_analysis.exceptions

Exception hierarchy for the odoo_analysis package.

All exceptions raised intentionally by the package derive from OdooAnalysisError, so callers can catch the whole hierarchy with a single except OdooAnalysisError: clause and degrade gracefully.

Reuse rule

New code in this package MUST raise one of these exceptions (or a subclass of OdooAnalysisError) instead of generic Exception / ValueError, so the CLI error-handling layer in cli.py stays consistent.

AuthenticationError

Bases: OdooAnalysisError

Raised when login to an Odoo instance fails.

Typical causes
  • Wrong credentials (password or API key).
  • Unreachable host or wrong database name.
  • odooly.Client could not establish the session.
Source code in src/odoo_analysis/exceptions.py
40
41
42
43
44
45
46
47
class AuthenticationError(OdooAnalysisError):
    """Raised when login to an Odoo instance fails.

    Typical causes:
        - Wrong credentials (password or API key).
        - Unreachable host or wrong database name.
        - `odooly.Client` could not establish the session.
    """

ConfigError

Bases: OdooAnalysisError

Raised when configuration loading or validation fails.

Typical causes
  • instances.json missing or malformed.
  • Pydantic schema validation failure (unsupported version, missing required field for the chosen Odoo version).
  • Required environment variable referenced by password_env / api_key_env is missing or empty.
  • Duplicate instance names in instances.json.
Source code in src/odoo_analysis/exceptions.py
27
28
29
30
31
32
33
34
35
36
37
class ConfigError(OdooAnalysisError):
    """Raised when configuration loading or validation fails.

    Typical causes:
        - `instances.json` missing or malformed.
        - Pydantic schema validation failure (unsupported version, missing
          required field for the chosen Odoo version).
        - Required environment variable referenced by `password_env` /
          `api_key_env` is missing or empty.
        - Duplicate instance names in `instances.json`.
    """

OdooAnalysisError

Bases: Exception

Base error for the odoo_analysis package.

Catch this to handle any failure produced intentionally by the package.

Source code in src/odoo_analysis/exceptions.py
20
21
22
23
24
class OdooAnalysisError(Exception):
    """Base error for the `odoo_analysis` package.

    Catch this to handle any failure produced intentionally by the package.
    """