spftrace 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.
spftrace-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott McKeown
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,208 @@
1
+ Metadata-Version: 2.5
2
+ Name: spftrace
3
+ Version: 0.1.0
4
+ Summary: RFC 7208 SPF evaluator that returns a full evaluation trace
5
+ Project-URL: Homepage, https://github.com/smck83/spftrace
6
+ Project-URL: Source, https://github.com/smck83/spftrace
7
+ Project-URL: Issues, https://github.com/smck83/spftrace/issues
8
+ Author: Scott McKeown
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: authentication,dns,email,rfc7208,spf,trace
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Communications :: Email
19
+ Classifier: Topic :: Internet :: Name Service (DNS)
20
+ Classifier: Topic :: Security
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: dnspython>=2.6
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=8; extra == 'test'
26
+ Requires-Dist: pyyaml>=6; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # spftrace
30
+
31
+ An RFC 7208 SPF evaluator that shows its working.
32
+
33
+ Most SPF libraries answer `pass` or `fail` and throw the reasoning away. spftrace
34
+ returns the reasoning: every DNS query with its rcode and timing, every macro
35
+ expansion before and after, every mechanism with its qualifier and why it did or
36
+ did not match, the running DNS-term and void-lookup counters, and the exact term
37
+ that broke a limit.
38
+
39
+ It is a library. There is no web UI here, and no framework dependency. Build your
40
+ own CLI, API or service on top.
41
+
42
+ - Own implementation of `check_host()`, not a wrapper around another evaluator
43
+ - Passes all 203 cases of the official openspf.org RFC 7208 test suite
44
+ - Async core with a blocking convenience wrapper
45
+ - One runtime dependency: dnspython
46
+ - Reads no environment variables and no system resolver config. Configuration is
47
+ explicit, so the consuming application stays in charge
48
+
49
+ ## Install
50
+
51
+ ```
52
+ pip install spftrace
53
+ ```
54
+
55
+ ## Use
56
+
57
+ ```python
58
+ import spftrace
59
+
60
+ result = spftrace.check("203.0.113.1", "user@example.com")
61
+
62
+ print(result.verdict) # pass, fail, softfail, neutral, none,
63
+ # permerror or temperror
64
+ print(result.dns_terms_used) # against the RFC limit of 10
65
+ print(result.to_dict()) # JSON-safe, with the full event trace
66
+ ```
67
+
68
+ From async code, await the async form instead. Calling `check()` inside a running
69
+ event loop raises `SpfUsageError` telling you so, rather than a confusing asyncio
70
+ error from several frames down.
71
+
72
+ ```python
73
+ result = await spftrace.acheck("203.0.113.1", "user@example.com")
74
+ ```
75
+
76
+ ### In FastAPI
77
+
78
+ ```python
79
+ from fastapi import FastAPI
80
+ import spftrace
81
+
82
+ app = FastAPI()
83
+
84
+ @app.get("/check")
85
+ async def check(ip: str, sender: str):
86
+ result = await spftrace.acheck(
87
+ ip, sender, nameservers=["1.1.1.1"], max_queries=75
88
+ )
89
+ return result.to_dict()
90
+ ```
91
+
92
+ Do not let users pass arbitrary resolver addresses through to `nameservers`. That
93
+ turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and
94
+ map the user's choice to one server side.
95
+
96
+ ### Options
97
+
98
+ ```python
99
+ await spftrace.acheck(
100
+ ip,
101
+ sender,
102
+ helo="mail.example.net", # defaults to the sender domain
103
+ policy="v=spf1 -all", # evaluate this instead of looking one up
104
+ nameservers=["192.0.2.53"], # defaults to 8.8.8.8
105
+ timeout=5.0, # per-query DNS timeout
106
+ max_queries=75, # hard cap on real lookups
107
+ time_limit=20.0, # overall deadline, checked between terms
108
+ receiver="mta01", # value of the %{r} macro
109
+ audit=False, # see below
110
+ )
111
+ ```
112
+
113
+ `policy=` evaluates a record you paste in rather than one published in DNS, which
114
+ is how you test a change before shipping it.
115
+
116
+ ### Bring your own resolver
117
+
118
+ Pass a `resolver` instead of `nameservers` to add caching, share a resolver across
119
+ checks, or test with no network at all.
120
+
121
+ ```python
122
+ from spftrace import Evaluator, Limits, ZoneResolver
123
+
124
+ zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
125
+ result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")
126
+ ```
127
+
128
+ Subclass `BaseResolver` and implement `async _lookup(name, rtype) -> (rcode, answers)`
129
+ for anything else. Query recording, caching, the void count and the budget are all
130
+ handled in the base class.
131
+
132
+ ## Errors are verdicts
133
+
134
+ RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an
135
+ exhausted query budget and a DNS timeout all come back as a `permerror` or
136
+ `temperror` verdict with the reason in the trace. You do not have to wrap a check
137
+ in `try` just to survive a hostile zone.
138
+
139
+ `SpfUsageError` is the exception you may see, and it always means the calling code
140
+ is wrong: `check()` from inside an event loop, or `resolver` and `nameservers`
141
+ supplied together.
142
+
143
+ ## Audit mode
144
+
145
+ The 10-term limit means evaluation stops at the eleventh lookup, so neither a real
146
+ MTA nor a normal check can tell you how many lookups an over-limit record actually
147
+ needs. `audit=True` keeps counting past the limit and reports the true figure.
148
+
149
+ The verdict is still forced to `permerror`. Visibility changes; the answer never
150
+ does. A matching mechanism sitting past the limit does not become a `pass`.
151
+
152
+ ## Command line
153
+
154
+ ```
155
+ spftrace 203.0.113.1 user@example.com
156
+ spftrace 203.0.113.1 user@example.com --json
157
+ spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
158
+ spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit
159
+ ```
160
+
161
+ `--dns` also reads `SPFTRACE_DNS`. That environment variable is a CLI convenience
162
+ only; the library itself never reads it.
163
+
164
+ ## Result
165
+
166
+ | Attribute | Meaning |
167
+ | --- | --- |
168
+ | `verdict` (alias `result`) | the RFC 7208 result string |
169
+ | `explanation` | expanded `exp=` text, on `fail` only |
170
+ | `trace` | ordered `Event` log with recursion depth |
171
+ | `queries` | every DNS query: name, type, rcode, answers, ms, void, source |
172
+ | `dns_terms_used` | terms consumed against the limit of 10 |
173
+ | `void_lookups_used` | void lookups against the limit of 2 |
174
+ | `elapsed_ms` | wall time for the evaluation |
175
+ | `warnings` | non-fatal notes about the record |
176
+
177
+ `to_dict()` is the stable JSON contract and carries `schema_version`. Additive keys
178
+ will not bump it; a change consumers must notice will.
179
+
180
+ ## Limits enforced
181
+
182
+ - 10 DNS terms over `include`, `a`, `mx`, `ptr`, `exists` and `redirect`, not
183
+ `ip4`, `ip6` or `all`
184
+ - 2 void lookups, counted once at the resolver. Counting per term double counts:
185
+ every enclosing `include` re-counts its children, and a single void three
186
+ includes deep became a false `permerror`
187
+ - 10 MX records per `mx`, 10 PTR names per `ptr`
188
+ - `exp` and `%{p}` do DNS but do not count against the term limit
189
+ - A separate hard cap on real queries, 75 by default, because the term limit
190
+ counts terms and not lookups: ten `mx` terms with ten MX records each is 10
191
+ terms but 111 queries
192
+
193
+ ## Tests
194
+
195
+ ```
196
+ pip install -e ".[test]"
197
+ pytest
198
+ ```
199
+
200
+ The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so
201
+ the gate cannot shift underneath you. It is not vendored. `pytest --rfc-strict`
202
+ fails rather than skips when the corpus cannot be fetched; CI uses it.
203
+ `SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml` runs the suite offline, still
204
+ hash-checked.
205
+
206
+ ## Licence
207
+
208
+ MIT.
@@ -0,0 +1,180 @@
1
+ # spftrace
2
+
3
+ An RFC 7208 SPF evaluator that shows its working.
4
+
5
+ Most SPF libraries answer `pass` or `fail` and throw the reasoning away. spftrace
6
+ returns the reasoning: every DNS query with its rcode and timing, every macro
7
+ expansion before and after, every mechanism with its qualifier and why it did or
8
+ did not match, the running DNS-term and void-lookup counters, and the exact term
9
+ that broke a limit.
10
+
11
+ It is a library. There is no web UI here, and no framework dependency. Build your
12
+ own CLI, API or service on top.
13
+
14
+ - Own implementation of `check_host()`, not a wrapper around another evaluator
15
+ - Passes all 203 cases of the official openspf.org RFC 7208 test suite
16
+ - Async core with a blocking convenience wrapper
17
+ - One runtime dependency: dnspython
18
+ - Reads no environment variables and no system resolver config. Configuration is
19
+ explicit, so the consuming application stays in charge
20
+
21
+ ## Install
22
+
23
+ ```
24
+ pip install spftrace
25
+ ```
26
+
27
+ ## Use
28
+
29
+ ```python
30
+ import spftrace
31
+
32
+ result = spftrace.check("203.0.113.1", "user@example.com")
33
+
34
+ print(result.verdict) # pass, fail, softfail, neutral, none,
35
+ # permerror or temperror
36
+ print(result.dns_terms_used) # against the RFC limit of 10
37
+ print(result.to_dict()) # JSON-safe, with the full event trace
38
+ ```
39
+
40
+ From async code, await the async form instead. Calling `check()` inside a running
41
+ event loop raises `SpfUsageError` telling you so, rather than a confusing asyncio
42
+ error from several frames down.
43
+
44
+ ```python
45
+ result = await spftrace.acheck("203.0.113.1", "user@example.com")
46
+ ```
47
+
48
+ ### In FastAPI
49
+
50
+ ```python
51
+ from fastapi import FastAPI
52
+ import spftrace
53
+
54
+ app = FastAPI()
55
+
56
+ @app.get("/check")
57
+ async def check(ip: str, sender: str):
58
+ result = await spftrace.acheck(
59
+ ip, sender, nameservers=["1.1.1.1"], max_queries=75
60
+ )
61
+ return result.to_dict()
62
+ ```
63
+
64
+ Do not let users pass arbitrary resolver addresses through to `nameservers`. That
65
+ turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and
66
+ map the user's choice to one server side.
67
+
68
+ ### Options
69
+
70
+ ```python
71
+ await spftrace.acheck(
72
+ ip,
73
+ sender,
74
+ helo="mail.example.net", # defaults to the sender domain
75
+ policy="v=spf1 -all", # evaluate this instead of looking one up
76
+ nameservers=["192.0.2.53"], # defaults to 8.8.8.8
77
+ timeout=5.0, # per-query DNS timeout
78
+ max_queries=75, # hard cap on real lookups
79
+ time_limit=20.0, # overall deadline, checked between terms
80
+ receiver="mta01", # value of the %{r} macro
81
+ audit=False, # see below
82
+ )
83
+ ```
84
+
85
+ `policy=` evaluates a record you paste in rather than one published in DNS, which
86
+ is how you test a change before shipping it.
87
+
88
+ ### Bring your own resolver
89
+
90
+ Pass a `resolver` instead of `nameservers` to add caching, share a resolver across
91
+ checks, or test with no network at all.
92
+
93
+ ```python
94
+ from spftrace import Evaluator, Limits, ZoneResolver
95
+
96
+ zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
97
+ result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")
98
+ ```
99
+
100
+ Subclass `BaseResolver` and implement `async _lookup(name, rtype) -> (rcode, answers)`
101
+ for anything else. Query recording, caching, the void count and the budget are all
102
+ handled in the base class.
103
+
104
+ ## Errors are verdicts
105
+
106
+ RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an
107
+ exhausted query budget and a DNS timeout all come back as a `permerror` or
108
+ `temperror` verdict with the reason in the trace. You do not have to wrap a check
109
+ in `try` just to survive a hostile zone.
110
+
111
+ `SpfUsageError` is the exception you may see, and it always means the calling code
112
+ is wrong: `check()` from inside an event loop, or `resolver` and `nameservers`
113
+ supplied together.
114
+
115
+ ## Audit mode
116
+
117
+ The 10-term limit means evaluation stops at the eleventh lookup, so neither a real
118
+ MTA nor a normal check can tell you how many lookups an over-limit record actually
119
+ needs. `audit=True` keeps counting past the limit and reports the true figure.
120
+
121
+ The verdict is still forced to `permerror`. Visibility changes; the answer never
122
+ does. A matching mechanism sitting past the limit does not become a `pass`.
123
+
124
+ ## Command line
125
+
126
+ ```
127
+ spftrace 203.0.113.1 user@example.com
128
+ spftrace 203.0.113.1 user@example.com --json
129
+ spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
130
+ spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit
131
+ ```
132
+
133
+ `--dns` also reads `SPFTRACE_DNS`. That environment variable is a CLI convenience
134
+ only; the library itself never reads it.
135
+
136
+ ## Result
137
+
138
+ | Attribute | Meaning |
139
+ | --- | --- |
140
+ | `verdict` (alias `result`) | the RFC 7208 result string |
141
+ | `explanation` | expanded `exp=` text, on `fail` only |
142
+ | `trace` | ordered `Event` log with recursion depth |
143
+ | `queries` | every DNS query: name, type, rcode, answers, ms, void, source |
144
+ | `dns_terms_used` | terms consumed against the limit of 10 |
145
+ | `void_lookups_used` | void lookups against the limit of 2 |
146
+ | `elapsed_ms` | wall time for the evaluation |
147
+ | `warnings` | non-fatal notes about the record |
148
+
149
+ `to_dict()` is the stable JSON contract and carries `schema_version`. Additive keys
150
+ will not bump it; a change consumers must notice will.
151
+
152
+ ## Limits enforced
153
+
154
+ - 10 DNS terms over `include`, `a`, `mx`, `ptr`, `exists` and `redirect`, not
155
+ `ip4`, `ip6` or `all`
156
+ - 2 void lookups, counted once at the resolver. Counting per term double counts:
157
+ every enclosing `include` re-counts its children, and a single void three
158
+ includes deep became a false `permerror`
159
+ - 10 MX records per `mx`, 10 PTR names per `ptr`
160
+ - `exp` and `%{p}` do DNS but do not count against the term limit
161
+ - A separate hard cap on real queries, 75 by default, because the term limit
162
+ counts terms and not lookups: ten `mx` terms with ten MX records each is 10
163
+ terms but 111 queries
164
+
165
+ ## Tests
166
+
167
+ ```
168
+ pip install -e ".[test]"
169
+ pytest
170
+ ```
171
+
172
+ The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so
173
+ the gate cannot shift underneath you. It is not vendored. `pytest --rfc-strict`
174
+ fails rather than skips when the corpus cannot be fetched; CI uses it.
175
+ `SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml` runs the suite offline, still
176
+ hash-checked.
177
+
178
+ ## Licence
179
+
180
+ MIT.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "spftrace"
7
+ version = "0.1.0"
8
+ description = "RFC 7208 SPF evaluator that returns a full evaluation trace"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Scott McKeown" }]
14
+ keywords = ["spf", "rfc7208", "email", "dns", "authentication", "trace"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Communications :: Email",
23
+ "Topic :: Internet :: Name Service (DNS)",
24
+ "Topic :: Security",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["dnspython>=2.6"]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8", "pyyaml>=6"]
31
+
32
+ [project.scripts]
33
+ spftrace = "spftrace.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/smck83/spftrace"
37
+ Source = "https://github.com/smck83/spftrace"
38
+ Issues = "https://github.com/smck83/spftrace/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/spftrace"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/spftrace", "tests", "README.md", "LICENSE"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ addopts = "-q"
@@ -0,0 +1,199 @@
1
+ """spftrace: an RFC 7208 SPF evaluator that shows its working.
2
+
3
+ The trace is the product, not a side effect. Every DNS query, macro expansion,
4
+ mechanism evaluation and limit decision is recorded and returned.
5
+
6
+ Quick use:
7
+
8
+ import spftrace
9
+ r = spftrace.check("203.0.113.1", "user@example.com")
10
+ print(r.verdict) # pass | fail | softfail | neutral | none |
11
+ # permerror | temperror
12
+ print(r.to_dict()) # stable JSON-safe structure
13
+
14
+ Inside an async application (FastAPI and friends), await the async form:
15
+
16
+ r = await spftrace.acheck("203.0.113.1", "user@example.com")
17
+
18
+ For full control over DNS, build the resolver and evaluator yourself:
19
+
20
+ from spftrace import Evaluator, Limits, LiveResolver
21
+ resolver = LiveResolver(["192.0.2.53"], timeout=3.0, max_queries=75)
22
+ result = await Evaluator(resolver, Limits()).evaluate(ip, sender, helo)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from typing import Iterable
28
+
29
+ from .errors import (
30
+ SpfError,
31
+ SpfNoneError,
32
+ SpfPermError,
33
+ SpfTempError,
34
+ SpfUsageError,
35
+ )
36
+ from .evaluator import (
37
+ DEFAULT_TIME_LIMIT,
38
+ MAX_DNS_TERMS,
39
+ MAX_MX_RECORDS,
40
+ MAX_PTR_NAMES,
41
+ MAX_VOID_LOOKUPS,
42
+ Evaluator,
43
+ Limits,
44
+ )
45
+ from .parser import Term, parse
46
+ from .resolver import BaseResolver, DnsError, LiveResolver, ZoneResolver
47
+ from .trace import DnsQueryRecord, Event, Result, Trace
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ #: Used only when no resolver and no nameservers are supplied. Callers that care
52
+ #: which resolver answers should pass their own; this library never reads the
53
+ #: system resolver configuration or any environment variable.
54
+ DEFAULT_NAMESERVERS: tuple[str, ...] = ("8.8.8.8",)
55
+
56
+ #: Hard cap on real DNS queries per evaluation. This is not the RFC's 10-term
57
+ #: limit: that counts terms, not lookups, and ten `mx` terms with ten MX records
58
+ #: each is 10 terms but 111 queries. This cap stops a hostile zone from turning
59
+ #: one check into unbounded traffic.
60
+ DEFAULT_MAX_QUERIES = 75
61
+
62
+ __all__ = [
63
+ "__version__",
64
+ "acheck",
65
+ "check",
66
+ "BaseResolver",
67
+ "DEFAULT_MAX_QUERIES",
68
+ "DEFAULT_NAMESERVERS",
69
+ "DEFAULT_TIME_LIMIT",
70
+ "DnsError",
71
+ "DnsQueryRecord",
72
+ "Evaluator",
73
+ "Event",
74
+ "Limits",
75
+ "LiveResolver",
76
+ "MAX_DNS_TERMS",
77
+ "MAX_MX_RECORDS",
78
+ "MAX_PTR_NAMES",
79
+ "MAX_VOID_LOOKUPS",
80
+ "Result",
81
+ "SpfError",
82
+ "SpfNoneError",
83
+ "SpfPermError",
84
+ "SpfTempError",
85
+ "SpfUsageError",
86
+ "Term",
87
+ "Trace",
88
+ "ZoneResolver",
89
+ "parse",
90
+ ]
91
+
92
+
93
+ async def acheck(
94
+ ip: str,
95
+ sender: str,
96
+ helo: str = "",
97
+ *,
98
+ policy: str | None = None,
99
+ resolver: BaseResolver | None = None,
100
+ nameservers: Iterable[str] | None = None,
101
+ timeout: float = 5.0,
102
+ max_queries: int | None = DEFAULT_MAX_QUERIES,
103
+ time_limit: float = DEFAULT_TIME_LIMIT,
104
+ receiver: str = "spftrace",
105
+ audit: bool = False,
106
+ ) -> Result:
107
+ """Evaluate SPF for `ip` sending as `sender`, returning a traced Result.
108
+
109
+ RFC outcomes are never exceptions. A malformed record, a blown lookup limit
110
+ or an exhausted query budget all come back as a `permerror` verdict with the
111
+ reason in the trace, so a caller never has to wrap this in try/except just to
112
+ survive a hostile zone. Exceptions are reserved for caller mistakes.
113
+
114
+ Args:
115
+ ip: the connecting IP. IPv4-mapped IPv6 is normalised to IPv4.
116
+ sender: MAIL FROM address. A bare domain is treated as postmaster@domain.
117
+ helo: HELO/EHLO name. Defaults to the sender domain.
118
+ policy: evaluate this record instead of looking one up in DNS. Useful for
119
+ testing a record you have not published yet.
120
+ resolver: a ready-made resolver. Mutually exclusive with `nameservers`.
121
+ Supply your own to add caching, or a ZoneResolver to test offline.
122
+ nameservers: resolver addresses to query. Defaults to DEFAULT_NAMESERVERS.
123
+ timeout: per-query DNS timeout in seconds.
124
+ max_queries: hard cap on real DNS queries, or None for no cap.
125
+ time_limit: overall deadline in seconds, checked between terms.
126
+ receiver: value of the %{r} macro.
127
+ audit: keep counting past the 10-term limit to report what a record
128
+ really needs. The verdict is still forced to permerror, so this
129
+ changes visibility and never the answer.
130
+
131
+ Raises:
132
+ SpfUsageError: both `resolver` and `nameservers` were supplied.
133
+ """
134
+ if resolver is not None and nameservers is not None:
135
+ raise SpfUsageError(
136
+ "pass either resolver or nameservers, not both: a supplied resolver "
137
+ "already carries its own nameservers, timeout and query budget"
138
+ )
139
+ if resolver is None:
140
+ resolver = LiveResolver(
141
+ nameservers if nameservers is not None else DEFAULT_NAMESERVERS,
142
+ timeout=timeout,
143
+ max_queries=max_queries,
144
+ )
145
+ evaluator = Evaluator(
146
+ resolver,
147
+ Limits(time_limit=time_limit, audit=audit),
148
+ receiver=receiver,
149
+ policy_override=policy,
150
+ )
151
+ return await evaluator.evaluate(ip, sender, helo)
152
+
153
+
154
+ def check(
155
+ ip: str,
156
+ sender: str,
157
+ helo: str = "",
158
+ *,
159
+ policy: str | None = None,
160
+ resolver: BaseResolver | None = None,
161
+ nameservers: Iterable[str] | None = None,
162
+ timeout: float = 5.0,
163
+ max_queries: int | None = DEFAULT_MAX_QUERIES,
164
+ time_limit: float = DEFAULT_TIME_LIMIT,
165
+ receiver: str = "spftrace",
166
+ audit: bool = False,
167
+ ) -> Result:
168
+ """Blocking form of `acheck`, for scripts and sync code.
169
+
170
+ Raises:
171
+ SpfUsageError: called from inside a running event loop. The evaluator is
172
+ async underneath; from async code, await `acheck` instead. Without
173
+ this check asyncio raises a confusing "cannot be called from a
174
+ running event loop" from several frames down.
175
+ """
176
+ try:
177
+ asyncio.get_running_loop()
178
+ except RuntimeError:
179
+ pass
180
+ else:
181
+ raise SpfUsageError(
182
+ "spftrace.check() cannot be called from a running event loop. "
183
+ "Use 'await spftrace.acheck(...)' instead."
184
+ )
185
+ return asyncio.run(
186
+ acheck(
187
+ ip,
188
+ sender,
189
+ helo,
190
+ policy=policy,
191
+ resolver=resolver,
192
+ nameservers=nameservers,
193
+ timeout=timeout,
194
+ max_queries=max_queries,
195
+ time_limit=time_limit,
196
+ receiver=receiver,
197
+ audit=audit,
198
+ )
199
+ )