targetprocess-py 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 (58) hide show
  1. targetprocess/__init__.py +231 -0
  2. targetprocess/_assignables.py +162 -0
  3. targetprocess/_base.py +318 -0
  4. targetprocess/_content.py +287 -0
  5. targetprocess/_dates.py +70 -0
  6. targetprocess/_entity_types.py +33 -0
  7. targetprocess/_generals.py +324 -0
  8. targetprocess/_joins.py +217 -0
  9. targetprocess/_lookups.py +477 -0
  10. targetprocess/_nested.py +223 -0
  11. targetprocess/_observability.py +368 -0
  12. targetprocess/client.py +430 -0
  13. targetprocess/exceptions.py +147 -0
  14. targetprocess/models.py +121 -0
  15. targetprocess/py.typed +1 -0
  16. targetprocess/request_handler.py +724 -0
  17. targetprocess/resources/__init__.py +76 -0
  18. targetprocess/resources/assignments.py +29 -0
  19. targetprocess/resources/attachments.py +164 -0
  20. targetprocess/resources/base.py +563 -0
  21. targetprocess/resources/bugs.py +20 -0
  22. targetprocess/resources/comments.py +23 -0
  23. targetprocess/resources/custom_activities.py +56 -0
  24. targetprocess/resources/custom_fields.py +27 -0
  25. targetprocess/resources/custom_rules.py +28 -0
  26. targetprocess/resources/entities.py +433 -0
  27. targetprocess/resources/entity_states.py +20 -0
  28. targetprocess/resources/entity_types.py +61 -0
  29. targetprocess/resources/epics.py +20 -0
  30. targetprocess/resources/features.py +20 -0
  31. targetprocess/resources/iterations.py +20 -0
  32. targetprocess/resources/priorities.py +85 -0
  33. targetprocess/resources/processes.py +65 -0
  34. targetprocess/resources/projects.py +20 -0
  35. targetprocess/resources/relation_types.py +59 -0
  36. targetprocess/resources/relations.py +34 -0
  37. targetprocess/resources/releases.py +20 -0
  38. targetprocess/resources/requests.py +20 -0
  39. targetprocess/resources/role_efforts.py +21 -0
  40. targetprocess/resources/roles.py +52 -0
  41. targetprocess/resources/severities.py +53 -0
  42. targetprocess/resources/tasks.py +20 -0
  43. targetprocess/resources/team_assignments.py +21 -0
  44. targetprocess/resources/team_iterations.py +24 -0
  45. targetprocess/resources/teams.py +20 -0
  46. targetprocess/resources/terms.py +28 -0
  47. targetprocess/resources/test_cases.py +20 -0
  48. targetprocess/resources/times.py +392 -0
  49. targetprocess/resources/user_stories.py +20 -0
  50. targetprocess/resources/users.py +20 -0
  51. targetprocess/resources/workflows.py +27 -0
  52. targetprocess/response_parser.py +113 -0
  53. targetprocess/transport.py +116 -0
  54. targetprocess/types.py +56 -0
  55. targetprocess_py-0.1.0.dist-info/METADATA +476 -0
  56. targetprocess_py-0.1.0.dist-info/RECORD +58 -0
  57. targetprocess_py-0.1.0.dist-info/WHEEL +4 -0
  58. targetprocess_py-0.1.0.dist-info/licenses/LICENSE +21 -0
