Skip to content

pyvelm.env

env

Cache

Field values keyed by (model_name, record_id, field_name).

Cache lives on the Environment, not on records. This is what makes computed-field invalidation tractable: invalidation is a key-deletion pass, not a graph walk over instance state.

Source code in pyvelm/env.py
class Cache:
    """Field values keyed by (model_name, record_id, field_name).

    Cache lives on the Environment, not on records. This is what makes
    computed-field invalidation tractable: invalidation is a key-deletion
    pass, not a graph walk over instance state.
    """

    def __init__(self) -> None:
        self._data: dict[tuple[str, int, str], Any] = {}

    def get(self, model_name: str, record_id: int, field_name: str) -> Any:
        return self._data[(model_name, record_id, field_name)]

    def set(self, model_name: str, record_id: int, field_name: str, value: Any) -> None:
        self._data[(model_name, record_id, field_name)] = value

    def contains(self, model_name: str, record_id: int, field_name: str) -> bool:
        return (model_name, record_id, field_name) in self._data

    def invalidate(
        self,
        model_name: str | None = None,
        ids: list[int] | None = None,
        fields: list[str] | None = None,
    ) -> None:
        if model_name is None and ids is None and fields is None:
            self._data.clear()
            return
        to_delete = []
        for key in self._data:
            m, i, f = key
            if model_name is not None and m != model_name:
                continue
            if ids is not None and i not in ids:
                continue
            if fields is not None and f not in fields:
                continue
            to_delete.append(key)
        for key in to_delete:
            del self._data[key]

Environment

First-class context threaded through every recordset.

Carries DB connection, user id, ad-hoc context dict, the registry, and the value cache. Recordsets are cheap views over an Environment.

