easyvista-python-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. easyvista_python_client/__init__.py +74 -0
  2. easyvista_python_client/_async/__init__.py +13 -0
  3. easyvista_python_client/_async/_concurrency.py +78 -0
  4. easyvista_python_client/_async/_transport.py +266 -0
  5. easyvista_python_client/_async/client.py +790 -0
  6. easyvista_python_client/_fields.py +26 -0
  7. easyvista_python_client/_html.py +41 -0
  8. easyvista_python_client/_sync/__init__.py +13 -0
  9. easyvista_python_client/_sync/_concurrency.py +50 -0
  10. easyvista_python_client/_sync/_transport.py +266 -0
  11. easyvista_python_client/_sync/client.py +790 -0
  12. easyvista_python_client/_transport.py +29 -0
  13. easyvista_python_client/config.py +71 -0
  14. easyvista_python_client/context.py +116 -0
  15. easyvista_python_client/directory.py +53 -0
  16. easyvista_python_client/exceptions.py +53 -0
  17. easyvista_python_client/field_model.py +74 -0
  18. easyvista_python_client/filters.py +82 -0
  19. easyvista_python_client/models/__init__.py +1 -0
  20. easyvista_python_client/models/action.py +65 -0
  21. easyvista_python_client/models/asset.py +36 -0
  22. easyvista_python_client/models/common.py +78 -0
  23. easyvista_python_client/models/department.py +68 -0
  24. easyvista_python_client/models/document.py +32 -0
  25. easyvista_python_client/models/employee.py +67 -0
  26. easyvista_python_client/models/request.py +172 -0
  27. easyvista_python_client/pagination.py +84 -0
  28. easyvista_python_client/py.typed +0 -0
  29. easyvista_python_client/references.py +146 -0
  30. easyvista_python_client/reporting.py +143 -0
  31. easyvista_python_client/resources/__init__.py +1 -0
  32. easyvista_python_client/resources/actions.py +72 -0
  33. easyvista_python_client/resources/assets.py +47 -0
  34. easyvista_python_client/resources/departments.py +57 -0
  35. easyvista_python_client/resources/descriptor.py +99 -0
  36. easyvista_python_client/resources/documents.py +78 -0
  37. easyvista_python_client/resources/employees.py +57 -0
  38. easyvista_python_client/resources/requests.py +91 -0
  39. easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
  40. easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
  41. easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
  42. easyvista_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,68 @@