targetprocess/_base.py ADDED
@@ -0,0 +1,318 @@
1
+ """Base entity classes, mirroring TargetProcess's own type hierarchy.
2
+
3
+ TP's ``/meta`` declares a real inheritance chain: most domain types derive from
4
+ ``General``, and the work-item types derive from ``Assignable``, which itself
5
+ derives from ``General``. :class:`GeneralEntity` and :class:`AssignableEntity`
6
+ model those two bases, so every field TP defines once is declared once here
7
+ rather than repeated on each concrete type.
8
+
9
+ :class:`Entity` and :class:`NamedEntity` sit above them and carry no TP
10
+ counterpart - they hold the identity fields every payload shares and the
11
+ ``Name`` split, so the types TP exposes outside the ``General`` chain (the
12
+ lookup and join types) have a base too.
13
+
14
+ The concrete entity models live in the sibling private modules and are
15
+ re-exported by :mod:`targetprocess.models`, which stays the import surface
16
+ callers use.
17
+ """
18
+
19
+ from datetime import datetime
20
+
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+ from targetprocess._dates import TPDateTime
24
+ from targetprocess._nested import (
25
+ AssignedUsers,
26
+ CustomFieldValue,
27
+ EntityRef,
28
+ EntityTypeRef,
29
+ RefWithImportance,
30
+ UserRef,
31
+ )
32
+
33
+
34
+ class Entity(BaseModel):
35
+ """Base class for all TargetProcess entities.
36
+
37
+ Provides the fields common to every TP entity. Entity types that carry no
38
+ ``Name`` field (``User``, ``Comment``, ``Assignment``, ``TeamAssignment``,
39
+ ``RoleEffort``, ``Relation``, ``Time``) extend this base directly; types
40
+ with a display name extend :class:`NamedEntity`, and the ``General`` /
41
+ ``Assignable`` families extend the two bases below that. Pydantic aliases
42
+ handle the API's PascalCase format automatically.
43
+
44
+ ``CreateDate`` and ``ModifyDate`` are declared here because most types
45
+ carry them, but TP exposes neither on every type - on a type whose
46
+ ``/meta`` omits them (the lookup and join types, largely) they simply stay
47
+ ``None``.
48
+
49
+ Undeclared API fields are never silently discarded: ``extra="allow"``
50
+ preserves them in ``model_extra`` under their wire (PascalCase) names,
51
+ reachable via attribute access and included by ``model_dump``. TP payload
52
+ shapes vary by instance and version, so undeclared keys are tolerated
53
+ rather than rejected - but kept, not dropped. Assigning an undeclared
54
+ attribute (a mistyped name included) likewise lands in ``model_extra``.
55
+
56
+ Attributes:
57
+ id: Unique identifier for the entity
58
+ resource_type: Type of the entity (e.g., "UserStory", "Bug", "User")
59
+ create_date: When the entity was created (optional)
60
+ modify_date: When the entity was last modified (optional)
61
+ custom_fields: Custom-field values embedded in the entity's
62
+ ``CustomFields`` array (present when fetched via ``include=``)
63
+ """
64
+
65
+ model_config = ConfigDict(
66
+ populate_by_name=True, # Allow both alias and field name
67
+ str_strip_whitespace=True, # Strip whitespace from strings
68
+ validate_assignment=True, # Validate on assignment
69
+ extra="allow", # Preserve undeclared API fields in model_extra
70
+ )
71
+
72
+ id: int = Field(alias="Id", description="Unique identifier")
73
+ resource_type: str | None = Field(default=None, alias="ResourceType", description="Entity type")
74
+ create_date: TPDateTime | None = Field(
75
+ default=None, alias="CreateDate", description="Creation timestamp"
76
+ )
77
+ modify_date: TPDateTime | None = Field(
78
+ default=None, alias="ModifyDate", description="Modification timestamp"
79
+ )
80
+ custom_fields: list[CustomFieldValue] | None = Field(
81
+ default=None, alias="CustomFields", description="Custom-field values"
82
+ )
83
+
84
+ # Property accessors for PascalCase access
85
+ @property
86
+ def Id(self) -> int:
87
+ """Access id field via PascalCase (API format)."""
88
+ return self.id
89
+
90
+ @property
91
+ def ResourceType(self) -> str | None:
92
+ """Access resource_type field via PascalCase (API format)."""
93
+ return self.resource_type
94
+
95
+ @property
96
+ def CreateDate(self) -> datetime | None:
97
+ """Access create_date field via PascalCase (API format)."""
98
+ return self.create_date
99
+
100
+ @property
101
+ def ModifyDate(self) -> datetime | None:
102
+ """Access modify_date field via PascalCase (API format)."""
103
+ return self.modify_date
104
+
105
+ @property
106
+ def CustomFields(self) -> list[CustomFieldValue] | None:
107
+ """Access custom_fields field via PascalCase (API format)."""
108
+ return self.custom_fields
109
+
110
+
111
+ class NamedEntity(Entity):
112
+ """Base class for TP entities that carry a display Name.
113
+
114
+ The common shape - most domain entities have a ``Name``. Which types do
115
+ not, and why, is stated once on :class:`Entity`.
116
+
117
+ Attributes:
118
+ name: Display name of the entity
119
+ """
120
+
121
+ name: str | None = Field(default=None, alias="Name", description="Display name")
122
+
123
+ @property
124
+ def Name(self) -> str | None:
125
+ """Access name field via PascalCase (API format)."""
126
+ return self.name
127
+
128
+
129
+ class GeneralEntity(NamedEntity):
130
+ """TP's ``General`` base - the shape shared by every domain entity.
131
+
132
+ ``Project``, ``Team``, ``Release``, ``Iteration``, ``TeamIteration`` and
133
+ ``TestCase`` derive from this directly; the work-item types reach it
134
+ through :class:`AssignableEntity`.
135
+
136
+ The four ``GeneralUser``-shaped references (``Owner``, ``Creator``,
137
+ ``LastEditor``, ``LastCommentedUser``) arrive as :class:`UserRef` - TP
138
+ sends them with ``FirstName``/``LastName``/``Login``/``FullName`` and no
139
+ ``Name`` key. ``Owner`` records who *created* the entity, not who is
140
+ working it: for a work item that is the ``Assignment`` collection (see
141
+ ``client.assignments``).
142
+
143
+ Attributes:
144
+ description: Detailed description
145
+ start_date: Start date/time
146
+ end_date: End date/time
147
+ last_comment_date: When the entity was last commented on
148
+ tags: Comma-separated tag string (TP stores tags as one string)
149
+ numeric_priority: TP's global ordering rank (a float, not a Priority)
150
+ entity_version: Per-record version counter (increments on every edit)
151
+ is_now: Whether the entity sits in the current time window
152
+ is_next: Whether the entity sits in the next time window
153
+ is_previous: Whether the entity sits in the previous time window
154
+ entity_type: The TP entity type this record is an instance of
155
+ owner: Who created the entity (TP deprecates this in favour of creator)
156
+ creator: Who created the entity
157
+ last_editor: Who last modified the entity
158
+ last_commented_user: Who last commented on the entity
159
+ project: Project reference
160
+ linked_test_plan: Linked test plan reference
161
+ milestone: Milestone reference
162
+ """
163
+
164
+ # Content
165
+ description: str | None = Field(
166
+ default=None, alias="Description", description="Detailed description"
167
+ )
168
+ tags: str | None = Field(default=None, alias="Tags", description="Comma-separated tags")
169
+
170
+ # Date tracking
171
+ start_date: TPDateTime | None = Field(
172
+ default=None, alias="StartDate", description="Start date/time"
173
+ )
174
+ end_date: TPDateTime | None = Field(default=None, alias="EndDate", description="End date/time")
175
+ last_comment_date: TPDateTime | None = Field(
176
+ default=None, alias="LastCommentDate", description="Last comment date/time"
177
+ )
178
+
179
+ # Ordering / versioning
180
+ numeric_priority: float | None = Field(
181
+ default=None, alias="NumericPriority", description="Global ordering rank"
182
+ )
183
+ entity_version: int | None = Field(
184
+ default=None, alias="EntityVersion", description="Per-record version counter"
185
+ )
186
+
187
+ # Time-window flags
188
+ is_now: bool | None = Field(default=None, alias="IsNow", description="In the current window")
189
+ is_next: bool | None = Field(default=None, alias="IsNext", description="In the next window")
190
+ is_previous: bool | None = Field(
191
+ default=None, alias="IsPrevious", description="In the previous window"
192
+ )
193
+
194
+ # Relationships
195
+ entity_type: EntityTypeRef | None = Field(
196
+ default=None, alias="EntityType", description="TP entity type"
197
+ )
198
+ owner: UserRef | None = Field(default=None, alias="Owner", description="Creator (deprecated)")
199
+ creator: UserRef | None = Field(default=None, alias="Creator", description="Creator")
200
+ last_editor: UserRef | None = Field(
201
+ default=None, alias="LastEditor", description="Last modifier"
202
+ )
203
+ last_commented_user: UserRef | None = Field(
204
+ default=None, alias="LastCommentedUser", description="Last commenter"
205
+ )
206
+ project: EntityRef | None = Field(default=None, alias="Project", description="Project")
207
+ linked_test_plan: EntityRef | None = Field(
208
+ default=None, alias="LinkedTestPlan", description="Linked test plan"
209
+ )
210
+ milestone: EntityRef | None = Field(default=None, alias="Milestone", description="Milestone")
211
+
212
+
213
+ class AssignableEntity(GeneralEntity):
214
+ """TP's ``Assignable`` base - the work-item shape.
215
+
216
+ ``UserStory``, ``Bug``, ``Task``, ``Feature``, ``Epic`` and ``Request``
217
+ derive from this. It adds the effort/time surface, the planning
218
+ references, and the workflow state that make a record schedulable.
219
+
220
+ Two fields read as a priority and are not interchangeable: ``priority`` is
221
+ the entity-type-scoped ``Priority`` record (arriving with an
222
+ ``Importance``), while ``numeric_priority`` on :class:`GeneralEntity` is
223
+ TP's continuous backlog-ordering rank.
224
+
225
+ ``team`` is deprecated upstream - TP's ``/meta`` marks it so, and
226
+ ``responsible_team`` (a ``TeamAssignment`` reference) is the current
227
+ surface - but it is still populated and still declared here.
228
+
229
+ The numeric fields carry no range constraint. TP computes most of them and
230
+ documents no bounds, and a constraint on a server-supplied value fails the
231
+ **whole entity**, not the field: one out-of-range roll-up would abort a
232
+ whole ``list()`` page with a ``ParseError``. A read model that faithfully
233
+ reports what TP sent is worth more than one that asserts an invariant the
234
+ API never promised. Write-side range checks belong where the value
235
+ originates - see ``times.upsert``, which validates before sending.
236
+
237
+ Attributes:
238
+ effort: Total effort estimate
239
+ effort_completed: Completed effort
240
+ effort_todo: Remaining effort
241
+ progress: Completion ratio TP derives from effort (0.0 - 1.0)
242
+ time_spent: Hours logged against the item
243
+ time_remain: Hours still expected
244
+ units: The unit effort is expressed in (e.g. "h", "pt")
245
+ lead_time: Days from creation to completion
246
+ cycle_time: Days from work starting to completion
247
+ last_state_change_date: When the entity state last changed
248
+ planned_start_date: Planned start (shadows the assigned iteration)
249
+ planned_end_date: Planned end (shadows the assigned iteration)
250
+ forecast_end_date: TP's projected completion date
251
+ entity_state: Current workflow state reference
252
+ priority: Priority reference, scoped to this entity type
253
+ release: Release reference
254
+ iteration: Iteration reference
255
+ team_iteration: TeamIteration (team sprint) reference
256
+ team: Team reference (deprecated upstream)
257
+ responsible_team: TeamAssignment reference
258
+ assigned_user: Assigned users
259
+ """
260
+
261
+ # Effort / time tracking
262
+ effort: float | None = Field(default=None, alias="Effort", description="Total effort")
263
+ effort_completed: float | None = Field(
264
+ default=None, alias="EffortCompleted", description="Completed effort"
265
+ )
266
+ effort_todo: float | None = Field(
267
+ default=None, alias="EffortToDo", description="Remaining effort"
268
+ )
269
+ time_spent: float | None = Field(default=None, alias="TimeSpent", description="Time spent")
270
+ time_remain: float | None = Field(
271
+ default=None, alias="TimeRemain", description="Time remaining"
272
+ )
273
+ progress: float | None = Field(default=None, alias="Progress", description="Completion ratio")
274
+ units: str | None = Field(default=None, alias="Units", description="Effort unit")
275
+
276
+ # Flow metrics
277
+ lead_time: float | None = Field(default=None, alias="LeadTime", description="Lead time (days)")
278
+ cycle_time: float | None = Field(
279
+ default=None, alias="CycleTime", description="Cycle time (days)"
280
+ )
281
+
282
+ # Date tracking
283
+ last_state_change_date: TPDateTime | None = Field(
284
+ default=None, alias="LastStateChangeDate", description="Last state change"
285
+ )
286
+ planned_start_date: TPDateTime | None = Field(
287
+ default=None, alias="PlannedStartDate", description="Planned start"
288
+ )
289
+ planned_end_date: TPDateTime | None = Field(
290
+ default=None, alias="PlannedEndDate", description="Planned end"
291
+ )
292
+ forecast_end_date: TPDateTime | None = Field(
293
+ default=None, alias="ForecastEndDate", description="Forecast end"
294
+ )
295
+
296
+ # Relationships
297
+ entity_state: EntityRef | None = Field(
298
+ default=None, alias="EntityState", description="Workflow state"
299
+ )
300
+ priority: RefWithImportance | None = Field(
301
+ default=None, alias="Priority", description="Priority"
302
+ )
303
+ release: EntityRef | None = Field(default=None, alias="Release", description="Release")
304
+ iteration: EntityRef | None = Field(default=None, alias="Iteration", description="Iteration")
305
+ team_iteration: EntityRef | None = Field(
306
+ default=None, alias="TeamIteration", description="Team iteration"
307
+ )
308
+ team: EntityRef | None = Field(
309
+ default=None, alias="Team", description="Team (deprecated upstream)"
310
+ )
311
+ responsible_team: EntityRef | None = Field(
312
+ default=None, alias="ResponsibleTeam", description="Responsible team assignment"
313
+ )
314
+
315
+ # Collections
316
+ assigned_user: AssignedUsers | None = Field(
317
+ default=None, alias="AssignedUser", description="Assigned users"
318
+ )
@@ -0,0 +1,287 @@
1
+ """The types that carry content attached to another entity.
2
+
3
+ A ``Comment`` and an ``Attachment`` both hang off a ``General``; a
4
+ ``CustomField`` describes a field configured on a process rather than a value
5
+ held by an entity. None sits in TP's ``General`` hierarchy.
6
+
7
+ Re-exported by :mod:`targetprocess.models`, which stays the import surface
8
+ callers use.
9
+ """
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from targetprocess._base import Entity, NamedEntity
14
+ from targetprocess._dates import TPDateTime
15
+ from targetprocess._nested import CustomFieldConfig, EntityRef, EntityTypeRef, UserRef
16
+
17
+
18
+ class Comment(Entity):
19
+ """Comment on a TP entity.
20
+
21
+ TP comments carry the comment body in ``Description`` and have no Name
22
+ field. ``General`` is the entity the comment is attached to; ``Owner`` is
23
+ the commenter. Threaded replies carry a ``ParentId``.
24
+
25
+ Attributes:
26
+ description: Comment body text
27
+ parent_id: Parent comment id for threaded replies (None at top level)
28
+ description_modify_date: When the comment body was last edited
29
+ is_private: Whether the comment is private
30
+ is_pinned: Whether the comment is pinned
31
+ general: The entity the comment is attached to
32
+ owner: The commenter
33
+ entity_version: TP's per-record version counter (increments on edit)
34
+ """
35
+
36
+ description: str | None = Field(
37
+ default=None, alias="Description", description="Comment body text"
38
+ )
39
+ parent_id: int | None = Field(
40
+ default=None, alias="ParentId", description="Parent comment id (threaded replies)"
41
+ )
42
+ description_modify_date: TPDateTime | None = Field(
43
+ default=None, alias="DescriptionModifyDate", description="When the body was last edited"
44
+ )
45
+ is_private: bool | None = Field(
46
+ default=None, alias="IsPrivate", description="Whether the comment is private"
47
+ )
48
+ is_pinned: bool | None = Field(
49
+ default=None, alias="IsPinned", description="Whether the comment is pinned"
50
+ )
51
+ general: EntityRef | None = Field(
52
+ default=None, alias="General", description="Attached-to entity"
53
+ )
54
+ owner: UserRef | None = Field(default=None, alias="Owner", description="Commenter")
55
+ entity_version: int | None = Field(
56
+ default=None, alias="EntityVersion", description="Per-record version counter"
57
+ )
58
+
59
+
60
+ class Attachment(NamedEntity):
61
+ """File attachment on a TP entity.
62
+
63
+ ``Name`` is the display filename; ``UniqueFileName`` is TP's stored name.
64
+ ``General`` is the entity the file is attached to; ``Owner`` is the
65
+ uploader. ``Uri`` is the download path for the file's bytes (relative to
66
+ the instance root, e.g. ``/Attachment.aspx?AttachmentID=1234``) - fetch it
67
+ via ``client.attachments.download``.
68
+
69
+ Attributes:
70
+ unique_file_name: TP's stored unique filename
71
+ description: Attachment description
72
+ date: Upload date/time
73
+ owner: The uploader
74
+ general: The entity the file is attached to
75
+ message: Optional attachment message
76
+ uri: Download path for the file bytes, relative to the instance root
77
+ mime_type: MIME type of the stored file
78
+ size: File size in bytes
79
+ thumbnail_uri: Download path for the thumbnail (images only)
80
+ """
81
+
82
+ unique_file_name: str | None = Field(
83
+ default=None, alias="UniqueFileName", description="Stored unique filename"
84
+ )
85
+ description: str | None = Field(
86
+ default=None, alias="Description", description="Attachment description"
87
+ )
88
+ date: TPDateTime | None = Field(default=None, alias="Date", description="Upload date/time")
89
+ owner: UserRef | None = Field(default=None, alias="Owner", description="Uploader")
90
+ general: EntityRef | None = Field(
91
+ default=None, alias="General", description="Attached-to entity"
92
+ )
93
+ message: EntityRef | None = Field(
94
+ default=None, alias="Message", description="Attachment message"
95
+ )
96
+ uri: str | None = Field(
97
+ default=None, alias="Uri", description="Download path for the file bytes"
98
+ )
99
+ mime_type: str | None = Field(default=None, alias="MimeType", description="MIME type")
100
+ size: int | None = Field(default=None, alias="Size", ge=0, description="File size in bytes")
101
+ thumbnail_uri: str | None = Field(
102
+ default=None, alias="ThumbnailUri", description="Thumbnail download path"
103
+ )
104
+
105
+
106
+ class UploadedFileRef(BaseModel):
107
+ """A reference nested in an ``/UploadFile.ashx`` response.
108
+
109
+ The same three roles :class:`Attachment` names - the uploader, the entity
110
+ the file landed on, and that entity as an Assignable - but projected by
111
+ the file endpoint rather than the JSON entity API, so the keys are
112
+ camelCase and the user shape carries its name fields inline. One
113
+ permissive model covers all of them: a user reference brings the name
114
+ fields, an entity reference brings ``name``, and ``extra="allow"`` keeps
115
+ anything else the endpoint sends.
116
+
117
+ Not in ``_nested`` with the other reference shapes on purpose: those
118
+ project TP's JSON entity API, and this one does not (see
119
+ :class:`UploadedAttachment`).
120
+
121
+ Attributes:
122
+ resource_type: Type of the referenced record
123
+ id: Id of the referenced record
124
+ name: Display name, on an entity reference
125
+ first_name: Given name, on a user reference
126
+ last_name: Family name, on a user reference
127
+ full_name: Display name, on a user reference
128
+ login: Login, on a user reference
129
+ """
130
+
131
+ model_config = ConfigDict(
132
+ populate_by_name=True,
133
+ str_strip_whitespace=True,
134
+ validate_assignment=True,
135
+ extra="allow",
136
+ )
137
+
138
+ resource_type: str | None = Field(
139
+ default=None, alias="resourceType", description="Referenced record's type"
140
+ )
141
+ id: int | None = Field(default=None, alias="id", description="Referenced record's Id")
142
+ name: str | None = Field(default=None, alias="name", description="Display name")
143
+ first_name: str | None = Field(default=None, alias="firstName", description="Given name")
144
+ last_name: str | None = Field(default=None, alias="lastName", description="Family name")
145
+ full_name: str | None = Field(default=None, alias="fullName", description="Display name")
146
+ login: str | None = Field(default=None, alias="login", description="Login")
147
+
148
+
149
+ class UploadedAttachment(BaseModel):
150
+ """The attachment record ``/UploadFile.ashx`` answers an upload with.
151
+
152
+ TargetProcess documents neither this endpoint's response body nor its
153
+ casing. What it actually sends - recorded in the write-path integration
154
+ suite rather than taken on trust - is
155
+ ``{"items": [<this shape>]}``: the created Attachment, hydrated much as
156
+ ``/api/v1/Attachments`` would hydrate it, but in **camelCase** and with
157
+ two extra fields the entity API does not carry (``persistedMimeType``,
158
+ ``persistedSize``) alongside their entity-API equivalents. ``date`` is
159
+ ISO-8601 rather than TP's ``/Date(ms±HHMM)/`` wire format, which
160
+ :data:`TPDateTime` passes through to pydantic unchanged.
161
+
162
+ Deliberately outside ``scripts/check_model_coverage.py``'s ``COLLECTIONS``
163
+ map: this is one endpoint's response projection, not a queryable entity
164
+ type, so there is no ``/meta`` to diff it against. The recorded cassette
165
+ is what pins the shape.
166
+
167
+ Attributes:
168
+ resource_type: Always ``"Attachment"``
169
+ id: Id of the created attachment record
170
+ name: Display filename, as sent
171
+ unique_file_name: TP's stored unique filename
172
+ description: Attachment description (the filename, by default)
173
+ date: Upload timestamp
174
+ persisted_mime_type: MIME type of the stored file
175
+ persisted_size: Stored size in bytes
176
+ is_empty: Whether the stored file has no content
177
+ uri: Absolute download URL for the file bytes
178
+ thumbnail_uri: Absolute download URL for the thumbnail
179
+ mime_type: MIME type of the stored file
180
+ size: File size in bytes
181
+ owner: The uploader
182
+ general: The entity the file was attached to
183
+ message: The message the file was attached to, if any
184
+ assignable: The attached-to entity as an Assignable, if it is one
185
+ """
186
+
187
+ model_config = ConfigDict(
188
+ populate_by_name=True,
189
+ str_strip_whitespace=True,
190
+ validate_assignment=True,
191
+ extra="allow",
192
+ )
193
+
194
+ resource_type: str | None = Field(default=None, alias="resourceType", description="Record type")
195
+ id: int = Field(alias="id", description="Created attachment's Id")
196
+ name: str | None = Field(default=None, alias="name", description="Display filename")
197
+ unique_file_name: str | None = Field(
198
+ default=None, alias="uniqueFileName", description="Stored unique filename"
199
+ )
200
+ description: str | None = Field(
201
+ default=None, alias="description", description="Attachment description"
202
+ )
203
+ date: TPDateTime | None = Field(default=None, alias="date", description="Upload timestamp")
204
+ persisted_mime_type: str | None = Field(
205
+ default=None, alias="persistedMimeType", description="Stored MIME type"
206
+ )
207
+ persisted_size: int | None = Field(
208
+ default=None, alias="persistedSize", ge=0, description="Stored size in bytes"
209
+ )
210
+ is_empty: bool | None = Field(
211
+ default=None, alias="isEmpty", description="Whether the stored file is empty"
212
+ )
213
+ uri: str | None = Field(default=None, alias="uri", description="Absolute download URL")
214
+ thumbnail_uri: str | None = Field(
215
+ default=None, alias="thumbnailUri", description="Absolute thumbnail URL"
216
+ )
217
+ mime_type: str | None = Field(default=None, alias="mimeType", description="MIME type")
218
+ size: int | None = Field(default=None, alias="size", ge=0, description="File size in bytes")
219
+ owner: UploadedFileRef | None = Field(default=None, alias="owner", description="Uploader")
220
+ general: UploadedFileRef | None = Field(
221
+ default=None, alias="general", description="Attached-to entity"
222
+ )
223
+ message: UploadedFileRef | None = Field(
224
+ default=None, alias="message", description="Attached-to message"
225
+ )
226
+ assignable: UploadedFileRef | None = Field(
227
+ default=None, alias="assignable", description="Attached-to entity as an Assignable"
228
+ )
229
+
230
+
231
+ class CustomField(NamedEntity):
232
+ """Custom-field definition (the queryable ``/CustomFields`` entity).
233
+
234
+ Describes a custom field configured on a Process/EntityType - its type,
235
+ constraints, and metadata. Actual per-entity values are carried by
236
+ ``CustomFieldValue`` (embedded in each entity's ``CustomFields`` array),
237
+ not here.
238
+
239
+ Attributes:
240
+ value: Field value from the definition endpoint (typically a string or
241
+ None; typed ``object`` to carry any value the endpoint returns)
242
+ field_type: Field type (e.g. "DropDown", "Text", "Date", "Number")
243
+ enabled_for_filter: Whether the field is enabled for filtering
244
+ required: Whether the field is required
245
+ numeric_priority: Ordering priority
246
+ is_system: Whether this is a system field
247
+ description: Field description
248
+ placeholder: Field placeholder text
249
+ max_text_length: Maximum text length
250
+ entity_field_name: Underlying entity field name, where the custom field
251
+ shadows a built-in one
252
+ config: Type-specific configuration (default value, units, formatting)
253
+ entity_type: EntityType the field applies to
254
+ process: Process the field belongs to
255
+ """
256
+
257
+ value: object = Field(default=None, alias="Value", description="Field value (polymorphic)")
258
+ field_type: str | None = Field(default=None, alias="FieldType", description="Field type")
259
+ enabled_for_filter: bool | None = Field(
260
+ default=None, alias="EnabledForFilter", description="Enabled for filtering"
261
+ )
262
+ required: bool | None = Field(default=None, alias="Required", description="Whether required")
263
+ numeric_priority: float | None = Field(
264
+ default=None, alias="NumericPriority", description="Ordering priority"
265
+ )
266
+ is_system: bool | None = Field(
267
+ default=None, alias="IsSystem", description="Whether a system field"
268
+ )
269
+ description: str | None = Field(
270
+ default=None, alias="Description", description="Field description"
271
+ )
272
+ placeholder: str | None = Field(
273
+ default=None, alias="Placeholder", description="Placeholder text"
274
+ )
275
+ max_text_length: int | None = Field(
276
+ default=None, alias="MaxTextLength", description="Maximum text length"
277
+ )
278
+ entity_field_name: str | None = Field(
279
+ default=None, alias="EntityFieldName", description="Shadowed entity field name"
280
+ )
281
+ config: CustomFieldConfig | None = Field(
282
+ default=None, alias="Config", description="Type-specific configuration"
283
+ )
284
+ entity_type: EntityTypeRef | None = Field(
285
+ default=None, alias="EntityType", description="Applicable entity type"
286
+ )
287
+ process: EntityRef | None = Field(default=None, alias="Process", description="Owning process")
@@ -0,0 +1,70 @@
1
+ """TP ``/Date(ms±HHMM)/`` wire-format date handling.
2
+
3
+ The public names here (``TPDateTime``, ``parse_tp_date``, ``format_tp_date``)
4
+ are re-exported by :mod:`targetprocess.models`, which remains the import
5
+ surface callers use.
6
+ """
7
+
8
+ import re
9
+ from datetime import UTC, datetime, timedelta, timezone
10
+ from typing import Annotated
11
+
12
+ from pydantic import BeforeValidator
13
+
14
+ _TP_DATE_RE = re.compile(r"^/Date\((-?\d+)(?:([+-])(\d{2})(\d{2}))?\)/$")
15
+
16
+
17
+ def parse_tp_date(value: object) -> object:
18
+ """Convert TP's ``/Date(ms±HHMM)/`` wire format to datetime.
19
+
20
+ Non-matching values (datetime, ISO strings, None) pass through for
21
+ pydantic's own datetime handling.
22
+ """
23
+ if isinstance(value, str):
24
+ m = _TP_DATE_RE.match(value)
25
+ if m:
26
+ ms = int(m.group(1))
27
+ if m.group(2):
28
+ sign = 1 if m.group(2) == "+" else -1
29
+ tz = timezone(sign * timedelta(hours=int(m.group(3)), minutes=int(m.group(4))))
30
+ else:
31
+ tz = UTC
32
+ return datetime.fromtimestamp(ms / 1000, tz=tz)
33
+ return value
34
+
35
+
36
+ TPDateTime = Annotated[datetime, BeforeValidator(parse_tp_date)]
37
+
38
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
39
+
40
+
41
+ def format_tp_date(value: datetime) -> str:
42
+ """Encode a timezone-aware datetime as TP's ``/Date(ms±HHMM)/`` wire format.
43
+
44
+ The inverse of :func:`parse_tp_date`. The offset written is the one the
45
+ datetime carries, so the caller decides which offset TP records.
46
+
47
+ Args:
48
+ value: Timezone-aware datetime to encode.
49
+
50
+ Returns:
51
+ The TP wire representation, e.g. ``/Date(1718366400000+0200)/``.
52
+
53
+ Raises:
54
+ ValueError: ``value`` is naive, so no offset can be written.
55
+ ValueError: ``value``'s offset is not a whole number of minutes, so
56
+ it cannot be represented in TP's ``±HHMM`` wire format.
57
+ """
58
+ offset = value.utcoffset()
59
+ if offset is None:
60
+ raise ValueError("format_tp_date requires a timezone-aware datetime")
61
+ if offset.total_seconds() % 60 != 0:
62
+ raise ValueError(f"format_tp_date requires a whole-minute UTC offset, got {offset}")
63
+ # Floor-divide two timedeltas rather than scaling ``timestamp()``: that
64
+ # returns a float, and int() truncates toward zero, which is wrong for
65
+ # pre-epoch instants.
66
+ milliseconds = (value - _EPOCH) // timedelta(milliseconds=1)
67
+ total_minutes = int(offset.total_seconds() // 60)
68
+ sign = "+" if total_minutes >= 0 else "-"
69
+ hours, minutes = divmod(abs(total_minutes), 60)
70
+ return f"/Date({milliseconds}{sign}{hours:02d}{minutes:02d})/"