dgc-core 1.0.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.
- dgc_core/__init__.py +82 -0
- dgc_core/auth.py +35 -0
- dgc_core/constants/__init__.py +0 -0
- dgc_core/constants/card.py +14 -0
- dgc_core/enums/__init__.py +0 -0
- dgc_core/enums/card.py +93 -0
- dgc_core/enums/submission.py +37 -0
- dgc_core/enums/user.py +8 -0
- dgc_core/env.py +35 -0
- dgc_core/fetch.py +124 -0
- dgc_core/logger.py +33 -0
- dgc_core/merge.py +235 -0
- dgc_core/mongo_cache.py +113 -0
- dgc_core/normalize.py +27 -0
- dgc_core/pricecharting.py +65 -0
- dgc_core/pub.py +41 -0
- dgc_core/regex.py +17 -0
- dgc_core/task.py +123 -0
- dgc_core-1.0.0.dist-info/METADATA +118 -0
- dgc_core-1.0.0.dist-info/RECORD +22 -0
- dgc_core-1.0.0.dist-info/WHEEL +5 -0
- dgc_core-1.0.0.dist-info/top_level.txt +1 -0
dgc_core/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Shared utilities, enums, and constants for DGC services."""
|
|
2
|
+
|
|
3
|
+
from .auth import verify_request
|
|
4
|
+
from .env import require_env
|
|
5
|
+
from .fetch import fetch_url, fetch_image
|
|
6
|
+
from .logger import log, LogLevel
|
|
7
|
+
from .merge import (
|
|
8
|
+
deep_merge,
|
|
9
|
+
detect_field_changes,
|
|
10
|
+
safe_any_changed,
|
|
11
|
+
prune_conflicting_set_unset,
|
|
12
|
+
)
|
|
13
|
+
from .mongo_cache import MongoCache
|
|
14
|
+
from .normalize import normalize_enum, normalize_object_id
|
|
15
|
+
from .pricecharting import split_name_and_number
|
|
16
|
+
from .pub import publish_message
|
|
17
|
+
from .regex import escape_for_regex
|
|
18
|
+
from .task import create_task
|
|
19
|
+
from .constants.card import (
|
|
20
|
+
ON_DEMAND_ELIGIBLE_GRADING_TYPES,
|
|
21
|
+
POST_GRADING_ELIGIBLE_GRADING_TYPES,
|
|
22
|
+
)
|
|
23
|
+
from .enums.card import (
|
|
24
|
+
ApprovalStatus,
|
|
25
|
+
RejectType,
|
|
26
|
+
RescanType,
|
|
27
|
+
GradingType,
|
|
28
|
+
InstantGradeType,
|
|
29
|
+
GradingField,
|
|
30
|
+
CardType,
|
|
31
|
+
TradingCardType,
|
|
32
|
+
SportsCardType,
|
|
33
|
+
CardCategory,
|
|
34
|
+
CardVersions,
|
|
35
|
+
)
|
|
36
|
+
from .enums.user import CollectorType
|
|
37
|
+
from .enums.submission import (
|
|
38
|
+
SubmissionStatus,
|
|
39
|
+
SubmissionSessionStatus,
|
|
40
|
+
SubmissionGradingLevel,
|
|
41
|
+
ReturnedVerificationStatus,
|
|
42
|
+
ReturnCreditSessionStatus,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"verify_request",
|
|
47
|
+
"require_env",
|
|
48
|
+
"fetch_url",
|
|
49
|
+
"fetch_image",
|
|
50
|
+
"log",
|
|
51
|
+
"LogLevel",
|
|
52
|
+
"deep_merge",
|
|
53
|
+
"detect_field_changes",
|
|
54
|
+
"safe_any_changed",
|
|
55
|
+
"prune_conflicting_set_unset",
|
|
56
|
+
"MongoCache",
|
|
57
|
+
"normalize_enum",
|
|
58
|
+
"normalize_object_id",
|
|
59
|
+
"split_name_and_number",
|
|
60
|
+
"publish_message",
|
|
61
|
+
"escape_for_regex",
|
|
62
|
+
"create_task",
|
|
63
|
+
"ON_DEMAND_ELIGIBLE_GRADING_TYPES",
|
|
64
|
+
"POST_GRADING_ELIGIBLE_GRADING_TYPES",
|
|
65
|
+
"ApprovalStatus",
|
|
66
|
+
"RejectType",
|
|
67
|
+
"RescanType",
|
|
68
|
+
"GradingType",
|
|
69
|
+
"InstantGradeType",
|
|
70
|
+
"GradingField",
|
|
71
|
+
"CardType",
|
|
72
|
+
"TradingCardType",
|
|
73
|
+
"SportsCardType",
|
|
74
|
+
"CollectorType",
|
|
75
|
+
"CardCategory",
|
|
76
|
+
"CardVersions",
|
|
77
|
+
"SubmissionStatus",
|
|
78
|
+
"SubmissionSessionStatus",
|
|
79
|
+
"SubmissionGradingLevel",
|
|
80
|
+
"ReturnedVerificationStatus",
|
|
81
|
+
"ReturnCreditSessionStatus",
|
|
82
|
+
]
|
dgc_core/auth.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import google.auth.transport.requests
|
|
2
|
+
import google.oauth2.id_token
|
|
3
|
+
|
|
4
|
+
def verify_request(request, allowed_service_account: str, allowed_audience: str) -> bool:
|
|
5
|
+
"""
|
|
6
|
+
Verifies that an incoming request is from a trusted source (e.g., Cloud Tasks)
|
|
7
|
+
using ID tokens.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
request: A Flask/Django-like request object with headers.
|
|
11
|
+
allowed_service_account: The expected service account email.
|
|
12
|
+
allowed_audience: The expected audience.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
bool: True if the request is verified, False otherwise.
|
|
16
|
+
"""
|
|
17
|
+
auth_header = request.headers.get("Authorization")
|
|
18
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
token = auth_header.split(" ")[1]
|
|
22
|
+
request_adapter = google.auth.transport.requests.Request()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
decoded_token = google.oauth2.id_token.verify_oauth2_token(
|
|
26
|
+
token,
|
|
27
|
+
request_adapter,
|
|
28
|
+
audience=allowed_audience
|
|
29
|
+
)
|
|
30
|
+
if decoded_token.get("email") != allowed_service_account:
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Auth error: {e}")
|
|
35
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dgc_core.enums.card import GradingType
|
|
2
|
+
|
|
3
|
+
ON_DEMAND_ELIGIBLE_GRADING_TYPES = {
|
|
4
|
+
GradingType.DEEP_2024,
|
|
5
|
+
GradingType.DEEP,
|
|
6
|
+
GradingType.INSTANT,
|
|
7
|
+
GradingType.DEEP_INSTANT
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
POST_GRADING_ELIGIBLE_GRADING_TYPES = {
|
|
11
|
+
GradingType.DEEP,
|
|
12
|
+
GradingType.INSTANT,
|
|
13
|
+
GradingType.DEEP_INSTANT
|
|
14
|
+
}
|
|
File without changes
|
dgc_core/enums/card.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from enum import IntEnum, Enum
|
|
2
|
+
from typing import TypeAlias
|
|
3
|
+
|
|
4
|
+
class ApprovalStatus(IntEnum):
|
|
5
|
+
RESCAN = -2
|
|
6
|
+
PENDING = -1
|
|
7
|
+
FAILED = 0
|
|
8
|
+
APPROVED = 1
|
|
9
|
+
PENDING_INSTANT = 3
|
|
10
|
+
APPROVING = 999
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RejectType(Enum):
|
|
14
|
+
INVALID_CARD = "Invalid card"
|
|
15
|
+
FAKE_HIGHRISK = "Fake - High Risk"
|
|
16
|
+
REPEAT = "Repeat"
|
|
17
|
+
MODEL_FAILURE = "Model failure"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RescanType(Enum):
|
|
21
|
+
ENVIRONMENT_LIGHTING = "Environment - Lighting",
|
|
22
|
+
ENVIRONMENT_ARTEFACTS = "Environment - Artefacts",
|
|
23
|
+
CARD_IN_SLAB_SLEEVE = "Card in Slab/Sleeve",
|
|
24
|
+
CARD_OBSTRUCTION = "Card - Obstruction",
|
|
25
|
+
CARD_ARTIFACTS = "CARD-ARTIFACTS",
|
|
26
|
+
SCAN_PROCESS_FAILURE = "Scan Process Failure"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GradingType(IntEnum):
|
|
30
|
+
DEEP_2024 = 0
|
|
31
|
+
DEEP = 1
|
|
32
|
+
ADDED = 2
|
|
33
|
+
SOLD = 3
|
|
34
|
+
INSTANT = 4
|
|
35
|
+
DEEP_INSTANT = 5
|
|
36
|
+
CARD_CENTERING = 6
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InstantGradeType(IntEnum):
|
|
40
|
+
OVERALL = 0
|
|
41
|
+
SUBGRADES = 1
|
|
42
|
+
OVERALL_TO_SUBGRADES = 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GradingField(Enum):
|
|
46
|
+
GRADING = "grading"
|
|
47
|
+
CENTER = "centering_grading"
|
|
48
|
+
SURFACE = "surface_grading"
|
|
49
|
+
EDGE = "edge_grading"
|
|
50
|
+
CORNER = "corner_grading"
|
|
51
|
+
ENVIRONMENT = "grading_environment"
|
|
52
|
+
CONFIDENCE = "grading_confidence"
|
|
53
|
+
STRUCTURE = "card_structure"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TradingCardType(str, Enum):
|
|
57
|
+
POKEMON = "pokemon"
|
|
58
|
+
LORCANA = "lorcana"
|
|
59
|
+
MAGIC = "magic"
|
|
60
|
+
YUGIOH = "yugioh"
|
|
61
|
+
DIGIMON = "digimon"
|
|
62
|
+
DRAGON_BALL = "dragon_ball"
|
|
63
|
+
ONE_PIECE = "one_piece"
|
|
64
|
+
STAR_WARS = 'star_wars'
|
|
65
|
+
MARVEL = 'marvel'
|
|
66
|
+
GARBAGE_PAIL = 'garbage_pail'
|
|
67
|
+
OTHER_TCG = 'other_tcg'
|
|
68
|
+
OTHER_NON_TCG = 'other'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SportsCardType(str, Enum):
|
|
72
|
+
BASEBALL = "baseball"
|
|
73
|
+
BASKETBALL = "basketball"
|
|
74
|
+
FOOTBALL = "football"
|
|
75
|
+
HOCKEY = "hockey"
|
|
76
|
+
SOCCER = "soccer"
|
|
77
|
+
RACING = "racing"
|
|
78
|
+
WRESTLING = "wrestling"
|
|
79
|
+
UFC = "ufc"
|
|
80
|
+
BOXING = "boxing"
|
|
81
|
+
GOLF = "golf"
|
|
82
|
+
TENNIS = "tennis"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CardType: TypeAlias = TradingCardType | SportsCardType
|
|
86
|
+
|
|
87
|
+
class CardCategory(str, Enum):
|
|
88
|
+
TRADING = "trading"
|
|
89
|
+
SPORTS = "sports"
|
|
90
|
+
|
|
91
|
+
class CardVersions(str, Enum):
|
|
92
|
+
DEFAULT = '0.0',
|
|
93
|
+
VERSION_1_0 = '1.0'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from enum import IntEnum, Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SubmissionStatus(IntEnum):
|
|
5
|
+
READY = 0
|
|
6
|
+
PROGRESS = 1
|
|
7
|
+
LEVEL = 2
|
|
8
|
+
VERIFY = 3
|
|
9
|
+
SUBMITTED = 4
|
|
10
|
+
RETURNED = 5
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SubmissionSessionStatus(str, Enum):
|
|
14
|
+
PENDING="pending"
|
|
15
|
+
PROCESSING="processing"
|
|
16
|
+
RETURNED="returned"
|
|
17
|
+
FAILED="failed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SubmissionGradingLevel(IntEnum):
|
|
21
|
+
VALUE_BULK=0
|
|
22
|
+
VALUE=1
|
|
23
|
+
VALUE_PLUS=2
|
|
24
|
+
REGULAR=3
|
|
25
|
+
EXPRESS=4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReturnedVerificationStatus(IntEnum):
|
|
29
|
+
PENDING = -1
|
|
30
|
+
FAILED = 0
|
|
31
|
+
VERIFIED = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReturnCreditSessionStatus(str, Enum):
|
|
35
|
+
PENDING="pending"
|
|
36
|
+
SUCCESS="success"
|
|
37
|
+
FAILED="failed"
|
dgc_core/enums/user.py
ADDED
dgc_core/env.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# dgc_core/env.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def require_env(name: str, default: Optional[str] = None) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Retrieve an environment variable.
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
- If variable exists and is a non-empty string → return it.
|
|
13
|
+
- If missing or empty and default provided → return default.
|
|
14
|
+
- If missing or empty and no default → raise RuntimeError.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
name: Environment variable name.
|
|
18
|
+
default: Optional fallback value.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
RuntimeError
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
value = os.getenv(name)
|
|
28
|
+
|
|
29
|
+
# Treat both None and "" as invalid/missing
|
|
30
|
+
if value is None or value == "":
|
|
31
|
+
if default is not None:
|
|
32
|
+
return default
|
|
33
|
+
raise RuntimeError(f"{name} env var must be set")
|
|
34
|
+
|
|
35
|
+
return value
|
dgc_core/fetch.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# dgc_core/fetch.py (updated)
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import requests
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
DEFAULT_RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
10
|
+
DEFAULT_USER_AGENT = (
|
|
11
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
12
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
13
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ImageResponse:
|
|
18
|
+
content: bytes
|
|
19
|
+
content_type: str
|
|
20
|
+
content_length: int | None
|
|
21
|
+
status_code: int
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class URLResponse:
|
|
25
|
+
status_code: int
|
|
26
|
+
headers: dict
|
|
27
|
+
content: Any
|
|
28
|
+
|
|
29
|
+
def fetch_url(
|
|
30
|
+
url: str,
|
|
31
|
+
params: Optional[dict] = None,
|
|
32
|
+
retries: int = 4,
|
|
33
|
+
timeout: int = 20,
|
|
34
|
+
extra_retryable_status_codes: set[int] | None = None,
|
|
35
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
36
|
+
base_delay: float = 0.5,
|
|
37
|
+
max_delay: float = 8.0,
|
|
38
|
+
response_type: Literal["text", "json"] = "text",
|
|
39
|
+
) -> URLResponse:
|
|
40
|
+
retryable_status_codes = set(DEFAULT_RETRYABLE_STATUS_CODES)
|
|
41
|
+
if extra_retryable_status_codes:
|
|
42
|
+
retryable_status_codes.update(extra_retryable_status_codes)
|
|
43
|
+
|
|
44
|
+
headers = {
|
|
45
|
+
"User-Agent": user_agent,
|
|
46
|
+
"Accept": (
|
|
47
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
|
48
|
+
"image/avif,image/webp,*/*;q=0.8"
|
|
49
|
+
),
|
|
50
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
51
|
+
"Referer": "https://www.google.com/",
|
|
52
|
+
"Connection": "keep-alive",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
with requests.Session() as session:
|
|
56
|
+
for attempt in range(retries + 1):
|
|
57
|
+
try:
|
|
58
|
+
response = session.get(url, headers=headers, timeout=timeout, params=params)
|
|
59
|
+
if response.status_code in retryable_status_codes:
|
|
60
|
+
raise requests.HTTPError(f"retryable status {response.status_code}", response=response)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
|
|
63
|
+
content = response.json() if response_type == "json" else response.text
|
|
64
|
+
return URLResponse(status_code=response.status_code, headers=dict(response.headers), content=content)
|
|
65
|
+
|
|
66
|
+
except requests.RequestException as exc:
|
|
67
|
+
retryable = exc.response is not None and exc.response.status_code in retryable_status_codes
|
|
68
|
+
if attempt >= retries or not retryable:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"Request failed after {attempt + 1} attempt(s). Retryable={retryable}. Error: {exc}"
|
|
71
|
+
) from exc
|
|
72
|
+
delay = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
|
|
73
|
+
time.sleep(delay)
|
|
74
|
+
|
|
75
|
+
raise RuntimeError("unreachable")
|
|
76
|
+
|
|
77
|
+
def fetch_image(
|
|
78
|
+
url: str,
|
|
79
|
+
params: Optional[dict] = None,
|
|
80
|
+
retries: int = 4,
|
|
81
|
+
timeout: int = 20,
|
|
82
|
+
extra_retryable_status_codes: set[int] | None = None,
|
|
83
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
84
|
+
base_delay: float = 0.5,
|
|
85
|
+
max_delay: float = 8.0,
|
|
86
|
+
) -> ImageResponse:
|
|
87
|
+
retryable_status_codes = set(DEFAULT_RETRYABLE_STATUS_CODES)
|
|
88
|
+
if extra_retryable_status_codes:
|
|
89
|
+
retryable_status_codes.update(extra_retryable_status_codes)
|
|
90
|
+
|
|
91
|
+
headers = {
|
|
92
|
+
"User-Agent": user_agent,
|
|
93
|
+
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
94
|
+
"Referer": "https://www.google.com/",
|
|
95
|
+
"Connection": "keep-alive",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
with requests.Session() as session:
|
|
99
|
+
for attempt in range(retries + 1):
|
|
100
|
+
try:
|
|
101
|
+
response = session.get(url, headers=headers, timeout=timeout, params=params, stream=True)
|
|
102
|
+
if response.status_code in retryable_status_codes:
|
|
103
|
+
raise requests.HTTPError(f"retryable status {response.status_code}", response=response)
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
|
|
106
|
+
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
|
107
|
+
content_length = response.headers.get("Content-Length")
|
|
108
|
+
return ImageResponse(
|
|
109
|
+
content=response.content,
|
|
110
|
+
content_type=content_type,
|
|
111
|
+
content_length=int(content_length) if content_length else None,
|
|
112
|
+
status_code=response.status_code,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
except requests.RequestException as exc:
|
|
116
|
+
retryable = exc.response is not None and exc.response.status_code in retryable_status_codes
|
|
117
|
+
if attempt >= retries or not retryable:
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
f"Image request failed after {attempt + 1} attempt(s). Retryable={retryable}. Error: {exc}"
|
|
120
|
+
) from exc
|
|
121
|
+
delay = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
|
|
122
|
+
time.sleep(delay)
|
|
123
|
+
|
|
124
|
+
raise RuntimeError("unreachable")
|
dgc_core/logger.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# dgc_core/logger.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogLevel(str, Enum):
|
|
8
|
+
DEBUG = "DEBUG"
|
|
9
|
+
INFO = "INFO"
|
|
10
|
+
WARNING = "WARNING"
|
|
11
|
+
ERROR = "ERROR"
|
|
12
|
+
CRITICAL = "CRITICAL"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log(level: LogLevel, message: str, **kwargs):
|
|
16
|
+
payload = {
|
|
17
|
+
"severity": level.value,
|
|
18
|
+
"message": message,
|
|
19
|
+
**_serialize(kwargs),
|
|
20
|
+
}
|
|
21
|
+
print(json.dumps(payload))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _serialize(data):
|
|
25
|
+
"""Make values JSON-safe (e.g., ObjectId)."""
|
|
26
|
+
safe = {}
|
|
27
|
+
for k, v in data.items():
|
|
28
|
+
try:
|
|
29
|
+
json.dumps(v)
|
|
30
|
+
safe[k] = v
|
|
31
|
+
except TypeError:
|
|
32
|
+
safe[k] = str(v)
|
|
33
|
+
return safe
|
dgc_core/merge.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# merge.py
|
|
2
|
+
from typing import Any, Dict, Tuple, Iterable, Set, Optional
|
|
3
|
+
|
|
4
|
+
# ---------- your existing deep_merge (unchanged) ----------
|
|
5
|
+
def deep_merge(base: dict, updates: dict, delete_nulls: bool = True) -> dict:
|
|
6
|
+
"""
|
|
7
|
+
Recursively merge two dictionaries.
|
|
8
|
+
Args:
|
|
9
|
+
base: original dict (must be a dict; caller should pass {} if None)
|
|
10
|
+
updates: updates to apply
|
|
11
|
+
delete_nulls: if True, keys with value None in updates are removed from result
|
|
12
|
+
Returns:
|
|
13
|
+
new dict with updates applied (shallow-copy semantics)
|
|
14
|
+
"""
|
|
15
|
+
result = base.copy()
|
|
16
|
+
for k, v in updates.items():
|
|
17
|
+
if delete_nulls and v is None:
|
|
18
|
+
result.pop(k, None)
|
|
19
|
+
elif isinstance(v, dict) and isinstance(result.get(k), dict):
|
|
20
|
+
result[k] = deep_merge(result[k], v, delete_nulls)
|
|
21
|
+
else:
|
|
22
|
+
result[k] = v
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------- internal helpers (renamed with leading underscore) ----------
|
|
27
|
+
def _deep_get(obj: Optional[Dict[str, Any]], path: str) -> Tuple[Any, bool]:
|
|
28
|
+
"""
|
|
29
|
+
Get a value by dot-path from a dict.
|
|
30
|
+
Returns (value, found_flag). found_flag is True even when the found value is None.
|
|
31
|
+
If path == "" returns (obj, True).
|
|
32
|
+
"""
|
|
33
|
+
if obj is None:
|
|
34
|
+
return None, False
|
|
35
|
+
current = obj
|
|
36
|
+
if path == "":
|
|
37
|
+
return current, True
|
|
38
|
+
for part in path.split("."):
|
|
39
|
+
if not isinstance(current, dict) or part not in current:
|
|
40
|
+
return None, False
|
|
41
|
+
current = current[part]
|
|
42
|
+
return current, True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _compute_new_value_from_delta_or_data(
|
|
46
|
+
data: Optional[Dict[str, Any]],
|
|
47
|
+
delta: Optional[Dict[str, Any]],
|
|
48
|
+
path: str,
|
|
49
|
+
delete_nulls: bool = True,
|
|
50
|
+
) -> Tuple[Any, bool]:
|
|
51
|
+
"""
|
|
52
|
+
Compute new value at `path` after applying delta onto data.
|
|
53
|
+
|
|
54
|
+
- Ancestor-none or ancestor scalar behavior unchanged: if an ancestor is present in delta
|
|
55
|
+
and is non-dict, nested paths are considered removed.
|
|
56
|
+
- If exact path present in delta:
|
|
57
|
+
- If value is None and delete_nulls True -> treated as removed (new_found=False)
|
|
58
|
+
- If value is dict and data has a dict at same path -> deep-merge and return merged dict
|
|
59
|
+
- Otherwise return delta's value
|
|
60
|
+
- Else fall back to data
|
|
61
|
+
"""
|
|
62
|
+
delta = delta or {}
|
|
63
|
+
data = data or {}
|
|
64
|
+
|
|
65
|
+
# Ancestor checks (unchanged): if any ancestor in delta is non-dict -> nested paths removed.
|
|
66
|
+
if path:
|
|
67
|
+
parts = path.split(".")
|
|
68
|
+
for i in range(1, len(parts)):
|
|
69
|
+
ancestor = ".".join(parts[:i])
|
|
70
|
+
anc_val, anc_found = _deep_get(delta, ancestor)
|
|
71
|
+
if anc_found:
|
|
72
|
+
if anc_val is None and delete_nulls:
|
|
73
|
+
return None, False
|
|
74
|
+
if not isinstance(anc_val, dict):
|
|
75
|
+
return None, False
|
|
76
|
+
# if ancestor is a dict in delta, nested keys may still exist; continue
|
|
77
|
+
|
|
78
|
+
# Exact path present in delta?
|
|
79
|
+
val, found_in_delta = _deep_get(delta, path)
|
|
80
|
+
if found_in_delta:
|
|
81
|
+
# deletion by None
|
|
82
|
+
if val is None and delete_nulls:
|
|
83
|
+
return None, False
|
|
84
|
+
|
|
85
|
+
# If both data and delta at this path are dicts -> do a deep merge
|
|
86
|
+
data_val, found_in_data = _deep_get(data, path)
|
|
87
|
+
if isinstance(val, dict) and found_in_data and isinstance(data_val, dict):
|
|
88
|
+
merged = deep_merge(data_val, val, delete_nulls)
|
|
89
|
+
return merged, True
|
|
90
|
+
|
|
91
|
+
# otherwise delta replaces
|
|
92
|
+
return val, True
|
|
93
|
+
|
|
94
|
+
# fallback to data
|
|
95
|
+
val, found_in_data = _deep_get(data, path)
|
|
96
|
+
if found_in_data:
|
|
97
|
+
return val, True
|
|
98
|
+
|
|
99
|
+
return None, False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _collect_paths(obj: Optional[Dict[str, Any]], prefix: str = "") -> Set[str]:
|
|
103
|
+
"""
|
|
104
|
+
Collect dot-paths for every key in obj.
|
|
105
|
+
- Includes intermediate dict keys and nested/leaf keys.
|
|
106
|
+
- Treats non-dict values (including lists, None) as leaves.
|
|
107
|
+
"""
|
|
108
|
+
paths: Set[str] = set()
|
|
109
|
+
if not isinstance(obj, dict) or obj is None:
|
|
110
|
+
return paths
|
|
111
|
+
|
|
112
|
+
for k, v in obj.items():
|
|
113
|
+
path = f"{prefix}.{k}" if prefix else k
|
|
114
|
+
paths.add(path)
|
|
115
|
+
if isinstance(v, dict) and v is not None:
|
|
116
|
+
paths.update(_collect_paths(v, path))
|
|
117
|
+
return paths
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_field_changes(
|
|
121
|
+
data: Optional[Dict[str, Any]],
|
|
122
|
+
delta: Optional[Dict[str, Any]],
|
|
123
|
+
fields: Optional[Iterable[str]] = None,
|
|
124
|
+
delete_nulls: bool = True,
|
|
125
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
126
|
+
"""
|
|
127
|
+
For each dot-path in `fields` (or for all discovered paths when fields is None) returns:
|
|
128
|
+
{
|
|
129
|
+
"old": <old_value_or_None>,
|
|
130
|
+
"old_found": True/False,
|
|
131
|
+
"new": <new_value_or_None>,
|
|
132
|
+
"new_found": True/False,
|
|
133
|
+
"changed": True/False,
|
|
134
|
+
"reason": "deleted"|"updated"|"added"|"unchanged"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Note: when new_found is False it means the field will be absent after merge (deleted).
|
|
138
|
+
"""
|
|
139
|
+
data = data or {}
|
|
140
|
+
delta = delta or {}
|
|
141
|
+
|
|
142
|
+
if fields is None:
|
|
143
|
+
paths_from_data = _collect_paths(data)
|
|
144
|
+
paths_from_delta = _collect_paths(delta)
|
|
145
|
+
fields_list = sorted(paths_from_data.union(paths_from_delta))
|
|
146
|
+
else:
|
|
147
|
+
fields_list = list(fields)
|
|
148
|
+
|
|
149
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
150
|
+
|
|
151
|
+
for path in fields_list:
|
|
152
|
+
old_val, old_found = _deep_get(data, path)
|
|
153
|
+
new_val, new_found = _compute_new_value_from_delta_or_data(data, delta, path, delete_nulls=delete_nulls)
|
|
154
|
+
|
|
155
|
+
# Determine changed/ reason
|
|
156
|
+
if not old_found and not new_found:
|
|
157
|
+
changed = False
|
|
158
|
+
reason = "unchanged"
|
|
159
|
+
elif not old_found and new_found:
|
|
160
|
+
changed = True
|
|
161
|
+
reason = "added"
|
|
162
|
+
elif old_found and not new_found:
|
|
163
|
+
changed = True
|
|
164
|
+
reason = "deleted"
|
|
165
|
+
else:
|
|
166
|
+
changed = old_val != new_val
|
|
167
|
+
reason = "updated" if changed else "unchanged"
|
|
168
|
+
|
|
169
|
+
out[path] = {
|
|
170
|
+
"old": old_val,
|
|
171
|
+
"old_found": old_found,
|
|
172
|
+
"new": None if not new_found else new_val,
|
|
173
|
+
"new_found": new_found,
|
|
174
|
+
"changed": changed,
|
|
175
|
+
"reason": reason,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def safe_any_changed(changes: Dict[str, Dict[str, Any]]) -> bool:
|
|
182
|
+
"""
|
|
183
|
+
Compute any_changed robustly by only checking dict entries that look like field entries.
|
|
184
|
+
"""
|
|
185
|
+
return any(
|
|
186
|
+
isinstance(info, dict) and info.get("changed", False)
|
|
187
|
+
for info in changes.values()
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def prune_conflicting_set_unset(set_map: Dict[str, Any], unset_map: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
192
|
+
"""
|
|
193
|
+
Remove keys from set_map and unset_map that conflict with parent keys.
|
|
194
|
+
Parents win: if 'record.test' exists, drop 'record.test.*' entries from both maps.
|
|
195
|
+
Also remove keys that appear in both maps (prefer $set).
|
|
196
|
+
"""
|
|
197
|
+
# normalize keys lists
|
|
198
|
+
set_keys = sorted(set_map.keys(), key=lambda k: (k.count("."), k))
|
|
199
|
+
unset_keys = sorted(unset_map.keys(), key=lambda k: (k.count("."), k))
|
|
200
|
+
|
|
201
|
+
# Build a set of parent keys present (both set and unset)
|
|
202
|
+
parent_keys = set()
|
|
203
|
+
for k in set_keys + unset_keys:
|
|
204
|
+
# check if any ancestor of k is present in set_map or unset_map
|
|
205
|
+
parts = k.split(".")
|
|
206
|
+
for i in range(1, len(parts)):
|
|
207
|
+
ancestor = ".".join(parts[:i])
|
|
208
|
+
if ancestor in set_map or ancestor in unset_map:
|
|
209
|
+
parent_keys.add(ancestor)
|
|
210
|
+
|
|
211
|
+
# If some parent keys exist, drop any deeper keys under them
|
|
212
|
+
def drop_children(map_obj: Dict[str, Any]) -> Dict[str, Any]:
|
|
213
|
+
kept = {}
|
|
214
|
+
for k, v in map_obj.items():
|
|
215
|
+
# if any ancestor is present in original maps, drop this key
|
|
216
|
+
parts = k.split(".")
|
|
217
|
+
ancestor_found = False
|
|
218
|
+
for i in range(1, len(parts)):
|
|
219
|
+
ancestor = ".".join(parts[:i])
|
|
220
|
+
if ancestor in set_map or ancestor in unset_map:
|
|
221
|
+
ancestor_found = True
|
|
222
|
+
break
|
|
223
|
+
if not ancestor_found:
|
|
224
|
+
kept[k] = v
|
|
225
|
+
return kept
|
|
226
|
+
|
|
227
|
+
pruned_set = drop_children(set_map)
|
|
228
|
+
pruned_unset = drop_children(unset_map)
|
|
229
|
+
|
|
230
|
+
# Remove keys present in both - prefer $set (so drop from unset)
|
|
231
|
+
for k in list(pruned_unset.keys()):
|
|
232
|
+
if k in pruned_set:
|
|
233
|
+
pruned_unset.pop(k, None)
|
|
234
|
+
|
|
235
|
+
return pruned_set, pruned_unset
|
dgc_core/mongo_cache.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Any, Tuple
|
|
5
|
+
from pymongo import MongoClient
|
|
6
|
+
from pymongo.errors import PyMongoError
|
|
7
|
+
|
|
8
|
+
_DEFAULT_SERVER_SELECTION_TIMEOUT_MS = 5000 # sensible default
|
|
9
|
+
|
|
10
|
+
class MongoCache:
|
|
11
|
+
"""
|
|
12
|
+
Very small thread-safe cache for pymongo.MongoClient instances keyed by (uri + client kwargs).
|
|
13
|
+
Re-uses MongoClient (and its internal connection pool) for performance.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
mongo = MongoCache()
|
|
17
|
+
db = mongo.get_db("mongodb+srv://user:pass@host", "mydb")
|
|
18
|
+
col = db.mycol
|
|
19
|
+
...
|
|
20
|
+
mongo.close_all()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
# key -> (MongoClient, created_ts)
|
|
26
|
+
self._clients: Dict[str, Tuple[MongoClient, float]] = {}
|
|
27
|
+
|
|
28
|
+
def _make_key(self, uri: str, kwargs: Dict[str, Any]) -> str:
|
|
29
|
+
if not kwargs:
|
|
30
|
+
return uri
|
|
31
|
+
items = tuple(sorted(kwargs.items()))
|
|
32
|
+
return f"{uri}::{items!r}"
|
|
33
|
+
|
|
34
|
+
def get_client(self, uri: str, validate: bool = False, **client_kwargs) -> MongoClient:
|
|
35
|
+
"""
|
|
36
|
+
Return a cached MongoClient for the given URI (create if missing).
|
|
37
|
+
validate: if True, attempt a lightweight ping after creating the client to ensure connectivity.
|
|
38
|
+
(Avoid calling validate=True on every request in high-throughput paths.)
|
|
39
|
+
client_kwargs: forwarded to MongoClient (e.g., maxPoolSize, tls, etc.)
|
|
40
|
+
"""
|
|
41
|
+
key = self._make_key(uri, client_kwargs)
|
|
42
|
+
|
|
43
|
+
with self._lock:
|
|
44
|
+
entry = self._clients.get(key)
|
|
45
|
+
if entry:
|
|
46
|
+
client, _ = entry
|
|
47
|
+
return client
|
|
48
|
+
|
|
49
|
+
# ensure serverSelectionTimeoutMS default unless caller provided it
|
|
50
|
+
if "serverSelectionTimeoutMS" not in client_kwargs:
|
|
51
|
+
client_kwargs["serverSelectionTimeoutMS"] = _DEFAULT_SERVER_SELECTION_TIMEOUT_MS
|
|
52
|
+
|
|
53
|
+
client = MongoClient(uri, **client_kwargs)
|
|
54
|
+
created_ts = time.time()
|
|
55
|
+
self._clients[key] = (client, created_ts)
|
|
56
|
+
|
|
57
|
+
if validate:
|
|
58
|
+
# do ping outside the lock to avoid blocking other threads
|
|
59
|
+
try:
|
|
60
|
+
client.admin.command("ping")
|
|
61
|
+
except PyMongoError:
|
|
62
|
+
# if validation fails, close client and remove from cache
|
|
63
|
+
with self._lock:
|
|
64
|
+
# remove only if still same object (race-safe)
|
|
65
|
+
current = self._clients.get(key)
|
|
66
|
+
if current and current[0] is client:
|
|
67
|
+
self._clients.pop(key, None)
|
|
68
|
+
try:
|
|
69
|
+
client.close()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
return client
|
|
75
|
+
|
|
76
|
+
def get_db(self, uri: str, dbname: str, validate: bool = False, **client_kwargs):
|
|
77
|
+
"""
|
|
78
|
+
Convenience to get a Database object from a cached client.
|
|
79
|
+
"""
|
|
80
|
+
client = self.get_client(uri, validate=validate, **client_kwargs)
|
|
81
|
+
return client[dbname]
|
|
82
|
+
|
|
83
|
+
def close(self, uri: str, **client_kwargs) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Close and remove a cached client for the given uri+client_kwargs.
|
|
86
|
+
"""
|
|
87
|
+
key = self._make_key(uri, client_kwargs)
|
|
88
|
+
with self._lock:
|
|
89
|
+
entry = self._clients.pop(key, None)
|
|
90
|
+
if entry:
|
|
91
|
+
client, _ = entry
|
|
92
|
+
try:
|
|
93
|
+
client.close()
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def close_all(self) -> None:
|
|
98
|
+
"""Close all clients in the cache."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
items = list(self._clients.items())
|
|
101
|
+
self._clients.clear()
|
|
102
|
+
for _, (client, _) in items:
|
|
103
|
+
try:
|
|
104
|
+
client.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def info(self) -> Dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Simple diagnostics: returns keys and creation timestamps (unix epoch seconds).
|
|
111
|
+
"""
|
|
112
|
+
with self._lock:
|
|
113
|
+
return {k: {"created_ts": v[1]} for k, v in self._clients.items()}
|
dgc_core/normalize.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# dgc_core/normalize.py
|
|
2
|
+
from bson.objectid import ObjectId
|
|
3
|
+
|
|
4
|
+
def normalize_enum(value, enum_cls):
|
|
5
|
+
"""
|
|
6
|
+
Safely convert a raw value to an Enum member.
|
|
7
|
+
|
|
8
|
+
Returns:
|
|
9
|
+
Enum member if valid
|
|
10
|
+
None if value cannot be converted
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
return enum_cls(value)
|
|
14
|
+
except (ValueError, TypeError):
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_object_id(value):
|
|
19
|
+
"""Convert string to ObjectId when possible, otherwise return as-is."""
|
|
20
|
+
if isinstance(value, ObjectId):
|
|
21
|
+
return value
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
try:
|
|
24
|
+
return ObjectId(value)
|
|
25
|
+
except Exception:
|
|
26
|
+
return value
|
|
27
|
+
return value
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Optional, TypedDict
|
|
3
|
+
|
|
4
|
+
from .regex import escape_for_regex
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CardParts(TypedDict):
|
|
8
|
+
name: str
|
|
9
|
+
number: Optional[str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def split_name_and_number(
|
|
13
|
+
input_value: Any,
|
|
14
|
+
real_number: Optional[Any] = None,
|
|
15
|
+
) -> CardParts:
|
|
16
|
+
s = str(input_value if input_value is not None else "").strip()
|
|
17
|
+
|
|
18
|
+
if not s:
|
|
19
|
+
return {"name": "", "number": None}
|
|
20
|
+
|
|
21
|
+
# 1) Try real_number match at the end, if provided
|
|
22
|
+
if real_number is not None:
|
|
23
|
+
rn = str(real_number).strip()
|
|
24
|
+
|
|
25
|
+
if rn:
|
|
26
|
+
escaped = escape_for_regex(rn)
|
|
27
|
+
|
|
28
|
+
# Matches:
|
|
29
|
+
# "... #123"
|
|
30
|
+
# "... # 123"
|
|
31
|
+
# "... 123"
|
|
32
|
+
pattern = rf"(?:^|[\s#]){escaped}\s*$"
|
|
33
|
+
match = re.search(pattern, s, flags=re.IGNORECASE)
|
|
34
|
+
|
|
35
|
+
if match:
|
|
36
|
+
name_part = s[: match.start()]
|
|
37
|
+
name_part = re.sub(r"[#\s]+$", "", name_part).strip()
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"name": name_part,
|
|
41
|
+
"number": rn,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# 2) Fallback: use last "#" split
|
|
45
|
+
idx = s.rfind("#")
|
|
46
|
+
|
|
47
|
+
if idx == -1:
|
|
48
|
+
return {
|
|
49
|
+
"name": s,
|
|
50
|
+
"number": None,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
name_part = s[:idx].strip()
|
|
54
|
+
number_part = s[idx + 1 :].strip()
|
|
55
|
+
|
|
56
|
+
if not number_part:
|
|
57
|
+
return {
|
|
58
|
+
"name": s,
|
|
59
|
+
"number": None,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"name": name_part,
|
|
64
|
+
"number": number_part,
|
|
65
|
+
}
|
dgc_core/pub.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# dgc_core/pub.py
|
|
2
|
+
import json
|
|
3
|
+
import traceback
|
|
4
|
+
from google.cloud import pubsub_v1
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
_publisher_client = None
|
|
8
|
+
|
|
9
|
+
def _get_publisher():
|
|
10
|
+
global _publisher_client
|
|
11
|
+
if _publisher_client is None:
|
|
12
|
+
_publisher_client = pubsub_v1.PublisherClient()
|
|
13
|
+
return _publisher_client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _topic_path_for(publisher: pubsub_v1.PublisherClient, project: str, topic: str) -> str:
|
|
17
|
+
"""Get topic path. Uses module publisher for testability/simplicity."""
|
|
18
|
+
if not project:
|
|
19
|
+
raise RuntimeError("Project id not found")
|
|
20
|
+
return publisher.topic_path(project, topic)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def publish_message(project_id: str, topic_name: str, envelope: Dict[str, Any]) -> str:
|
|
24
|
+
if not project_id:
|
|
25
|
+
raise ValueError("project_id not set")
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
publisher = _get_publisher()
|
|
29
|
+
topic = _topic_path_for(publisher, project_id, topic_name)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"ERROR: topic setup failed: {e}\n{traceback.format_exc()}")
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
future = publisher.publish(topic, data=json.dumps(envelope).encode("utf-8"))
|
|
36
|
+
msg_id = future.result()
|
|
37
|
+
print(f"INFO: Pub/Sub publish topic={topic} msg_id: {msg_id}")
|
|
38
|
+
return msg_id
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"ERROR: publish failed: {e}\n{traceback.format_exc()}")
|
|
41
|
+
raise
|
dgc_core/regex.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_REGEX_META_PATTERN = re.compile(r"[.*+?^${}()|[\]\\]")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def escape_for_regex(input_value: Any, max_length: int = 100) -> str:
|
|
9
|
+
safe_str = str(input_value if input_value is not None else "")
|
|
10
|
+
capped = safe_str[:max_length]
|
|
11
|
+
|
|
12
|
+
# Escape regex metacharacters first, same as TS:
|
|
13
|
+
# capped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
escaped = _REGEX_META_PATTERN.sub(r"\\\g<0>", capped)
|
|
15
|
+
|
|
16
|
+
# Convert whitespace runs to literal regex token: \s+
|
|
17
|
+
return re.sub(r"\s+", r"\\s+", escaped)
|
dgc_core/task.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone, timedelta
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from google.cloud import tasks_v2
|
|
8
|
+
from google.api_core import exceptions as gcloud_exceptions
|
|
9
|
+
from google.protobuf import timestamp_pb2
|
|
10
|
+
|
|
11
|
+
# Retry defaults (same intent as your TS version)
|
|
12
|
+
DEFAULT_RETRIES = 3
|
|
13
|
+
BASE_DELAY_MS = 300
|
|
14
|
+
MAX_DELAY_MS = 2000
|
|
15
|
+
TIMEOUT_MS = 15000
|
|
16
|
+
|
|
17
|
+
_client = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_client():
|
|
21
|
+
global _client
|
|
22
|
+
if _client is None:
|
|
23
|
+
_client = tasks_v2.CloudTasksClient()
|
|
24
|
+
return _client
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_retryable_cloud_tasks(err: Exception) -> bool:
|
|
28
|
+
"""Return True if error is retryable."""
|
|
29
|
+
if isinstance(err, gcloud_exceptions.AlreadyExists):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
if isinstance(err, (
|
|
33
|
+
gcloud_exceptions.ServiceUnavailable, # gRPC 14
|
|
34
|
+
gcloud_exceptions.DeadlineExceeded,
|
|
35
|
+
gcloud_exceptions.TooManyRequests, # 429
|
|
36
|
+
gcloud_exceptions.InternalServerError, # 500
|
|
37
|
+
gcloud_exceptions.BadGateway, # 502
|
|
38
|
+
gcloud_exceptions.GatewayTimeout # 504
|
|
39
|
+
)):
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sleep_with_jitter(attempt: int):
|
|
46
|
+
exp = min(MAX_DELAY_MS, BASE_DELAY_MS * (2 ** attempt))
|
|
47
|
+
delay_ms = random.randint(0, exp)
|
|
48
|
+
time.sleep(delay_ms / 1000)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_task(
|
|
52
|
+
project_id: str,
|
|
53
|
+
region: str,
|
|
54
|
+
queue_name: str,
|
|
55
|
+
audience: str,
|
|
56
|
+
service_account: str,
|
|
57
|
+
data: dict,
|
|
58
|
+
schedule_delay: Optional[timedelta] = None,
|
|
59
|
+
task_id: Optional[str] = None,
|
|
60
|
+
retries: int = DEFAULT_RETRIES,
|
|
61
|
+
):
|
|
62
|
+
if not all([project_id, region, queue_name, service_account, audience]):
|
|
63
|
+
raise ValueError("Missing required Cloud Tasks parameters")
|
|
64
|
+
|
|
65
|
+
client = _get_client()
|
|
66
|
+
parent = client.queue_path(project_id, region, queue_name)
|
|
67
|
+
|
|
68
|
+
task = {
|
|
69
|
+
"http_request": {
|
|
70
|
+
"http_method": tasks_v2.HttpMethod.POST,
|
|
71
|
+
"url": audience,
|
|
72
|
+
"headers": {"Content-Type": "application/json"},
|
|
73
|
+
"body": json.dumps(data).encode(),
|
|
74
|
+
"oidc_token": {
|
|
75
|
+
"service_account_email": service_account,
|
|
76
|
+
"audience": audience,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if schedule_delay:
|
|
82
|
+
ts = timestamp_pb2.Timestamp()
|
|
83
|
+
ts.FromDatetime(datetime.now(timezone.utc) + schedule_delay)
|
|
84
|
+
task["schedule_time"] = ts
|
|
85
|
+
|
|
86
|
+
# Idempotent task creation
|
|
87
|
+
if task_id:
|
|
88
|
+
task["name"] = f"{parent}/tasks/{task_id}"
|
|
89
|
+
|
|
90
|
+
start_time = time.time()
|
|
91
|
+
attempt = 0
|
|
92
|
+
last_err = None
|
|
93
|
+
|
|
94
|
+
while attempt <= retries:
|
|
95
|
+
elapsed_ms = (time.time() - start_time) * 1000
|
|
96
|
+
if elapsed_ms >= TIMEOUT_MS:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
response = client.create_task(parent=parent, task=task)
|
|
101
|
+
print(f"✅ Created Cloud Task: {response.name}")
|
|
102
|
+
return response.name
|
|
103
|
+
|
|
104
|
+
except gcloud_exceptions.AlreadyExists:
|
|
105
|
+
# Treat idempotent create as success
|
|
106
|
+
task_name = f"{parent}/tasks/{task_id}" if task_id else None
|
|
107
|
+
print(f"⚠️ Task already exists: {task_name}")
|
|
108
|
+
return task_name
|
|
109
|
+
|
|
110
|
+
except Exception as err:
|
|
111
|
+
last_err = err
|
|
112
|
+
|
|
113
|
+
if not _is_retryable_cloud_tasks(err):
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
if attempt >= retries:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
print(f"⚠️ create_task retry #{attempt + 1}: {err}")
|
|
120
|
+
_sleep_with_jitter(attempt + 1)
|
|
121
|
+
attempt += 1
|
|
122
|
+
|
|
123
|
+
raise RuntimeError(f"create_task failed after {attempt} retries") from last_err
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dgc-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Shared Python utilities and domain types for DGC services
|
|
5
|
+
Author-email: velopace <velopace7@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/velopace739/dgc-core-python
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: functions-framework>=3.10.1
|
|
11
|
+
Requires-Dist: flask>=3.1.3
|
|
12
|
+
Requires-Dist: firebase-admin>=7.4.0
|
|
13
|
+
Requires-Dist: google-api-core>=2.30.3
|
|
14
|
+
Requires-Dist: google-auth>=2.49.2
|
|
15
|
+
Requires-Dist: google-auth-oauthlib>=1.3.1
|
|
16
|
+
Requires-Dist: google-cloud-pubsub>=2.37.0
|
|
17
|
+
Requires-Dist: google-cloud-tasks>=2.22.0
|
|
18
|
+
Requires-Dist: protobuf>=7.34.1
|
|
19
|
+
Requires-Dist: pydantic>=2.13.3
|
|
20
|
+
Requires-Dist: pymongo>=4.17.0
|
|
21
|
+
Requires-Dist: requests>=2.32.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: build>=1.3.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest>=8.4.1; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=6.2.1; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=6.1.0; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# dgc-core
|
|
29
|
+
|
|
30
|
+
Shared Python utilities and domain types for DGC services. The distribution is
|
|
31
|
+
named `dgc-core`; the Python package is `dgc_core`. Requires Python 3.10 or later.
|
|
32
|
+
|
|
33
|
+
This repository combines `opticedge-cloud-utils-python` (`opticedge_cloud_utils`
|
|
34
|
+
1.0.33) and `opticedge-types-python` (`opticedge_types` 1.0.53). Existing utility
|
|
35
|
+
behavior, public exports, enum values, and eligibility constants are preserved.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Install from this checkout during development:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
python -m pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
After version 1.0.0 is published to your package index, consumers can use
|
|
46
|
+
`dgc-core==1.0.0` in their requirements.
|
|
47
|
+
|
|
48
|
+
## Imports
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from dgc_core import ApprovalStatus, GradingType, LogLevel, MongoCache, log, require_env
|
|
52
|
+
from dgc_core.pub import publish_message
|
|
53
|
+
from dgc_core.enums.card import CardCategory
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Utilities, enums, and constants are exported together from `dgc_core`. Utility
|
|
57
|
+
modules live directly in the package, alongside `enums/` and `constants/`.
|
|
58
|
+
Helpers cover authentication, configuration, HTTP fetching, logging, merging,
|
|
59
|
+
MongoDB cache, normalization, PriceCharting parsing, Pub/Sub, regex, and Cloud
|
|
60
|
+
Tasks. Domain definitions cover cards, users, submissions, and grading eligibility.
|
|
61
|
+
|
|
62
|
+
## Migrating existing consumers
|
|
63
|
+
|
|
64
|
+
Replace the `opticedge-cloud-utils` and `opticedge-types` requirements with the
|
|
65
|
+
single `dgc-core` requirement after publication, and update imports to `dgc_core`:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
python -m pip install dgc-core==1.0.0
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Previous import | New import |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `from opticedge_cloud_utils import log` | `from dgc_core import log` |
|
|
74
|
+
| `from opticedge_types import GradingType` | `from dgc_core import GradingType` |
|
|
75
|
+
| `from opticedge_cloud_utils.pub import publish_message` | `from dgc_core.pub import publish_message` |
|
|
76
|
+
| `from opticedge_types.enums.card import GradingType` | `from dgc_core.enums.card import GradingType` |
|
|
77
|
+
| `from opticedge_types.constants.card import ON_DEMAND_ELIGIBLE_GRADING_TYPES` | `from dgc_core.constants.card import ON_DEMAND_ELIGIBLE_GRADING_TYPES` |
|
|
78
|
+
|
|
79
|
+
Update mock and patch paths in consumer tests using the same mapping. The
|
|
80
|
+
distribution provides only the `dgc_core` package.
|
|
81
|
+
|
|
82
|
+
Service requirements are not changed by this repository merge. Services pinned
|
|
83
|
+
to older releases should run their own tests when adopting the combined release.
|
|
84
|
+
|
|
85
|
+
## Development and verification
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
make test
|
|
89
|
+
make check
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without Make, create a virtual environment and activate it, then run:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
python -m pip install -r requirements-dev.txt
|
|
96
|
+
python -m pytest --cov=dgc_core --cov-report=term-missing
|
|
97
|
+
python -m build
|
|
98
|
+
python -m twine check dist/*
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The tests mock external calls. `make build` clears old `build/` and `dist/` output
|
|
102
|
+
and produces a wheel and source distribution for the current version.
|
|
103
|
+
|
|
104
|
+
## Releasing
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
make bump-patch
|
|
108
|
+
make publish
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`make bump-patch` increments `[project].version` in `pyproject.toml` (for example,
|
|
112
|
+
`1.0.0` to `1.0.1`) and preserves the rest of the file's formatting. It uses the
|
|
113
|
+
base Python interpreter and works before creating the virtual environment.
|
|
114
|
+
|
|
115
|
+
`make publish` builds fresh artifacts, checks them with Twine, and uploads them
|
|
116
|
+
to your configured package index (PyPI by default), using your existing Twine
|
|
117
|
+
credentials or configuration. Publishing uses the current version; it does not
|
|
118
|
+
bump the version automatically.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
dgc_core/__init__.py,sha256=qFNuOPwgdMLwWWKYMougvW8noub8q-LoH63jBQa0Tas,1982
|
|
2
|
+
dgc_core/auth.py,sha256=rpKXc5QzVOKVGo5LXkRgHV0KnJ_fCam6AOhQcbC_1Mo,1197
|
|
3
|
+
dgc_core/env.py,sha256=vsADmzxr03-1OL2GPpsVurC1lM5ZJ83MyKEcdE26QSU,826
|
|
4
|
+
dgc_core/fetch.py,sha256=wGqODCLJSGJVT1_ckOr5A-VzDB4WHmuh41QMm6VFjjQ,4840
|
|
5
|
+
dgc_core/logger.py,sha256=WZRfk8NI1meDbhriiDPJD3Jar_bcT1pc8b8f-Lib0qA,672
|
|
6
|
+
dgc_core/merge.py,sha256=X2_iq1QsmqUBoB1BFhRVlnIQgwmaAGfvCrhOwxr7joE,8453
|
|
7
|
+
dgc_core/mongo_cache.py,sha256=GEPJr99Rs7FUJ-cWFe2uvUgjNUuurr3dQJAcQnNBVWQ,4129
|
|
8
|
+
dgc_core/normalize.py,sha256=9i7TDbuG9GAoIucouS176pT1n5jW9mKOZ5TtH96hsWs,685
|
|
9
|
+
dgc_core/pricecharting.py,sha256=lu3Ld-GpgbOKtcYMc3tUQfUbJmlAyedBV5w_6JPHO1A,1526
|
|
10
|
+
dgc_core/pub.py,sha256=IEQPSIK6xh-MtIZyzt7Eo991ZDabfBuijsJdSpzlEGY,1361
|
|
11
|
+
dgc_core/regex.py,sha256=zspUPnRcuXHbi4XrI8XHvfwD9EDkaYDyf3DzGFD2t5U,549
|
|
12
|
+
dgc_core/task.py,sha256=MzRREqGSWcMxxpuChiutw3zsAUh0cmR2HGu6OLN-UiE,3609
|
|
13
|
+
dgc_core/constants/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
dgc_core/constants/card.py,sha256=KeVWNtlINGIcywTGa23a-by7tTYJV0C1MLVMi19yodc,320
|
|
15
|
+
dgc_core/enums/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
dgc_core/enums/card.py,sha256=aOqj0wBjbEnkY4Ma12yqZboS3vjADye0JVYO3LPmE4c,2162
|
|
17
|
+
dgc_core/enums/submission.py,sha256=aDxo6e1WMswPPNjK2tzqUmvzLpn6e2a-64fa-xh8OQc,655
|
|
18
|
+
dgc_core/enums/user.py,sha256=qLu3sZXtjB48AS6YzpOZPfhqkLh7WczpZraFX1uSgvQ,144
|
|
19
|
+
dgc_core-1.0.0.dist-info/METADATA,sha256=HaMFhzzwh9P9wwS7N5JlGHY8YsN6XDtWPNvQg8WiFlY,4433
|
|
20
|
+
dgc_core-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
21
|
+
dgc_core-1.0.0.dist-info/top_level.txt,sha256=SE45YLPHjYQ0ec8BSOD4llr1a1oX-QrWmGprCpEj1x4,9
|
|
22
|
+
dgc_core-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dgc_core
|