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.
- sqlmirror/__init__.py +1 -0
- sqlmirror/apply.py +136 -0
- sqlmirror/backfill.py +65 -0
- sqlmirror/cli.py +335 -0
- sqlmirror/cron.py +103 -0
- sqlmirror/db.py +469 -0
- sqlmirror/db_mssql.py +530 -0
- sqlmirror/dev/__init__.py +1 -0
- sqlmirror/dev/cli.py +166 -0
- sqlmirror/dev/compare.py +87 -0
- sqlmirror/dev/e2e.py +261 -0
- sqlmirror/dev/golden.py +182 -0
- sqlmirror/dev/mock_jira.py +347 -0
- sqlmirror/dev/site.py +383 -0
- sqlmirror/dev/synth.py +769 -0
- sqlmirror/golden/01_issues_by_status.sql +8 -0
- sqlmirror/golden/02_issue_keys.sql +7 -0
- sqlmirror/golden/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/04_open_by_priority.sql +10 -0
- sqlmirror/golden/05_time_in_status.sql +16 -0
- sqlmirror/golden/06_cycle_time.sql +16 -0
- sqlmirror/golden/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/08_fix_versions.sql +11 -0
- sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/10_components.sql +10 -0
- sqlmirror/golden/11_top_labels.sql +9 -0
- sqlmirror/golden/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/14_sprint_completion.sql +14 -0
- sqlmirror/golden/15_epics.sql +12 -0
- sqlmirror/golden/16_subtasks.sql +10 -0
- sqlmirror/golden/17_blocked_issues.sql +11 -0
- sqlmirror/golden/18_most_discussed.sql +10 -0
- sqlmirror/golden/19_assignee_workload.sql +11 -0
- sqlmirror/golden/20_resolutions.sql +7 -0
- sqlmirror/golden/21_issue_history.sql +10 -0
- sqlmirror/golden/22_reopened.sql +11 -0
- sqlmirror/golden/23_overdue.sql +8 -0
- sqlmirror/golden/24_cascading_select.sql +13 -0
- sqlmirror/golden/25_user_picker.sql +12 -0
- sqlmirror/golden/26_attachments.sql +9 -0
- sqlmirror/golden/27_project_categories.sql +8 -0
- sqlmirror/golden/28_issue_types.sql +7 -0
- sqlmirror/golden/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/30_project_leads.sql +7 -0
- sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
- sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
- sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
- sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
- sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
- sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
- sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/tsql/10_components.sql +10 -0
- sqlmirror/golden/tsql/11_top_labels.sql +8 -0
- sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
- sqlmirror/golden/tsql/15_epics.sql +11 -0
- sqlmirror/golden/tsql/16_subtasks.sql +9 -0
- sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
- sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
- sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
- sqlmirror/golden/tsql/20_resolutions.sql +7 -0
- sqlmirror/golden/tsql/21_issue_history.sql +10 -0
- sqlmirror/golden/tsql/22_reopened.sql +11 -0
- sqlmirror/golden/tsql/23_overdue.sql +8 -0
- sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
- sqlmirror/golden/tsql/25_user_picker.sql +12 -0
- sqlmirror/golden/tsql/26_attachments.sql +9 -0
- sqlmirror/golden/tsql/27_project_categories.sql +8 -0
- sqlmirror/golden/tsql/28_issue_types.sql +7 -0
- sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/tsql/30_project_leads.sql +7 -0
- sqlmirror/idmap.py +161 -0
- sqlmirror/jira.py +223 -0
- sqlmirror/mapper.py +774 -0
- sqlmirror/reconcile.py +73 -0
- sqlmirror/samples/dc_export_example.csv +19 -0
- sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
- sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
- sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
- sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
- sqlmirror/schema.py +518 -0
- sqlmirror/source.py +117 -0
- sqlmirror/sqlcheck.py +647 -0
- sqlmirror/util.py +80 -0
- sqlmirror-0.2.0.dist-info/METADATA +19 -0
- sqlmirror-0.2.0.dist-info/RECORD +95 -0
- sqlmirror-0.2.0.dist-info/WHEEL +5 -0
- sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
- 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()]
|