Source code in pyvelm/env.py
 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
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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
class Environment:
    """First-class context threaded through every recordset.

    Carries DB connection, user id, ad-hoc context dict, the registry,
    and the value cache. Recordsets are cheap views over an Environment.
    """

    def __init__(
        self,
        conn,
        registry: Registry,
        uid: int = 1,
        context: dict | None = None,
    ) -> None:
        self.conn = conn
        self.uid = uid
        self.context = dict(context or {})
        self.registry = registry
        self.cache = Cache()
        # In-flight transaction depth for nested savepoint support; 0 means
        # no transaction open, calls auto-commit per-statement.
        self._tx_depth: int = 0
        # Compute orchestration flag — set by compute_field, gates writes.
        self._in_compute: bool = False
        # ACL bypass flag — flipped temporarily while resolving the
        # current user's groups or evaluating ir.model.access rows, to
        # avoid infinite recursion through check_access.
        self._acl_bypass: bool = False
        # Per-(model, perm) access-decision cache so every field read
        # doesn't re-query ir.model.access. Invalidate on logout / uid
        # switch by constructing a fresh Environment.
        self._access_cache: dict[tuple[str, str], bool] = {}

    def __getitem__(self, model_name: str):
        model_cls = self.registry[model_name]
        return model_cls(self, ())

    def query(self, model_name: str):
        """Fluent query builder for the registry's effective *model_name* class.

        Prefer ``env["model.name"].query()`` when you already have a recordset;
        both resolve ``registry[model_name]`` after ``_inherit`` merges.
        """
        from .query import Query

        model_cls = self.registry[model_name]
        return Query.for_model(model_cls, self)

    def _derive(self, *, context: dict, acl_bypass: bool) -> "Environment":
        """Build a sibling env sharing conn + registry + value cache.

        Recordsets are cheap views over an env, so deriving a new one is
        how we vary a single axis (context, sudo) without disturbing the
        caller's env. The cache is shared because it's keyed by
        ``(model, id, field)`` only — access control is enforced at the
        model boundary, not the cache, exactly as in Odoo.
        """
        new = Environment(
            self.conn,
            registry=self.registry,
            uid=self.uid,
            context=context,
        )
        new.cache = self.cache
        new._acl_bypass = acl_bypass
        return new

    def with_context(self, **overrides) -> "Environment":
        # Preserve the sudo flag so `env.sudo().with_context(...)` (and
        # `.with_company(...)`) stay in sudo mode, matching Odoo.
        return self._derive(
            context={**self.context, **overrides},
            acl_bypass=self._acl_bypass,
        )

    def sudo(self, flag: bool = True) -> "Environment":
        """Return an env that bypasses ``ir.model.access`` + ``ir.rule``.

        Mirrors Odoo's ``env.sudo()``. The returned env keeps the same
        ``uid`` — audit trails and ``{"placeholder": "uid"}`` rules still
        attribute to the real user — but every CRUD access check and
        record-rule domain is skipped. ``sudo(False)`` returns an
        enforced env.

        Use it for trusted internal work that must read or write rows the
        current user can't reach directly (cross-company lookups, system
        bookkeeping). It's the supported replacement for poking
        ``env._acl_bypass`` by hand::

            companies = env.with_company(None).sudo()["res.company"].search([])

        Idempotent: returns ``self`` when the flag already matches.
        """
        flag = bool(flag)
        if bool(self._acl_bypass) == flag:
            return self
        return self._derive(context=dict(self.context), acl_bypass=flag)

    # ------ Company ------

    @property
    def company_id(self) -> int | None:
        """Active company scope, or None for no scoping.

        Set via `with_company(id)` or by passing ``company_id`` to
        `with_context`. Superuser and ACL-bypass paths can also check
        this to know the intended tenant without having the restriction
        applied.
        """
        return self.context.get("company_id")

    def with_company(self, company_id: int | None) -> "Environment":
        """Return a copy of this environment scoped to *company_id*.

        Passing ``None`` removes any existing company scope (useful for
        cross-company superuser operations).
        """
        return self.with_context(company_id=company_id)

    # ------ ACL ------

    SUPERUSER_ID = 1

    def is_superuser(self) -> bool:
        """Stage 5 convention: uid=1 bypasses every ACL check.

        Matches Odoo's `SUPERUSER_ID`. The installer, migration scripts,
        and module install hooks all run as superuser.
        """
        return self.uid == self.SUPERUSER_ID

    @property
    def user_group_ids(self) -> set[int]:
        """The set of `res.groups` ids the current user belongs to.

        Cached on the Environment for the request lifetime so
        per-statement ACL checks don't re-query.
        """
        cached = getattr(self, "_user_groups_cache", None)
        if cached is not None:
            return cached
        if self.uid is None or "res.users" not in self.registry:
            result: set[int] = set()
        else:
            # Bypass ACL on this lookup — chicken-and-egg otherwise.
            prev = self._acl_bypass
            self._acl_bypass = True
            try:
                user = self["res.users"].browse(self.uid)
                # If the recorded uid doesn't exist (deleted user, etc),
                # treat as anonymous.
                if not self["res.users"].search([("id", "=", self.uid)]):
                    result = set()
                else:
                    result = set(user.group_ids.ids)
            finally:
                self._acl_bypass = prev
        self._user_groups_cache = result
        return result

    def prime_current_user_cache(self) -> None:
        """Load common fields for ``env.uid`` under ACL bypass.

        Values stay in the request cache so later reads (layout chrome,
        profile, timezone) succeed even when the user lacks broad
        ``res.users`` / ``res.groups`` grants. Idempotent per request.
        """
        if self.uid is None or "res.users" not in self.registry:
            return
        prev = self._acl_bypass
        self._acl_bypass = True
        try:
            users = self["res.users"].search([("id", "=", self.uid)], limit=1)
            if not users:
                return
            user = users
            user.ensure_one()
            _ = user.name, user.login
            if "avatar_url" in self.registry["res.users"]._fields:
                _ = user.avatar_url
            _ = user.company_id
            if user.group_ids:
                for group in user.group_ids:
                    _ = group.name
        finally:
            self._acl_bypass = prev

    def _access_granted(self, model_name: str, perm: str) -> bool:
        """Return whether the current principal may perform *perm* on *model_name*."""
        if self.is_superuser() or self._acl_bypass:
            return True
        if "ir.model.access" not in self.registry:
            return True
        cache_key = (model_name, perm)
        cached = self._access_cache.get(cache_key)
        if cached is not None:
            return bool(cached)
        Access = self["ir.model.access"]
        prev = self._acl_bypass
        self._acl_bypass = True
        try:
            domain = [
                ("model", "=", model_name),
                (f"perm_{perm}", "=", True),
            ]
            if self.uid is None:
                domain.append(("group_id", "=", None))
                granted = bool(Access.search(domain, limit=1))
            else:
                ids = self.user_group_ids
                anyone = Access.search(
                    domain + [("group_id", "=", None)], limit=1
                )
                granted = bool(anyone)
                if not granted and ids:
                    granted = bool(
                        Access.search(
                            domain + [("group_id", "in", list(ids))],
                            limit=1,
                        )
                    )
            self._access_cache[cache_key] = granted
            return granted
        finally:
            self._acl_bypass = prev

    def has_access(self, model_name: str, perm: str) -> bool:
        """Non-throwing access check — use in templates and UI gating."""
        return self._access_granted(model_name, perm)

    def access_flags(self, model_name: str) -> dict[str, bool]:
        """CRUD booleans for *model_name* (read / write / create / unlink)."""
        return {
            p: self._access_granted(model_name, p)
            for p in ("read", "write", "create", "unlink")
        }

    def check_access(self, model_name: str, perm: str) -> None:
        """Raise PermissionError if the current user lacks `perm` on
        `model_name`. No-op for superuser or while bypass is set.

        `perm` is one of: read / write / create / unlink.
        """
        if self._access_granted(model_name, perm):
            return
        if self.uid is None:
            raise PermissionError(
                f"Access denied: {perm} on {model_name} (anonymous)"
            )
        raise PermissionError(
            f"Access denied: {perm} on {model_name} (uid={self.uid})"
        )

    # ------ Policies (record-aware authorization) ------

    def can(
        self,
        record_or_model: object,
        action: str,
        *,
        perm: str | None = None,
        model: str | None = None,
        **kwargs: Any,
    ) -> bool:
        """Return whether *action* is allowed (policy + optional ACL ceiling).

        - **perm**: optional ACL ceiling to require (e.g. "write").
        - **model**: override model name when *record_or_model* isn't a recordset.

        If no policy is registered for the model (or the method doesn't exist),
        this falls back to the ACL ceiling check only (when *perm* is provided),
        otherwise True.
        """
        # Resolve model + record.
        record = None
        model_name = model
        if model_name is None and hasattr(record_or_model, "_name"):
            model_name = getattr(record_or_model, "_name")
            record = record_or_model
        elif model_name is None:
            model_name = str(record_or_model)

        if perm:
            if not self.has_access(str(model_name), str(perm)):
                return False

        # Evaluate policy (if any). None => no opinion.
        decision = eval_policy(
            self,
            model_name=str(model_name),
            action=str(action),
            record=record,
            **kwargs,
        )
        if decision is None:
            return True
        return bool(decision)

    def check_can(
        self,
        record_or_model: object,
        action: str,
        *,
        perm: str | None = None,
        model: str | None = None,
        **kwargs: Any,
    ) -> None:
        """Raise PermissionError if :meth:`can` is false."""
        if self.can(
            record_or_model,
            action,
            perm=perm,
            model=model,
            **kwargs,
        ):
            return
        model_name = model
        if model_name is None and hasattr(record_or_model, "_name"):
            model_name = getattr(record_or_model, "_name")
        raise PermissionError(
            f"Access denied: {action} on {model_name} (uid={self.uid})"
        )

    def collect_record_rules(self, model_name: str, perm: str) -> list:
        """Return the union of domain leaves to AND-inject into
        searches on `model_name` for `perm`. Empty for superuser /
        bypass / when ir.rule isn't installed."""
        if self.is_superuser() or self._acl_bypass:
            return []
        if "ir.rule" not in self.registry:
            return []
        Rule = self["ir.rule"]
        prev = self._acl_bypass
        self._acl_bypass = True
        try:
            domain = [
                ("model", "=", model_name),
                (f"perm_{perm}", "=", True),
            ]
            if self.uid is None:
                # Anonymous: only global rules apply.
                domain.append(("group_id", "=", None))
                rules = Rule.search(domain)
            else:
                ids = self.user_group_ids
                # Global rules + rules for any of our groups.
                global_rules = Rule.search(domain + [("group_id", "=", None)])
                if ids:
                    group_rules = Rule.search(
                        domain + [("group_id", "in", list(ids))],
                    )
                else:
                    group_rules = global_rules.__class__(self, ())
                rules = global_rules
                if group_rules:
                    rules = global_rules.__class__(
                        self, tuple({*global_rules.ids, *group_rules.ids})
                    )
            # Read domains while still under bypass — accessing
            # `r.domain` triggers _read on ir.rule, which re-enters
            # check_access. Without bypass, that recurses and denies.
            import json
            out: list = []
            for r in rules:
                raw = json.loads(r.domain)
                out.extend(self._resolve_rule_leaves(raw))
        finally:
            self._acl_bypass = prev

        return out

    def _resolve_rule_leaves(self, raw_domain: list) -> list:
        """Substitute {placeholder: name} dicts with env-side values."""
        resolved = []
        for leaf in raw_domain:
            if not isinstance(leaf, (list, tuple)):
                resolved.append(leaf)
                continue
            attr, op, value = leaf
            # Single placeholder dict.
            if isinstance(value, dict) and "placeholder" in value:
                value = self._resolve_placeholder(value["placeholder"])
            # List value that may contain placeholder dicts (e.g. "in" operator).
            elif isinstance(value, list):
                value = [
                    self._resolve_placeholder(item["placeholder"])
                    if isinstance(item, dict) and "placeholder" in item
                    else item
                    for item in value
                ]
            resolved.append((attr, op, value))
        return resolved

    def _resolve_placeholder(self, ph: str):
        """Return the env-side value for a placeholder name."""
        if ph in ("uid", "user_id"):
            return self.uid
        if ph == "company_id":
            # Apps can still write per-group ir.rules that reference
            # the active company. The model-level filter applied by
            # `BaseModel.search` for `_company_scoped` models is the
            # default mechanism; this placeholder is for the
            # finer-grained case.
            return self.company_id
        raise ValueError(f"Unknown ir.rule placeholder {ph!r}")

    # ------ Transactions ------

    def transaction(self):
        """Return a context manager for an atomic unit of work.

        Outer call opens a real transaction; nested calls use savepoints
        so partial work can roll back independently.  On exception the
        active scope rolls back; otherwise it commits / releases.

        This is the explicit boundary that install/migrate flows use to
        keep schema mutations atomic. CRUD calls outside any transaction
        are still effectively auto-committed because the connection is
        configured that way.
        """
        env = self

        class _TxContext:
            def __enter__(self_inner):
                if env._tx_depth == 0:
                    if env.conn.autocommit:
                        env.conn.autocommit = False
                    env._tx_opened_autocommit = False
                    env._tx_depth = 1
                    self_inner._kind = "tx"
                else:
                    sp_name = f"_pyvelm_sp{env._tx_depth}"
                    env.conn.execute(f"SAVEPOINT {sp_name}")
                    env._tx_depth += 1
                    self_inner._kind = "sp"
                    self_inner._sp_name = sp_name
                return env

            def __exit__(self_inner, exc_type, exc, tb):
                try:
                    if self_inner._kind == "tx":
                        if exc is None:
                            try:
                                env.conn.commit()
                            except Exception:  # noqa: BLE001
                                # If application code swallowed an exception inside
                                # the `with` block after a SQL error, the connection
                                # is left in an aborted transaction state and commit
                                # will fail (e.g. psycopg.errors.InFailedSqlTransaction).
                                # Roll back to restore a usable connection.
                                env.conn.rollback()
                                raise
                        else:
                            env.conn.rollback()
                    else:
                        sp = self_inner._sp_name
                        if exc is None:
                            try:
                                env.conn.execute(f"RELEASE SAVEPOINT {sp}")
                            except Exception:  # noqa: BLE001
                                env.conn.execute(f"ROLLBACK TO SAVEPOINT {sp}")
                                raise
                        else:
                            env.conn.execute(f"ROLLBACK TO SAVEPOINT {sp}")
                finally:
                    env._tx_depth -= 1
                    if env._tx_depth == 0:
                        # Reopen autocommit so subsequent ad-hoc statements
                        # don't sit in an implicit transaction.
                        env.conn.autocommit = True
                return False

        return _TxContext()

    # ------ Computed-field orchestration ------

    def compute_field(self, record, field) -> None:
        """Run a compute method on the records that need it.

        Computes are bulk-friendly: the method sees the whole recordset and
        is expected to iterate. For stored fields, cached values flush to
        SQL after the method returns; for non-stored, the cache write is
        the final state until the next invalidation.
        """
        model_cls = type(record)
        # Expand to all of record's ids — compute is bulk by convention.
        recs = model_cls(self, record._ids)
        prev = self._in_compute
        self._in_compute = True
        try:
            method = getattr(recs, field.compute)
            method()
        finally:
            self._in_compute = prev
        if field.is_stored:
            for rid in recs._ids:
                if not self.cache.contains(model_cls._name, rid, field.name):
                    raise RuntimeError(
                        f"Compute {field.compute!r} did not set "
                        f"{model_cls._name}.{field.name} for id={rid}"
                    )
                value = self.cache.get(model_cls._name, rid, field.name)
                self.conn.execute(
                    f'UPDATE "{model_cls._table}" SET "{field.column}" = %s '
                    f'WHERE "id" = %s',
                    [field.to_sql_param(value), rid],
                )

    def notify_changed(self, model_name: str, ids, fields) -> None:
        """Propagate field changes through the compute dependency graph.

        Each `(model_name, field)` change consults `_edge_index` for the
        listening compute fields. Each `HopEdge.find_source_ids` walks any
        relational hops backward to land on the source-side ids that need
        invalidation. BFS through transitive dependents:

          - drop the cache entry,
          - if the dependent is stored, recompute now and UPDATE the SQL
            column (so other sessions see the new value),
          - enqueue further dependents.
        """
        from collections import deque

        if not ids or not fields:
            return
        ids = list(ids)
        queue: deque = deque()

        def fan_out(m: str, fs, idset):
            for f in fs:
                for dep_model, dep_field, edge in self.registry._edge_index.get(
                    (m, f), []
                ):
                    affected = edge.find_source_ids(self, list(idset))
                    if affected:
                        queue.append((dep_model, dep_field, set(affected)))

        fan_out(model_name, fields, ids)

        seen: dict[tuple[str, str], set[int]] = {}
        while queue:
            m, f, idset = queue.popleft()
            key = (m, f)
            already = seen.get(key, set())
            new_ids = idset - already
            if not new_ids:
                continue
            seen[key] = already | new_ids
            self.cache.invalidate(model_name=m, ids=list(new_ids), fields=[f])
            field = self.registry[m]._fields[f]
            if field.is_stored:
                recs = self.registry[m](self, tuple(new_ids))
                self.compute_field(recs, field)
            fan_out(m, [f], new_ids)

