correspond 0.0.2__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.
correspond/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """correspond: a channel facade for AI agents.
2
+
3
+ Read, listen, write and identify the sender over GitHub, email, push notifications,
4
+ Telegram and a web inbox, through one model and one set of verbs::
5
+
6
+ >>> import correspond # doctest: +SKIP
7
+ >>> messages = correspond.read("github:octocat/hello-world#1") # doctest: +SKIP
8
+ >>> messages[0].author.handle, messages[0].authenticity.grade.value # doctest: +SKIP
9
+ ('octocat', 'platform')
10
+ >>> correspond.send("ntfy:", "backup finished", dry_run=True).plan # doctest: +SKIP
11
+
12
+ A conversation reference is ``<channel>:<id>``. Each channel implements the operations it
13
+ can (``read``, ``listen``, ``send``, ``edit``, ``react``, ``upload``, ``verify``); asking
14
+ for one it lacks raises :class:`NotSupported`, and :func:`capabilities` says so in advance.
15
+ correspond knows no people: a message's ``author`` is what the platform attests, and its
16
+ ``authenticity`` is how sure the channel is.
17
+
18
+ The command line (``correspond read github:octocat/hello-world#1``) and the MCP server use
19
+ the same verbs, through :mod:`correspond.tools`.
20
+ """
21
+
22
+ from correspond.errors import (
23
+ ChannelError,
24
+ CorrespondError,
25
+ InvalidRef,
26
+ MissingRequirement,
27
+ NotSupported,
28
+ UnknownChannel,
29
+ )
30
+ from correspond.model import (
31
+ Account,
32
+ Attachment,
33
+ Authenticity,
34
+ Capabilities,
35
+ ChannelIdentity,
36
+ ConversationRef,
37
+ Draft,
38
+ Event,
39
+ Grade,
40
+ HistoryDepth,
41
+ Message,
42
+ SendResult,
43
+ Support,
44
+ )
45
+ from correspond.ops import (
46
+ Editor,
47
+ Listener,
48
+ Reactor,
49
+ Reader,
50
+ Uploader,
51
+ Verifier,
52
+ Writer,
53
+ capabilities,
54
+ edit,
55
+ get_channel,
56
+ listen,
57
+ parse_ref,
58
+ react,
59
+ read,
60
+ send,
61
+ upload,
62
+ verify,
63
+ )
64
+ from correspond.registry import check_requirements, register_channel, unregister_channel
65
+ from correspond.registry import channels as channel_registry
66
+ from correspond.routing import RouteDecision, check_binding, metadata_rule, route
67
+
68
+ __all__ = [
69
+ "Account",
70
+ "Attachment",
71
+ "Authenticity",
72
+ "Capabilities",
73
+ "ChannelError",
74
+ "ChannelIdentity",
75
+ "ConversationRef",
76
+ "CorrespondError",
77
+ "Draft",
78
+ "Editor",
79
+ "Event",
80
+ "Grade",
81
+ "HistoryDepth",
82
+ "InvalidRef",
83
+ "Listener",
84
+ "Message",
85
+ "MissingRequirement",
86
+ "NotSupported",
87
+ "Reactor",
88
+ "Reader",
89
+ "RouteDecision",
90
+ "SendResult",
91
+ "Support",
92
+ "UnknownChannel",
93
+ "Uploader",
94
+ "Verifier",
95
+ "Writer",
96
+ "capabilities",
97
+ "channel_registry",
98
+ "check_binding",
99
+ "check_requirements",
100
+ "edit",
101
+ "get_channel",
102
+ "listen",
103
+ "metadata_rule",
104
+ "parse_ref",
105
+ "react",
106
+ "read",
107
+ "register_channel",
108
+ "route",
109
+ "send",
110
+ "unregister_channel",
111
+ "upload",
112
+ "verify",
113
+ ]
correspond/__main__.py ADDED
@@ -0,0 +1,106 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``correspond`` on the command line: ``cw`` over :data:`correspond.tools.TOOLS`.
3
+
4
+ ``--json`` anywhere prints the tool's result as JSON instead of text. ``-`` as the text of
5
+ ``send`` or ``edit`` reads it from stdin. A result with ``ok: false`` exits with status 1.
6
+ """
7
+
8
+ import dataclasses
9
+ import json
10
+ import sys
11
+
12
+ import cw
13
+
14
+ from correspond import tools
15
+ from correspond.render import render
16
+
17
+ _REF = {
18
+ "help": "a conversation reference, <channel>:<id> (e.g. github:octocat/hello-world#1)"
19
+ }
20
+ _DRY_RUN = {"help": "show the plan; contact nothing and change nothing"}
21
+ #: Per-parameter help for the command line (the tools' docstrings are the command help).
22
+ HELP = {
23
+ "requirements": {"channel": {"help": "a channel name, e.g. telegram"}},
24
+ "capabilities": {"channel": {"help": "a channel name, e.g. github"}},
25
+ "ref": {"ref": _REF},
26
+ "read": {
27
+ "ref": _REF,
28
+ "since": {"help": "only messages sent or edited at or after this ISO 8601 time"},
29
+ "limit": {"help": "keep the most recent N messages"},
30
+ },
31
+ "listen": {
32
+ "ref": _REF,
33
+ "limit": {"help": "at most N events"},
34
+ "peek": {"help": "show new events without moving the cursor"},
35
+ "data_dir": {"help": "where cursors are kept (default: the data root)"},
36
+ },
37
+ "send": {
38
+ "ref": _REF,
39
+ "text": {"help": "the message, or - to read it from stdin"},
40
+ "title": {"help": "a title; on github:owner/repo it opens an issue"},
41
+ "reply_to": {"help": "the id of the message this answers"},
42
+ "priority": {"help": "low, normal, high or urgent (channels with priorities)"},
43
+ "dry_run": _DRY_RUN,
44
+ },
45
+ "edit": {
46
+ "ref": _REF,
47
+ "message_id": {"help": "the message id, as read shows it"},
48
+ "text": {"help": "the new text, or - to read it from stdin"},
49
+ "dry_run": _DRY_RUN,
50
+ },
51
+ "react": {
52
+ "ref": _REF,
53
+ "message_id": {"help": "the message id, as read shows it"},
54
+ "reaction": {"help": "the reaction, e.g. +1 or eyes (see capabilities)"},
55
+ "dry_run": _DRY_RUN,
56
+ },
57
+ }
58
+
59
+
60
+ def _egress(as_json):
61
+ def egress(result, *, out, err):
62
+ if as_json:
63
+ print(json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out)
64
+ return 0 if result.get("ok", True) else 1
65
+ stdout, stderr, code = render(result)
66
+ if stderr:
67
+ print(stderr, file=err)
68
+ if stdout:
69
+ print(stdout, file=out)
70
+ return code
71
+
72
+ return egress
73
+
74
+
75
+ def main(argv=None):
76
+ """Run one ``correspond`` command."""
77
+ for stream in (
78
+ sys.stdout,
79
+ sys.stderr,
80
+ ): # a cp1252 pipe must not crash on a message's characters
81
+ getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
82
+ argv = list(sys.argv[1:] if argv is None else argv)
83
+ as_json = "--json" in argv
84
+ commands = {tool.__name__.replace("_", "-"): tool for tool in tools.TOOLS}
85
+ config = {
86
+ command: {param: dict(spec) for param, spec in params.items()}
87
+ for command, params in HELP.items()
88
+ }
89
+ for command in ("send", "edit"):
90
+ config[command]["text"]["codec"] = lambda text: (
91
+ sys.stdin.read() if text == "-" else text
92
+ )
93
+ raise SystemExit(
94
+ cw.dispatch(
95
+ commands,
96
+ [a for a in argv if a != "--json"],
97
+ prog="correspond",
98
+ convention=dataclasses.replace(cw.MODERN, default_in_help=False),
99
+ egress=_egress(as_json),
100
+ config=config,
101
+ )
102
+ )
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,6 @@
1
+ """The built-in channel adapters, one module each.
2
+
3
+ The registry imports a module only when its channel is first used, so an adapter's
4
+ dependencies (none, for the v0.1 adapters) are never paid for by code that does not use
5
+ that channel.
6
+ """
@@ -0,0 +1,131 @@
1
+ """A small HTTP client over ``urllib``, shared by the HTTP adapters, with failures classified.
2
+
3
+ Adapters take it as ``http=``. The call shape is
4
+ ``http(method, url, *, headers=None, body=None, timeout=DEFAULT_TIMEOUT_S) -> Response``:
5
+ an HTTP error status comes back as a ``Response`` like any other, and only "could not reach
6
+ the server" raises, as a ``ChannelError`` of kind ``network`` that names the host and never
7
+ the URL (a URL path can carry a token). Tests pass a scripted function of the same shape.
8
+
9
+ >>> classify(429, "slow down", {"retry-after": "3"}).retry_after
10
+ 3.0
11
+ >>> classify(404, "gone").kind
12
+ 'not_found'
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass, field
23
+ from email.utils import parsedate_to_datetime
24
+ from typing import Any
25
+ from urllib.parse import urlsplit
26
+
27
+ from correspond.errors import ChannelError
28
+
29
+ __all__ = [
30
+ "DEFAULT_TIMEOUT_S",
31
+ "Response",
32
+ "classify",
33
+ "retry_after_seconds",
34
+ "urllib_http",
35
+ ]
36
+
37
+ DEFAULT_TIMEOUT_S = 30
38
+ USER_AGENT = "correspond (+https://github.com/thorwhalen/correspond)"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Response:
43
+ """An HTTP response: status, lower-cased headers, raw body."""
44
+
45
+ status: int
46
+ headers: Mapping[str, str] = field(default_factory=dict)
47
+ body: bytes = b""
48
+
49
+ def json(self) -> Any:
50
+ """The body parsed as JSON, or ``None`` when it is not JSON."""
51
+ try:
52
+ return json.loads(self.body.decode("utf-8") or "null")
53
+ except (UnicodeDecodeError, json.JSONDecodeError):
54
+ return None
55
+
56
+
57
+ def _lowered(headers: Any) -> dict[str, str]:
58
+ return {k.lower(): v for k, v in headers.items()} if headers else {}
59
+
60
+
61
+ def urllib_http(
62
+ method: str,
63
+ url: str,
64
+ *,
65
+ headers: Mapping[str, str] | None = None,
66
+ body: bytes | None = None,
67
+ timeout: float = DEFAULT_TIMEOUT_S,
68
+ ) -> Response:
69
+ """One request with the standard library."""
70
+ request = urllib.request.Request(
71
+ url,
72
+ data=body,
73
+ method=method,
74
+ headers={"User-Agent": USER_AGENT, **(headers or {})},
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(request, timeout=timeout) as reply:
78
+ return Response(reply.status, _lowered(reply.headers), reply.read())
79
+ except urllib.error.HTTPError as error:
80
+ try:
81
+ payload = error.read()
82
+ except OSError:
83
+ payload = b""
84
+ return Response(error.code, _lowered(error.headers), payload or b"")
85
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
86
+ reason = getattr(error, "reason", None) or type(error).__name__
87
+ raise ChannelError(
88
+ f"could not reach {urlsplit(url).hostname}: {reason}",
89
+ kind="network",
90
+ retryable=True,
91
+ ) from None
92
+
93
+
94
+ def retry_after_seconds(headers: Mapping[str, str] | None) -> float | None:
95
+ """``Retry-After`` in seconds (it may be a number or an HTTP date), or ``None``."""
96
+ raw = (headers or {}).get("retry-after")
97
+ if not raw:
98
+ return None
99
+ try:
100
+ return max(0.0, float(raw))
101
+ except ValueError:
102
+ try:
103
+ return max(0.0, parsedate_to_datetime(raw).timestamp() - time.time())
104
+ except (TypeError, ValueError):
105
+ return None
106
+
107
+
108
+ def classify(
109
+ status: int, message: str, headers: Mapping[str, str] | None = None
110
+ ) -> ChannelError:
111
+ """The :class:`~correspond.errors.ChannelError` an HTTP error status means."""
112
+ retry_after = retry_after_seconds(headers)
113
+ if status == 429:
114
+ return ChannelError(
115
+ message, kind="rate_limited", retryable=True, retry_after=retry_after
116
+ )
117
+ if status == 401:
118
+ return ChannelError(message, kind="auth")
119
+ if status == 403:
120
+ return ChannelError(message, kind="permission")
121
+ if status in (404, 410):
122
+ return ChannelError(message, kind="not_found")
123
+ if status in (408, 504):
124
+ return ChannelError(
125
+ message, kind="network", retryable=True, retry_after=retry_after
126
+ )
127
+ if 400 <= status < 500:
128
+ return ChannelError(message, kind="validation")
129
+ return ChannelError(
130
+ message, kind="unavailable", retryable=True, retry_after=retry_after
131
+ )