stint 0.1.0a0__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 (63) hide show
  1. stint/__init__.py +110 -0
  2. stint/autogen/__init__.py +25 -0
  3. stint/autogen/desired.py +351 -0
  4. stint/autogen/diff.py +1001 -0
  5. stint/autogen/emit.py +578 -0
  6. stint/autogen/loader.py +48 -0
  7. stint/autogen/stamp.py +272 -0
  8. stint/cli/__init__.py +5 -0
  9. stint/cli/app.py +25 -0
  10. stint/cli/cmd_reflect.py +123 -0
  11. stint/cli/cmd_revision.py +235 -0
  12. stint/cli/cmd_stamp.py +89 -0
  13. stint/cli/cmd_upgrade.py +165 -0
  14. stint/cli/cmd_validate.py +48 -0
  15. stint/cli/env_config.py +137 -0
  16. stint/cli/main.py +28 -0
  17. stint/client/__init__.py +6 -0
  18. stint/client/auth.py +67 -0
  19. stint/client/http.py +131 -0
  20. stint/dialects/__init__.py +8 -0
  21. stint/dialects/base.py +33 -0
  22. stint/dialects/jira/__init__.py +5 -0
  23. stint/dialects/jira/_base.py +883 -0
  24. stint/dialects/jira/cloud.py +158 -0
  25. stint/dialects/jira/common.py +245 -0
  26. stint/engine.py +89 -0
  27. stint/exceptions.py +90 -0
  28. stint/fields.py +83 -0
  29. stint/migrations/__init__.py +40 -0
  30. stint/migrations/base.py +148 -0
  31. stint/migrations/context.py +52 -0
  32. stint/migrations/exceptions.py +24 -0
  33. stint/migrations/loader.py +62 -0
  34. stint/migrations/op.py +889 -0
  35. stint/migrations/runner.py +167 -0
  36. stint/py.typed +0 -0
  37. stint/query/__init__.py +27 -0
  38. stint/query/adf.py +28 -0
  39. stint/query/columns.py +47 -0
  40. stint/query/expr.py +198 -0
  41. stint/query/hydrate.py +146 -0
  42. stint/query/payload.py +206 -0
  43. stint/query/select.py +64 -0
  44. stint/query/session.py +282 -0
  45. stint/query/sync.py +103 -0
  46. stint/registry.py +81 -0
  47. stint/schema/__init__.py +18 -0
  48. stint/schema/_meta.py +49 -0
  49. stint/schema/field_config.py +29 -0
  50. stint/schema/issuetype.py +177 -0
  51. stint/schema/project.py +91 -0
  52. stint/schema/screen.py +53 -0
  53. stint/state/__init__.py +6 -0
  54. stint/state/file.py +187 -0
  55. stint/state/lock.py +82 -0
  56. stint/state/snapshot.py +160 -0
  57. stint/validate.py +73 -0
  58. stint-0.1.0a0.dist-info/METADATA +268 -0
  59. stint-0.1.0a0.dist-info/RECORD +63 -0
  60. stint-0.1.0a0.dist-info/WHEEL +5 -0
  61. stint-0.1.0a0.dist-info/entry_points.txt +2 -0
  62. stint-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  63. stint-0.1.0a0.dist-info/top_level.txt +1 -0