company_id property

company_id: int | None

Active company scope, or None for no scoping.

Set via with_company(id) or by passing company_id to with_context. Superuser and ACL-bypass paths can also check this to know the intended tenant without having the restriction applied.

user_group_ids property

user_group_ids: set[int]

The set of res.groups ids the current user belongs to.

Cached on the Environment for the request lifetime so per-statement ACL checks don't re-query.

query

query(model_name: str)

Fluent query builder for the registry's effective model_name class.

Prefer env["model.name"].query() when you already have a recordset; both resolve registry[model_name] after _inherit merges.

Source code in pyvelm/env.py
def query(self, model_name: str):
    """Fluent query builder for the registry's effective *model_name* class.

    Prefer ``env["model.name"].query()`` when you already have a recordset;
    both resolve ``registry[model_name]`` after ``_inherit`` merges.
    """
    from .query import Query

    model_cls = self.registry[model_name]
    return Query.for_model(model_cls, self)

sudo

sudo(flag: bool = True) -> 'Environment'

Return an env that bypasses ir.model.access + ir.rule.

Mirrors Odoo's env.sudo(). The returned env keeps the same uid — audit trails and {"placeholder": "uid"} rules still attribute to the real user — but every CRUD access check and record-rule domain is skipped. sudo(False) returns an enforced env.