1
+ """Models for the EasyVista ``departments`` resource.
2
+
3
+ ``Department`` declares the stable scalar fields; ``extra="allow"`` (from
4
+ ``EasyvistaModel``) preserves the localized ``DEPARTMENT_<lang>`` label columns (used
5
+ by ``name``), the ``COMMENT_DEPARTMENT`` Memo link (surfaced by
6
+ ``classify_fields().links`` and read via ``client.get_department_comment``), and any
7
+ instance-specific columns. Field aliases are grounded in the live inventory
8
+ (``docs/easyvista-field-inventory.md``). Writes are **provisional** pending an
9
+ authorised profile (spec open item O-DIR-2).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pydantic import Field
15
+
16
+ from ..references import localized_label
17
+ from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
18
+
19
+
20
+ class Department(EasyvistaModel):
21
+ """A department (organisation node) as returned by the API."""
22
+
23
+ department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
24
+ department_code: str | None = Field(default=None, alias="DEPARTMENT_CODE")
25
+ department_path: str | None = Field(default=None, alias="DEPARTMENT_PATH")
26
+ manager_id: OptionalInt = Field(default=None, alias="MANAGER_ID")
27
+ level: OptionalInt = Field(default=None, alias="LEVEL")
28
+ href: str | None = Field(default=None, alias="HREF")
29
+
30
+ @property
31
+ def name(self) -> str | None:
32
+ """Best localized department label, falling back to code then path.
33
+
34
+ A plain property (not a serialized field), so it never recurses through
35
+ ``model_dump`` or appears in ``classify_fields``.
36
+ """
37
+ return localized_label(
38
+ self.model_dump(by_alias=True),
39
+ "DEPARTMENT",
40
+ fallbacks=(self.department_code, self.department_path),
41
+ )
42
+
43
+
44
+ class PostDepartment(EasyvistaWriteModel):
45
+ """Provisional payload for creating a department.
46
+
47
+ Sent inside the envelope ``{"departments": [...]}``. Field set is a best guess
48
+ pending an authorised profile (spec open item O-DIR-2);
49
+ ``custom_fields`` serialize with an ``e_`` prefix (see ``EasyvistaWriteModel``).
50
+ """
51
+
52
+ department_code: str | None = None
53
+ department_en: str | None = None
54
+ department_fr: str | None = None
55
+ manager_id: int | None = None
56
+ parent_department_id: int | None = None
57
+
58
+
59
+ class DepartmentUpdate(EasyvistaWriteModel):
60
+ """Provisional payload for updating a department via PUT.
61
+
62
+ Field set pending an authorised profile (spec open item O-DIR-2).
63
+ """
64
+
65
+ department_code: str | None = None
66
+ department_en: str | None = None
67
+ department_fr: str | None = None
68
+ manager_id: int | None = None
@@ -0,0 +1,32 @@
1
+ """Model for documents attached to an EasyVista ticket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field, model_validator
6
+
7
+ from .common import EasyvistaModel
8
+
9
+
10
+ class Document(EasyvistaModel):
11
+ """A document attached to a ticket (or the HREF returned after upload).
12
+
13
+ The live ``GET requests/{rfc}/documents`` list exposes each attachment as
14
+ ``DOCUMENT`` (filename), ``DOCUMENT_ID`` and ``DDL_HREF`` (the direct-download
15
+ URL) — verified against a live instance. ``filename`` falls back to
16
+ ``DOCUMENT`` (then ``NAME``) so it is populated on both the list shape and any
17
+ ``FILE_NAME``-style shape; ``download_href`` is the URL to fetch the bytes.
18
+ """
19
+
20
+ href: str | None = Field(default=None, alias="HREF")
21
+ filename: str | None = Field(default=None, alias="FILE_NAME")
22
+ name: str | None = Field(default=None, alias="NAME")
23
+ document: str | None = Field(default=None, alias="DOCUMENT")
24
+ document_id: str | None = Field(default=None, alias="DOCUMENT_ID")
25
+ download_href: str | None = Field(default=None, alias="DDL_HREF")
26
+
27
+ @model_validator(mode="after")
28
+ def _fill_filename(self) -> Document:
29
+ """Populate ``filename`` from ``DOCUMENT`` or ``NAME`` when unset."""
30
+ if not self.filename:
31
+ self.filename = self.document or self.name
32
+ return self
@@ -0,0 +1,67 @@
1
+ """Models for the EasyVista ``employees`` resource.
2
+
3
+ ``Employee`` declares the fields the richer single-record GET returns
4
+ (``GET employees/{id}``). ``E_MAIL`` is a **declared official** field, so the generic
5
+ field model never misclassifies it as a custom ``e_*`` column. ``extra="allow"``
6
+ preserves the ``COMMENT_EMPLOYEE`` Memo link and any other columns. Aliases are
7
+ grounded in the live inventory (``docs/easyvista-field-inventory.md``). Writes are
8
+ **provisional** pending an authorised profile (spec open item O-DIR-2).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydantic import Field
14
+
15
+ from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
16
+
17
+
18
+ class Employee(EasyvistaModel):
19
+ """An employee (person) as returned by the single-record GET."""
20
+
21
+ employee_id: OptionalInt = Field(default=None, alias="EMPLOYEE_ID")
22
+ last_name: str | None = Field(default=None, alias="LAST_NAME")
23
+ e_mail: str | None = Field(default=None, alias="E_MAIL")
24
+ department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
25
+ department_path: str | None = Field(default=None, alias="DEPARTMENT_PATH")
26
+ location_id: OptionalInt = Field(default=None, alias="LOCATION_ID")
27
+ phone_number: str | None = Field(default=None, alias="PHONE_NUMBER")
28
+ cellular_number: str | None = Field(default=None, alias="CELLULAR_NUMBER")
29
+ profil_id: OptionalInt = Field(default=None, alias="PROFIL_ID")
30
+ manager_id: OptionalInt = Field(default=None, alias="MANAGER_ID")
31
+ employee_guid: str | None = Field(default=None, alias="EMPLOYEE_GUID")
32
+ identification: str | None = Field(default=None, alias="IDENTIFICATION")
33
+ login: str | None = Field(default=None, alias="LOGIN")
34
+ function_id: OptionalInt = Field(default=None, alias="FUNCTION_ID")
35
+ language_id: OptionalInt = Field(default=None, alias="LANGUAGE_ID")
36
+ last_update: str | None = Field(default=None, alias="LAST_UPDATE")
37
+ href: str | None = Field(default=None, alias="HREF")
38
+
39
+
40
+ class PostEmployee(EasyvistaWriteModel):
41
+ """Provisional payload for creating an employee (envelope ``{"employees": [...]}``).
42
+
43
+ Field set is a best guess pending an authorised profile (spec open item O-DIR-2);
44
+ ``custom_fields`` serialize with an ``e_`` prefix (see ``EasyvistaWriteModel``).
45
+ """
46
+
47
+ last_name: str | None = None
48
+ e_mail: str | None = None
49
+ department_id: int | None = None
50
+ location_id: int | None = None
51
+ phone_number: str | None = None
52
+ cellular_number: str | None = None
53
+ manager_id: int | None = None
54
+ login: str | None = None
55
+ function_id: int | None = None
56
+
57
+
58
+ class EmployeeUpdate(EasyvistaWriteModel):
59
+ """Provisional payload for updating an employee via PUT (spec open item O-DIR-2)."""
60
+
61
+ last_name: str | None = None
62
+ e_mail: str | None = None
63
+ department_id: int | None = None
64
+ location_id: int | None = None
65
+ phone_number: str | None = None
66
+ cellular_number: str | None = None
67
+ manager_id: int | None = None
@@ -0,0 +1,172 @@
1
+ """Models for the EasyVista ``requests`` resource (tickets).
2
+
3
+ ``Request``'s declared fields are those verified present on live single-ticket
4
+ GETs (see its class docstring for exactly which, and which are deliberately
5
+ left undeclared); ``extra="allow"`` preserves everything else. ``PostRequest``
6
+ and ``RequestUpdate`` field sets follow the documented create/update bodies
7
+ (see their own docstrings).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from pydantic import Field, model_validator
15
+
16
+ from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
17
+
18
+
19
+ class Request(EasyvistaModel):
20
+ """A ticket (incident/request) as returned by the API.
21
+
22
+ The declared fields are those verified present on live single-ticket GETs.
23
+ ``extra="allow"`` preserves everything else, including the deliberately
24
+ undeclared ones:
25
+
26
+ * ``*_PATH`` (``SD_CATALOG_PATH``, ``DEPARTMENT_PATH``) — verified live:
27
+ returned and populated, but **silently ignored as search conditions** (a
28
+ real value returns the whole table while its ``*_ID`` sibling filters).
29
+ ``LOCATION_PATH`` is *presumed* to behave the same by family resemblance
30
+ only — no sampled ticket carried both ``LOCATION_PATH`` and
31
+ ``LOCATION_ID``, so it was never actually tested. Left undeclared so this
32
+ model never invites filtering on any of them.
33
+ * ``E_*`` — instance-specific custom fields; they belong in the custom
34
+ bucket of :meth:`~EasyvistaModel.classify_fields`, not here.
35
+ * ``AVAILABLE_FIELD_*`` — the API's spare slots.
36
+
37
+ ``TITLE`` is empty on tickets created through the portal/catalog on some
38
+ instances (the human summary lives in ``DESCRIPTION`` / the catalog path),
39
+ so ``title`` is legitimately ``None`` for those; it is populated for tickets
40
+ created through this client with ``PostRequest(title=...)``.
41
+ """
42
+
43
+ # identity
44
+ rfc_number: str | None = Field(default=None, alias="RFC_NUMBER")
45
+ request_id: OptionalInt = Field(default=None, alias="REQUEST_ID")
46
+ href: str | None = Field(default=None, alias="HREF")
47
+
48
+ # content
49
+ title: str | None = Field(default=None, alias="TITLE")
50
+ # The list view returns DESCRIPTION inline (a string); the single-ticket GET
51
+ # expands it into an HREF reference object (``{"HREF": ".../description"}``).
52
+ # Accept either so both read paths validate. Whether the resolved text is
53
+ # HTML or plain text is still unverified (spec open item O4).
54
+ description: str | dict[str, Any] | None = Field(default=None, alias="DESCRIPTION")
55
+ external_reference: str | None = Field(default=None, alias="EXTERNAL_REFERENCE")
56
+
57
+ # classification
58
+ sd_catalog_id: OptionalInt = Field(default=None, alias="SD_CATALOG_ID")
59
+ status_id: OptionalInt = Field(default=None, alias="STATUS_ID")
60
+ urgency_id: OptionalInt = Field(default=None, alias="URGENCY_ID")
61
+ impact_id: OptionalInt = Field(default=None, alias="IMPACT_ID")
62
+ severity_id: OptionalInt = Field(default=None, alias="SEVERITY_ID")
63
+ # Write-side ``PostRequest.origin`` reads back as REQUEST_ORIGIN_ID; ``ORIGIN``
64
+ # itself is not returned (spec open item O-ORIGIN).
65
+ request_origin_id: OptionalInt = Field(default=None, alias="REQUEST_ORIGIN_ID")
66
+
67
+ # parties and place
68
+ department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
69
+ location_id: OptionalInt = Field(default=None, alias="LOCATION_ID")
70
+ requestor_id: OptionalInt = Field(default=None, alias="REQUESTOR_ID")
71
+ recipient_id: OptionalInt = Field(default=None, alias="RECIPIENT_ID")
72
+ owner_id: OptionalInt = Field(default=None, alias="OWNER_ID")
73
+
74
+ # timestamps and time limits — verified *returned*; their accepted write
75
+ # format is NOT verified (both a string and an int probe return HTTP 590),
76
+ # so no datetime parsing is claimed here. See spec open item O-590-DATE.
77
+ #
78
+ # These are the OFFICIAL time fields, portable across EasyVista
79
+ # deployments. The instance-specific GTR/GTI family (``E_GTR_STATUS``,
80
+ # ``E_GTI_UT``, ``E_DELAI_PEC``…) is deliberately NOT declared: it does not
81
+ # exist on another deployment, so it belongs in the custom bucket of
82
+ # :meth:`classify_fields`, reached by name at the call site.
83
+ submit_date_ut: str | None = Field(default=None, alias="SUBMIT_DATE_UT")
84
+ creation_date_ut: str | None = Field(default=None, alias="CREATION_DATE_UT")
85
+ max_resolution_date_ut: str | None = Field(
86
+ default=None, alias="MAX_RESOLUTION_DATE_UT"
87
+ )
88
+ expected_date_ut: str | None = Field(default=None, alias="EXPECTED_DATE_UT")
89
+ end_date_ut: str | None = Field(default=None, alias="END_DATE_UT")
90
+ last_update: str | None = Field(default=None, alias="LAST_UPDATE")
91
+ sla_id: OptionalInt = Field(default=None, alias="SLA_ID")
92
+ # Verified live (2026-07-28 Phase 0 probe, U6) as a string on every ticket
93
+ # checked -- never an int -- so no int branch is declared here.
94
+ time_used_to_solve_request: str | None = Field(
95
+ default=None, alias="TIME_USED_TO_SOLVE_REQUEST"
96
+ )
97
+
98
+ @model_validator(mode="after")
99
+ def _derive_rfc_from_href(self) -> Request:
100
+ """Populate ``rfc_number`` from ``href`` when the API omits it.
101
+
102
+ ``POST /requests`` (create) returns an HREF-only body
103
+ ``{"HREF": ".../requests/<id>"}`` with no ``RFC_NUMBER``; the trailing
104
+ path segment of that HREF *is* the ticket's RFC (verified live), so
105
+ callers can use ``ticket.rfc_number`` immediately after a create. Reads,
106
+ which already carry ``RFC_NUMBER``, are left untouched.
107
+ """
108
+ if not self.rfc_number and isinstance(self.href, str) and self.href:
109
+ tail = self.href.rstrip("/").rsplit("/", 1)[-1].split("?", 1)[0]
110
+ if tail:
111
+ self.rfc_number = tail
112
+ return self
113
+
114
+
115
+ class PostRequest(EasyvistaWriteModel):
116
+ """Payload for creating a ticket.
117
+
118
+ Field set matches the documented create body (``docs/API_Info.md``),
119
+ verified against a live instance: a ticket needs at minimum ``catalog_code``
120
+ plus ``title`` (and typically ``origin`` / ``department_id``). The exact
121
+ mandatory fields are configured **per catalog on the EasyVista side**, so the
122
+ client cannot know them statically; a missing one is rejected server-side and
123
+ surfaces as :class:`EasyvistaValidationError` (HTTP 590, code 2013), not a
124
+ retried server error. ``custom_fields`` values are serialized with an ``e_``
125
+ prefix unless they already start with ``e_`` (see :class:`EasyvistaWriteModel`).
126
+
127
+ ``catalog_code`` is the only verified way to name a catalog here. An earlier
128
+ ``catalog_guid`` field was removed: it is absent from the documented create
129
+ body, and it cannot be verified on a profile where ``GET /catalog-requests``
130
+ returns 403 (no way to obtain a real catalog GUID).
131
+
132
+ ``description`` supplied at create time was **not** readable back through
133
+ either Memo on the verified instance -- neither ``DESCRIPTION`` nor
134
+ ``COMMENT``. To set body text you can read again, follow the create with
135
+ ``update_ticket(rfc, RequestUpdate(description=...))``.
136
+ """
137
+
138
+ catalog_code: str | None = None
139
+ title: str | None = None
140
+ description: str | None = None
141
+ origin: int | None = None
142
+ department_id: int | None = None
143
+ urgency_id: int | None = None
144
+ impact_id: int | None = None
145
+ severity_id: int | None = None
146
+ recipient_id: int | None = None
147
+ recipient_mail: str | None = None
148
+ external_reference: str | None = None
149
+
150
+
151
+ class RequestUpdate(EasyvistaWriteModel):
152
+ """Payload for updating a ticket via PUT.
153
+
154
+ ``docs/API_Info.md`` documents only the create, comment and close bodies, so
155
+ the update body is not vendor-documented. Every field here is one verified
156
+ accepted against a live instance -- ``title`` by the Phase 0 probe and by
157
+ ``integration_tests/test_live_ticket_identity.py``. Nothing is added
158
+ speculatively: an unaccepted field would silently no-op or raise HTTP 590.
159
+
160
+ ``description`` writes the ticket's **COMMENT** Memo, not ``DESCRIPTION`` --
161
+ verified live. EasyVista models ``COMMENT`` as the request's justification
162
+ and ``DESCRIPTION`` as a separate Memo; which one a deployment actually
163
+ populates is a per-instance configuration choice. On the instance this
164
+ client was verified against, ``DESCRIPTION`` is empty on every ticket and
165
+ ``COMMENT`` carries the body text. Read it back with
166
+ ``resolve_memo("requests/{rfc}/comment")``, or take
167
+ ``TicketContext.comment``, which resolves it for you.
168
+ """
169
+
170
+ status_id: int | None = None
171
+ title: str | None = None
172
+ description: str | None = None
@@ -0,0 +1,84 @@
1
+ """Search-result container and record extraction for list endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ @dataclass
12
+ class SearchResult(Generic[T]):
13
+ """A page of search results plus EasyVista's record counts.
14
+
15
+ ``next_url`` is the API's ``@next`` link (an ``offset``-based next-page URL)
16
+ when more records exist than were returned, else ``None``. It is the
17
+ authoritative "are there more pages?" signal used by the iterators.
18
+ """
19
+
20
+ records: list[T]
21
+ record_count: int
22
+ total_record_count: int
23
+ href: str | None = None
24
+ next_url: str | None = None
25
+
26
+
27
+ def _to_int(value: Any, default: int) -> int:
28
+ """Coerce an EasyVista count to ``int``, falling back to ``default``."""
29
+ if value is None:
30
+ return default
31
+ try:
32
+ return int(value)
33
+ except (TypeError, ValueError):
34
+ return default
35
+
36
+
37
+ def build_search_result(data: Any, records: list[T]) -> SearchResult[T]:
38
+ """Assemble a :class:`SearchResult` from a payload and its typed records.
39
+
40
+ The live API returns ``record_count`` / ``total_record_count`` as **strings**
41
+ (spec open item O1), so they are coerced to ``int`` here — the single place
42
+ both the ``requests`` and ``assets`` search parsers funnel through.
43
+ """
44
+ if not isinstance(data, dict):
45
+ return SearchResult(
46
+ records=records,
47
+ record_count=len(records),
48
+ total_record_count=len(records),
49
+ )
50
+ count = _to_int(data.get("record_count"), len(records))
51
+ total = _to_int(data.get("total_record_count"), count)
52
+ href = data.get("HREF")
53
+ next_url = data.get("@next")
54
+ return SearchResult(
55
+ records=records,
56
+ record_count=count,
57
+ total_record_count=total,
58
+ href=href if isinstance(href, str) else None,
59
+ next_url=next_url if isinstance(next_url, str) else None,
60
+ )
61
+
62
+
63
+ def extract_records(data: Any, envelope_key: str | None = None) -> list[dict[str, Any]]:
64
+ """Pull a list of record dicts out of an EasyVista JSON payload.
65
+
66
+ Handles the ``records`` list (GET list), the resource-named create/list
67
+ envelopes, and a bare single object. ``envelope_key`` names a resource's own
68
+ envelope (e.g. ``"departments"``) so a response echoed in that wrapper is
69
+ unwrapped too; it is checked right after ``records`` and before the legacy
70
+ defaults. With ``envelope_key=None`` the behavior is unchanged.
71
+ """
72
+ if isinstance(data, dict):
73
+ keys = ["records"]
74
+ if envelope_key and envelope_key not in keys:
75
+ keys.append(envelope_key)
76
+ keys.extend(k for k in ("requests", "assets", "documents") if k not in keys)
77
+ for key in keys:
78
+ value = data.get(key)
79
+ if isinstance(value, list):
80
+ return [r for r in value if isinstance(r, dict)]
81
+ return [data]
82
+ if isinstance(data, list):
83
+ return [r for r in data if isinstance(r, dict)]
84
+ return []
File without changes
@@ -0,0 +1,146 @@
1
+ """Generic, config-free resolution of EasyVista reference attributes.
2
+
3
+ EasyVista returns reference attributes in two shapes: nested objects that carry a
4
+ human label in ``*_EN`` / ``*_FR`` / ``*_PATH`` sub-keys (e.g. ``STATUS``,
5
+ ``DEPARTMENT``, ``CATALOG_REQUEST``), and bare ids (e.g. ``URGENCY_ID``). This
6
+ module normalizes both to a :class:`Reference` using only the API's naming
7
+ conventions, so any field — including custom ``e_*`` fields on any instance —
8
+ resolves the same way with no registry or configuration.
9
+
10
+ Leaf module: stdlib only, no model/client imports.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Reference:
21
+ """A normalized EasyVista reference: an id and/or a human label."""
22
+
23
+ id: str | None
24
+ label: str | None
25
+
26
+ @property
27
+ def display(self) -> str | None:
28
+ """The human label when available, else the id, else ``None``."""
29
+ return self.label or self.id
30
+
31
+
32
+ def _scalar(value: Any) -> str | None:
33
+ """A non-empty id-like scalar as a string, else ``None`` (bools rejected)."""
34
+ if isinstance(value, bool):
35
+ return None
36
+ if isinstance(value, (str, int)) and str(value).strip():
37
+ return str(value).strip()
38
+ return None
39
+
40
+
41
+ def _nested_label(nested: dict[str, Any] | None) -> str | None:
42
+ """First non-empty ``*_EN`` then ``*_FR`` then ``*_PATH`` string; never an href."""
43
+ if not nested:
44
+ return None
45
+ # Suffix-scan (not <name>_* prefix) so labels under any sub-key resolve —
46
+ # e.g. CATALOG_REQUEST's human label lives in TITLE_FR / TITLE_EN.
47
+ for suffix in ("_EN", "_FR", "_PATH"):
48
+ for key, value in nested.items():
49
+ if (
50
+ key.upper().endswith(suffix)
51
+ and isinstance(value, str)
52
+ and value.strip()
53
+ ):
54
+ return value.strip()
55
+ return None
56
+
57
+
58
+ def resolve_reference(record: dict[str, Any], name: str) -> Reference:
59
+ """Resolve reference ``name`` in a model's by-alias dump to ``(id, label)``.
60
+
61
+ ``name`` is a raw API field name, matched case-insensitively. See the module
62
+ docstring for the conventions. Never raises; a non-dict record or a missing
63
+ field yields an empty :class:`Reference`.
64
+ """
65
+ if not isinstance(record, dict):
66
+ return Reference(id=None, label=None)
67
+ # Index top-level keys case-insensitively (EasyVista keys are ALL_CAPS, but
68
+ # custom fields on some instances are not — stay generic).
69
+ upper = {k.upper(): v for k, v in record.items() if isinstance(k, str)}
70
+ key = name.upper()
71
+
72
+ value = upper.get(key)
73
+ nested = value if isinstance(value, dict) else None
74
+
75
+ label = _nested_label(nested)
76
+ id_ = _resolve_id(upper, key, nested)
77
+ return Reference(id=id_, label=label)
78
+
79
+
80
+ def _resolve_id(
81
+ upper: dict[str, Any], key: str, nested: dict[str, Any] | None
82
+ ) -> str | None:
83
+ # 1. top-level <name>_ID / <name>_GUID / the scalar at <name> itself
84
+ for candidate in (f"{key}_ID", f"{key}_GUID", key):
85
+ got = _scalar(upper.get(candidate))
86
+ if got is not None:
87
+ return got
88
+ if nested:
89
+ # 2. exact <name>_ID / <name>_GUID inside the nested object
90
+ for sub in (f"{key}_ID", f"{key}_GUID"):
91
+ for nkey, nval in nested.items():
92
+ if isinstance(nkey, str) and nkey.upper() == sub:
93
+ got = _scalar(nval)
94
+ if got is not None:
95
+ return got
96
+ # Best-effort: first *_ID/_GUID sub-key by dict order (fine for the
97
+ # single-id nested objects EasyVista returns).
98
+ for nkey, nval in nested.items():
99
+ if isinstance(nkey, str) and nkey.upper().endswith(("_ID", "_GUID")):
100
+ got = _scalar(nval)
101
+ if got is not None:
102
+ return got
103
+ return None
104
+
105
+
106
+ _LANG_SUFFIXES = ("_EN", "_FR", "_GE", "_IT", "_PO", "_SP")
107
+
108
+
109
+ def _usable_label(value: Any) -> str | None:
110
+ """A stripped non-empty string that is not a ``[bracketed]`` placeholder.
111
+
112
+ Returns ``None`` otherwise.
113
+ """
114
+ if not isinstance(value, str):
115
+ return None
116
+ text = value.strip()
117
+ if not text or (text.startswith("[") and text.endswith("]")):
118
+ return None
119
+ return text
120
+
121
+
122
+ def localized_label(
123
+ record: dict[str, Any], prefix: str, *, fallbacks: tuple[str | None, ...] = ()
124
+ ) -> str | None:
125
+ """Best populated ``"<prefix>_<lang>"`` label, else first usable ``fallbacks``.
126
+
127
+ Scans the EasyVista language columns ``<prefix>_EN`` → ``_FR`` → ``_GE`` → ``_IT``
128
+ → ``_PO`` → ``_SP`` and returns the first value that is a non-empty string and not
129
+ a ``[bracketed]`` placeholder (unpopulated localized columns on a single-language
130
+ instance echo ``"[CODE]"``). Only the language suffixes are considered, so
131
+ ``_CODE`` / ``_PATH`` are never mistaken for a label. Falls back to the first
132
+ usable value in ``fallbacks`` (e.g. a code then a path). Case-insensitive on keys;
133
+ never raises; returns ``None`` when nothing usable is found.
134
+ """
135
+ if isinstance(record, dict):
136
+ upper = {k.upper(): v for k, v in record.items() if isinstance(k, str)}
137
+ base = prefix.upper()
138
+ for suffix in _LANG_SUFFIXES:
139
+ got = _usable_label(upper.get(base + suffix))
140
+ if got is not None:
141
+ return got
142
+ for value in fallbacks:
143
+ got = _usable_label(value)
144
+ if got is not None:
145
+ return got
146
+ return None