dgc-core 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. dgc_core-1.0.0/MANIFEST.in +2 -0
  2. dgc_core-1.0.0/Makefile +33 -0
  3. dgc_core-1.0.0/PKG-INFO +118 -0
  4. dgc_core-1.0.0/README.md +91 -0
  5. dgc_core-1.0.0/dgc_core/__init__.py +82 -0
  6. dgc_core-1.0.0/dgc_core/auth.py +35 -0
  7. dgc_core-1.0.0/dgc_core/constants/__init__.py +0 -0
  8. dgc_core-1.0.0/dgc_core/constants/card.py +14 -0
  9. dgc_core-1.0.0/dgc_core/enums/__init__.py +0 -0
  10. dgc_core-1.0.0/dgc_core/enums/card.py +93 -0
  11. dgc_core-1.0.0/dgc_core/enums/submission.py +37 -0
  12. dgc_core-1.0.0/dgc_core/enums/user.py +8 -0
  13. dgc_core-1.0.0/dgc_core/env.py +35 -0
  14. dgc_core-1.0.0/dgc_core/fetch.py +124 -0
  15. dgc_core-1.0.0/dgc_core/logger.py +33 -0
  16. dgc_core-1.0.0/dgc_core/merge.py +235 -0
  17. dgc_core-1.0.0/dgc_core/mongo_cache.py +113 -0
  18. dgc_core-1.0.0/dgc_core/normalize.py +27 -0
  19. dgc_core-1.0.0/dgc_core/pricecharting.py +65 -0
  20. dgc_core-1.0.0/dgc_core/pub.py +41 -0
  21. dgc_core-1.0.0/dgc_core/regex.py +17 -0
  22. dgc_core-1.0.0/dgc_core/task.py +123 -0
  23. dgc_core-1.0.0/dgc_core.egg-info/PKG-INFO +118 -0
  24. dgc_core-1.0.0/dgc_core.egg-info/SOURCES.txt +44 -0
  25. dgc_core-1.0.0/dgc_core.egg-info/dependency_links.txt +1 -0
  26. dgc_core-1.0.0/dgc_core.egg-info/requires.txt +18 -0
  27. dgc_core-1.0.0/dgc_core.egg-info/top_level.txt +1 -0
  28. dgc_core-1.0.0/pyproject.toml +52 -0
  29. dgc_core-1.0.0/requirements-dev.txt +1 -0
  30. dgc_core-1.0.0/requirements.txt +1 -0
  31. dgc_core-1.0.0/scripts/release.py +67 -0
  32. dgc_core-1.0.0/setup.cfg +4 -0
  33. dgc_core-1.0.0/tests/test_auth.py +79 -0
  34. dgc_core-1.0.0/tests/test_env.py +44 -0
  35. dgc_core-1.0.0/tests/test_fetch.py +136 -0
  36. dgc_core-1.0.0/tests/test_logger.py +94 -0
  37. dgc_core-1.0.0/tests/test_merge.py +500 -0
  38. dgc_core-1.0.0/tests/test_mongo_cache.py +332 -0
  39. dgc_core-1.0.0/tests/test_normalize.py +74 -0
  40. dgc_core-1.0.0/tests/test_pricecharting.py +230 -0
  41. dgc_core-1.0.0/tests/test_pub.py +101 -0
  42. dgc_core-1.0.0/tests/test_public_api.py +12 -0
  43. dgc_core-1.0.0/tests/test_regex.py +64 -0
  44. dgc_core-1.0.0/tests/test_release.py +149 -0
  45. dgc_core-1.0.0/tests/test_task.py +178 -0
  46. dgc_core-1.0.0/tests/test_types_contract.py +218 -0