Use it for trusted internal work that must read or write rows the current user can't reach directly (cross-company lookups, system bookkeeping). It's the supported replacement for poking env._acl_bypass by hand::

companies = env.with_company(None).sudo()["res.company"].search([])

Idempotent: returns self when the flag already matches.

Source code in pyvelm/env.py
def sudo(self, flag: bool = True) -> "Environment":
    """Return an env that bypasses ``ir.model.access`` + ``ir.rule``.

    Mirrors Odoo's ``env.sudo()``. The returned env keeps the same
    ``uid`` — audit trails and ``{"placeholder": "uid"}`` rules still
    attribute to the real user — but every CRUD access check and
    record-rule domain is skipped. ``sudo(False)`` returns an
    enforced env.

    Use it for trusted internal work that must read or write rows the
    current user can't reach directly (cross-company lookups, system
    bookkeeping). It's the supported replacement for poking
    ``env._acl_bypass`` by hand::

        companies = env.with_company(None).sudo()["res.company"].search([])

    Idempotent: returns ``self`` when the flag already matches.
    """
    flag = bool(flag)
    if bool(self._acl_bypass) == flag:
        return self
    return self._derive(context=dict(self.context), acl_bypass=flag)

with_company

with_company(company_id: int | None) -> 'Environment'

Return a copy of this environment scoped to company_id.

