optout 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
optout/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """OptOut — self-hosted data broker opt-out tool."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,36 @@
1
+ """Loads and validates broker YAML files against BrokerDef."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from pydantic import ValidationError
9
+
10
+ from .schema import BrokerDef
11
+
12
+
13
+ def load_broker(path: Path) -> BrokerDef:
14
+ """Parse and validate a single broker YAML file."""
15
+ data = yaml.safe_load(path.read_text())
16
+ try:
17
+ return BrokerDef.model_validate(data)
18
+ except ValidationError as exc:
19
+ raise ValueError(f"Invalid broker definition at {path}:\n{exc}") from exc
20
+
21
+
22
+ def load_all_brokers(brokers_dir: Path) -> list[BrokerDef]:
23
+ """Load every *.yml file in brokers_dir, sorted by slug."""
24
+ results: list[BrokerDef] = []
25
+ errors: list[str] = []
26
+
27
+ for yml in sorted(brokers_dir.glob("*.yml")):
28
+ try:
29
+ results.append(load_broker(yml))
30
+ except (ValueError, yaml.YAMLError) as exc:
31
+ errors.append(str(exc))
32
+
33
+ if errors:
34
+ raise ValueError(f"{len(errors)} broker(s) failed validation:\n" + "\n\n".join(errors))
35
+
36
+ return results
@@ -0,0 +1,56 @@
1
+ """In-memory broker catalog, populated from the brokers/ YAML directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+
9
+ from .loader import load_all_brokers
10
+ from .schema import BrokerDef
11
+
12
+ _registry: dict[str, BrokerDef] = {}
13
+ _loaded = False
14
+
15
+
16
+ def _brokers_dir() -> Path:
17
+ """Return the directory containing broker YAML files.
18
+
19
+ Priority:
20
+ 1. OPTOUT_BROKERS_DIR env var — lets users/tests override with a custom set.
21
+ 2. importlib.resources — finds the YAMLs bundled inside the installed package,
22
+ works for both editable installs (real filesystem path) and wheel installs.
23
+ """
24
+ env = os.environ.get("OPTOUT_BROKERS_DIR")
25
+ if env:
26
+ return Path(env)
27
+ return Path(str(files("optout").joinpath("data/brokers")))
28
+
29
+
30
+ def load_registry(brokers_dir: Path | None = None) -> None:
31
+ global _registry, _loaded
32
+ directory = brokers_dir or _brokers_dir()
33
+ if not directory.exists():
34
+ _registry = {}
35
+ _loaded = True
36
+ return
37
+ _registry = {b.slug: b for b in load_all_brokers(directory)}
38
+ _loaded = True
39
+
40
+
41
+ def _ensure_loaded() -> None:
42
+ if not _loaded:
43
+ load_registry()
44
+
45
+
46
+ def get_broker(slug: str) -> BrokerDef:
47
+ _ensure_loaded()
48
+ try:
49
+ return _registry[slug]
50
+ except KeyError:
51
+ raise KeyError(f"Unknown broker slug: '{slug}'. Run `optout brokers list` to see options.")
52
+
53
+
54
+ def all_brokers() -> list[BrokerDef]:
55
+ _ensure_loaded()
56
+ return sorted(_registry.values(), key=lambda b: b.name)
@@ -0,0 +1,196 @@
1
+ """Pydantic models for broker YAML definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Resilience primitives
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ class RetryConfig(BaseModel):
16
+ attempts: int = 3
17
+ backoff_seconds: float = 1.0
18
+
19
+
20
+ class StepBase(BaseModel):
21
+ id: str
22
+ label: str | None = None
23
+ retry: RetryConfig | None = None
24
+ optional: bool = False
25
+ on_failure: Literal["abort", "skip", "prompt"] = "abort"
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Step types (discriminated union on `type`)
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class SearchStep(StepBase):
34
+ type: Literal["search"]
35
+ search_url_template: str
36
+ result_selector: str
37
+ pick_strategy: str = "prompt_user"
38
+
39
+
40
+ class NavigateStep(StepBase):
41
+ type: Literal["navigate"]
42
+ url: str
43
+
44
+
45
+ class FormField(BaseModel):
46
+ selector: str
47
+ value: str
48
+ use_type: bool = False # type char-by-char; needed for React forms that ignore fill()
49
+
50
+
51
+ class FormFillStep(StepBase):
52
+ type: Literal["form_fill"]
53
+ fields: dict[str, FormField]
54
+ frame: str | None = None # CSS selector for an iframe to fill inside
55
+
56
+
57
+ class PromptUserIfPresentStep(StepBase):
58
+ type: Literal["prompt_user_if_present"]
59
+ selector: str
60
+ message: str
61
+ frame: str | None = None # CSS selector for an iframe to probe
62
+
63
+
64
+ class ClickStep(StepBase):
65
+ type: Literal["click"]
66
+ selector: str
67
+ frame: str | None = None # CSS selector for an iframe to click inside
68
+
69
+
70
+ class SmsVerificationStep(StepBase):
71
+ type: Literal["sms_verification_prompt"]
72
+ code_selector: str
73
+ message: str
74
+
75
+
76
+ class CaptureStep(StepBase):
77
+ type: Literal["capture"]
78
+ method: str # "screenshot" is the only current value
79
+ save_as: str
80
+
81
+
82
+ class WaitForStep(StepBase):
83
+ type: Literal["wait_for"]
84
+ selector: str | None = None
85
+ js_condition: str | None = None # JS predicate: () => bool; polled until truthy
86
+ timeout_ms: int = 120_000
87
+ message: str | None = None
88
+ frame: str | None = None # CSS selector for an iframe to probe
89
+
90
+ @model_validator(mode="after")
91
+ def _requires_selector_or_condition(self) -> WaitForStep:
92
+ if not self.selector and not self.js_condition:
93
+ raise ValueError("wait_for step requires 'selector' or 'js_condition'")
94
+ return self
95
+
96
+
97
+ class OpenInBrowserStep(StepBase):
98
+ type: Literal["open_in_browser"]
99
+ url: str
100
+ instructions: str
101
+
102
+
103
+ class CollectInputStep(StepBase):
104
+ type: Literal["collect_input"]
105
+ message: str
106
+ store_as: str # key written into StepState, e.g. "found_listing_url"
107
+
108
+
109
+ class AssertStep(StepBase):
110
+ type: Literal["assert"]
111
+ selector: str | None = None
112
+ text_contains: str | None = None
113
+ url_matches: str | None = None
114
+ js_condition: str | None = None
115
+
116
+ @model_validator(mode="after")
117
+ def _requires_at_least_one_condition(self) -> AssertStep:
118
+ if not any([self.selector, self.text_contains, self.url_matches, self.js_condition]):
119
+ raise ValueError("assert step requires at least one condition field")
120
+ return self
121
+
122
+
123
+ BrokerStep = Annotated[
124
+ SearchStep
125
+ | NavigateStep
126
+ | FormFillStep
127
+ | PromptUserIfPresentStep
128
+ | ClickStep
129
+ | SmsVerificationStep
130
+ | CaptureStep
131
+ | WaitForStep
132
+ | OpenInBrowserStep
133
+ | CollectInputStep
134
+ | AssertStep,
135
+ Field(discriminator="type"),
136
+ ]
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Scan config (how to check if the user appears on a broker)
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ class BrokerScanConfig(BaseModel):
145
+ search_url_template: str
146
+ result_selector: str
147
+ match_fields: list[str] = []
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Top-level BrokerDef
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class BrokerDef(BaseModel):
156
+ slug: str
157
+ name: str
158
+ domain: str
159
+ category: str
160
+ parent_company: str | None = None
161
+
162
+ method: Literal["web_form", "email", "postal", "manual"]
163
+ opt_out_url: str | None = None
164
+ opt_out_email: str | None = None
165
+ opt_out_postal_address: str | None = None
166
+
167
+ required_fields: list[str] = []
168
+ optional_fields: list[str] = []
169
+
170
+ requires_listing_url: bool = False
171
+ requires_phone_verification: bool = False
172
+ requires_email_verification: bool = False
173
+ requires_id_upload: bool = False
174
+ requires_notarization: bool = False
175
+
176
+ legal_basis: list[str]
177
+ statutory_response_days: int
178
+ re_add_window_days: int | None = None
179
+
180
+ last_verified_at: date | None = None
181
+ notes: str | None = None
182
+
183
+ scan: BrokerScanConfig | None = None
184
+ steps: list[BrokerStep] = []
185
+
186
+ @model_validator(mode="after")
187
+ def _method_fields_consistent(self) -> BrokerDef:
188
+ if self.method == "email" and not self.opt_out_email:
189
+ raise ValueError(
190
+ f"Broker '{self.slug}': opt_out_email is required when method is 'email'"
191
+ )
192
+ if self.method == "web_form" and not self.opt_out_url:
193
+ raise ValueError(
194
+ f"Broker '{self.slug}': opt_out_url is required when method is 'web_form'"
195
+ )
196
+ return self