metaspn-schemas 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.
- metaspn_schemas-0.1.0/LICENSE +21 -0
- metaspn_schemas-0.1.0/PKG-INFO +119 -0
- metaspn_schemas-0.1.0/README.md +97 -0
- metaspn_schemas-0.1.0/pyproject.toml +36 -0
- metaspn_schemas-0.1.0/setup.cfg +4 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/__init__.py +46 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/core.py +67 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/entities.py +34 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/features.py +46 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/outcomes.py +47 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/social.py +38 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/state_fragments.py +50 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/tasks.py +31 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/utils/__init__.py +14 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/utils/ids.py +21 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/utils/serde.py +130 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas/utils/time.py +24 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas.egg-info/PKG-INFO +119 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas.egg-info/SOURCES.txt +23 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas.egg-info/dependency_links.txt +1 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas.egg-info/requires.txt +5 -0
- metaspn_schemas-0.1.0/src/metaspn_schemas.egg-info/top_level.txt +1 -0
- metaspn_schemas-0.1.0/test/test_backcompat.py +24 -0
- metaspn_schemas-0.1.0/test/test_ids.py +20 -0
- metaspn_schemas-0.1.0/test/test_serde.py +173 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MetaSPN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaspn-schemas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Canonical schemas for MetaSPN-compatible signal processing systems
|
|
5
|
+
Author: MetaSPN
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MetaSPN/metaspn-schemas
|
|
8
|
+
Project-URL: Repository, https://github.com/MetaSPN/metaspn-schemas
|
|
9
|
+
Project-URL: Issues, https://github.com/MetaSPN/metaspn-schemas/issues
|
|
10
|
+
Keywords: schemas,signal-processing,events,dataclasses,metaspn
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# metaspn-schemas
|
|
24
|
+
|
|
25
|
+
Canonical, stdlib-only schema package for MetaSPN-compatible systems.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install metaspn-schemas
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install -e .[dev]
|
|
37
|
+
pytest -q
|
|
38
|
+
python -m build
|
|
39
|
+
python -m twine check dist/*
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Design constraints
|
|
43
|
+
|
|
44
|
+
- Tiny and dependency-light (stdlib only)
|
|
45
|
+
- Frozen dataclasses for immutable-by-default objects
|
|
46
|
+
- Explicit `schema_version` on all public objects
|
|
47
|
+
- `to_dict()` / `from_dict()` on every schema
|
|
48
|
+
- UTC ISO-8601 datetime serialization
|
|
49
|
+
- Traceability metadata (`trace_id`, `caused_by`, provenance)
|
|
50
|
+
- Privacy mode support (`to_dict(privacy_mode=True)` omits raw blobs)
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import timezone, datetime
|
|
56
|
+
|
|
57
|
+
from metaspn_schemas import SignalEnvelope
|
|
58
|
+
from metaspn_schemas.utils.ids import generate_id
|
|
59
|
+
|
|
60
|
+
signal = SignalEnvelope(
|
|
61
|
+
signal_id=generate_id("signal"),
|
|
62
|
+
timestamp=datetime.now(timezone.utc),
|
|
63
|
+
source="linkedin.webhook",
|
|
64
|
+
payload_type="SocialPostSeen",
|
|
65
|
+
payload={"post_id": "123", "platform": "linkedin"},
|
|
66
|
+
schema_version="0.1",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
as_dict = signal.to_dict()
|
|
70
|
+
round_trip = SignalEnvelope.from_dict(as_dict)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Public imports
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from metaspn_schemas import (
|
|
77
|
+
SignalEnvelope,
|
|
78
|
+
EmissionEnvelope,
|
|
79
|
+
Task,
|
|
80
|
+
Result,
|
|
81
|
+
SocialPostSeen,
|
|
82
|
+
ProfileEnriched,
|
|
83
|
+
ScoresComputed,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Package layout
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
metaspn-schemas/
|
|
91
|
+
pyproject.toml
|
|
92
|
+
README.md
|
|
93
|
+
src/metaspn_schemas/
|
|
94
|
+
__init__.py
|
|
95
|
+
core.py
|
|
96
|
+
tasks.py
|
|
97
|
+
entities.py
|
|
98
|
+
social.py
|
|
99
|
+
outcomes.py
|
|
100
|
+
features.py
|
|
101
|
+
state_fragments.py
|
|
102
|
+
utils/
|
|
103
|
+
ids.py
|
|
104
|
+
time.py
|
|
105
|
+
serde.py
|
|
106
|
+
test/
|
|
107
|
+
test_serde.py
|
|
108
|
+
test_ids.py
|
|
109
|
+
test_backcompat.py
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Release
|
|
113
|
+
|
|
114
|
+
Release automation is configured in:
|
|
115
|
+
|
|
116
|
+
- `.github/workflows/ci.yml`
|
|
117
|
+
- `.github/workflows/publish.yml`
|
|
118
|
+
|
|
119
|
+
See `RELEASE.md` for the end-to-end push + PyPI release flow.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# metaspn-schemas
|
|
2
|
+
|
|
3
|
+
Canonical, stdlib-only schema package for MetaSPN-compatible systems.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install metaspn-schemas
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Development
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m pip install -e .[dev]
|
|
15
|
+
pytest -q
|
|
16
|
+
python -m build
|
|
17
|
+
python -m twine check dist/*
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Design constraints
|
|
21
|
+
|
|
22
|
+
- Tiny and dependency-light (stdlib only)
|
|
23
|
+
- Frozen dataclasses for immutable-by-default objects
|
|
24
|
+
- Explicit `schema_version` on all public objects
|
|
25
|
+
- `to_dict()` / `from_dict()` on every schema
|
|
26
|
+
- UTC ISO-8601 datetime serialization
|
|
27
|
+
- Traceability metadata (`trace_id`, `caused_by`, provenance)
|
|
28
|
+
- Privacy mode support (`to_dict(privacy_mode=True)` omits raw blobs)
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from datetime import timezone, datetime
|
|
34
|
+
|
|
35
|
+
from metaspn_schemas import SignalEnvelope
|
|
36
|
+
from metaspn_schemas.utils.ids import generate_id
|
|
37
|
+
|
|
38
|
+
signal = SignalEnvelope(
|
|
39
|
+
signal_id=generate_id("signal"),
|
|
40
|
+
timestamp=datetime.now(timezone.utc),
|
|
41
|
+
source="linkedin.webhook",
|
|
42
|
+
payload_type="SocialPostSeen",
|
|
43
|
+
payload={"post_id": "123", "platform": "linkedin"},
|
|
44
|
+
schema_version="0.1",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
as_dict = signal.to_dict()
|
|
48
|
+
round_trip = SignalEnvelope.from_dict(as_dict)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Public imports
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from metaspn_schemas import (
|
|
55
|
+
SignalEnvelope,
|
|
56
|
+
EmissionEnvelope,
|
|
57
|
+
Task,
|
|
58
|
+
Result,
|
|
59
|
+
SocialPostSeen,
|
|
60
|
+
ProfileEnriched,
|
|
61
|
+
ScoresComputed,
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Package layout
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
metaspn-schemas/
|
|
69
|
+
pyproject.toml
|
|
70
|
+
README.md
|
|
71
|
+
src/metaspn_schemas/
|
|
72
|
+
__init__.py
|
|
73
|
+
core.py
|
|
74
|
+
tasks.py
|
|
75
|
+
entities.py
|
|
76
|
+
social.py
|
|
77
|
+
outcomes.py
|
|
78
|
+
features.py
|
|
79
|
+
state_fragments.py
|
|
80
|
+
utils/
|
|
81
|
+
ids.py
|
|
82
|
+
time.py
|
|
83
|
+
serde.py
|
|
84
|
+
test/
|
|
85
|
+
test_serde.py
|
|
86
|
+
test_ids.py
|
|
87
|
+
test_backcompat.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Release
|
|
91
|
+
|
|
92
|
+
Release automation is configured in:
|
|
93
|
+
|
|
94
|
+
- `.github/workflows/ci.yml`
|
|
95
|
+
- `.github/workflows/publish.yml`
|
|
96
|
+
|
|
97
|
+
See `RELEASE.md` for the end-to-end push + PyPI release flow.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metaspn-schemas"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Canonical schemas for MetaSPN-compatible signal processing systems"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "MetaSPN" }]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
keywords = ["schemas", "signal-processing", "events", "dataclasses", "metaspn"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/MetaSPN/metaspn-schemas"
|
|
23
|
+
Repository = "https://github.com/MetaSPN/metaspn-schemas"
|
|
24
|
+
Issues = "https://github.com/MetaSPN/metaspn-schemas/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8", "build>=1.2.0", "twine>=5.0.0"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = { "" = "src" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["test"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from metaspn_schemas.core import (
|
|
2
|
+
EmissionEnvelope,
|
|
3
|
+
EntityRef,
|
|
4
|
+
SchemaVersion,
|
|
5
|
+
SignalEnvelope,
|
|
6
|
+
TraceContext,
|
|
7
|
+
)
|
|
8
|
+
from metaspn_schemas.entities import EntityAliasAdded, EntityMerged, EntityResolved
|
|
9
|
+
from metaspn_schemas.features import (
|
|
10
|
+
GameClassified,
|
|
11
|
+
PlaybookRouted,
|
|
12
|
+
ProfileEnriched,
|
|
13
|
+
ScoresComputed,
|
|
14
|
+
)
|
|
15
|
+
from metaspn_schemas.outcomes import MeetingBooked, MessageSent, ReplyReceived, RevenueEvent
|
|
16
|
+
from metaspn_schemas.social import ProfileSnapshotSeen, SocialPostSeen
|
|
17
|
+
from metaspn_schemas.state_fragments import Attempts, Cooldowns, Evidence, Identity, Scores
|
|
18
|
+
from metaspn_schemas.tasks import Result, Task
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Attempts",
|
|
22
|
+
"Cooldowns",
|
|
23
|
+
"EmissionEnvelope",
|
|
24
|
+
"EntityAliasAdded",
|
|
25
|
+
"EntityMerged",
|
|
26
|
+
"EntityRef",
|
|
27
|
+
"EntityResolved",
|
|
28
|
+
"Evidence",
|
|
29
|
+
"GameClassified",
|
|
30
|
+
"Identity",
|
|
31
|
+
"MeetingBooked",
|
|
32
|
+
"MessageSent",
|
|
33
|
+
"PlaybookRouted",
|
|
34
|
+
"ProfileEnriched",
|
|
35
|
+
"ProfileSnapshotSeen",
|
|
36
|
+
"ReplyReceived",
|
|
37
|
+
"Result",
|
|
38
|
+
"RevenueEvent",
|
|
39
|
+
"SchemaVersion",
|
|
40
|
+
"Scores",
|
|
41
|
+
"ScoresComputed",
|
|
42
|
+
"SignalEnvelope",
|
|
43
|
+
"SocialPostSeen",
|
|
44
|
+
"Task",
|
|
45
|
+
"TraceContext",
|
|
46
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
from metaspn_schemas.utils.time import ensure_utc
|
|
9
|
+
|
|
10
|
+
DEFAULT_SCHEMA_VERSION = "0.1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SchemaVersion(Serializable):
|
|
15
|
+
package: str = "metaspn-schemas"
|
|
16
|
+
version: str = DEFAULT_SCHEMA_VERSION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EntityRef(Serializable):
|
|
21
|
+
ref_type: str
|
|
22
|
+
value: str
|
|
23
|
+
platform: str | None = None
|
|
24
|
+
label: str | None = None
|
|
25
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class TraceContext(Serializable):
|
|
30
|
+
trace_id: str
|
|
31
|
+
caused_by: tuple[str, ...] = field(default_factory=tuple)
|
|
32
|
+
provenance: str | None = None
|
|
33
|
+
redactions: tuple[str, ...] = field(default_factory=tuple)
|
|
34
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
privacy_mode: bool = False
|
|
36
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SignalEnvelope(Serializable):
|
|
41
|
+
signal_id: str
|
|
42
|
+
timestamp: datetime
|
|
43
|
+
source: str
|
|
44
|
+
payload_type: str
|
|
45
|
+
payload: Any
|
|
46
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
47
|
+
entity_refs: tuple[EntityRef, ...] = field(default_factory=tuple)
|
|
48
|
+
trace: TraceContext | None = None
|
|
49
|
+
raw: dict[str, Any] | None = field(default=None, metadata={"omit_in_privacy_mode": True})
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
object.__setattr__(self, "timestamp", ensure_utc(self.timestamp))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class EmissionEnvelope(Serializable):
|
|
57
|
+
emission_id: str
|
|
58
|
+
timestamp: datetime
|
|
59
|
+
emission_type: str
|
|
60
|
+
payload: Any
|
|
61
|
+
caused_by: str
|
|
62
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
63
|
+
trace: TraceContext | None = None
|
|
64
|
+
entity_refs: tuple[EntityRef, ...] = field(default_factory=tuple)
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
object.__setattr__(self, "timestamp", ensure_utc(self.timestamp))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class EntityResolved(Serializable):
|
|
12
|
+
entity_id: str
|
|
13
|
+
resolver: str
|
|
14
|
+
resolved_at: datetime
|
|
15
|
+
confidence: float
|
|
16
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EntityMerged(Serializable):
|
|
21
|
+
entity_id: str
|
|
22
|
+
merged_from: tuple[str, ...]
|
|
23
|
+
merged_at: datetime
|
|
24
|
+
reason: str | None = None
|
|
25
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class EntityAliasAdded(Serializable):
|
|
30
|
+
entity_id: str
|
|
31
|
+
alias: str
|
|
32
|
+
alias_type: str
|
|
33
|
+
added_at: datetime
|
|
34
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProfileEnriched(Serializable):
|
|
12
|
+
entity_id: str
|
|
13
|
+
enriched_at: datetime
|
|
14
|
+
summary: str
|
|
15
|
+
topics: tuple[str, ...] = field(default_factory=tuple)
|
|
16
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
object.__setattr__(self, "topics", tuple(sorted(self.topics)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ScoresComputed(Serializable):
|
|
24
|
+
entity_id: str
|
|
25
|
+
computed_at: datetime
|
|
26
|
+
scores: dict[str, float]
|
|
27
|
+
scorer: str
|
|
28
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PlaybookRouted(Serializable):
|
|
33
|
+
task_id: str
|
|
34
|
+
routed_at: datetime
|
|
35
|
+
playbook: str
|
|
36
|
+
rationale: str | None = None
|
|
37
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class GameClassified(Serializable):
|
|
42
|
+
entity_id: str
|
|
43
|
+
classified_at: datetime
|
|
44
|
+
label: str
|
|
45
|
+
confidence: float
|
|
46
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class MessageSent(Serializable):
|
|
12
|
+
message_id: str
|
|
13
|
+
channel: str
|
|
14
|
+
recipient: str
|
|
15
|
+
sent_at: datetime
|
|
16
|
+
subject: str | None = None
|
|
17
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ReplyReceived(Serializable):
|
|
22
|
+
reply_id: str
|
|
23
|
+
message_id: str
|
|
24
|
+
sender: str
|
|
25
|
+
received_at: datetime
|
|
26
|
+
sentiment: str | None = None
|
|
27
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class MeetingBooked(Serializable):
|
|
32
|
+
meeting_id: str
|
|
33
|
+
organizer: str
|
|
34
|
+
booked_at: datetime
|
|
35
|
+
starts_at: datetime
|
|
36
|
+
attendees: tuple[str, ...]
|
|
37
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RevenueEvent(Serializable):
|
|
42
|
+
revenue_id: str
|
|
43
|
+
amount: float
|
|
44
|
+
currency: str
|
|
45
|
+
recognized_at: datetime
|
|
46
|
+
source: str
|
|
47
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SocialPostSeen(Serializable):
|
|
12
|
+
post_id: str
|
|
13
|
+
platform: str
|
|
14
|
+
author_handle: str
|
|
15
|
+
content: str
|
|
16
|
+
seen_at: datetime
|
|
17
|
+
url: str | None = None
|
|
18
|
+
topics: tuple[str, ...] = field(default_factory=tuple)
|
|
19
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
object.__setattr__(self, "topics", tuple(sorted(self.topics)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ProfileSnapshotSeen(Serializable):
|
|
27
|
+
profile_id: str
|
|
28
|
+
platform: str
|
|
29
|
+
handle: str
|
|
30
|
+
display_name: str | None
|
|
31
|
+
bio: str | None
|
|
32
|
+
seen_at: datetime
|
|
33
|
+
followers_count: int | None = None
|
|
34
|
+
topics: tuple[str, ...] = field(default_factory=tuple)
|
|
35
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
object.__setattr__(self, "topics", tuple(sorted(self.topics)))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION
|
|
7
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Identity(Serializable):
|
|
12
|
+
entity_id: str
|
|
13
|
+
canonical_name: str | None = None
|
|
14
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
15
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Evidence(Serializable):
|
|
20
|
+
evidence_id: str
|
|
21
|
+
entity_id: str
|
|
22
|
+
source: str
|
|
23
|
+
collected_at: datetime
|
|
24
|
+
attributes: dict[str, str] = field(default_factory=dict)
|
|
25
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Scores(Serializable):
|
|
30
|
+
entity_id: str
|
|
31
|
+
values: dict[str, float]
|
|
32
|
+
updated_at: datetime
|
|
33
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Cooldowns(Serializable):
|
|
38
|
+
entity_id: str
|
|
39
|
+
channel: str
|
|
40
|
+
until: datetime
|
|
41
|
+
reason: str | None = None
|
|
42
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Attempts(Serializable):
|
|
47
|
+
entity_id: str
|
|
48
|
+
count: int
|
|
49
|
+
last_attempt_at: datetime | None = None
|
|
50
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from metaspn_schemas.core import DEFAULT_SCHEMA_VERSION, EntityRef
|
|
8
|
+
from metaspn_schemas.utils.serde import Serializable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Task(Serializable):
|
|
13
|
+
task_id: str
|
|
14
|
+
task_type: str
|
|
15
|
+
created_at: datetime
|
|
16
|
+
priority: int
|
|
17
|
+
entity_ref: EntityRef
|
|
18
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Result(Serializable):
|
|
25
|
+
result_id: str
|
|
26
|
+
task_id: str
|
|
27
|
+
status: str
|
|
28
|
+
completed_at: datetime
|
|
29
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
errors: tuple[str, ...] = field(default_factory=tuple)
|
|
31
|
+
schema_version: str = DEFAULT_SCHEMA_VERSION
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from metaspn_schemas.utils.ids import generate_id
|
|
2
|
+
from metaspn_schemas.utils.serde import Serializable, dataclass_from_dict, dataclass_to_dict
|
|
3
|
+
from metaspn_schemas.utils.time import datetime_to_str, ensure_utc, str_to_datetime, utc_now
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Serializable",
|
|
7
|
+
"dataclass_from_dict",
|
|
8
|
+
"dataclass_to_dict",
|
|
9
|
+
"datetime_to_str",
|
|
10
|
+
"ensure_utc",
|
|
11
|
+
"generate_id",
|
|
12
|
+
"str_to_datetime",
|
|
13
|
+
"utc_now",
|
|
14
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
VALID_PREFIXES = {
|
|
7
|
+
"signal": "s",
|
|
8
|
+
"emission": "e",
|
|
9
|
+
"task": "t",
|
|
10
|
+
"result": "r",
|
|
11
|
+
"entity": "ent",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_id(prefix: str | None = None) -> str:
|
|
16
|
+
token = uuid.uuid4().hex
|
|
17
|
+
if prefix is None:
|
|
18
|
+
return token
|
|
19
|
+
normalized = prefix.strip().lower()
|
|
20
|
+
mapped = VALID_PREFIXES.get(normalized, normalized)
|
|
21
|
+
return f"{mapped}_{token}"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
|
|
7
|
+
|
|
8
|
+
from metaspn_schemas.utils.time import datetime_to_str, str_to_datetime
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Serializable:
|
|
14
|
+
def to_dict(self, *, privacy_mode: bool = False) -> dict[str, Any]:
|
|
15
|
+
return dataclass_to_dict(self, privacy_mode=privacy_mode)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls: type[T], data: dict[str, Any]) -> T:
|
|
19
|
+
return dataclass_from_dict(cls, data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dataclass_to_dict(obj: Any, *, privacy_mode: bool = False) -> dict[str, Any]:
|
|
23
|
+
if not is_dataclass(obj):
|
|
24
|
+
raise TypeError("dataclass_to_dict expects a dataclass instance")
|
|
25
|
+
|
|
26
|
+
output: dict[str, Any] = {}
|
|
27
|
+
for f in fields(obj):
|
|
28
|
+
if privacy_mode and f.metadata.get("omit_in_privacy_mode"):
|
|
29
|
+
continue
|
|
30
|
+
value = getattr(obj, f.name)
|
|
31
|
+
output[f.name] = _to_primitive(value, privacy_mode=privacy_mode)
|
|
32
|
+
return output
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_primitive(value: Any, *, privacy_mode: bool) -> Any:
|
|
36
|
+
if is_dataclass(value):
|
|
37
|
+
return dataclass_to_dict(value, privacy_mode=privacy_mode)
|
|
38
|
+
if isinstance(value, datetime):
|
|
39
|
+
return datetime_to_str(value)
|
|
40
|
+
if isinstance(value, tuple):
|
|
41
|
+
return [_to_primitive(v, privacy_mode=privacy_mode) for v in value]
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
return [_to_primitive(v, privacy_mode=privacy_mode) for v in value]
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {k: _to_primitive(v, privacy_mode=privacy_mode) for k, v in sorted(value.items())}
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dataclass_from_dict(cls: type[T], data: dict[str, Any]) -> T:
|
|
50
|
+
if not is_dataclass(cls):
|
|
51
|
+
raise TypeError("dataclass_from_dict expects a dataclass type")
|
|
52
|
+
|
|
53
|
+
hints = get_type_hints(cls)
|
|
54
|
+
kwargs: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
for f in fields(cls):
|
|
57
|
+
hint = hints.get(f.name, Any)
|
|
58
|
+
if f.name in data:
|
|
59
|
+
kwargs[f.name] = _coerce_value(hint, data[f.name])
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if f.default is not MISSING:
|
|
63
|
+
kwargs[f.name] = f.default
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if f.default_factory is not MISSING: # type: ignore[attr-defined]
|
|
67
|
+
kwargs[f.name] = f.default_factory() # type: ignore[misc]
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
raise ValueError(f"Missing required field: {f.name}")
|
|
71
|
+
|
|
72
|
+
return cls(**kwargs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _coerce_value(hint: Any, value: Any) -> Any:
|
|
76
|
+
if value is None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
origin = get_origin(hint)
|
|
80
|
+
args = get_args(hint)
|
|
81
|
+
|
|
82
|
+
if hint is Any:
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
if hint is datetime:
|
|
86
|
+
if isinstance(value, datetime):
|
|
87
|
+
return value
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return str_to_datetime(value)
|
|
90
|
+
raise TypeError(f"Cannot parse datetime from {type(value)!r}")
|
|
91
|
+
|
|
92
|
+
if origin in (Union, types.UnionType):
|
|
93
|
+
last_error: Exception | None = None
|
|
94
|
+
for option in args:
|
|
95
|
+
if option is type(None):
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
return _coerce_value(option, value)
|
|
99
|
+
except Exception as err: # noqa: BLE001
|
|
100
|
+
last_error = err
|
|
101
|
+
if last_error is not None:
|
|
102
|
+
raise last_error
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
if origin is tuple:
|
|
106
|
+
item_type = args[0] if args else Any
|
|
107
|
+
return tuple(_coerce_value(item_type, item) for item in value)
|
|
108
|
+
|
|
109
|
+
if origin is list:
|
|
110
|
+
item_type = args[0] if args else Any
|
|
111
|
+
return [_coerce_value(item_type, item) for item in value]
|
|
112
|
+
|
|
113
|
+
if origin is dict:
|
|
114
|
+
key_type = args[0] if len(args) > 0 else Any
|
|
115
|
+
value_type = args[1] if len(args) > 1 else Any
|
|
116
|
+
return {
|
|
117
|
+
_coerce_value(key_type, k): _coerce_value(value_type, v)
|
|
118
|
+
for k, v in value.items()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if is_dataclass(hint):
|
|
122
|
+
if isinstance(value, hint):
|
|
123
|
+
return value
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
return dataclass_from_dict(hint, value)
|
|
126
|
+
|
|
127
|
+
if hint in (str, int, float, bool):
|
|
128
|
+
return hint(value)
|
|
129
|
+
|
|
130
|
+
return value
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def utc_now() -> datetime:
|
|
7
|
+
return datetime.now(timezone.utc)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_utc(value: datetime) -> datetime:
|
|
11
|
+
if value.tzinfo is None:
|
|
12
|
+
return value.replace(tzinfo=timezone.utc)
|
|
13
|
+
return value.astimezone(timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def datetime_to_str(value: datetime) -> str:
|
|
17
|
+
utc_value = ensure_utc(value)
|
|
18
|
+
return utc_value.isoformat().replace("+00:00", "Z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def str_to_datetime(value: str) -> datetime:
|
|
22
|
+
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
23
|
+
parsed = datetime.fromisoformat(text)
|
|
24
|
+
return ensure_utc(parsed)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaspn-schemas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Canonical schemas for MetaSPN-compatible signal processing systems
|
|
5
|
+
Author: MetaSPN
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MetaSPN/metaspn-schemas
|
|
8
|
+
Project-URL: Repository, https://github.com/MetaSPN/metaspn-schemas
|
|
9
|
+
Project-URL: Issues, https://github.com/MetaSPN/metaspn-schemas/issues
|
|
10
|
+
Keywords: schemas,signal-processing,events,dataclasses,metaspn
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# metaspn-schemas
|
|
24
|
+
|
|
25
|
+
Canonical, stdlib-only schema package for MetaSPN-compatible systems.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install metaspn-schemas
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install -e .[dev]
|
|
37
|
+
pytest -q
|
|
38
|
+
python -m build
|
|
39
|
+
python -m twine check dist/*
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Design constraints
|
|
43
|
+
|
|
44
|
+
- Tiny and dependency-light (stdlib only)
|
|
45
|
+
- Frozen dataclasses for immutable-by-default objects
|
|
46
|
+
- Explicit `schema_version` on all public objects
|
|
47
|
+
- `to_dict()` / `from_dict()` on every schema
|
|
48
|
+
- UTC ISO-8601 datetime serialization
|
|
49
|
+
- Traceability metadata (`trace_id`, `caused_by`, provenance)
|
|
50
|
+
- Privacy mode support (`to_dict(privacy_mode=True)` omits raw blobs)
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import timezone, datetime
|
|
56
|
+
|
|
57
|
+
from metaspn_schemas import SignalEnvelope
|
|
58
|
+
from metaspn_schemas.utils.ids import generate_id
|
|
59
|
+
|
|
60
|
+
signal = SignalEnvelope(
|
|
61
|
+
signal_id=generate_id("signal"),
|
|
62
|
+
timestamp=datetime.now(timezone.utc),
|
|
63
|
+
source="linkedin.webhook",
|
|
64
|
+
payload_type="SocialPostSeen",
|
|
65
|
+
payload={"post_id": "123", "platform": "linkedin"},
|
|
66
|
+
schema_version="0.1",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
as_dict = signal.to_dict()
|
|
70
|
+
round_trip = SignalEnvelope.from_dict(as_dict)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Public imports
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from metaspn_schemas import (
|
|
77
|
+
SignalEnvelope,
|
|
78
|
+
EmissionEnvelope,
|
|
79
|
+
Task,
|
|
80
|
+
Result,
|
|
81
|
+
SocialPostSeen,
|
|
82
|
+
ProfileEnriched,
|
|
83
|
+
ScoresComputed,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Package layout
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
metaspn-schemas/
|
|
91
|
+
pyproject.toml
|
|
92
|
+
README.md
|
|
93
|
+
src/metaspn_schemas/
|
|
94
|
+
__init__.py
|
|
95
|
+
core.py
|
|
96
|
+
tasks.py
|
|
97
|
+
entities.py
|
|
98
|
+
social.py
|
|
99
|
+
outcomes.py
|
|
100
|
+
features.py
|
|
101
|
+
state_fragments.py
|
|
102
|
+
utils/
|
|
103
|
+
ids.py
|
|
104
|
+
time.py
|
|
105
|
+
serde.py
|
|
106
|
+
test/
|
|
107
|
+
test_serde.py
|
|
108
|
+
test_ids.py
|
|
109
|
+
test_backcompat.py
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Release
|
|
113
|
+
|
|
114
|
+
Release automation is configured in:
|
|
115
|
+
|
|
116
|
+
- `.github/workflows/ci.yml`
|
|
117
|
+
- `.github/workflows/publish.yml`
|
|
118
|
+
|
|
119
|
+
See `RELEASE.md` for the end-to-end push + PyPI release flow.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/metaspn_schemas/__init__.py
|
|
5
|
+
src/metaspn_schemas/core.py
|
|
6
|
+
src/metaspn_schemas/entities.py
|
|
7
|
+
src/metaspn_schemas/features.py
|
|
8
|
+
src/metaspn_schemas/outcomes.py
|
|
9
|
+
src/metaspn_schemas/social.py
|
|
10
|
+
src/metaspn_schemas/state_fragments.py
|
|
11
|
+
src/metaspn_schemas/tasks.py
|
|
12
|
+
src/metaspn_schemas.egg-info/PKG-INFO
|
|
13
|
+
src/metaspn_schemas.egg-info/SOURCES.txt
|
|
14
|
+
src/metaspn_schemas.egg-info/dependency_links.txt
|
|
15
|
+
src/metaspn_schemas.egg-info/requires.txt
|
|
16
|
+
src/metaspn_schemas.egg-info/top_level.txt
|
|
17
|
+
src/metaspn_schemas/utils/__init__.py
|
|
18
|
+
src/metaspn_schemas/utils/ids.py
|
|
19
|
+
src/metaspn_schemas/utils/serde.py
|
|
20
|
+
src/metaspn_schemas/utils/time.py
|
|
21
|
+
test/test_backcompat.py
|
|
22
|
+
test/test_ids.py
|
|
23
|
+
test/test_serde.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metaspn_schemas
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from metaspn_schemas.core import SignalEnvelope
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_signal_backcompat_prior_minor_payload() -> None:
|
|
9
|
+
legacy_payload = {
|
|
10
|
+
"signal_id": "s_legacy",
|
|
11
|
+
"timestamp": "2025-01-01T10:30:00Z",
|
|
12
|
+
"source": "legacy.source",
|
|
13
|
+
"payload_type": "SocialPostSeen",
|
|
14
|
+
"payload": {"post_id": "p1"},
|
|
15
|
+
"schema_version": "0.0",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
signal = SignalEnvelope.from_dict(legacy_payload)
|
|
19
|
+
|
|
20
|
+
assert signal.signal_id == "s_legacy"
|
|
21
|
+
assert signal.timestamp == datetime(2025, 1, 1, 10, 30, tzinfo=timezone.utc)
|
|
22
|
+
assert signal.entity_refs == ()
|
|
23
|
+
assert signal.trace is None
|
|
24
|
+
assert signal.raw is None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from metaspn_schemas.utils.ids import generate_id
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_generate_id_default() -> None:
|
|
7
|
+
value = generate_id()
|
|
8
|
+
assert "_" not in value
|
|
9
|
+
assert len(value) == 32
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_generate_id_with_known_prefix() -> None:
|
|
13
|
+
value = generate_id("signal")
|
|
14
|
+
assert value.startswith("s_")
|
|
15
|
+
assert len(value.split("_", 1)[1]) == 32
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_generate_id_with_custom_prefix() -> None:
|
|
19
|
+
value = generate_id("custom")
|
|
20
|
+
assert value.startswith("custom_")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from metaspn_schemas.core import EmissionEnvelope, EntityRef, SchemaVersion, SignalEnvelope, TraceContext
|
|
6
|
+
from metaspn_schemas.entities import EntityAliasAdded, EntityMerged, EntityResolved
|
|
7
|
+
from metaspn_schemas.features import GameClassified, PlaybookRouted, ProfileEnriched, ScoresComputed
|
|
8
|
+
from metaspn_schemas.outcomes import MeetingBooked, MessageSent, ReplyReceived, RevenueEvent
|
|
9
|
+
from metaspn_schemas.social import ProfileSnapshotSeen, SocialPostSeen
|
|
10
|
+
from metaspn_schemas.state_fragments import Attempts, Cooldowns, Evidence, Identity, Scores
|
|
11
|
+
from metaspn_schemas.tasks import Result, Task
|
|
12
|
+
|
|
13
|
+
NOW = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def assert_round_trip(instance: object, cls: type) -> None:
|
|
17
|
+
as_dict = instance.to_dict()
|
|
18
|
+
rebuilt = cls.from_dict(as_dict)
|
|
19
|
+
assert rebuilt == instance
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_round_trip_core_schemas() -> None:
|
|
23
|
+
entity_ref = EntityRef(ref_type="entity_id", value="ent_1")
|
|
24
|
+
trace = TraceContext(
|
|
25
|
+
trace_id="tr_1",
|
|
26
|
+
caused_by=("s_1",),
|
|
27
|
+
provenance="ingestor",
|
|
28
|
+
redactions=("content",),
|
|
29
|
+
metadata={"a": "1", "b": "2"},
|
|
30
|
+
)
|
|
31
|
+
signal = SignalEnvelope(
|
|
32
|
+
signal_id="s_1",
|
|
33
|
+
timestamp=NOW,
|
|
34
|
+
source="source",
|
|
35
|
+
payload_type="SocialPostSeen",
|
|
36
|
+
payload={"b": 2, "a": 1},
|
|
37
|
+
entity_refs=(entity_ref,),
|
|
38
|
+
trace=trace,
|
|
39
|
+
raw={"secret": "raw"},
|
|
40
|
+
)
|
|
41
|
+
emission = EmissionEnvelope(
|
|
42
|
+
emission_id="e_1",
|
|
43
|
+
timestamp=NOW,
|
|
44
|
+
emission_type="ScoresComputed",
|
|
45
|
+
payload={"score": 0.9},
|
|
46
|
+
caused_by="s_1",
|
|
47
|
+
trace=trace,
|
|
48
|
+
entity_refs=(entity_ref,),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert_round_trip(SchemaVersion(), SchemaVersion)
|
|
52
|
+
assert_round_trip(entity_ref, EntityRef)
|
|
53
|
+
assert_round_trip(trace, TraceContext)
|
|
54
|
+
assert_round_trip(signal, SignalEnvelope)
|
|
55
|
+
assert_round_trip(emission, EmissionEnvelope)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_round_trip_task_contracts() -> None:
|
|
59
|
+
task = Task(
|
|
60
|
+
task_id="t_1",
|
|
61
|
+
task_type="enrich.profile",
|
|
62
|
+
created_at=NOW,
|
|
63
|
+
priority=1,
|
|
64
|
+
entity_ref=EntityRef(ref_type="email", value="x@example.com"),
|
|
65
|
+
inputs={"x": 1},
|
|
66
|
+
context={"team": "growth"},
|
|
67
|
+
)
|
|
68
|
+
result = Result(
|
|
69
|
+
result_id="r_1",
|
|
70
|
+
task_id="t_1",
|
|
71
|
+
status="ok",
|
|
72
|
+
completed_at=NOW,
|
|
73
|
+
outputs={"done": True},
|
|
74
|
+
errors=(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert_round_trip(task, Task)
|
|
78
|
+
assert_round_trip(result, Result)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_round_trip_social_outcomes_entities_features_state_fragments() -> None:
|
|
82
|
+
instances = [
|
|
83
|
+
SocialPostSeen("p1", "linkedin", "@a", "hello", NOW, topics=("b", "a")),
|
|
84
|
+
ProfileSnapshotSeen("pr1", "linkedin", "@a", "A", "Bio", NOW, topics=("x", "c")),
|
|
85
|
+
MessageSent("m1", "email", "x@example.com", NOW),
|
|
86
|
+
ReplyReceived("rp1", "m1", "y@example.com", NOW),
|
|
87
|
+
MeetingBooked("mt1", "x@example.com", NOW, NOW, ("a", "b")),
|
|
88
|
+
RevenueEvent("rev1", 120.5, "USD", NOW, "stripe"),
|
|
89
|
+
EntityResolved("ent1", "resolver", NOW, 0.91),
|
|
90
|
+
EntityMerged("ent1", ("ent2", "ent3"), NOW, "dedupe"),
|
|
91
|
+
EntityAliasAdded("ent1", "acme", "domain", NOW),
|
|
92
|
+
ProfileEnriched("ent1", NOW, "summary", topics=("go", "ai")),
|
|
93
|
+
ScoresComputed("ent1", NOW, {"fit": 0.88}, "v1"),
|
|
94
|
+
PlaybookRouted("t1", NOW, "warm_outbound"),
|
|
95
|
+
GameClassified("ent1", NOW, "six_games", 0.77),
|
|
96
|
+
Identity("ent1", "Acme", ("Acme Inc",)),
|
|
97
|
+
Evidence("ev1", "ent1", "web", NOW, {"domain": "acme.com"}),
|
|
98
|
+
Scores("ent1", {"intent": 0.2}, NOW),
|
|
99
|
+
Cooldowns("ent1", "email", NOW),
|
|
100
|
+
Attempts("ent1", 2, NOW),
|
|
101
|
+
]
|
|
102
|
+
classes = [
|
|
103
|
+
SocialPostSeen,
|
|
104
|
+
ProfileSnapshotSeen,
|
|
105
|
+
MessageSent,
|
|
106
|
+
ReplyReceived,
|
|
107
|
+
MeetingBooked,
|
|
108
|
+
RevenueEvent,
|
|
109
|
+
EntityResolved,
|
|
110
|
+
EntityMerged,
|
|
111
|
+
EntityAliasAdded,
|
|
112
|
+
ProfileEnriched,
|
|
113
|
+
ScoresComputed,
|
|
114
|
+
PlaybookRouted,
|
|
115
|
+
GameClassified,
|
|
116
|
+
Identity,
|
|
117
|
+
Evidence,
|
|
118
|
+
Scores,
|
|
119
|
+
Cooldowns,
|
|
120
|
+
Attempts,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
for instance, cls in zip(instances, classes, strict=True):
|
|
124
|
+
assert_round_trip(instance, cls)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_datetime_serialization_format_is_utc_iso8601() -> None:
|
|
128
|
+
signal = SignalEnvelope(
|
|
129
|
+
signal_id="s_utc",
|
|
130
|
+
timestamp=NOW,
|
|
131
|
+
source="test",
|
|
132
|
+
payload_type="x",
|
|
133
|
+
payload={},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
data = signal.to_dict()
|
|
137
|
+
assert data["timestamp"] == "2026-01-01T12:00:00Z"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_privacy_mode_omits_raw_blob() -> None:
|
|
141
|
+
signal = SignalEnvelope(
|
|
142
|
+
signal_id="s_priv",
|
|
143
|
+
timestamp=NOW,
|
|
144
|
+
source="test",
|
|
145
|
+
payload_type="x",
|
|
146
|
+
payload={},
|
|
147
|
+
raw={"body": "sensitive"},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
data = signal.to_dict(privacy_mode=True)
|
|
151
|
+
assert "raw" not in data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_deterministic_dict_key_ordering() -> None:
|
|
155
|
+
signal = SignalEnvelope(
|
|
156
|
+
signal_id="s_ord",
|
|
157
|
+
timestamp=NOW,
|
|
158
|
+
source="test",
|
|
159
|
+
payload_type="x",
|
|
160
|
+
payload={"z": {"b": 2, "a": 1}, "a": 10},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
payload = signal.to_dict()["payload"]
|
|
164
|
+
assert list(payload.keys()) == ["a", "z"]
|
|
165
|
+
assert list(payload["z"].keys()) == ["a", "b"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_topic_lists_are_sorted_on_construction() -> None:
|
|
169
|
+
post = SocialPostSeen("p", "x", "@u", "c", NOW, topics=("ml", "ai"))
|
|
170
|
+
enriched = ProfileEnriched("ent", NOW, "s", topics=("z", "a"))
|
|
171
|
+
|
|
172
|
+
assert post.topics == ("ai", "ml")
|
|
173
|
+
assert enriched.topics == ("a", "z")
|