Passing None removes any existing company scope (useful for cross-company superuser operations).

Source code in pyvelm/env.py
def with_company(self, company_id: int | None) -> "Environment":
    """Return a copy of this environment scoped to *company_id*.

    Passing ``None`` removes any existing company scope (useful for
    cross-company superuser operations).
    """
    return self.with_context(company_id=company_id)

is_superuser

is_superuser() -> bool

Stage 5 convention: uid=1 bypasses every ACL check.

Matches Odoo's SUPERUSER_ID. The installer, migration scripts, and module install hooks all run as superuser.

Source code in pyvelm/env.py
def is_superuser(self) -> bool:
    """Stage 5 convention: uid=1 bypasses every ACL check.

    Matches Odoo's `SUPERUSER_ID`. The installer, migration scripts,
    and module install hooks all run as superuser.
    """
    return self.uid == self.SUPERUSER_ID

prime_current_user_cache

prime_current_user_cache() -> None

Load common fields for env.uid under ACL bypass.

Values stay in the request cache so later reads (layout chrome, profile, timezone) succeed even when the user lacks broad res.users / res.groups grants. Idempotent per request.

Source code in pyvelm/env.py
def prime_current_user_cache(self) -> None:
    """Load common fields for ``env.uid`` under ACL bypass.

    Values stay in the request cache so later reads (layout chrome,
    profile, timezone) succeed even when the user lacks broad
    ``res.users`` / ``res.groups`` grants. Idempotent per request.
    """
    if self.uid is None or "res.users" not in self.registry:
        return
    prev = self._acl_bypass
    self._acl_bypass = True
    try:
        users = self["res.users"].search([("id", "=", self.uid)], limit=1)
        if not users:
            return
        user = users
        user.ensure_one()
        _ = user.name, user.login
        if "avatar_url" in self.registry["res.users"]._fields:
            _ = user.avatar_url
        _ = user.company_id
        if user.group_ids:
            for group in user.group_ids:
                _ = group.name
    finally:
        self._acl_bypass = prev

