openterms-py 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenTerms
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,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ include pyproject.toml
4
+ recursive-include openterms *.py *.typed
@@ -0,0 +1,552 @@
1
+ Metadata-Version: 2.4
2
+ Name: openterms-py
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the OpenTerms Protocol — query machine-readable AI agent permissions.
5
+ Author-email: OpenTerms <hello@openterms.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://openterms.com
8
+ Project-URL: Documentation, https://openterms.com/docs
9
+ Project-URL: Repository, https://github.com/jstibal/openterms-py
10
+ Project-URL: Bug Tracker, https://github.com/jstibal/openterms-py/issues
11
+ Keywords: openTerms,ai,agents,permissions,llm,langchain,crewai
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: requests>=2.28
26
+ Provides-Extra: async
27
+ Requires-Dist: httpx>=0.24; extra == "async"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Requires-Dist: pytest-cov; extra == "dev"
31
+ Requires-Dist: responses>=0.23; extra == "dev"
32
+ Requires-Dist: mypy>=1.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.1; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # openterms-py
37
+
38
+ Python SDK for the [OpenTerms Protocol](https://openterms.com).
39
+
40
+ Query machine-readable AI agent permissions from `openterms.json` files before
41
+ your agent acts on a domain.
42
+
43
+ ```
44
+ pip install openterms-py
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Core API
50
+
51
+ ```python
52
+ import openterms
53
+
54
+ # Fetch the full openterms.json (cached in memory, TTL 1h by default)
55
+ terms = openterms.fetch("github.com")
56
+
57
+ # Check a single permission
58
+ result = openterms.check("github.com", "api_access")
59
+ # result.decision → "allow" | "deny" | "not_specified"
60
+ # bool(result) → True when decision is "allow"
61
+
62
+ # Get the discovery block (MCP servers, OpenAPI specs)
63
+ disc = openterms.discover("github.com")
64
+
65
+ # Generate a local compliance receipt
66
+ rec = openterms.receipt("github.com", "api_access", result.decision)
67
+ print(rec.to_dict())
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ Requires Python 3.9+ and [`requests`](https://pypi.org/project/requests/)
75
+ (installed automatically).
76
+
77
+ ```bash
78
+ pip install openterms-py
79
+ ```
80
+
81
+ Optional async support via [`httpx`](https://pypi.org/project/httpx/):
82
+
83
+ ```bash
84
+ pip install "openterms-py[async]"
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Functions
90
+
91
+ ### `fetch(domain) → dict | None`
92
+
93
+ Fetches `/.well-known/openterms.json` from the domain, falling back to
94
+ `/openterms.json`. Returns the parsed JSON dict or `None` if unreachable.
95
+
96
+ Results are cached in memory. The TTL is taken from the server's
97
+ `Cache-Control: max-age=N` header, or the configured default (3600s).
98
+
99
+ ```python
100
+ terms = openterms.fetch("stripe.com")
101
+ if terms:
102
+ print(terms.get("service"))
103
+ print(terms.get("permissions"))
104
+ ```
105
+
106
+ ---
107
+
108
+ ### `check(domain, action) → CheckResult`
109
+
110
+ Returns allow/deny for a single permission key. Evaluates to `True` in
111
+ boolean context when the decision is `"allow"`.
112
+
113
+ ```python
114
+ result = openterms.check("stripe.com", "api_access")
115
+
116
+ if result:
117
+ print("Access allowed")
118
+ else:
119
+ print(f"Blocked: {result.decision}") # "deny" or "not_specified"
120
+
121
+ # Access all fields
122
+ print(result.domain) # "stripe.com"
123
+ print(result.action) # "api_access"
124
+ print(result.decision) # "allow" | "deny" | "not_specified"
125
+ print(result.raw_value) # the raw value from permissions block
126
+ print(result.source) # "cache" | "network"
127
+ ```
128
+
129
+ Common permission keys: `scrape_data`, `api_access`, `read_content`,
130
+ `index_content`, `train_on_content`, `execute_code`, `access_user_data`.
131
+
132
+ ---
133
+
134
+ ### `discover(domain) → DiscoveryResult | None`
135
+
136
+ Returns the `discovery` block from the domain's `openterms.json`, or
137
+ `None` if absent.
138
+
139
+ ```python
140
+ disc = openterms.discover("acme-corp.com")
141
+ if disc:
142
+ for server in disc.mcp_servers:
143
+ print(server.url, server.transport, server.description)
144
+ for spec in disc.api_specs:
145
+ print(spec.url, spec.type)
146
+ ```
147
+
148
+ `DiscoveryResult` fields:
149
+ - `mcp_servers` — list of `McpServer(url, transport, description)`
150
+ - `api_specs` — list of `ApiSpec(url, type, description)`
151
+
152
+ ---
153
+
154
+ ### `receipt(domain, action, decision) → Receipt`
155
+
156
+ Generates a minimal ORS compliance receipt. Local artifact only — nothing
157
+ is sent to any server.
158
+
159
+ ```python
160
+ result = openterms.check("github.com", "scrape_data")
161
+ rec = openterms.receipt("github.com", "scrape_data", result.decision)
162
+
163
+ print(rec.to_dict())
164
+ # {
165
+ # "domain": "github.com",
166
+ # "action": "scrape_data",
167
+ # "decision": "deny",
168
+ # "timestamp": "2026-04-11T10:40:00Z",
169
+ # "openterms_hash": "a3f2...c91d"
170
+ # }
171
+
172
+ # Log it, write to a file, store in your DB — your choice
173
+ import json
174
+ with open("compliance_log.jsonl", "a") as f:
175
+ f.write(json.dumps(rec.to_dict()) + "\n")
176
+ ```
177
+
178
+ ---
179
+
180
+ ### `configure(default_ttl, timeout, user_agent)`
181
+
182
+ Adjust the shared client settings. Clears the existing cache.
183
+
184
+ ```python
185
+ openterms.configure(
186
+ default_ttl=600, # 10-minute cache
187
+ timeout=5, # 5-second HTTP timeout
188
+ )
189
+ ```
190
+
191
+ ### `clear_cache(domain=None)`
192
+
193
+ Flush cached entries. Pass a domain to evict a single entry, or call with
194
+ no args to flush everything.
195
+
196
+ ```python
197
+ openterms.clear_cache("github.com") # evict one domain
198
+ openterms.clear_cache() # flush all
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Plain Python example
204
+
205
+ No framework, just a permission gate before an HTTP call.
206
+
207
+ ```python
208
+ import requests
209
+ import openterms
210
+
211
+ TARGET_DOMAIN = "data-provider.com"
212
+
213
+ def fetch_data_if_permitted(url: str) -> dict | None:
214
+ result = openterms.check(TARGET_DOMAIN, "api_access")
215
+
216
+ # Record the decision
217
+ rec = openterms.receipt(TARGET_DOMAIN, "api_access", result.decision)
218
+ print("Receipt:", rec.to_dict())
219
+
220
+ if not result:
221
+ print(f"api_access is {result.decision} for {TARGET_DOMAIN}. Aborting.")
222
+ return None
223
+
224
+ resp = requests.get(url, timeout=10)
225
+ resp.raise_for_status()
226
+ return resp.json()
227
+
228
+
229
+ data = fetch_data_if_permitted("https://data-provider.com/api/items")
230
+ ```
231
+
232
+ ---
233
+
234
+ ## LangChain integration
235
+
236
+ Gate a web-interaction tool behind an OpenTerms permission check.
237
+
238
+ ### Option 1 — Custom Tool with permission guard
239
+
240
+ ```python
241
+ from langchain_core.tools import tool
242
+ import openterms
243
+
244
+ @tool
245
+ def fetch_page_content(url: str) -> str:
246
+ """Fetch the text content of a web page.
247
+
248
+ Only proceeds if the domain's openterms.json permits scraping.
249
+ """
250
+ from urllib.parse import urlparse
251
+ import requests
252
+
253
+ domain = urlparse(url).hostname or url
254
+
255
+ result = openterms.check(domain, "scrape_data")
256
+
257
+ # Log the compliance receipt
258
+ rec = openterms.receipt(domain, "scrape_data", result.decision)
259
+ print(f"[OpenTerms] receipt: {rec.to_dict()}")
260
+
261
+ if not result:
262
+ return (
263
+ f"Cannot fetch {url}: scrape_data is '{result.decision}' "
264
+ f"for {domain} per their openterms.json."
265
+ )
266
+
267
+ resp = requests.get(url, timeout=10)
268
+ resp.raise_for_status()
269
+ return resp.text[:4000]
270
+
271
+
272
+ # Use in an agent
273
+ from langchain_anthropic import ChatAnthropic
274
+ from langgraph.prebuilt import create_react_agent
275
+
276
+ llm = ChatAnthropic(model="claude-3-5-sonnet-20241022")
277
+ agent = create_react_agent(llm, tools=[fetch_page_content])
278
+
279
+ result = agent.invoke({
280
+ "messages": [("user", "Summarise the content at https://example.com")]
281
+ })
282
+ ```
283
+
284
+ ### Option 2 — Pre-action callback on any browser tool
285
+
286
+ Wrap an existing tool class to inject the permission check transparently:
287
+
288
+ ```python
289
+ from langchain_core.tools import BaseTool
290
+ from langchain_core.callbacks import CallbackManagerForToolRun
291
+ from typing import Optional, Type, Any
292
+ from pydantic import BaseModel
293
+ import openterms
294
+
295
+
296
+ class OpenTermsGuard(BaseTool):
297
+ """Wraps any web tool and gates execution on OpenTerms permission."""
298
+
299
+ name: str = "openTerms_guarded_browser"
300
+ description: str = "Fetch a URL, checking OpenTerms permissions first."
301
+ permission: str = "scrape_data"
302
+ wrapped_tool: Any # the underlying LangChain browser/fetch tool
303
+
304
+ class ArgsSchema(BaseModel):
305
+ url: str
306
+
307
+ args_schema: Type[BaseModel] = ArgsSchema
308
+
309
+ def _run(
310
+ self,
311
+ url: str,
312
+ run_manager: Optional[CallbackManagerForToolRun] = None,
313
+ ) -> str:
314
+ from urllib.parse import urlparse
315
+ domain = urlparse(url).hostname or url
316
+ result = openterms.check(domain, self.permission)
317
+
318
+ rec = openterms.receipt(domain, self.permission, result.decision)
319
+ print(f"[OpenTerms] {rec.to_dict()}")
320
+
321
+ if not result:
322
+ return (
323
+ f"Blocked by OpenTerms: '{self.permission}' is "
324
+ f"'{result.decision}' for {domain}."
325
+ )
326
+ return self.wrapped_tool.run(url)
327
+
328
+
329
+ # Usage
330
+ from langchain_community.tools import BrowserTool # or any fetch tool
331
+ browser = BrowserTool()
332
+ guarded = OpenTermsGuard(wrapped_tool=browser)
333
+ ```
334
+
335
+ ### Option 3 — Discover MCP servers for a domain before connecting
336
+
337
+ ```python
338
+ import openterms
339
+
340
+ def get_mcp_servers_for_domain(domain: str) -> list[dict]:
341
+ disc = openterms.discover(domain)
342
+ if not disc:
343
+ return []
344
+ return [
345
+ {"url": s.url, "transport": s.transport}
346
+ for s in disc.mcp_servers
347
+ ]
348
+
349
+ servers = get_mcp_servers_for_domain("acme-corp.com")
350
+ # [{"url": "https://acme-corp.com/mcp/sse", "transport": "sse"}]
351
+ ```
352
+
353
+ ---
354
+
355
+ ## CrewAI integration
356
+
357
+ Gate a CrewAI agent's web tasks behind OpenTerms permission checks.
358
+
359
+ ### Option 1 — Custom tool for CrewAI
360
+
361
+ ```python
362
+ from crewai_tools import BaseTool
363
+ import openterms
364
+ import requests
365
+
366
+
367
+ class OpenTermsWebTool(BaseTool):
368
+ name: str = "web_fetch_with_permissions"
369
+ description: str = (
370
+ "Fetch content from a URL. "
371
+ "Checks the domain's OpenTerms permissions before proceeding. "
372
+ "Returns an error string if the domain denies the requested action."
373
+ )
374
+ permission: str = "scrape_data"
375
+
376
+ def _run(self, url: str) -> str:
377
+ from urllib.parse import urlparse
378
+ domain = urlparse(url).hostname or url
379
+
380
+ result = openterms.check(domain, self.permission)
381
+
382
+ # Store receipt for audit trail
383
+ rec = openterms.receipt(domain, self.permission, result.decision)
384
+ # In production: write rec.to_dict() to your audit log
385
+
386
+ if not result:
387
+ return (
388
+ f"OpenTerms check failed for {domain}: "
389
+ f"{self.permission} = {result.decision}. "
390
+ "Do not proceed with this URL."
391
+ )
392
+
393
+ resp = requests.get(url, timeout=10)
394
+ resp.raise_for_status()
395
+ return resp.text[:4000]
396
+
397
+
398
+ # Use in a CrewAI agent
399
+ from crewai import Agent, Task, Crew
400
+
401
+ web_tool = OpenTermsWebTool(permission="scrape_data")
402
+
403
+ researcher = Agent(
404
+ role="Web Researcher",
405
+ goal="Research topics from web sources that permit scraping.",
406
+ backstory="You respect site permissions and only access allowed content.",
407
+ tools=[web_tool],
408
+ verbose=True,
409
+ )
410
+
411
+ task = Task(
412
+ description="Find and summarise pricing information from competitor websites.",
413
+ expected_output="A bullet-point comparison of competitor pricing.",
414
+ agent=researcher,
415
+ )
416
+
417
+ crew = Crew(agents=[researcher], tasks=[task], verbose=True)
418
+ result = crew.kickoff()
419
+ ```
420
+
421
+ ### Option 2 — Callback hook for CrewAI task lifecycle
422
+
423
+ Use a `before_kickoff` step to pre-validate all domains a task will touch:
424
+
425
+ ```python
426
+ from crewai import Crew, Agent, Task, Process
427
+ from typing import Union
428
+ import openterms
429
+
430
+
431
+ def check_domain_permissions(
432
+ domains: list[str],
433
+ action: str,
434
+ ) -> dict[str, str]:
435
+ """
436
+ Returns {domain: decision} for all domains.
437
+ Raises ValueError if any domain explicitly denies the action.
438
+ """
439
+ results = {}
440
+ denied = []
441
+ for domain in domains:
442
+ r = openterms.check(domain, action)
443
+ results[domain] = r.decision
444
+ if r.decision == "deny":
445
+ denied.append(domain)
446
+ if denied:
447
+ raise ValueError(
448
+ f"OpenTerms: {action!r} denied for: {', '.join(denied)}"
449
+ )
450
+ return results
451
+
452
+
453
+ # Before running your Crew, validate the target domains
454
+ target_domains = ["competitor-a.com", "competitor-b.com"]
455
+
456
+ try:
457
+ permissions = check_domain_permissions(target_domains, "scrape_data")
458
+ print(f"All domains permitted: {permissions}")
459
+ # safe to proceed
460
+ # crew.kickoff(...)
461
+ except ValueError as e:
462
+ print(f"Aborting: {e}")
463
+ ```
464
+
465
+ ### Option 3 — API discovery for CrewAI MCP tool selection
466
+
467
+ ```python
468
+ import openterms
469
+ from crewai import Agent
470
+
471
+ def build_agent_for_domain(domain: str) -> Agent:
472
+ disc = openterms.discover(domain)
473
+
474
+ tools = []
475
+ if disc and disc.api_specs:
476
+ # Dynamically load tools from discovered OpenAPI specs
477
+ for spec in disc.api_specs:
478
+ print(f"Found API spec: {spec.url} ({spec.type})")
479
+ # Load spec and generate tools here (e.g. via openapi-core)
480
+
481
+ return Agent(
482
+ role="Domain Specialist",
483
+ goal=f"Interact with {domain} using its declared API.",
484
+ backstory=f"You have been given the API specs for {domain}.",
485
+ tools=tools,
486
+ )
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Models reference
492
+
493
+ ```python
494
+ # CheckResult
495
+ result.domain # str
496
+ result.action # str
497
+ result.decision # "allow" | "deny" | "not_specified"
498
+ result.raw_value # Any — the raw permissions value (bool, dict, None)
499
+ result.source # "cache" | "network"
500
+ bool(result) # True iff decision == "allow"
501
+
502
+ # DiscoveryResult
503
+ disc.mcp_servers # list[McpServer]
504
+ disc.api_specs # list[ApiSpec]
505
+
506
+ # McpServer
507
+ server.url # str
508
+ server.transport # str ("sse" | "stdio" | "streamable-http")
509
+ server.description # str | None
510
+
511
+ # ApiSpec
512
+ spec.url # str
513
+ spec.type # str ("openapi_3" | "swagger_2" | "graphql_schema")
514
+ spec.description # str | None
515
+
516
+ # Receipt
517
+ rec.domain # str
518
+ rec.action # str
519
+ rec.decision # "allow" | "deny" | "not_specified"
520
+ rec.timestamp # str (ISO 8601 UTC)
521
+ rec.openterms_hash # str (SHA-256 hex, empty if domain was unreachable)
522
+ rec.to_dict() # → dict
523
+ ```
524
+
525
+ ---
526
+
527
+ ## Advanced configuration
528
+
529
+ ```python
530
+ import openterms
531
+
532
+ # Shorter cache, stricter timeout
533
+ openterms.configure(default_ttl=300, timeout=5)
534
+
535
+ # Per-request: bypass cache by clearing first
536
+ openterms.clear_cache("github.com")
537
+ result = openterms.check("github.com", "api_access")
538
+
539
+ # Use your own client instance (e.g. for testing with a mock cache)
540
+ from openterms.client import OpenTermsClient
541
+ from openterms.cache import TermsCache
542
+
543
+ custom_cache = TermsCache()
544
+ client = OpenTermsClient(default_ttl=0, cache=custom_cache)
545
+ result = client.check("github.com", "api_access")
546
+ ```
547
+
548
+ ---
549
+
550
+ ## License
551
+
552
+ MIT