keble-data-infra-contract 0.1.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 (44) hide show
  1. keble_data_infra_contract-0.1.0/.gitignore +42 -0
  2. keble_data_infra_contract-0.1.0/PKG-INFO +55 -0
  3. keble_data_infra_contract-0.1.0/README.md +45 -0
  4. keble_data_infra_contract-0.1.0/pyproject.toml +36 -0
  5. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/__init__.py +13 -0
  6. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/errors.py +60 -0
  7. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/manifests.py +84 -0
  8. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/__init__.py +0 -0
  9. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/base.py +23 -0
  10. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/creator.py +54 -0
  11. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/creator_link.py +56 -0
  12. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/identity.py +121 -0
  13. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/listing.py +57 -0
  14. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/metric.py +222 -0
  15. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/offer.py +68 -0
  16. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/product.py +42 -0
  17. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/provenance.py +68 -0
  18. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/store.py +29 -0
  19. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/video.py +106 -0
  20. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/video_enrichment.py +414 -0
  21. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/operations.py +60 -0
  22. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/__init__.py +2 -0
  23. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/creator_provider.py +42 -0
  24. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/provider.py +58 -0
  25. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/relation_provider.py +47 -0
  26. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/seller_provider.py +34 -0
  27. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/video_enrichment.py +152 -0
  28. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/video_provider.py +41 -0
  29. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/__init__.py +2 -0
  30. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/query.py +397 -0
  31. keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/result.py +157 -0
  32. keble_data_infra_contract-0.1.0/tests/conftest.py +17 -0
  33. keble_data_infra_contract-0.1.0/tests/contract/test_creator_ranking.py +214 -0
  34. keble_data_infra_contract-0.1.0/tests/contract/test_dependency_direction.py +41 -0
  35. keble_data_infra_contract-0.1.0/tests/contract/test_manifests.py +74 -0
  36. keble_data_infra_contract-0.1.0/tests/contract/test_queries.py +39 -0
  37. keble_data_infra_contract-0.1.0/tests/contract/test_ranking.py +282 -0
  38. keble_data_infra_contract-0.1.0/tests/unit/test_contract_model.py +34 -0
  39. keble_data_infra_contract-0.1.0/tests/unit/test_identity.py +152 -0
  40. keble_data_infra_contract-0.1.0/tests/unit/test_money_identity.py +73 -0
  41. keble_data_infra_contract-0.1.0/tests/unit/test_product.py +65 -0
  42. keble_data_infra_contract-0.1.0/tests/unit/test_provenance.py +58 -0
  43. keble_data_infra_contract-0.1.0/tests/unit/test_video.py +184 -0
  44. keble_data_infra_contract-0.1.0/tests/unit/test_video_enrichment.py +192 -0