has_access

has_access(model_name: str, perm: str) -> bool

Non-throwing access check — use in templates and UI gating.

Source code in pyvelm/env.py
def has_access(self, model_name: str, perm: str) -> bool:
    """Non-throwing access check — use in templates and UI gating."""
    return self._access_granted(model_name, perm)

access_flags

access_flags(model_name: str) -> dict[str, bool]

CRUD booleans for model_name (read / write / create / unlink).

Source code in pyvelm/env.py
def access_flags(self, model_name: str) -> dict[str, bool]:
    """CRUD booleans for *model_name* (read / write / create / unlink)."""
    return {
        p: self._access_granted(model_name, p)
        for p in ("read", "write", "create", "unlink")
    }

check_access

check_access(model_name: str, perm: str) -> None

Raise PermissionError if the current user lacks perm on model_name. No-op for superuser or while bypass is set.

perm is one of: read / write / create / unlink.

Source code in pyvelm/env.py
def check_access(self, model_name: str, perm: str) -> None:
    """Raise PermissionError if the current user lacks `perm` on
    `model_name`. No-op for superuser or while bypass is set.

    `perm` is one of: read / write / create / unlink.
    """
    if self._access_granted(model_name, perm):
        return
    if self.uid is None:
        raise PermissionError(
            f"Access denied: {perm} on {model_name} (anonymous)"
        )
    raise PermissionError(
        f"Access denied: {perm} on {model_name} (uid={self.uid})"
    )

can

can(record_or_model: object, action: str, *, perm: str | None = None, model: str | None = None, **kwargs: Any) -> bool

Return whether action is allowed (policy + optional ACL ceiling).

  • perm: optional ACL ceiling to require (e.g. "write").
  • model: override model name when record_or_model isn't a recordset.