stint/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """stint - declarative schema-as-code and ORM for work-management systems.
2
+
3
+ Define projects, issuetypes, and custom fields as Pydantic models. Apply that
4
+ definition to a target Jira instance, mirror it across environments, then
5
+ insert and query issues through the same models. Jira is the first dialect.
6
+ The dialect protocol is the extension point for additional backends.
7
+
8
+ M0: schema-plane declarative classes (IssueType, Project, CustomField, Screen,
9
+ ScreenScheme, FieldConfiguration). Validation only, no I/O.
10
+
11
+ M1 first slice: dialect protocol, engine factory, HTTP client with PAT auth,
12
+ Jira DC dialect with /serverInfo detection and /field reflection, YAML state
13
+ file load/save.
14
+ """
15
+
16
+ from stint import migrations
17
+ from stint.client.auth import APITokenAuth, BasicAuth, PATAuth
18
+ from stint.engine import Engine, create_engine
19
+ from stint.exceptions import (
20
+ AuthenticationError,
21
+ ConfigurationError,
22
+ NotFoundError,
23
+ PartialCommitError,
24
+ PermissionError,
25
+ ReflectionError,
26
+ RegistryError,
27
+ StateFileCorruptError,
28
+ StateFileError,
29
+ StintError,
30
+ TransportError,
31
+ UnsupportedTMPOpError,
32
+ )
33
+ from stint.fields import (
34
+ CustomField,
35
+ DateField,
36
+ DateTimeField,
37
+ MultiSelectField,
38
+ NumberField,
39
+ SelectField,
40
+ TextAreaField,
41
+ TextField,
42
+ UserField,
43
+ )
44
+ from stint.migrations import Migration, MigrationContext, get_context, load_migrations, op
45
+ from stint.query import AsyncSession, CommitResult, Session, and_, not_, or_, select
46
+ from stint.registry import registry
47
+ from stint.schema import FieldConfiguration, IssueType, Project, Screen, ScreenScheme
48
+ from stint.state import (
49
+ CustomFieldSnapshot,
50
+ ServerInfoSnapshot,
51
+ Snapshot,
52
+ StateFile,
53
+ )
54
+ from stint.validate import validate, validate_or_raise
55
+
56
+ __version__ = "0.1.0a0"
57
+
58
+ __all__ = [
59
+ "APITokenAuth",
60
+ "AsyncSession",
61
+ "AuthenticationError",
62
+ "BasicAuth",
63
+ "CommitResult",
64
+ "ConfigurationError",
65
+ "CustomField",
66
+ "CustomFieldSnapshot",
67
+ "DateField",
68
+ "DateTimeField",
69
+ "Engine",
70
+ "FieldConfiguration",
71
+ "IssueType",
72
+ "Migration",
73
+ "MigrationContext",
74
+ "MultiSelectField",
75
+ "NotFoundError",
76
+ "NumberField",
77
+ "PATAuth",
78
+ "PartialCommitError",
79
+ "StintError",
80
+ "PermissionError",
81
+ "Project",
82
+ "ReflectionError",
83
+ "RegistryError",
84
+ "Screen",
85
+ "ScreenScheme",
86
+ "SelectField",
87
+ "Session",
88
+ "ServerInfoSnapshot",
89
+ "Snapshot",
90
+ "StateFile",
91
+ "StateFileCorruptError",
92
+ "StateFileError",
93
+ "TextAreaField",
94
+ "TextField",
95
+ "TransportError",
96
+ "UnsupportedTMPOpError",
97
+ "UserField",
98
+ "and_",
99
+ "create_engine",
100
+ "get_context",
101
+ "load_migrations",
102
+ "migrations",
103
+ "not_",
104
+ "op",
105
+ "or_",
106
+ "registry",
107
+ "select",
108
+ "validate",
109
+ "validate_or_raise",
110
+ ]
@@ -0,0 +1,25 @@
1
+ """Migration autogeneration.
2
+
3
+ Public API:
4
+ - autogenerate(schema_module, state, snapshot, *, message, allow_delete=False)
5
+ -> str (Python source of the new migration file)
6
+ - render_empty(message, *, parents) -> str (skeleton with no ops)
7
+ - render_merge(message, *, parents) -> str (merge-only skeleton)
8
+ - new_revision_id() -> str
9
+ - new_filename(message, when=None) -> str
10
+ - stamp(schema_module, state, snapshot) -> StampReport
11
+ """
12
+
13
+ from stint.autogen.emit import (
14
+ new_filename,
15
+ new_revision_id,
16
+ render_empty,
17
+ render_merge,
18
+ )
19
+
20
+ __all__ = [
21
+ "new_filename",
22
+ "new_revision_id",
23
+ "render_empty",
24
+ "render_merge",
25
+ ]
@@ -0,0 +1,351 @@
1
+ """Build a DesiredSnapshot from the schema registry.
2
+
3
+ The desired snapshot has the same shape as the reflected Snapshot but is built
4
+ from Pydantic class declarations rather than HTTP reflection. The diff
5
+ algorithm compares the two.
6
+
7
+ ITSS and FCS are derived per-project: each Project gets one synthesized ITSS
8
+ (alias `{project_key}_itss`) and one synthesized FCS (alias `{project_key}_fcs`),
9
+ with `default` mapped to the first issuetype's screen scheme / field config.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from stint.fields import CustomField
18
+ from stint.registry import registry as default_registry
19
+
20
+ if TYPE_CHECKING:
21
+ from stint.registry import Registry
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class DesiredCustomField:
26
+ alias: str
27
+ name: str
28
+ type_id: str
29
+ description: str
30
+ options: tuple[str, ...] # ordered
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DesiredIssueType:
35
+ alias: str
36
+ name: str
37
+ description: str
38
+ subtask: bool = False
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DesiredScreenTab:
43
+ """One tab per screen for now: 'Fields'. Field order preserved."""
44
+
45
+ name: str
46
+ field_refs: tuple[str, ...] # system field names OR custom-field aliases
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DesiredScreen:
51
+ alias: str
52
+ name: str
53
+ description: str
54
+ tabs: tuple[DesiredScreenTab, ...]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DesiredScreenScheme:
59
+ alias: str
60
+ name: str
61
+ description: str
62
+ screens: dict[str, str] # op ("default","create","edit","view") -> screen alias
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class DesiredFieldConfigurationItem:
67
+ field_alias: str
68
+ required: bool = False
69
+ hidden: bool = False
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class DesiredFieldConfiguration:
74
+ alias: str
75
+ name: str
76
+ description: str
77
+ items: tuple[DesiredFieldConfigurationItem, ...]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DesiredIssueTypeScreenScheme:
82
+ alias: str
83
+ name: str
84
+ description: str
85
+ # issuetype alias (or "default") -> screen-scheme alias
86
+ mappings: dict[str, str]
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class DesiredIssueTypeScheme:
91
+ """An IssueTypeScheme: the set of issuetypes available in a project. Stint
92
+ synthesizes one per Project from its ``__issuetypes__`` list."""
93
+
94
+ alias: str
95
+ name: str
96
+ description: str
97
+ issuetypes: tuple[str, ...] # ordered issuetype aliases
98
+ default_issuetype: str # one of the entries in ``issuetypes``
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class DesiredFieldConfigurationScheme:
103
+ alias: str
104
+ name: str
105
+ description: str
106
+ # issuetype alias (or "default") -> field-configuration alias
107
+ mappings: dict[str, str]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class DesiredProject:
112
+ alias: str # uses project key
113
+ key: str
114
+ name: str
115
+ project_type_key: str
116
+ lead: str
117
+ description: str
118
+ style: str # "company-managed" or "team-managed"
119
+ issuetype_scheme: str | None # alias
120
+ issuetype_screen_scheme: str | None # alias
121
+ field_configuration_scheme: str | None # alias
122
+
123
+
124
+ @dataclass
125
+ class DesiredSnapshot:
126
+ custom_fields: dict[str, DesiredCustomField] = field(default_factory=dict)
127
+ issuetypes: dict[str, DesiredIssueType] = field(default_factory=dict)
128
+ screens: dict[str, DesiredScreen] = field(default_factory=dict)
129
+ screen_schemes: dict[str, DesiredScreenScheme] = field(default_factory=dict)
130
+ issuetype_schemes: dict[str, DesiredIssueTypeScheme] = field(default_factory=dict)
131
+ issuetype_screen_schemes: dict[str, DesiredIssueTypeScreenScheme] = field(
132
+ default_factory=dict,
133
+ )
134
+ field_configurations: dict[str, DesiredFieldConfiguration] = field(
135
+ default_factory=dict,
136
+ )
137
+ field_configuration_schemes: dict[str, DesiredFieldConfigurationScheme] = field(
138
+ default_factory=dict,
139
+ )
140
+ projects: dict[str, DesiredProject] = field(default_factory=dict)
141
+
142
+
143
+ SYSTEM_FIELD_NAMES = {
144
+ "Summary",
145
+ "Description",
146
+ "Reporter",
147
+ "Assignee",
148
+ "Priority",
149
+ "Created",
150
+ "Updated",
151
+ "Status",
152
+ "Resolution",
153
+ "Labels",
154
+ "Components",
155
+ "Fix Version/s",
156
+ "Affects Version/s",
157
+ "Due Date",
158
+ "Environment",
159
+ "Issue Type",
160
+ }
161
+
162
+
163
+ def build_desired_snapshot(registry: Registry | None = None) -> DesiredSnapshot:
164
+ """Walk the registry and assemble a complete desired snapshot.
165
+
166
+ The default registry is the process-wide one populated by class definition.
167
+ Pass an explicit registry for isolation in tests.
168
+ """
169
+ reg = registry or default_registry
170
+ desired = DesiredSnapshot()
171
+
172
+ # ── Custom fields ────────────────────────────────────────────────
173
+ for alias, cf in reg.custom_fields.items():
174
+ desired.custom_fields[alias] = DesiredCustomField(
175
+ alias=alias,
176
+ name=cf.name,
177
+ type_id=cf.type.jira_type_id,
178
+ description=cf.description,
179
+ options=tuple(cf.options),
180
+ )
181
+
182
+ # ── Issue types ──────────────────────────────────────────────────
183
+ for alias, it_cls in reg.issuetypes.items():
184
+ desired.issuetypes[alias] = DesiredIssueType(
185
+ alias=alias,
186
+ name=getattr(it_cls, "__title__", alias),
187
+ description=getattr(it_cls, "__description__", "") or "",
188
+ subtask=getattr(it_cls, "__subtask__", False),
189
+ )
190
+
191
+ # ── Screens (one tab per screen: "Fields") ──────────────────────
192
+ for alias, scr in reg.screens.items():
193
+ field_refs: list[str] = []
194
+ for ref in scr.fields:
195
+ if isinstance(ref, CustomField):
196
+ field_refs.append(ref.alias) # marker: resolve via state at apply time
197
+ else:
198
+ field_refs.append(str(ref)) # system field
199
+ desired.screens[alias] = DesiredScreen(
200
+ alias=alias,
201
+ name=scr.name,
202
+ description=scr.description,
203
+ tabs=(DesiredScreenTab(name="Fields", field_refs=tuple(field_refs)),),
204
+ )
205
+
206
+ # ── Screen schemes ───────────────────────────────────────────────
207
+ for alias, ss in reg.screen_schemes.items():
208
+ # Map the user's create/edit/view to Jira's default/create/edit/view.
209
+ # Default falls back to view (matches Jira's "no override" behavior).
210
+ screens = {
211
+ "default": ss.view.alias,
212
+ "create": ss.create.alias,
213
+ "edit": ss.edit.alias,
214
+ "view": ss.view.alias,
215
+ }
216
+ desired.screen_schemes[alias] = DesiredScreenScheme(
217
+ alias=alias,
218
+ name=ss.name,
219
+ description=ss.description,
220
+ screens=screens,
221
+ )
222
+
223
+ # ── Field configurations ─────────────────────────────────────────
224
+ for alias, fc in reg.field_configurations.items():
225
+ items: list[DesiredFieldConfigurationItem] = []
226
+ for ref in fc.required:
227
+ field_alias = ref.alias if isinstance(ref, CustomField) else str(ref)
228
+ items.append(
229
+ DesiredFieldConfigurationItem(
230
+ field_alias=field_alias,
231
+ required=True,
232
+ hidden=False,
233
+ )
234
+ )
235
+ for ref in fc.hidden:
236
+ field_alias = ref.alias if isinstance(ref, CustomField) else str(ref)
237
+ items.append(
238
+ DesiredFieldConfigurationItem(
239
+ field_alias=field_alias,
240
+ required=False,
241
+ hidden=True,
242
+ )
243
+ )
244
+ desired.field_configurations[alias] = DesiredFieldConfiguration(
245
+ alias=alias,
246
+ name=fc.name,
247
+ description=fc.description,
248
+ items=tuple(items),
249
+ )
250
+
251
+ # ── Projects + derived IssueTypeScheme / ITSS / FCS ──────────────
252
+ for project_key, proj_cls in reg.projects.items():
253
+ issuetypes = list(getattr(proj_cls, "__issuetypes__", []))
254
+ its_alias = _derive_its(desired, project_key, issuetypes)
255
+ itss_alias = _derive_itss(desired, project_key, issuetypes)
256
+ fcs_alias = _derive_fcs(desired, project_key, issuetypes)
257
+ desired.projects[project_key] = DesiredProject(
258
+ alias=project_key,
259
+ key=project_key,
260
+ name=getattr(proj_cls, "__title__", project_key),
261
+ project_type_key=getattr(proj_cls, "__project_type__", "software"),
262
+ lead=getattr(proj_cls, "__lead__", "") or "",
263
+ description=getattr(proj_cls, "__description__", "") or "",
264
+ style=getattr(proj_cls, "__style__", "company-managed"),
265
+ issuetype_scheme=its_alias,
266
+ issuetype_screen_scheme=itss_alias,
267
+ field_configuration_scheme=fcs_alias,
268
+ )
269
+ return desired
270
+
271
+
272
+ def _derive_its(
273
+ desired: DesiredSnapshot,
274
+ project_key: str,
275
+ issuetypes: list,
276
+ ) -> str | None:
277
+ """Synthesize an IssueTypeScheme for the project from its ``__issuetypes__``
278
+ list. None if the project has no issuetypes (the project will fall through
279
+ to Jira's default scheme). Default issuetype is the first non-subtask
280
+ entry (Atlassian requires at least one standard issuetype in a scheme)."""
281
+ if not issuetypes:
282
+ return None
283
+ aliases = [it_cls.__alias__ for it_cls in issuetypes]
284
+ standard = [it_cls.__alias__ for it_cls in issuetypes if not getattr(it_cls, "__subtask__", False)]
285
+ if not standard:
286
+ # Jira rejects schemes with only subtasks; skip derivation and let
287
+ # the project use Jira's default scheme.
288
+ return None
289
+ alias = f"{project_key}_its"
290
+ desired.issuetype_schemes[alias] = DesiredIssueTypeScheme(
291
+ alias=alias,
292
+ name=f"{project_key} Issue Type Scheme",
293
+ description=f"Auto-derived for project {project_key}",
294
+ issuetypes=tuple(aliases),
295
+ default_issuetype=standard[0],
296
+ )
297
+ return alias
298
+
299
+
300
+ def _derive_itss(
301
+ desired: DesiredSnapshot,
302
+ project_key: str,
303
+ issuetypes: list,
304
+ ) -> str | None:
305
+ """Synthesize an ITSS for the project. None if no issuetype carries a
306
+ screen scheme. Mappings: each issuetype's __screen_scheme__ is its mapping;
307
+ 'default' = the first issuetype that carries one."""
308
+ typed: list[tuple[str, str]] = [] # (issuetype_alias, ss_alias)
309
+ for it_cls in issuetypes:
310
+ ss = getattr(it_cls, "__screen_scheme__", None)
311
+ if ss is not None:
312
+ typed.append((it_cls.__alias__, ss.alias))
313
+ if not typed:
314
+ return None
315
+ alias = f"{project_key}_itss"
316
+ mappings: dict[str, str] = {"default": typed[0][1]}
317
+ for it_alias, ss_alias in typed:
318
+ mappings[it_alias] = ss_alias
319
+ desired.issuetype_screen_schemes[alias] = DesiredIssueTypeScreenScheme(
320
+ alias=alias,
321
+ name=f"{project_key} Issue Type Screen Scheme",
322
+ description=f"Auto-derived for project {project_key}",
323
+ mappings=mappings,
324
+ )
325
+ return alias
326
+
327
+
328
+ def _derive_fcs(
329
+ desired: DesiredSnapshot,
330
+ project_key: str,
331
+ issuetypes: list,
332
+ ) -> str | None:
333
+ """Same shape as _derive_itss but for FieldConfiguration."""
334
+ typed: list[tuple[str, str]] = []
335
+ for it_cls in issuetypes:
336
+ fc = getattr(it_cls, "__field_configuration__", None)
337
+ if fc is not None:
338
+ typed.append((it_cls.__alias__, fc.alias))
339
+ if not typed:
340
+ return None
341
+ alias = f"{project_key}_fcs"
342
+ mappings: dict[str, str] = {"default": typed[0][1]}
343
+ for it_alias, fc_alias in typed:
344
+ mappings[it_alias] = fc_alias
345
+ desired.field_configuration_schemes[alias] = DesiredFieldConfigurationScheme(
346
+ alias=alias,
347
+ name=f"{project_key} Field Configuration Scheme",
348
+ description=f"Auto-derived for project {project_key}",
349
+ mappings=mappings,
350
+ )
351
+ return alias