@@ -0,0 +1,2 @@
1
+ include Makefile requirements.txt requirements-dev.txt
2
+ include scripts/release.py
@@ -0,0 +1,33 @@
1
+ VENV ?= venv
2
+ PYTHON ?= python
3
+
4
+ ifeq ($(OS),Windows_NT)
5
+ VENV_PYTHON := $(VENV)/Scripts/python.exe
6
+ else
7
+ VENV_PYTHON := $(VENV)/bin/python
8
+ endif
9
+
10
+ .PHONY: venv test coverage-html build check publish bump-patch
11
+
12
+ venv:
13
+ $(PYTHON) -m venv $(VENV)
14
+ $(VENV_PYTHON) -m pip install -r requirements-dev.txt
15
+
16
+ test: venv
17
+ $(VENV_PYTHON) -m pytest --cov=dgc_core --cov-report=term-missing
18
+
19
+ coverage-html: test
20
+ $(VENV_PYTHON) -m coverage html
21
+
22
+ build: venv
23
+ $(PYTHON) scripts/release.py clean-build
24
+ $(VENV_PYTHON) -m build
25
+
26
+ check: build
27
+ $(VENV_PYTHON) -m twine check dist/*
28
+
29
+ publish: check
30
+ $(VENV_PYTHON) -m twine upload dist/*
31
+
32
+ bump-patch:
33
+ $(PYTHON) scripts/release.py bump-patch
@@ -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,91 @@
1
+ # dgc-core
2
+
3
+ Shared Python utilities and domain types for DGC services. The distribution is
4
+ named `dgc-core`; the Python package is `dgc_core`. Requires Python 3.10 or later.
5
+
6
+ This repository combines `opticedge-cloud-utils-python` (`opticedge_cloud_utils`
7
+ 1.0.33) and `opticedge-types-python` (`opticedge_types` 1.0.53). Existing utility
8
+ behavior, public exports, enum values, and eligibility constants are preserved.
9
+
10
+ ## Installation
11
+
12
+ Install from this checkout during development:
13
+
14
+ ```sh
15
+ python -m pip install -e .
16
+ ```
17
+
18
+ After version 1.0.0 is published to your package index, consumers can use
19
+ `dgc-core==1.0.0` in their requirements.
20
+
21
+ ## Imports
22
+
23
+ ```python
24
+ from dgc_core import ApprovalStatus, GradingType, LogLevel, MongoCache, log, require_env
25
+ from dgc_core.pub import publish_message
26
+ from dgc_core.enums.card import CardCategory
27
+ ```
28
+
29
+ Utilities, enums, and constants are exported together from `dgc_core`. Utility
30
+ modules live directly in the package, alongside `enums/` and `constants/`.
31
+ Helpers cover authentication, configuration, HTTP fetching, logging, merging,
32
+ MongoDB cache, normalization, PriceCharting parsing, Pub/Sub, regex, and Cloud
33
+ Tasks. Domain definitions cover cards, users, submissions, and grading eligibility.
34
+
35
+ ## Migrating existing consumers
36
+
37
+ Replace the `opticedge-cloud-utils` and `opticedge-types` requirements with the
38
+ single `dgc-core` requirement after publication, and update imports to `dgc_core`:
39
+
40
+ ```sh
41
+ python -m pip install dgc-core==1.0.0
42
+ ```
43
+
44
+ | Previous import | New import |
45
+ | --- | --- |
46
+ | `from opticedge_cloud_utils import log` | `from dgc_core import log` |
47
+ | `from opticedge_types import GradingType` | `from dgc_core import GradingType` |
48
+ | `from opticedge_cloud_utils.pub import publish_message` | `from dgc_core.pub import publish_message` |
49
+ | `from opticedge_types.enums.card import GradingType` | `from dgc_core.enums.card import GradingType` |
50
+ | `from opticedge_types.constants.card import ON_DEMAND_ELIGIBLE_GRADING_TYPES` | `from dgc_core.constants.card import ON_DEMAND_ELIGIBLE_GRADING_TYPES` |
51
+
52
+ Update mock and patch paths in consumer tests using the same mapping. The
53
+ distribution provides only the `dgc_core` package.
54
+
55
+ Service requirements are not changed by this repository merge. Services pinned
56
+ to older releases should run their own tests when adopting the combined release.
57
+
58
+ ## Development and verification
59
+
60
+ ```sh
61
+ make test
62
+ make check
63
+ ```
64
+
65
+ Without Make, create a virtual environment and activate it, then run:
66
+
67
+ ```sh
68
+ python -m pip install -r requirements-dev.txt
69
+ python -m pytest --cov=dgc_core --cov-report=term-missing
70
+ python -m build
71
+ python -m twine check dist/*
72
+ ```
73
+
74
+ The tests mock external calls. `make build` clears old `build/` and `dist/` output
75
+ and produces a wheel and source distribution for the current version.
76
+
77
+ ## Releasing
78
+
79
+ ```sh
80
+ make bump-patch
81
+ make publish
82
+ ```
83
+
84
+ `make bump-patch` increments `[project].version` in `pyproject.toml` (for example,
85
+ `1.0.0` to `1.0.1`) and preserves the rest of the file's formatting. It uses the
86
+ base Python interpreter and works before creating the virtual environment.
87
+
88
+ `make publish` builds fresh artifacts, checks them with Twine, and uploads them
89
+ to your configured package index (PyPI by default), using your existing Twine
90
+ credentials or configuration. Publishing uses the current version; it does not
91
+ bump the version automatically.
@@ -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
+ ]
@@ -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
@@ -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"
@@ -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
@@ -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
@@ -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")