sqlmirror 0.2.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 (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/source.py ADDED
@@ -0,0 +1,117 @@
1
+ """Where change files come from: an S3-compatible bucket or a local folder.
2
+
3
+ Layout written by the Forge app under the configured prefix:
4
+
5
+ issues/YYYY/MM/DD/HH/<epoch ms>-<random>.ndjson.gz issue snapshots and tombstones
6
+ metadata/YYYY/MM/DD/<epoch ms>-<random>.json.gz configuration snapshot
7
+ _sqlmirror/status.json app status (license, version, last sync)
8
+
9
+ Keys sort in time order, so listing with StartAfter returns new files.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import gzip
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Any, Iterator
18
+ from urllib.parse import urlparse
19
+
20
+
21
+ class Source:
22
+ prefix: str = ""
23
+
24
+ def list(self, sub: str, start_after: str | None = None) -> Iterator[str]:
25
+ raise NotImplementedError
26
+
27
+ def read(self, key: str) -> bytes | None:
28
+ raise NotImplementedError
29
+
30
+ def describe(self) -> str:
31
+ raise NotImplementedError
32
+
33
+ def key(self, sub: str) -> str:
34
+ return f"{self.prefix}{sub}" if self.prefix else sub
35
+
36
+ def status(self) -> dict[str, Any] | None:
37
+ raw = self.read(self.key("_sqlmirror/status.json"))
38
+ if raw is None:
39
+ return None
40
+ try:
41
+ return json.loads(raw)
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ class LocalSource(Source):
47
+ """A folder with the same layout as the bucket (for tests and air-gapped transfer)."""
48
+
49
+ def __init__(self, root: str | os.PathLike[str]):
50
+ self.root = Path(root)
51
+ self.prefix = ""
52
+
53
+ def list(self, sub: str, start_after: str | None = None) -> Iterator[str]:
54
+ base = self.root / sub
55
+ if not base.exists():
56
+ return iter(())
57
+ keys = sorted(str(p.relative_to(self.root)).replace(os.sep, "/") for p in base.rglob("*") if p.is_file())
58
+ return iter([k for k in keys if start_after is None or k > start_after])
59
+
60
+ def read(self, key: str) -> bytes | None:
61
+ p = self.root / key
62
+ return p.read_bytes() if p.exists() else None
63
+
64
+ def describe(self) -> str:
65
+ return str(self.root)
66
+
67
+
68
+ class S3Source(Source):
69
+ """Any S3-compatible bucket: AWS S3, Cloudflare R2, Google Cloud Storage (HMAC), MinIO."""
70
+
71
+ def __init__(self, url: str, *, endpoint_url: str | None = None, region: str | None = None,
72
+ profile: str | None = None):
73
+ import boto3 # imported lazily so local tests don't need AWS libraries configured
74
+
75
+ parsed = urlparse(url)
76
+ if parsed.scheme != "s3" or not parsed.netloc:
77
+ raise ValueError("bucket URL must look like s3://bucket/prefix")
78
+ self.bucket = parsed.netloc
79
+ prefix = parsed.path.lstrip("/")
80
+ self.prefix = prefix if not prefix or prefix.endswith("/") else prefix + "/"
81
+ session = boto3.session.Session(profile_name=profile) if profile else boto3.session.Session()
82
+ self.client = session.client("s3", endpoint_url=endpoint_url, region_name=region)
83
+
84
+ def list(self, sub: str, start_after: str | None = None) -> Iterator[str]:
85
+ kwargs: dict[str, Any] = {"Bucket": self.bucket, "Prefix": self.key(sub)}
86
+ if start_after:
87
+ kwargs["StartAfter"] = start_after
88
+ paginator = self.client.get_paginator("list_objects_v2")
89
+ for page in paginator.paginate(**kwargs):
90
+ for obj in page.get("Contents", []) or []:
91
+ yield obj["Key"]
92
+
93
+ def read(self, key: str) -> bytes | None:
94
+ try:
95
+ return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
96
+ except self.client.exceptions.NoSuchKey:
97
+ return None
98
+
99
+ def describe(self) -> str:
100
+ return f"s3://{self.bucket}/{self.prefix}"
101
+
102
+
103
+ def open_source(location: str, **kwargs: Any) -> Source:
104
+ if location.startswith("s3://"):
105
+ return S3Source(location, **kwargs)
106
+ return LocalSource(location)
107
+
108
+
109
+ def parse_records(key: str, raw: bytes) -> list[dict[str, Any]]:
110
+ """Decode a change file into records (one JSON object per line, optionally gzipped)."""
111
+ if key.endswith(".gz"):
112
+ raw = gzip.decompress(raw)
113
+ text = raw.decode("utf-8")
114
+ if key.endswith(".json") or key.endswith(".json.gz"):
115
+ obj = json.loads(text)
116
+ return obj if isinstance(obj, list) else [obj]
117
+ return [json.loads(line) for line in text.splitlines() if line.strip()]