If no policy is registered for the model (or the method doesn't exist), this falls back to the ACL ceiling check only (when perm is provided), otherwise True.

Source code in pyvelm/env.py
def can(
    self,
    record_or_model: object,
    action: str,
    *,
    perm: str | None = None,
    model: str | None = None,
    **kwargs: Any,
) -> bool:
    """Return whether *action* is allowed (policy + optional ACL ceiling).

    - **perm**: optional ACL ceiling to require (e.g. "write").
    - **model**: override model name when *record_or_model* isn't a recordset.

    If no policy is registered for the model (or the method doesn't exist),
    this falls back to the ACL ceiling check only (when *perm* is provided),
    otherwise True.
    """
    # Resolve model + record.
    record = None
    model_name = model
    if model_name is None and hasattr(record_or_model, "_name"):
        model_name = getattr(record_or_model, "_name")
        record = record_or_model
    elif model_name is None:
        model_name = str(record_or_model)

    if perm:
        if not self.has_access(str(model_name), str(perm)):
            return False

    # Evaluate policy (if any). None => no opinion.
    decision = eval_policy(
        self,
        model_name=str(model_name),
        action=str(action),
        record=record,
        **kwargs,
    )
    if decision is None:
        return True
    return bool(decision)

check_can

check_can(record_or_model: object, action: str, *, perm: str | None = None, model: str | None = None, **kwargs: Any) -> None

Raise PermissionError if :meth:can is false.

Source code in pyvelm/env.py
def check_can(
    self,
    record_or_model: object,
    action: str,
    *,
    perm: str | None = None,
    model: str | None = None,
    **kwargs: Any,
) -> None:
    """Raise PermissionError if :meth:`can` is false."""
    if self.can(
        record_or_model,
        action,
        perm=perm,
        model=model,
        **kwargs,
    ):
        return
    model_name = model
    if model_name is None and hasattr(record_or_model, "_name"):
        model_name = getattr(record_or_model, "_name")
    raise PermissionError(
        f"Access denied: {action} on {model_name} (uid={self.uid})"
    )

collect_record_rules

collect_record_rules(model_name: str, perm: str) -> list

Return the union of domain leaves to AND-inject into searches on model_name for perm. Empty for superuser / bypass / when ir.rule isn't installed.

Source code in pyvelm/env.py
def collect_record_rules(self, model_name: str, perm: str) -> list:
    """Return the union of domain leaves to AND-inject into
    searches on `model_name` for `perm`. Empty for superuser /
    bypass / when ir.rule isn't installed."""
    if self.is_superuser() or self._acl_bypass:
        return []
    if "ir.rule" not in self.registry:
        return []
    Rule = self["ir.rule"]
    prev = self._acl_bypass
    self._acl_bypass = True
    try:
        domain = [
            ("model", "=", model_name),
            (f"perm_{perm}", "=", True),
        ]
        if self.uid is None:
            # Anonymous: only global rules apply.
            domain.append(("group_id", "=", None))
            rules = Rule.search(domain)
        else:
            ids = self.user_group_ids
            # Global rules + rules for any of our groups.
            global_rules = Rule.search(domain + [("group_id", "=", None)])
            if ids:
                group_rules = Rule.search(
                    domain + [("group_id", "in", list(ids))],
                )
            else:
                group_rules = global_rules.__class__(self, ())
            rules = global_rules
            if group_rules:
                rules = global_rules.__class__(
                    self, tuple({*global_rules.ids, *group_rules.ids})
                )
        # Read domains while still under bypass — accessing
        # `r.domain` triggers _read on ir.rule, which re-enters
        # check_access. Without bypass, that recurses and denies.
        import json
        out: list = []
        for r in rules:
            raw = json.loads(r.domain)
            out.extend(self._resolve_rule_leaves(raw))
    finally:
        self._acl_bypass = prev

    return out

transaction

transaction()

Return a context manager for an atomic unit of work.

Outer call opens a real transaction; nested calls use savepoints so partial work can roll back independently. On exception the active scope rolls back; otherwise it commits / releases.

This is the explicit boundary that install/migrate flows use to keep schema mutations atomic. CRUD calls outside any transaction are still effectively auto-committed because the connection is configured that way.

Source code in pyvelm/env.py
def transaction(self):
    """Return a context manager for an atomic unit of work.

    Outer call opens a real transaction; nested calls use savepoints
    so partial work can roll back independently.  On exception the
    active scope rolls back; otherwise it commits / releases.

    This is the explicit boundary that install/migrate flows use to
    keep schema mutations atomic. CRUD calls outside any transaction
    are still effectively auto-committed because the connection is
    configured that way.
    """
    env = self

    class _TxContext:
        def __enter__(self_inner):
            if env._tx_depth == 0:
                if env.conn.autocommit:
                    env.conn.autocommit = False
                env._tx_opened_autocommit = False
                env._tx_depth = 1
                self_inner._kind = "tx"
            else:
                sp_name = f"_pyvelm_sp{env._tx_depth}"
                env.conn.execute(f"SAVEPOINT {sp_name}")
                env._tx_depth += 1
                self_inner._kind = "sp"
                self_inner._sp_name = sp_name
            return env

        def __exit__(self_inner, exc_type, exc, tb):
            try:
                if self_inner._kind == "tx":
                    if exc is None:
                        try:
                            env.conn.commit()
                        except Exception:  # noqa: BLE001
                            # If application code swallowed an exception inside
                            # the `with` block after a SQL error, the connection
                            # is left in an aborted transaction state and commit
                            # will fail (e.g. psycopg.errors.InFailedSqlTransaction).
                            # Roll back to restore a usable connection.
                            env.conn.rollback()
                            raise
                    else:
                        env.conn.rollback()
                else:
                    sp = self_inner._sp_name
                    if exc is None:
                        try:
                            env.conn.execute(f"RELEASE SAVEPOINT {sp}")
                        except Exception:  # noqa: BLE001
                            env.conn.execute(f"ROLLBACK TO SAVEPOINT {sp}")
                            raise
                    else:
                        env.conn.execute(f"ROLLBACK TO SAVEPOINT {sp}")
            finally:
                env._tx_depth -= 1
                if env._tx_depth == 0:
                    # Reopen autocommit so subsequent ad-hoc statements
                    # don't sit in an implicit transaction.
                    env.conn.autocommit = True
            return False

    return _TxContext()

compute_field

compute_field(record, field) -> None

Run a compute method on the records that need it.

Computes are bulk-friendly: the method sees the whole recordset and is expected to iterate. For stored fields, cached values flush to SQL after the method returns; for non-stored, the cache write is the final state until the next invalidation.

Source code in pyvelm/env.py
def compute_field(self, record, field) -> None:
    """Run a compute method on the records that need it.

    Computes are bulk-friendly: the method sees the whole recordset and
    is expected to iterate. For stored fields, cached values flush to
    SQL after the method returns; for non-stored, the cache write is
    the final state until the next invalidation.
    """
    model_cls = type(record)
    # Expand to all of record's ids — compute is bulk by convention.
    recs = model_cls(self, record._ids)
    prev = self._in_compute
    self._in_compute = True
    try:
        method = getattr(recs, field.compute)
        method()
    finally:
        self._in_compute = prev
    if field.is_stored:
        for rid in recs._ids:
            if not self.cache.contains(model_cls._name, rid, field.name):
                raise RuntimeError(
                    f"Compute {field.compute!r} did not set "
                    f"{model_cls._name}.{field.name} for id={rid}"
                )
            value = self.cache.get(model_cls._name, rid, field.name)
            self.conn.execute(
                f'UPDATE "{model_cls._table}" SET "{field.column}" = %s '
                f'WHERE "id" = %s',
                [field.to_sql_param(value), rid],
            )

notify_changed

notify_changed(model_name: str, ids, fields) -> None

Propagate field changes through the compute dependency graph.

Each (model_name, field) change consults _edge_index for the listening compute fields. Each HopEdge.find_source_ids walks any relational hops backward to land on the source-side ids that need invalidation. BFS through transitive dependents:

  • drop the cache entry,
  • if the dependent is stored, recompute now and UPDATE the SQL column (so other sessions see the new value),
  • enqueue further dependents.
Source code in pyvelm/env.py
def notify_changed(self, model_name: str, ids, fields) -> None:
    """Propagate field changes through the compute dependency graph.

    Each `(model_name, field)` change consults `_edge_index` for the
    listening compute fields. Each `HopEdge.find_source_ids` walks any
    relational hops backward to land on the source-side ids that need
    invalidation. BFS through transitive dependents:

      - drop the cache entry,
      - if the dependent is stored, recompute now and UPDATE the SQL
        column (so other sessions see the new value),
      - enqueue further dependents.
    """
    from collections import deque

    if not ids or not fields:
        return
    ids = list(ids)
    queue: deque = deque()

    def fan_out(m: str, fs, idset):
        for f in fs:
            for dep_model, dep_field, edge in self.registry._edge_index.get(
                (m, f), []
            ):
                affected = edge.find_source_ids(self, list(idset))
                if affected:
                    queue.append((dep_model, dep_field, set(affected)))

    fan_out(model_name, fields, ids)

    seen: dict[tuple[str, str], set[int]] = {}
    while queue:
        m, f, idset = queue.popleft()
        key = (m, f)
        already = seen.get(key, set())
        new_ids = idset - already
        if not new_ids:
            continue
        seen[key] = already | new_ids
        self.cache.invalidate(model_name=m, ids=list(new_ids), fields=[f])
        field = self.registry[m]._fields[f]
        if field.is_stored:
            recs = self.registry[m](self, tuple(new_ids))
            self.compute_field(recs, field)
        fan_out(m, [f], new_ids)