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 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
@@ -0,0 +1,8 @@
1
+ from enum import IntEnum
2
+
3
+ class CollectorType(IntEnum):
4
+ NON_MEMBER = -1
5
+ FREE = 2
6
+ ROOKIE = 1
7
+ COLLECTOR = 3
8
+ ELITE = 0
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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dgc_core