@@ -0,0 +1,42 @@
1
+ # Generated by Cargo
2
+ # will have compiled files and executables
3
+ debug/
4
+ target/
5
+ cert/
6
+
7
+ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
8
+ # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
9
+ Cargo.lock
10
+
11
+ # These are backup files generated by rustfmt
12
+ **/*.rs.bk
13
+
14
+ # MSVC Windows builds of rustc generate these, which store debugging information
15
+ *.pdb
16
+ *__ignored__*
17
+
18
+ *.env
19
+ **/.env.docker
20
+ *.DS_Store
21
+ **/.DS_Store
22
+
23
+ .vscode
24
+ .idea
25
+
26
+ *.crt
27
+
28
+ uploads/
29
+ !strapi-v5/public/uploads/
30
+ !strapi-v5/public/uploads/.gitkeep
31
+ test/
32
+ resources/
33
+ Brewfile
34
+ qdrant/
35
+ *.pyc
36
+ **.pyc.*
37
+ */traefik/certs
38
+ */traefik/certs/*
39
+ gcp.json
40
+ .history
41
+
42
+ prod.env.*
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: keble-data-infra-contract
3
+ Version: 0.1.0
4
+ Summary: Framework-free raw commerce contracts shared by Keble data providers and the raw API.
5
+ Author-email: zhenhao-ma <bob0103779@gmail.com>
6
+ Requires-Python: <3.14,>=3.13
7
+ Requires-Dist: keble-helpers<2,>=1.50.0
8
+ Requires-Dist: pydantic<3,>=2.10
9
+ Description-Content-Type: text/markdown
10
+
11
+ # keble-data-infra-contract
12
+
13
+ Install the provider-neutral public contract with
14
+ `pip install "keble-data-infra-contract>=0.1.0,<1"`.
15
+
16
+ Framework-free, provider-neutral raw-commerce contracts. This distribution owns
17
+ validated request/response models, provider protocols, operation keys, manifests,
18
+ and typed domain errors. It intentionally has no FastAPI, database, HTTP client,
19
+ provider, platform, or idea dependency.
20
+
21
+ The distribution declares only registry-resolvable runtime dependencies. Do not
22
+ add workspace-relative `tool.uv.sources` here: downstream Git/subdirectory installs
23
+ must build this contract without inheriting paths outside the repository checkout.
24
+
25
+ Provider packages own implementations and native DTOs. The raw API consumes
26
+ provider manifests and constructs HTTP paths from their typed operation keys.
27
+
28
+ ```bash
29
+ uv run --package keble-data-infra-contract pytest -q keble-data-infra-contract/tests
30
+ uv run --package keble-data-infra-contract npx --yes pyright keble-data-infra-contract
31
+ uv build --package keble-data-infra-contract
32
+ ```
33
+
34
+ Contract values inherit `ContractModel`, which accepts snake-case Python names
35
+ and camel-case wire aliases, rejects undeclared fields, and is frozen. Persisted
36
+ Mongo shapes do not belong here; provider/API packages own `SchemaBase` /
37
+ `MongoObjectBase` aggregates when durability is required.
38
+ `ContractModel` and the deliberately producer-tolerant Job/Result projection
39
+ both configure Pydantic through `keble-helpers.PydanticModelConfig`; a local
40
+ `ConfigDict` convention would split alias and validation ownership again.
41
+
42
+ `Money`, `Currency`, and `AmazonMarketplace` are imported from
43
+ `keble-helpers`. Provider-native response details remain inside the provider
44
+ package instead of crossing this boundary through an untyped extension bag.
45
+
46
+ Video enrichment uses the same framework-free boundary: immutable asset,
47
+ submission, job, completion-event, result, and normalized-projection models plus
48
+ one `VideoEnrichmentGateway` Protocol. HTTP submission and Mongo correlation
49
+ remain implementations owned by `keble-data-infra-api`.
50
+
51
+ Authorized provider transcripts use one canonical `VideoTranscript`/
52
+ `VideoTranscriptSegment` shape across submission, authoritative result reads, and
53
+ the local projection. Reuse evidence binds the normalized transcript checksum to
54
+ the data-infra source reference and media SHA-256; a title/description such as
55
+ `ShortVideo.caption` is not a spoken transcript and cannot bypass ASR.
@@ -0,0 +1,45 @@
1
+ # keble-data-infra-contract
2
+
3
+ Install the provider-neutral public contract with
4
+ `pip install "keble-data-infra-contract>=0.1.0,<1"`.
5
+
6
+ Framework-free, provider-neutral raw-commerce contracts. This distribution owns
7
+ validated request/response models, provider protocols, operation keys, manifests,
8
+ and typed domain errors. It intentionally has no FastAPI, database, HTTP client,
9
+ provider, platform, or idea dependency.
10
+
11
+ The distribution declares only registry-resolvable runtime dependencies. Do not
12
+ add workspace-relative `tool.uv.sources` here: downstream Git/subdirectory installs
13
+ must build this contract without inheriting paths outside the repository checkout.
14
+
15
+ Provider packages own implementations and native DTOs. The raw API consumes
16
+ provider manifests and constructs HTTP paths from their typed operation keys.
17
+
18
+ ```bash
19
+ uv run --package keble-data-infra-contract pytest -q keble-data-infra-contract/tests
20
+ uv run --package keble-data-infra-contract npx --yes pyright keble-data-infra-contract
21
+ uv build --package keble-data-infra-contract
22
+ ```
23
+
24
+ Contract values inherit `ContractModel`, which accepts snake-case Python names
25
+ and camel-case wire aliases, rejects undeclared fields, and is frozen. Persisted
26
+ Mongo shapes do not belong here; provider/API packages own `SchemaBase` /
27
+ `MongoObjectBase` aggregates when durability is required.
28
+ `ContractModel` and the deliberately producer-tolerant Job/Result projection
29
+ both configure Pydantic through `keble-helpers.PydanticModelConfig`; a local
30
+ `ConfigDict` convention would split alias and validation ownership again.
31
+
32
+ `Money`, `Currency`, and `AmazonMarketplace` are imported from
33
+ `keble-helpers`. Provider-native response details remain inside the provider
34
+ package instead of crossing this boundary through an untyped extension bag.
35
+
36
+ Video enrichment uses the same framework-free boundary: immutable asset,
37
+ submission, job, completion-event, result, and normalized-projection models plus
38
+ one `VideoEnrichmentGateway` Protocol. HTTP submission and Mongo correlation
39
+ remain implementations owned by `keble-data-infra-api`.
40
+
41
+ Authorized provider transcripts use one canonical `VideoTranscript`/
42
+ `VideoTranscriptSegment` shape across submission, authoritative result reads, and
43
+ the local projection. Reuse evidence binds the normalized transcript checksum to
44
+ the data-infra source reference and media SHA-256; a title/description such as
45
+ `ShortVideo.caption` is not a spoken transcript and cannot bypass ASR.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "keble-data-infra-contract"
7
+ version = "0.1.0"
8
+ description = "Framework-free raw commerce contracts shared by Keble data providers and the raw API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13,<3.14"
11
+ dependencies = [
12
+ "keble-helpers>=1.50.0,<2",
13
+ "pydantic>=2.10,<3",
14
+ ]
15
+
16
+ [[project.authors]]
17
+ name = "zhenhao-ma"
18
+ email = "bob0103779@gmail.com"
19
+
20
+ [dependency-groups]
21
+ test = [
22
+ "pytest>=8.2,<9",
23
+ "pytest-asyncio>=1.4,<2",
24
+ ]
25
+
26
+ [tool.uv]
27
+ default-groups = ["test"]
28
+
29
+ [tool.pytest.ini_options]
30
+ addopts = "--strict-markers"
31
+ testpaths = ["tests"]
32
+ asyncio_mode = "auto"
33
+ markers = [
34
+ "unit: fast deterministic tests with no network",
35
+ "contract: recorded or fixture-based external contract tests",
36
+ ]
@@ -0,0 +1,13 @@
1
+ """Public framework-free contract surface for Keble raw data providers."""
2
+
3
+ from .manifests import ProviderManifest, ServiceOperation
4
+ from .operations import CommerceAction, CommerceResource, RouteKey
5
+
6
+ __all__ = [
7
+ "CommerceAction",
8
+ "CommerceResource",
9
+ "ProviderManifest",
10
+ "RouteKey",
11
+ "ServiceOperation",
12
+ ]
13
+
@@ -0,0 +1,60 @@
1
+ """Typed errors shared by provider services and the raw API boundary."""
2
+
3
+ from .operations import RouteKey
4
+ from .models.identity import Channel, ProviderId
5
+
6
+
7
+ class DataInfraError(Exception):
8
+ """Base class for expected raw-data contract and provider failures."""
9
+
10
+
11
+ class DuplicateOperationError(DataInfraError):
12
+ """Raised when two manifests declare the same provider operation key."""
13
+
14
+ def __init__(self, *, key: RouteKey) -> None:
15
+ super().__init__(f"duplicate provider operation: {key.operation_id()}")
16
+ self.key = key
17
+
18
+
19
+ class OperationNotSupportedError(DataInfraError):
20
+ """Raised when a provider does not declare the requested capability."""
21
+
22
+ def __init__(self, *, key: RouteKey) -> None:
23
+ super().__init__(f"provider operation is not supported: {key.operation_id()}")
24
+ self.key = key
25
+
26
+
27
+ class ProviderNotFoundError(DataInfraError):
28
+ """Raised when provider discovery targets an unregistered identity."""
29
+
30
+ def __init__(self, *, channel: Channel, provider_id: ProviderId) -> None:
31
+ super().__init__(
32
+ f"provider is not registered: channel={channel.value}, provider={provider_id.root}"
33
+ )
34
+ self.channel = channel
35
+ self.provider_id = provider_id
36
+
37
+
38
+ class ProviderRequestValidationError(DataInfraError):
39
+ """Raised when a provider capability requires a missing or invalid field."""
40
+
41
+ def __init__(self, *, field: str, message: str) -> None:
42
+ super().__init__(f"invalid provider request field {field}: {message}")
43
+ self.field = field
44
+ self.message = message
45
+
46
+
47
+ class ProviderUnavailableError(DataInfraError):
48
+ """Raised when a declared provider cannot complete an upstream request."""
49
+
50
+
51
+ class ProviderAuthenticationError(DataInfraError):
52
+ """Raised when provider credentials are absent or rejected."""
53
+
54
+
55
+ class ProviderRateLimitError(DataInfraError):
56
+ """Raised when an upstream provider rejects work because of a rate limit."""
57
+
58
+
59
+ class ProviderTimeoutError(DataInfraError):
60
+ """Raised when an upstream provider exceeds its configured timeout."""
@@ -0,0 +1,84 @@
1
+ """Strongly typed provider operation manifests without HTTP dependencies."""
2
+
3
+ from collections.abc import Awaitable, Callable, Iterable
4
+ from dataclasses import dataclass
5
+ from typing import Generic, TypeVar, cast
6
+
7
+ from .errors import DuplicateOperationError
8
+ from .models.base import ContractModel
9
+ from .models.identity import Channel, ProviderId
10
+ from .operations import RouteKey
11
+
12
+ RequestT = TypeVar("RequestT", bound=ContractModel)
13
+ ResponseT = TypeVar("ResponseT", bound=ContractModel)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ServiceOperation(Generic[RequestT, ResponseT]):
18
+ """Bind one provider-owned service method to a framework-free operation key.
19
+
20
+ Side effects if changes:
21
+ - provider packages construct these bindings in `provider_manifest`;
22
+ - the API registry validates requests and responses through them;
23
+ - route generation uses their request and response model classes.
24
+ """
25
+
26
+ key: RouteKey
27
+ request_model: type[RequestT]
28
+ response_model: type[ResponseT]
29
+ handler: Callable[[RequestT], Awaitable[ResponseT]]
30
+
31
+ async def invoke(self, request: ContractModel) -> ContractModel:
32
+ """Validate the erased registry input, invoke, and verify output type."""
33
+
34
+ typed_request = self.request_model.model_validate(request)
35
+ response = await self.handler(typed_request)
36
+ return self.response_model.model_validate(response)
37
+
38
+ def erase(self) -> "ServiceOperation[ContractModel, ContractModel]":
39
+ """Erase generic parameters only at the heterogeneous registry boundary."""
40
+
41
+ return cast("ServiceOperation[ContractModel, ContractModel]", self)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ProviderManifest:
46
+ """Complete, explicit capability set exported by one provider service.
47
+
48
+ Steps:
49
+ 1. validate every operation belongs to the manifest channel/provider;
50
+ 2. reject duplicate route keys before API startup;
51
+ 3. expose only registered operations as provider capabilities.
52
+
53
+ Side effects if changes:
54
+ - raw API startup and provider discovery;
55
+ - Amazon and TikTok service exports;
56
+ - duplicate route and OpenAPI guards.
57
+ """
58
+
59
+ channel: Channel
60
+ provider_id: ProviderId
61
+ operations: tuple[ServiceOperation[ContractModel, ContractModel], ...]
62
+
63
+ @classmethod
64
+ def build(
65
+ cls,
66
+ *,
67
+ channel: Channel,
68
+ provider_id: ProviderId,
69
+ operations: Iterable[ServiceOperation[ContractModel, ContractModel]],
70
+ ) -> "ProviderManifest":
71
+ """Validate ownership and uniqueness while freezing one manifest."""
72
+
73
+ materialized = tuple(operations)
74
+ seen: set[RouteKey] = set()
75
+ for operation in materialized:
76
+ if operation.key.channel is not channel:
77
+ raise ValueError("manifest operation channel does not match manifest owner")
78
+ if operation.key.provider_id != provider_id:
79
+ raise ValueError("manifest operation provider does not match manifest owner")
80
+ if operation.key in seen:
81
+ raise DuplicateOperationError(key=operation.key)
82
+ seen.add(operation.key)
83
+ return cls(channel=channel, provider_id=provider_id, operations=materialized)
84
+
@@ -0,0 +1,23 @@
1
+ """Shared model inheritance for framework-free data-infra contracts."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from keble_helpers.typings import PydanticModelConfig
6
+
7
+
8
+ class ContractModel(BaseModel):
9
+ """Base for immutable camel-case raw-data contracts.
10
+
11
+ Steps:
12
+ 1. accept Python snake_case and HTTP camelCase field names;
13
+ 2. reject undeclared provider payload fields;
14
+ 3. keep validated contract values immutable across adapters.
15
+
16
+ Side effects if changes:
17
+ - every provider manifest request and response;
18
+ - `keble-data-infra-api` OpenAPI generation;
19
+ - the Stand Out raw HTTP adapter fixtures.
20
+ """
21
+
22
+ model_config = PydanticModelConfig.default(extra="forbid", frozen=True)
23
+
@@ -0,0 +1,54 @@
1
+ """CommerceCreator — universal channel creator entity.
2
+
3
+ A CommerceCreator is a content creator / influencer as they appear on a
4
+ specific commerce channel (TikTok today; other creator-carrying channels
5
+ later). It is the ranked entity of CreatorRankingProvider.rank_creators.
6
+
7
+ Design rules (mirrors CommerceListing):
8
+ - Provider-native creator details remain internal to the provider package.
9
+ - ``metrics`` is a list of MetricObservation (entity_scope=CREATOR), not a
10
+ KV bag. Follower counts, follower growth, and attributed GMV all flow
11
+ through MetricObservation.
12
+ - ``identities`` preserves the provider-native creator ID alongside the
13
+ channel creator ID for deduplication and provenance.
14
+ - Only the cross-channel universal core lives here (id, handle, display
15
+ name, avatar, market, metrics). Channel-specific scores/flags remain in
16
+ the provider package.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from keble_data_infra_contract.models.base import ContractModel
22
+
23
+ from pydantic import Field
24
+
25
+ from keble_data_infra_contract.models.identity import Channel, ExternalIdentity
26
+ from keble_data_infra_contract.models.metric import MetricObservation
27
+ from keble_data_infra_contract.models.provenance import Provenance
28
+
29
+
30
+ class CommerceCreator(ContractModel):
31
+ """A creator on a specific commerce channel in a specific market.
32
+
33
+ FastMoss / TikTok mapping (Phase 2):
34
+ channel ← Channel.TIKTOK
35
+ market ← item.region (e.g., "US")
36
+ channel_creator_id ← creator.uid
37
+ handle ← creator.unique_id (the @handle)
38
+ display_name ← creator.nickname
39
+ avatar_url ← creator.avatar
40
+ identities ← [ExternalIdentity(provider=fastmoss, namespace="tiktok_creator_uid", …)]
41
+ metrics ← [MetricObservation(follower_count/follower_growth/gmv, CREATOR scope), …]
42
+ provenance ← Provenance(provider=fastmoss, captured_at=…)
43
+ """
44
+
45
+
46
+ channel: Channel
47
+ market: str | None = None
48
+ channel_creator_id: str
49
+ handle: str | None = None
50
+ display_name: str | None = None
51
+ avatar_url: str | None = None
52
+ identities: list[ExternalIdentity] = Field(default_factory=list)
53
+ metrics: list[MetricObservation] = Field(default_factory=list)
54
+ provenance: Provenance | None = None
@@ -0,0 +1,56 @@
1
+ """CreatorProductLink — universal creator↔product promotion relation (Phase 3).
2
+
3
+ Represents the "creator promotes product" edge in the Product↔Creator
4
+ relation graph. A single relation type (PROMOTES) is used for both
5
+ directions of the query (product→creators via R7, creator→products via R8).
6
+
7
+ Design rules (mirrors CommerceListing / CommerceCreator):
8
+ - ``creator`` and ``listing`` hold the universal commerce entities.
9
+ - ``metrics`` carries ONLY the per-pair attributed metrics (units_sold and
10
+ gmv attributed to THIS creator for THIS product specifically), NOT the
11
+ creator's all-product totals or the product's all-creator totals.
12
+ - ``entity_scope=LISTING`` is used for attributed sales/gmv metrics on a
13
+ listing; ``entity_scope=CREATOR`` is used for creator-scoped metrics.
14
+ - ``provenance`` records which provider supplied this relation edge.
15
+
16
+ Source semantics (carried transparently from the channel layer):
17
+ - R7 (/product/v1/creatorList): snapshot relation, no ``window_days``.
18
+ - R8 (/creator/v1/productList): rolling ``window_days`` ∈ {0,7,14,28,90}.
19
+ ``window_days=None`` means the channel layer did not supply a window
20
+ (snapshot or unknown).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from keble_data_infra_contract.models.base import ContractModel
26
+
27
+ from typing import Literal
28
+
29
+ from pydantic import Field
30
+
31
+ from keble_data_infra_contract.models.creator import CommerceCreator
32
+ from keble_data_infra_contract.models.listing import CommerceListing
33
+ from keble_data_infra_contract.models.metric import MetricObservation
34
+ from keble_data_infra_contract.models.provenance import Provenance
35
+
36
+
37
+ class CreatorProductLink(ContractModel):
38
+ """A creator↔product promotion relation with attributed metrics.
39
+
40
+ ``relation`` is always "PROMOTES" (the only currently verified relation
41
+ type; other types may be introduced in later phases without breaking this
42
+ model since it is typed as a Literal).
43
+ ``metrics`` contains attributed MetricObservation objects only (units_sold
44
+ and gmv for this specific pair).
45
+ ``window_days`` is the original R8 ``days`` parameter; None for R7 snapshots.
46
+ Provider-specific link attribution that lacks a normalized representation
47
+ stays inside the provider package and is not exposed as an escape hatch.
48
+ """
49
+
50
+
51
+ creator: CommerceCreator
52
+ listing: CommerceListing
53
+ relation: Literal["PROMOTES"] = "PROMOTES"
54
+ metrics: list[MetricObservation] = Field(default_factory=list)
55
+ window_days: int | None = None
56
+ provenance: Provenance | None = None
@@ -0,0 +1,121 @@
1
+ """Universal identity types for the Commerce layer.
2
+
3
+ ProviderId — validated string key for a data provider (open set: fastmoss, echotik, …).
4
+ Channel — closed enum of buyer-facing commerce channels (TikTok, Amazon, Shopify).
5
+ ExternalIdentity — provider-native identity tuple for a commerce entity.
6
+
7
+ Design rules:
8
+ - Channel is a closed enum: channels are structurally significant cross-channel keys.
9
+ - ProviderId is an open RootModel[str]: new providers do not require an enum change.
10
+ - ExternalIdentity.identity_key() is deterministic — equal inputs always produce
11
+ the same string, making it safe to use as a deduplication / merge key.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from keble_data_infra_contract.models.base import ContractModel
17
+
18
+ from enum import Enum
19
+
20
+ from pydantic import Field, RootModel, field_validator
21
+ from keble_helpers.typings import PydanticModelConfig
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # ProviderId
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class ProviderId(RootModel[str]):
30
+ """Validated string identity for a commerce data provider.
31
+
32
+ Rules: lowercase letters, digits, and underscores only.
33
+ The set of providers is open — new providers do not need an enum change.
34
+ """
35
+
36
+ model_config = PydanticModelConfig.default(frozen=True)
37
+
38
+ @field_validator("root")
39
+ @classmethod
40
+ def _validate(cls, value: str) -> str:
41
+ normalized = value.strip().lower()
42
+ if not normalized or not normalized.replace("_", "").isalnum():
43
+ raise ValueError(
44
+ "ProviderId must contain only lowercase letters, digits, or underscores"
45
+ )
46
+ return normalized
47
+
48
+ def __str__(self) -> str:
49
+ return self.root
50
+
51
+ def __repr__(self) -> str:
52
+ return f"ProviderId({self.root!r})"
53
+
54
+ def __eq__(self, other: object) -> bool:
55
+ if isinstance(other, ProviderId):
56
+ return self.root == other.root
57
+ if isinstance(other, str):
58
+ return self.root == other
59
+ return NotImplemented
60
+
61
+ def __hash__(self) -> int:
62
+ return hash(self.root)
63
+
64
+
65
+ # Well-known provider constants — the set remains open for extension.
66
+ PROVIDER_FASTMOSS = ProviderId("fastmoss")
67
+ PROVIDER_ECHOTIK = ProviderId("echotik")
68
+ PROVIDER_KEEPA = ProviderId("keepa")
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Channel
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class Channel(str, Enum):
77
+ """Closed enum of buyer-facing commerce channels.
78
+
79
+ Channels are structural keys in the Universal model (e.g., routing,
80
+ cross-channel deduplication), so they must be enumerated and compile-time
81
+ checkable — unlike providers, which are open.
82
+ """
83
+
84
+ TIKTOK = "tiktok"
85
+ AMAZON = "amazon"
86
+ SHOPIFY = "shopify"
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # ExternalIdentity
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class ExternalIdentity(ContractModel):
95
+ """Provider-native identity for a single commerce entity.
96
+
97
+ The four-tuple (provider, namespace, value, sorted scope) forms a stable,
98
+ deterministic key used for deduplication and merge operations.
99
+
100
+ Rules:
101
+ - ``value`` must be the actual external ID string — never a derived or
102
+ fabricated value.
103
+ - ``namespace`` distinguishes identity types within a provider
104
+ (e.g., "tiktok_product_id" vs "tiktok_seller_id").
105
+ - ``scope`` captures contextual qualifiers such as region; it is optional
106
+ but must not be used to carry business metrics.
107
+ """
108
+
109
+
110
+ provider: ProviderId
111
+ namespace: str
112
+ value: str
113
+ scope: dict[str, str] = Field(default_factory=dict)
114
+
115
+ def identity_key(self) -> str:
116
+ """Stable, deterministic key from all four components.
117
+
118
+ Suitable for use as a deduplication or merge key across sources.
119
+ """
120
+ sorted_scope = "|".join(f"{k}={v}" for k, v in sorted(self.scope.items()))
121
+ return f"{self.provider}:{self.namespace}:{self.value}:{sorted_scope}"
@@ -0,0 +1,57 @@
1
+ """CommerceListing — universal channel listing entity.
2
+
3
+ A CommerceListing is a product as it appears in a specific channel and market.
4
+ It is the primary cross-channel unit of comparison.
5
+
6
+ Design rules (§4.3):
7
+ - Provider-native details remain inside the provider package and do not cross
8
+ the normalized public contract.
9
+ - ``metrics`` is a list of MetricObservation, not a KV bag.
10
+ - ``identities`` preserves the provider-native ID alongside the channel
11
+ product ID for deduplication and provenance.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from keble_data_infra_contract.models.base import ContractModel
17
+
18
+ from pydantic import Field
19
+
20
+ from keble_data_infra_contract.models.identity import Channel, ExternalIdentity
21
+ from keble_data_infra_contract.models.metric import MetricObservation
22
+ from keble_data_infra_contract.models.offer import CommerceOffer
23
+ from keble_data_infra_contract.models.provenance import Provenance
24
+
25
+
26
+ class CommerceListing(ContractModel):
27
+ """A product listed on a specific commerce channel in a specific market.
28
+
29
+ FastMoss / TikTok mapping (first chain):
30
+ channel ← Channel.TIKTOK
31
+ market ← item.region (e.g., "US")
32
+ channel_product_id ← item.tiktok_product_id
33
+ identities ← [ExternalIdentity(provider=fastmoss, …)]
34
+ store_ref ← item.seller_id (as string)
35
+ offer ← CommerceOffer(current_price=Money(…))
36
+ metrics ← [MetricObservation(sales_quantity, D7, …), …]
37
+ provenance ← Provenance(provider=fastmoss, captured_at=…)
38
+ """
39
+
40
+
41
+ channel: Channel
42
+ market: str | None = None
43
+ channel_product_id: str
44
+ # Universal display fields (Phase A). Cross-channel stable semantics only:
45
+ # title ← FastMoss item.title / EchoTik product_name / Keepa title
46
+ # primary_image_url ← FastMoss item.cover / EchoTik cover_url / Keepa main image
47
+ # A provider that does not return them leaves them None (honest absence —
48
+ # e.g. EchoTik product/ranklist has no cover field). The URL is carried
49
+ # as-is: it may be a CDN link that expires upstream; it is never rewritten
50
+ # or fabricated. Channel-specific media fields stay in channel_details.
51
+ title: str | None = None
52
+ primary_image_url: str | None = None
53
+ identities: list[ExternalIdentity] = Field(default_factory=list)
54
+ store_ref: str | None = None
55
+ offer: CommerceOffer | None = None
56
+ metrics: list[MetricObservation] = Field(default_factory=list)
57
+ provenance: Provenance | None = None