dpdpkit-core 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dpdpkit/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """dpdpkit — developer primitives for India's DPDP Act, 2023 and DPDP Rules, 2025.
2
+
3
+ dpdpkit helps you implement obligations; it does not provide legal advice or guarantee compliance.
4
+ """
5
+
6
+ # Adapters ship subpackages (``dpdpkit.fastapi``, ``dpdpkit.django``) from separate distributions.
7
+ from pkgutil import extend_path
8
+
9
+ __path__ = extend_path(__path__, __name__)
10
+
11
+ __version__ = "0.1.0a1"
12
+
13
+ from .config import kit_from_config, load_config, sync_notice
14
+ from .errors import (
15
+ ConfigError,
16
+ ConsentRequired,
17
+ DpdpkitError,
18
+ InvalidTransition,
19
+ LedgerTampered,
20
+ NotFound,
21
+ NoticeInvalid,
22
+ PolicyError,
23
+ ValidationFailed,
24
+ )
25
+ from .kit import ContactResolver, Kit
26
+ from .models import (
27
+ ConsentStatus,
28
+ Contact,
29
+ DataItem,
30
+ LegalBasis,
31
+ Processor,
32
+ Purpose,
33
+ RequestKind,
34
+ RequestStatus,
35
+ RetentionRule,
36
+ RetentionTrigger,
37
+ Role,
38
+ ScheduleStatus,
39
+ )
40
+ from .notify import ConsoleNotifier, MemoryNotifier, Notifier, NullNotifier, SmtpNotifier
41
+ from .policy import Policy
42
+ from .registry import Registry
43
+ from .repository import InMemoryRepository, Repository
44
+
45
+ __all__ = [
46
+ "ConfigError",
47
+ "ConsentRequired",
48
+ "ConsentStatus",
49
+ "ConsoleNotifier",
50
+ "Contact",
51
+ "ContactResolver",
52
+ "DataItem",
53
+ "DpdpkitError",
54
+ "InMemoryRepository",
55
+ "InvalidTransition",
56
+ "Kit",
57
+ "LedgerTampered",
58
+ "LegalBasis",
59
+ "MemoryNotifier",
60
+ "NotFound",
61
+ "NoticeInvalid",
62
+ "Notifier",
63
+ "NullNotifier",
64
+ "Policy",
65
+ "PolicyError",
66
+ "Processor",
67
+ "Purpose",
68
+ "Registry",
69
+ "Repository",
70
+ "RequestKind",
71
+ "RequestStatus",
72
+ "RetentionRule",
73
+ "RetentionTrigger",
74
+ "Role",
75
+ "ScheduleStatus",
76
+ "SmtpNotifier",
77
+ "ValidationFailed",
78
+ "__version__",
79
+ "kit_from_config",
80
+ "load_config",
81
+ "sync_notice",
82
+ ]
dpdpkit/_util.py ADDED
@@ -0,0 +1,63 @@
1
+ """Small helpers shared by every core module. Not public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import uuid
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ Clock = Callable[[], dt.datetime]
14
+
15
+
16
+ def utcnow() -> dt.datetime:
17
+ return dt.datetime.now(dt.timezone.utc)
18
+
19
+
20
+ def as_utc(value: dt.datetime) -> dt.datetime:
21
+ """Treat naive datetimes as UTC (some databases drop tzinfo) and convert aware ones to UTC."""
22
+ if value.tzinfo is None:
23
+ return value.replace(tzinfo=dt.timezone.utc)
24
+ return value.astimezone(dt.timezone.utc)
25
+
26
+
27
+ def new_id(prefix: str) -> str:
28
+ return f"{prefix}_{uuid.uuid4().hex}"
29
+
30
+
31
+ def _canonical_default(value: Any) -> Any:
32
+ if isinstance(value, dt.datetime):
33
+ # Fixed format so a value read back from any database hashes identically.
34
+ return as_utc(value).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
35
+ if isinstance(value, dt.date):
36
+ return value.isoformat()
37
+ if hasattr(value, "value"): # enums
38
+ return value.value
39
+ raise TypeError(f"cannot canonicalise {type(value).__name__}")
40
+
41
+
42
+ def canonical_json(data: Any) -> bytes:
43
+ """Deterministic JSON used for hashing and signing."""
44
+ return json.dumps(
45
+ data, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=_canonical_default
46
+ ).encode("utf-8")
47
+
48
+
49
+ def sha256_hex(data: bytes) -> str:
50
+ return hashlib.sha256(data).hexdigest()
51
+
52
+
53
+ def hmac_sha256_hex(key: bytes, data: bytes) -> str:
54
+ return hmac.new(key, data, hashlib.sha256).hexdigest()
55
+
56
+
57
+ def mask_address(value: str) -> str:
58
+ """Mask an email address or phone number for receipts and logs."""
59
+ if "@" in value:
60
+ local, _, domain = value.partition("@")
61
+ return f"{local[:1]}***@{domain}"
62
+ digits = value.strip()
63
+ return "*" * max(len(digits) - 4, 0) + digits[-4:]
dpdpkit/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ """The ``dpdpkit`` command.
2
+
3
+ Commands that need data (``ledger``, ``retention``, ``export``) load your kit with
4
+ ``--kit module:attribute`` or the ``DPDPKIT_KIT`` environment variable. The attribute may be a
5
+ :class:`~dpdpkit.Kit`, an object with a ``.kit`` attribute (such as an adapter), or a zero-argument
6
+ callable returning either.
7
+
8
+ Adapters add subcommands through the ``dpdpkit.cli`` entry-point group: each entry point is a
9
+ callable receiving the argparse sub-parsers object.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import datetime as dt
16
+ import importlib
17
+ import json
18
+ import os
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from importlib.metadata import entry_points
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ from .config import STARTER_CONFIG
28
+ from .errors import DpdpkitError, LedgerTampered
29
+ from .kit import Kit
30
+ from .policy import Policy, available_packs
31
+
32
+
33
+ def load_kit(spec: str | None) -> Kit:
34
+ spec = spec or os.environ.get("DPDPKIT_KIT")
35
+ if not spec or ":" not in spec:
36
+ raise SystemExit("error: pass --kit module:attribute (or set DPDPKIT_KIT) to load your kit")
37
+ module_name, _, attr = spec.partition(":")
38
+ sys.path.insert(0, os.getcwd())
39
+ obj: Any = importlib.import_module(module_name)
40
+ for part in attr.split("."):
41
+ obj = getattr(obj, part)
42
+ if callable(obj) and not isinstance(obj, Kit) and not hasattr(obj, "kit"):
43
+ obj = obj()
44
+ if hasattr(obj, "kit") and not isinstance(obj, Kit):
45
+ obj = obj.kit
46
+ if not isinstance(obj, Kit):
47
+ raise SystemExit(f"error: {spec} is not a dpdpkit Kit")
48
+ return obj
49
+
50
+
51
+ def _cmd_init(args: argparse.Namespace) -> int:
52
+ target = Path(args.dir) / "dpdpkit.yaml"
53
+ if target.exists() and not args.force:
54
+ print(f"{target} already exists (use --force to overwrite)", file=sys.stderr)
55
+ return 1
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+ target.write_text(STARTER_CONFIG, encoding="utf-8")
58
+ print(f"wrote {target}")
59
+ print("next: edit purposes and notice text, then wire a repository (see your adapter's quickstart)")
60
+ return 0
61
+
62
+
63
+ def _policy_from(ref: str, overlays: Sequence[str]) -> Policy:
64
+ return Policy.from_file(ref, overlays) if Path(ref).is_file() else Policy.load(ref, overlays)
65
+
66
+
67
+ def _cmd_policy(args: argparse.Namespace) -> int:
68
+ if args.policy_command == "list":
69
+ for pack_id in available_packs():
70
+ print(pack_id)
71
+ return 0
72
+ if args.policy_command == "show":
73
+ pack = _policy_from(args.pack or available_packs()[-1], args.overlay)
74
+ print(yaml.safe_dump(pack.as_dict(), sort_keys=False, allow_unicode=True), end="")
75
+ return 0
76
+ old = _policy_from(args.old, [])
77
+ new = _policy_from(args.new, [])
78
+ changes = old.diff(new)
79
+ if not changes:
80
+ print(f"no differences between {old.id} and {new.id}")
81
+ return 0
82
+ print(f"{old.id} -> {new.id}")
83
+ for change in changes:
84
+ print(f" {change.key}: {json.dumps(change.old)} -> {json.dumps(change.new)}")
85
+ return 0
86
+
87
+
88
+ def _cmd_ledger(args: argparse.Namespace) -> int:
89
+ kit = load_kit(args.kit)
90
+ if args.ledger_command == "root":
91
+ print(kit.ledger.root_hash().model_dump_json(indent=2))
92
+ return 0
93
+ try:
94
+ result = kit.ledger.verify()
95
+ except LedgerTampered as exc:
96
+ print(f"FAILED: {exc}", file=sys.stderr)
97
+ return 2
98
+ print(
99
+ f"ok: {result.checked} rows verified; consent head {result.consent_head[:16]}…, "
100
+ f"audit head {result.audit_head[:16]}…"
101
+ )
102
+ return 0
103
+
104
+
105
+ def _cmd_retention(args: argparse.Namespace) -> int:
106
+ kit = load_kit(args.kit)
107
+ if args.retention_command == "run":
108
+ run = kit.retention.run()
109
+ print(run.model_dump_json(indent=2))
110
+ return 0
111
+ items = kit.retention.preview(horizon=dt.timedelta(hours=args.hours))
112
+ if not items:
113
+ print(f"nothing due in the next {args.hours} hours")
114
+ for item in items:
115
+ print(f"{item.at.isoformat()} {item.action:<5} {item.principal} {item.purpose} ({item.status.value})")
116
+ attention = kit.retention.needs_attention()
117
+ if attention:
118
+ print(f"\n{len(attention)} schedule(s) need attention:")
119
+ for s in attention:
120
+ print(f" {s.id} {s.principal} {s.purpose}: {s.attention_reason}")
121
+ return 0
122
+
123
+
124
+ def _cmd_export(args: argparse.Namespace) -> int:
125
+ kit = load_kit(args.kit)
126
+ if args.evidence:
127
+ data = kit.export.evidence_pack()
128
+ out = Path(args.out or "dpdpkit-evidence.zip")
129
+ out.write_bytes(data)
130
+ print(f"wrote {out}")
131
+ return 0
132
+ if not args.principal:
133
+ print("error: --principal is required (or use --evidence)", file=sys.stderr)
134
+ return 1
135
+ text = (
136
+ kit.export.principal_html(args.principal)
137
+ if args.format == "html"
138
+ else json.dumps(kit.export.principal(args.principal), indent=2, ensure_ascii=False)
139
+ )
140
+ if args.out:
141
+ Path(args.out).write_text(text, encoding="utf-8")
142
+ print(f"wrote {args.out}")
143
+ else:
144
+ print(text)
145
+ return 0
146
+
147
+
148
+ def build_parser() -> argparse.ArgumentParser:
149
+ parser = argparse.ArgumentParser(prog="dpdpkit", description="dpdpkit command line")
150
+ parser.add_argument("--version", action="version", version=f"dpdpkit-core {_version()}")
151
+ sub = parser.add_subparsers(dest="command", required=True)
152
+
153
+ p = sub.add_parser("init", help="write a starter dpdpkit.yaml")
154
+ p.add_argument("--dir", default=".")
155
+ p.add_argument("--force", action="store_true")
156
+ p.set_defaults(func=_cmd_init)
157
+
158
+ p = sub.add_parser("policy", help="show, list or diff policy packs")
159
+ psub = p.add_subparsers(dest="policy_command", required=True)
160
+ psub.add_parser("list")
161
+ show = psub.add_parser("show")
162
+ show.add_argument("--pack", help="pack id or file (default: newest shipped pack)")
163
+ show.add_argument("--overlay", action="append", default=[])
164
+ diff = psub.add_parser("diff")
165
+ diff.add_argument("old")
166
+ diff.add_argument("new")
167
+ p.set_defaults(func=_cmd_policy)
168
+
169
+ p = sub.add_parser("ledger", help="verify the hash chain or print the root hash")
170
+ p.add_argument("ledger_command", choices=["verify", "root"])
171
+ p.add_argument("--kit")
172
+ p.set_defaults(func=_cmd_ledger)
173
+
174
+ p = sub.add_parser("retention", help="preview or run erasure schedules")
175
+ p.add_argument("retention_command", choices=["preview", "run"])
176
+ p.add_argument("--hours", type=int, default=168)
177
+ p.add_argument("--kit")
178
+ p.set_defaults(func=_cmd_retention)
179
+
180
+ p = sub.add_parser("export", help="export a principal's data, or an evidence pack")
181
+ p.add_argument("--principal")
182
+ p.add_argument("--evidence", action="store_true")
183
+ p.add_argument("--format", choices=["json", "html"], default="json")
184
+ p.add_argument("--out")
185
+ p.add_argument("--kit")
186
+ p.set_defaults(func=_cmd_export)
187
+
188
+ for ep in entry_points(group="dpdpkit.cli"):
189
+ try:
190
+ ep.load()(sub)
191
+ except Exception as exc: # a broken plugin must not break the core CLI
192
+ print(f"warning: dpdpkit CLI plugin {ep.name} failed to load: {exc}", file=sys.stderr)
193
+ return parser
194
+
195
+
196
+ def _version() -> str:
197
+ from . import __version__
198
+
199
+ return __version__
200
+
201
+
202
+ def main(argv: Sequence[str] | None = None) -> int:
203
+ args = build_parser().parse_args(argv)
204
+ try:
205
+ return int(args.func(args))
206
+ except DpdpkitError as exc:
207
+ print(f"error: {exc}", file=sys.stderr)
208
+ return 1
209
+
210
+
211
+ if __name__ == "__main__": # pragma: no cover
212
+ sys.exit(main())
dpdpkit/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """``dpdpkit.yaml``: purposes, notice text, contact details and policy selection.
2
+
3
+ Adapters and the server build a :class:`~dpdpkit.Kit` from this file and call :func:`sync_notice`
4
+ at start-up, which publishes a new notice version only when the configured text changed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from .errors import ConfigError, NotFound
16
+ from .kit import Kit
17
+ from .models import Notice, NoticeContent
18
+ from .notices import content_hash
19
+ from .registry import Registry
20
+ from .repository import Repository
21
+
22
+ STARTER_CONFIG = """\
23
+ # dpdpkit configuration.
24
+ # Purposes, notice wording and retention periods are decisions for you and your counsel.
25
+ # dpdpkit records and enforces what you configure; it does not decide what is lawful.
26
+ policy: dpdp-rules-2025.v1
27
+ overlays: []
28
+
29
+ contact:
30
+ name: Example Pvt Ltd
31
+ email: privacy@example.in
32
+ rights_url: https://example.in/privacy/rights
33
+
34
+ data_items:
35
+ - {id: email, name: Email address}
36
+ - {id: phone, name: Mobile number}
37
+ - {id: name, name: Full name}
38
+ - {id: order_history, name: Order history}
39
+
40
+ processors:
41
+ - {id: mailer, name: Email delivery provider, country: IN}
42
+
43
+ purposes:
44
+ - id: account
45
+ title: Create and run your account
46
+ description: We use these details to sign you in and provide the service.
47
+ data_items: [email, phone, name, order_history]
48
+ required: true
49
+ retention: {trigger: last_activity, period_days: 1095}
50
+ - id: marketing
51
+ title: Send you offers
52
+ description: Occasional emails about products and discounts.
53
+ data_items: [email]
54
+ processors: [mailer]
55
+ retention: {trigger: withdrawal, period_days: 0}
56
+
57
+ notice:
58
+ title: How we use your personal data
59
+ body: Choose what you agree to. You can change your choices at any time.
60
+ links:
61
+ withdraw: https://example.in/privacy/preferences
62
+ rights: https://example.in/privacy/rights
63
+ board_complaint: https://example.in/privacy/board-complaint # replace with the Board's complaint page
64
+ translations:
65
+ hi:
66
+ title: हम आपके व्यक्तिगत डेटा का उपयोग कैसे करते हैं
67
+ body: चुनें कि आप किस बात से सहमत हैं। आप कभी भी अपनी पसंद बदल सकते हैं।
68
+ purposes:
69
+ account: {title: आपका खाता बनाना और चलाना}
70
+ marketing: {title: आपको ऑफ़र भेजना}
71
+ """
72
+
73
+
74
+ def load_config(path: str | Path) -> dict[str, Any]:
75
+ try:
76
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
77
+ except OSError as exc:
78
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
79
+ if not isinstance(data, dict):
80
+ raise ConfigError(f"{path} must contain a mapping")
81
+ if "policy" not in data:
82
+ raise ConfigError(f"{path} must name a policy pack (policy: dpdp-rules-2025.v1)")
83
+ return data
84
+
85
+
86
+ def kit_from_config(config: Mapping[str, Any] | str | Path, repository: Repository, **kwargs: Any) -> Kit:
87
+ """Build a :class:`Kit` from a config mapping or file. ``kwargs`` override or extend Kit arguments."""
88
+ cfg = load_config(config) if isinstance(config, (str, Path)) else dict(config)
89
+ tenant_id = str(kwargs.pop("tenant_id", cfg.get("tenant_id", "default")))
90
+ return Kit(
91
+ repository=repository,
92
+ policy=str(cfg["policy"]),
93
+ overlays=list(cfg.get("overlays") or []),
94
+ registry=kwargs.pop("registry", None) or Registry.from_config(cfg, tenant_id),
95
+ contact=kwargs.pop("contact", None) or cfg.get("contact") or {},
96
+ tenant_id=tenant_id,
97
+ **kwargs,
98
+ )
99
+
100
+
101
+ def notice_translations(kit: Kit, notice_cfg: Mapping[str, Any]) -> dict[str, NoticeContent]:
102
+ try:
103
+ links = dict(notice_cfg["links"])
104
+ title = str(notice_cfg["title"])
105
+ except KeyError as exc:
106
+ raise ConfigError(f"notice config is missing {exc}") from exc
107
+ body = str(notice_cfg.get("body", ""))
108
+ default = kit.policy.default_language
109
+ out = {default: kit.notices.build_content(title=title, body=body, links=links)}
110
+ for locale, text in (notice_cfg.get("translations") or {}).items():
111
+ out[str(locale)] = kit.notices.build_content(
112
+ title=str(text.get("title", title)),
113
+ body=str(text.get("body", body)),
114
+ links={**links, **(text.get("links") or {})},
115
+ purposes=text.get("purposes") or {},
116
+ )
117
+ return out
118
+
119
+
120
+ def sync_notice(kit: Kit, config: Mapping[str, Any], *, actor: str = "config") -> list[Notice] | None:
121
+ """Publish the configured notice if it differs from the current one. Returns the new notices or None."""
122
+ notice_cfg = config.get("notice")
123
+ if not notice_cfg:
124
+ return None
125
+ wanted = notice_translations(kit, notice_cfg)
126
+ try:
127
+ current = kit.notices.current()
128
+ existing = {
129
+ n.locale: n.content_hash for n in kit.repository.list_notices(kit.tenant_id, version=current.version)
130
+ }
131
+ except NotFound:
132
+ existing = {}
133
+ if existing == {loc: content_hash(c) for loc, c in wanted.items()}:
134
+ return None
135
+ return kit.notices.publish(wanted, actor=actor)