reef-vcs 0.4.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.
Files changed (109) hide show
  1. reef/__init__.py +70 -0
  2. reef/_build.py +6 -0
  3. reef/_calls.py +174 -0
  4. reef/_channel.py +233 -0
  5. reef/_connect.py +162 -0
  6. reef/_env.py +12 -0
  7. reef/_identity.py +80 -0
  8. reef/_lease_core.py +104 -0
  9. reef/_methods.py +3132 -0
  10. reef/_paths.py +215 -0
  11. reef/_presence_core.py +208 -0
  12. reef/_remote.py +194 -0
  13. reef/_rpc.py +33 -0
  14. reef/_rpcs.py +528 -0
  15. reef/_stream.py +56 -0
  16. reef/_tls.py +195 -0
  17. reef/aio/__init__.py +17 -0
  18. reef/aio/_methods.py +3132 -0
  19. reef/aio/_stream.py +64 -0
  20. reef/aio/client.py +371 -0
  21. reef/aio/presence.py +143 -0
  22. reef/aio/workspace.py +255 -0
  23. reef/api/__init__.py +1 -0
  24. reef/api/py.typed +0 -0
  25. reef/api/v1/__init__.py +217 -0
  26. reef/api/v1/changes_pb2.py +73 -0
  27. reef/api/v1/changes_pb2.pyi +565 -0
  28. reef/api/v1/changes_pb2_grpc.py +271 -0
  29. reef/api/v1/changes_pb2_grpc.pyi +251 -0
  30. reef/api/v1/daemon_pb2.py +53 -0
  31. reef/api/v1/daemon_pb2.pyi +212 -0
  32. reef/api/v1/daemon_pb2_grpc.py +156 -0
  33. reef/api/v1/daemon_pb2_grpc.pyi +109 -0
  34. reef/api/v1/diff_pb2.py +63 -0
  35. reef/api/v1/diff_pb2.pyi +707 -0
  36. reef/api/v1/diff_pb2_grpc.py +121 -0
  37. reef/api/v1/diff_pb2_grpc.pyi +125 -0
  38. reef/api/v1/error_pb2.py +42 -0
  39. reef/api/v1/error_pb2.pyi +173 -0
  40. reef/api/v1/error_pb2_grpc.py +24 -0
  41. reef/api/v1/error_pb2_grpc.pyi +25 -0
  42. reef/api/v1/files_pb2.py +71 -0
  43. reef/api/v1/files_pb2.pyi +569 -0
  44. reef/api/v1/files_pb2_grpc.py +241 -0
  45. reef/api/v1/files_pb2_grpc.pyi +250 -0
  46. reef/api/v1/history_pb2.py +64 -0
  47. reef/api/v1/history_pb2.pyi +612 -0
  48. reef/api/v1/history_pb2_grpc.py +114 -0
  49. reef/api/v1/history_pb2_grpc.pyi +108 -0
  50. reef/api/v1/maintenance_pb2.py +67 -0
  51. reef/api/v1/maintenance_pb2.pyi +391 -0
  52. reef/api/v1/maintenance_pb2_grpc.py +249 -0
  53. reef/api/v1/maintenance_pb2_grpc.pyi +162 -0
  54. reef/api/v1/operations_pb2.py +85 -0
  55. reef/api/v1/operations_pb2.pyi +837 -0
  56. reef/api/v1/operations_pb2_grpc.py +317 -0
  57. reef/api/v1/operations_pb2_grpc.pyi +372 -0
  58. reef/api/v1/options_pb2.py +37 -0
  59. reef/api/v1/options_pb2.pyi +68 -0
  60. reef/api/v1/options_pb2_grpc.py +24 -0
  61. reef/api/v1/options_pb2_grpc.pyi +20 -0
  62. reef/api/v1/presence_pb2.py +68 -0
  63. reef/api/v1/presence_pb2.pyi +458 -0
  64. reef/api/v1/presence_pb2_grpc.py +243 -0
  65. reef/api/v1/presence_pb2_grpc.pyi +255 -0
  66. reef/api/v1/remote_pb2.py +79 -0
  67. reef/api/v1/remote_pb2.pyi +547 -0
  68. reef/api/v1/remote_pb2_grpc.py +239 -0
  69. reef/api/v1/remote_pb2_grpc.pyi +124 -0
  70. reef/api/v1/repositories_pb2.py +66 -0
  71. reef/api/v1/repositories_pb2.pyi +304 -0
  72. reef/api/v1/repositories_pb2_grpc.py +237 -0
  73. reef/api/v1/repositories_pb2_grpc.pyi +118 -0
  74. reef/api/v1/status_pb2.py +56 -0
  75. reef/api/v1/status_pb2.pyi +397 -0
  76. reef/api/v1/status_pb2_grpc.py +125 -0
  77. reef/api/v1/status_pb2_grpc.pyi +133 -0
  78. reef/api/v1/stores_pb2.py +62 -0
  79. reef/api/v1/stores_pb2.pyi +376 -0
  80. reef/api/v1/stores_pb2_grpc.py +152 -0
  81. reef/api/v1/stores_pb2_grpc.pyi +99 -0
  82. reef/api/v1/symbols_pb2.py +85 -0
  83. reef/api/v1/symbols_pb2.pyi +880 -0
  84. reef/api/v1/symbols_pb2_grpc.py +231 -0
  85. reef/api/v1/symbols_pb2_grpc.pyi +225 -0
  86. reef/api/v1/workspaces_pb2.py +112 -0
  87. reef/api/v1/workspaces_pb2.pyi +1321 -0
  88. reef/api/v1/workspaces_pb2_grpc.py +393 -0
  89. reef/api/v1/workspaces_pb2_grpc.pyi +239 -0
  90. reef/client.py +440 -0
  91. reef/daemon/__init__.py +1 -0
  92. reef/daemon/py.typed +0 -0
  93. reef/daemon/v0/__init__.py +1 -0
  94. reef/daemon/v0/daemon_pb2.py +42 -0
  95. reef/daemon/v0/daemon_pb2.pyi +125 -0
  96. reef/daemon/v0/daemon_pb2_grpc.py +101 -0
  97. reef/daemon/v0/daemon_pb2_grpc.pyi +62 -0
  98. reef/daemon/v0/error_pb2.py +36 -0
  99. reef/daemon/v0/error_pb2.pyi +67 -0
  100. reef/daemon/v0/error_pb2_grpc.py +24 -0
  101. reef/daemon/v0/error_pb2_grpc.pyi +23 -0
  102. reef/errors.py +409 -0
  103. reef/presence.py +169 -0
  104. reef/py.typed +0 -0
  105. reef/workspace.py +309 -0
  106. reef_vcs-0.4.0.dist-info/METADATA +124 -0
  107. reef_vcs-0.4.0.dist-info/RECORD +109 -0
  108. reef_vcs-0.4.0.dist-info/WHEEL +4 -0
  109. reef_vcs-0.4.0.dist-info/licenses/LICENSE +202 -0
reef/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """reef-vcs: the Python client of the Reef daemon (R2.06, ADR 0054).
2
+
3
+ Reef gives each agent its own workspace: a copy-on-write copy of a git or jj repository, leased
4
+ to one process and snapshotted after every write. This package talks to a running daemon (it
5
+ never starts one), over its socket or a remote listener::
6
+
7
+ import reef
8
+
9
+ with reef.connect() as client:
10
+ repo = client.register_repository(path=".").repository
11
+ with client.workspace(repo.id, purpose="fix the parser") as ws:
12
+ (ws.path / "parser.py").write_text("...")
13
+
14
+ Every RPC of ``reef.api.v1`` is a method on :class:`Client` (and, awaited, on
15
+ ``reef.aio.AsyncClient``), generated from the schema; messages and enums are importable from
16
+ ``reef.api.v1``. Errors are :class:`ReefError` subclasses by kind, with the daemon's ``hint`` and
17
+ ``next``.
18
+ """
19
+
20
+ from reef import _env # noqa: F401 # before grpc is imported
21
+ from reef._build import API_REVISION, COMMIT, VERSION
22
+ from reef.client import Client, connect
23
+ from reef.errors import (
24
+ AlreadyExistsError,
25
+ ConfigError,
26
+ ConflictError,
27
+ DaemonNotRunningError,
28
+ ErrorKind,
29
+ IncompatibleError,
30
+ InternalError,
31
+ InvalidArgumentError,
32
+ IoError,
33
+ NotFoundError,
34
+ PermissionDeniedError,
35
+ ReefError,
36
+ RemoteConnectError,
37
+ RemoteMisdirectedError,
38
+ RemoteTlsError,
39
+ UnavailableError,
40
+ UnsupportedError,
41
+ )
42
+ from reef.workspace import WorkspaceHandle
43
+
44
+ __version__ = VERSION
45
+
46
+ __all__ = [
47
+ "API_REVISION",
48
+ "COMMIT",
49
+ "AlreadyExistsError",
50
+ "Client",
51
+ "ConfigError",
52
+ "ConflictError",
53
+ "DaemonNotRunningError",
54
+ "ErrorKind",
55
+ "IncompatibleError",
56
+ "InternalError",
57
+ "InvalidArgumentError",
58
+ "IoError",
59
+ "NotFoundError",
60
+ "PermissionDeniedError",
61
+ "ReefError",
62
+ "RemoteConnectError",
63
+ "RemoteMisdirectedError",
64
+ "RemoteTlsError",
65
+ "UnavailableError",
66
+ "UnsupportedError",
67
+ "WorkspaceHandle",
68
+ "__version__",
69
+ "connect",
70
+ ]
reef/_build.py ADDED
@@ -0,0 +1,6 @@
1
+ """What this build of reef-vcs was generated from. Generated; do not edit."""
2
+
3
+ VERSION = "0.4.0"
4
+ CARGO_VERSION = "0.4.0"
5
+ API_REVISION = 14
6
+ COMMIT = "aa27f4011a17"
reef/_calls.py ADDED
@@ -0,0 +1,174 @@
1
+ """What the generated methods call: building a request, and the clients' call primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from collections.abc import AsyncIterator, Iterable, Iterator, Mapping
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
+
9
+ from google.protobuf.descriptor import FieldDescriptor
10
+ from google.protobuf.duration_pb2 import Duration
11
+ from google.protobuf.message import Message
12
+ from google.protobuf.timestamp_pb2 import Timestamp
13
+
14
+ from reef._rpc import Rpc
15
+
16
+ if TYPE_CHECKING:
17
+ from reef._stream import Stream
18
+ from reef.aio._stream import AsyncStream
19
+
20
+ Req = TypeVar("Req", bound=Message)
21
+ Resp = TypeVar("Resp", bound=Message)
22
+
23
+
24
+ def method_name(rpc: Rpc[Any, Any]) -> str:
25
+ """The RPC's method name on the clients."""
26
+ out = []
27
+ for index, char in enumerate(rpc.name):
28
+ if char.isupper() and index:
29
+ out.append("_")
30
+ out.append(char.lower())
31
+ return "".join(out)
32
+
33
+
34
+ def build(rpc: Rpc[Req, Any], request: Req | None, fields: Mapping[str, Any]) -> Req:
35
+ """The request: ``request`` as given, or a new one with the keyword fields that were given.
36
+
37
+ Raises:
38
+ TypeError: Both a request and keyword fields, or a request of the wrong type.
39
+ ValueError: An enum given by a name it does not have.
40
+ """
41
+ given = {name: value for name, value in fields.items() if value is not None}
42
+ if request is not None:
43
+ if given:
44
+ raise TypeError(
45
+ f"{method_name(rpc)}() takes a request or keyword fields, not both "
46
+ f"(got a request and {', '.join(sorted(given))})"
47
+ )
48
+ if not isinstance(request, rpc.request):
49
+ raise TypeError(
50
+ f"{method_name(rpc)}() takes a {rpc.request.__name__}, "
51
+ f"not a {type(request).__name__}"
52
+ )
53
+ return request
54
+ message = rpc.request()
55
+ descriptor = message.DESCRIPTOR
56
+ for name, value in given.items():
57
+ _set(message, descriptor.fields_by_name[name], value)
58
+ return message
59
+
60
+
61
+ def _is_map(field: Any) -> bool:
62
+ return (
63
+ field.type == FieldDescriptor.TYPE_MESSAGE
64
+ and field.message_type is not None
65
+ and field.message_type.GetOptions().map_entry
66
+ )
67
+
68
+
69
+ def _convert(field: Any, value: Any) -> Any:
70
+ """One value as the message field takes it."""
71
+ if field.type == FieldDescriptor.TYPE_ENUM and isinstance(value, str):
72
+ enum = field.enum_type
73
+ found = enum.values_by_name.get(value)
74
+ if found is None:
75
+ names = ", ".join(enum.values_by_name)
76
+ raise ValueError(
77
+ f"{field.name}: {value!r} is not one of {enum.name}'s values ({names})"
78
+ )
79
+ return found.number
80
+ if field.type == FieldDescriptor.TYPE_MESSAGE:
81
+ full = field.message_type.full_name
82
+ if full == "google.protobuf.Timestamp" and isinstance(value, dt.datetime):
83
+ stamp = Timestamp()
84
+ stamp.FromDatetime(value)
85
+ return stamp
86
+ if full == "google.protobuf.Duration" and isinstance(value, dt.timedelta):
87
+ duration = Duration()
88
+ duration.FromTimedelta(value)
89
+ return duration
90
+ return value
91
+
92
+
93
+ def _set(message: Message, field: Any, value: Any) -> None:
94
+ name = field.name
95
+ if _is_map(field):
96
+ target = getattr(message, name)
97
+ value_field = field.message_type.fields_by_name["value"]
98
+ for key, item in dict(value).items():
99
+ converted = _convert(value_field, item)
100
+ if value_field.type == FieldDescriptor.TYPE_MESSAGE:
101
+ target[key].CopyFrom(converted)
102
+ else:
103
+ target[key] = converted
104
+ return
105
+ if field.is_repeated:
106
+ if isinstance(value, (str, bytes)):
107
+ raise TypeError(f"{name} takes a list, not a single {type(value).__name__}")
108
+ getattr(message, name).extend(_convert(field, item) for item in value)
109
+ return
110
+ converted = _convert(field, value)
111
+ if field.type == FieldDescriptor.TYPE_MESSAGE:
112
+ target = getattr(message, name)
113
+ target.SetInParent()
114
+ target.CopyFrom(converted)
115
+ else:
116
+ setattr(message, name, converted)
117
+
118
+
119
+ class SyncCalls:
120
+ """The sync client's call primitives, which the generated methods use."""
121
+
122
+ def _unary(self, rpc: Rpc[Req, Resp], request: Req) -> Resp:
123
+ raise NotImplementedError
124
+
125
+ def _stream(self, rpc: Rpc[Req, Resp], request: Req) -> Stream[Resp]:
126
+ raise NotImplementedError
127
+
128
+ def _pages(self, rpc: Rpc[Req, Resp], request: Req) -> Iterator[Resp]:
129
+ """Every page, lazily: the same request with each ``next_page_token``."""
130
+ page_request = rpc.request()
131
+ page_request.CopyFrom(request)
132
+ while True:
133
+ page = self._unary(rpc, page_request)
134
+ yield page
135
+ token: str = page.next_page_token # type: ignore[attr-defined]
136
+ if not token:
137
+ return
138
+ page_request.page_token = token # type: ignore[attr-defined]
139
+
140
+ def _items(self, rpc: Rpc[Req, Resp], request: Req, field: str) -> Iterator[Any]:
141
+ """Every page's ``field``, item by item."""
142
+ for page in self._pages(rpc, request):
143
+ yield from getattr(page, field)
144
+
145
+
146
+ class AsyncCalls:
147
+ """The asyncio client's call primitives, which the generated methods use."""
148
+
149
+ async def _unary(self, rpc: Rpc[Req, Resp], request: Req) -> Resp:
150
+ raise NotImplementedError
151
+
152
+ def _stream(self, rpc: Rpc[Req, Resp], request: Req) -> AsyncStream[Resp]:
153
+ raise NotImplementedError
154
+
155
+ async def _pages(self, rpc: Rpc[Req, Resp], request: Req) -> AsyncIterator[Resp]:
156
+ """Every page, lazily: the same request with each ``next_page_token``."""
157
+ page_request = rpc.request()
158
+ page_request.CopyFrom(request)
159
+ while True:
160
+ page = await self._unary(rpc, page_request)
161
+ yield page
162
+ token: str = page.next_page_token # type: ignore[attr-defined]
163
+ if not token:
164
+ return
165
+ page_request.page_token = token # type: ignore[attr-defined]
166
+
167
+ async def _items(self, rpc: Rpc[Req, Resp], request: Req, field: str) -> AsyncIterator[Any]:
168
+ """Every page's ``field``, item by item."""
169
+ async for page in self._pages(rpc, request):
170
+ for item in getattr(page, field):
171
+ yield item
172
+
173
+
174
+ __all__ = ["AsyncCalls", "Iterable", "SyncCalls", "build"]
reef/_channel.py ADDED
@@ -0,0 +1,233 @@
1
+ """How a client reaches the daemon: its socket, or a remote listener (R2.08), as grpc channels.
2
+
3
+ The socket's channel sets ``:authority`` to ``localhost``: grpc's default, the percent-encoded
4
+ socket path, is refused by the daemon's HTTP/2 server. A remote daemon is checked before grpc
5
+ connects, by a handshake of the SDK's own (``_tls``): against the pin, or against the same roots
6
+ grpc will trust. grpc then trusts only that.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import os
13
+ import ssl
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import grpc
18
+
19
+ from reef import _tls
20
+ from reef._remote import RemoteTarget
21
+ from reef.errors import RemoteConnectError, RemoteTlsError
22
+
23
+ MAX_MESSAGE_BYTES = 4 << 20
24
+ """The largest message a daemon decodes or sends: tonic's default, which the daemon keeps."""
25
+
26
+ KEEPALIVE_MS = 30_000
27
+
28
+ ROOTS_ENV = "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"
29
+
30
+
31
+ @dataclass
32
+ class Spec:
33
+ """Everything needed to open a channel to one daemon."""
34
+
35
+ target: str
36
+ """grpc's target: ``unix:<path>`` or ``<host>:<port>``."""
37
+ label: str
38
+ """How errors name it: the socket path, or the remote's origin."""
39
+ options: list[tuple[str, Any]]
40
+ credentials: grpc.ChannelCredentials | None = None
41
+ remote: RemoteTarget | None = None
42
+ leaf: _tls.Leaf | None = field(default=None, repr=False)
43
+ """The pinned remote's certificate grpc trusts."""
44
+ root_certificates: bytes | None = field(default=None, repr=False)
45
+
46
+ def open(self) -> grpc.Channel:
47
+ """A sync channel."""
48
+ if self.credentials is None:
49
+ return grpc.insecure_channel(self.target, options=self.options)
50
+ return grpc.secure_channel(self.target, self.credentials, options=self.options)
51
+
52
+ def open_aio(self) -> grpc.aio.Channel:
53
+ """An asyncio channel, on the running loop."""
54
+ if self.credentials is None:
55
+ return grpc.aio.insecure_channel(self.target, options=self.options)
56
+ return grpc.aio.secure_channel(self.target, self.credentials, options=self.options)
57
+
58
+
59
+ def _limits() -> list[tuple[str, Any]]:
60
+ return [
61
+ ("grpc.max_send_message_length", MAX_MESSAGE_BYTES),
62
+ ("grpc.max_receive_message_length", MAX_MESSAGE_BYTES),
63
+ ]
64
+
65
+
66
+ def local(socket: str) -> Spec:
67
+ """The daemon's socket."""
68
+ return Spec(
69
+ target=f"unix:{socket}",
70
+ label=socket,
71
+ options=[("grpc.default_authority", "localhost"), *_limits()],
72
+ )
73
+
74
+
75
+ def _grpc_roots() -> bytes:
76
+ """The roots grpc trusts by default: its environment override, else its bundled file."""
77
+ path = os.environ.get(ROOTS_ENV) or os.path.join(
78
+ os.path.dirname(grpc.__file__), "_cython", "_credentials", "roots.pem"
79
+ )
80
+ with open(path, "rb") as file:
81
+ return file.read()
82
+
83
+
84
+ def _presented(url_host: str, der: bytes) -> str:
85
+ del url_host
86
+ try:
87
+ return _tls.parse(der).fingerprint
88
+ except _tls.DerError:
89
+ return "an unparsable certificate"
90
+
91
+
92
+ def remote(
93
+ target: RemoteTarget,
94
+ *,
95
+ connect_timeout: float,
96
+ root_certificates: bytes | None = None,
97
+ ) -> Spec:
98
+ """A remote daemon, verified before anything is sent.
99
+
100
+ Raises:
101
+ RemoteConnectError: Nothing answered.
102
+ RemoteTlsError: Its certificate was refused, or the handshake failed.
103
+ """
104
+ url = target.url
105
+ origin = url.origin
106
+ try:
107
+ der = _tls.fetch_leaf(url, connect_timeout)
108
+ except ssl.SSLError as error:
109
+ raise RemoteTlsError(origin, "handshake", reason=str(error)) from error
110
+ except OSError as error:
111
+ raise RemoteConnectError(origin, str(error)) from error
112
+ options: list[tuple[str, Any]] = [
113
+ ("grpc.default_authority", url.authority),
114
+ ("grpc.keepalive_time_ms", KEEPALIVE_MS),
115
+ ("grpc.keepalive_permit_without_calls", 1),
116
+ ("grpc.http2.max_pings_without_data", 0),
117
+ *_limits(),
118
+ ]
119
+ call_credentials = grpc.access_token_call_credentials(target.token)
120
+ if target.fingerprint:
121
+ try:
122
+ leaf = _tls.parse(der)
123
+ except _tls.DerError as error:
124
+ raise RemoteTlsError(origin, "handshake", reason=f"its certificate: {error}") from error
125
+ if leaf.fingerprint != target.fingerprint:
126
+ raise RemoteTlsError(
127
+ origin,
128
+ "pin_mismatch",
129
+ presented=leaf.fingerprint,
130
+ expected=target.fingerprint,
131
+ )
132
+ check_dates(leaf, origin, dt.datetime.now(dt.UTC))
133
+ # grpc checks the certificate's names, and a call's :authority against them, even with
134
+ # the key pinned. The pin is what authenticates the daemon, so when the URL's host is not
135
+ # one of the names (an IP address the certificate does not list, say), the SDK names one
136
+ # that is, as the daemon's own Host check accepts.
137
+ name = _tls.target_name(leaf, url.host)
138
+ if name is not None:
139
+ options.append(("grpc.ssl_target_name_override", name))
140
+ if name != url.host:
141
+ options[0] = ("grpc.default_authority", _authority(name, url.port))
142
+ credentials = grpc.composite_channel_credentials(
143
+ grpc.ssl_channel_credentials(root_certificates=leaf.pem), call_credentials
144
+ )
145
+ return Spec(
146
+ target=url.authority,
147
+ label=origin,
148
+ options=options,
149
+ credentials=credentials,
150
+ remote=target,
151
+ leaf=leaf,
152
+ )
153
+ roots = root_certificates or _grpc_roots()
154
+ context = ssl.create_default_context(cadata=roots.decode("ascii", "replace"))
155
+ try:
156
+ context.check_hostname = True
157
+ import socket
158
+
159
+ with (
160
+ socket.create_connection((url.host, url.port), timeout=connect_timeout) as raw,
161
+ context.wrap_socket(raw, server_hostname=url.host),
162
+ ):
163
+ pass
164
+ except ssl.SSLCertVerificationError as error:
165
+ raise RemoteTlsError(
166
+ origin,
167
+ "untrusted",
168
+ presented=_presented(url.host, der),
169
+ reason=error.verify_message or str(error),
170
+ ) from error
171
+ except ssl.SSLError as error:
172
+ raise RemoteTlsError(origin, "handshake", reason=str(error)) from error
173
+ except OSError as error:
174
+ raise RemoteConnectError(origin, str(error)) from error
175
+ credentials = grpc.composite_channel_credentials(
176
+ grpc.ssl_channel_credentials(root_certificates=root_certificates), call_credentials
177
+ )
178
+ return Spec(
179
+ target=url.authority,
180
+ label=origin,
181
+ options=options,
182
+ credentials=credentials,
183
+ remote=target,
184
+ root_certificates=root_certificates,
185
+ )
186
+
187
+
188
+ def _authority(host: str, port: int) -> str:
189
+ return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
190
+
191
+
192
+ def check_dates(leaf: _tls.Leaf, origin: str, now: dt.datetime) -> None:
193
+ """Raises :class:`RemoteTlsError` (``handshake``) for a pinned certificate outside its dates.
194
+
195
+ grpc checks the dates even with the key pinned, which the pin does not need; a lapsed
196
+ certificate would otherwise fail inside grpc as a connection that never comes up.
197
+ """
198
+ if leaf.not_after is not None and now > leaf.not_after:
199
+ raise RemoteTlsError(
200
+ origin,
201
+ "handshake",
202
+ reason=f"the daemon's certificate expired at {leaf.not_after:%Y-%m-%d %H:%M} UTC",
203
+ hint=(
204
+ "The daemon's certificate has lapsed. A generated one is renewed when the "
205
+ "daemon starts: restart it on its machine. Renew a supplied one "
206
+ "(`[remote] tls_cert`)."
207
+ ),
208
+ )
209
+ if leaf.not_before is not None and now < leaf.not_before:
210
+ raise RemoteTlsError(
211
+ origin,
212
+ "handshake",
213
+ reason=f"the daemon's certificate is valid only from "
214
+ f"{leaf.not_before:%Y-%m-%d %H:%M} UTC; check this machine's clock",
215
+ )
216
+
217
+
218
+ def repinned(spec: Spec, connect_timeout: float) -> Spec | None:
219
+ """For a pinned remote whose connection failed: a new spec if the daemon now presents a new
220
+ certificate for the pinned key (a renewal), else None.
221
+
222
+ Raises:
223
+ RemoteTlsError: It now presents another key.
224
+ """
225
+ if spec.remote is None or spec.leaf is None or not spec.remote.fingerprint:
226
+ return None
227
+ try:
228
+ der = _tls.fetch_leaf(spec.remote.url, connect_timeout)
229
+ except (OSError, ssl.SSLError):
230
+ return None
231
+ if der == spec.leaf.der:
232
+ return None
233
+ return remote(spec.remote, connect_timeout=connect_timeout)
reef/_connect.py ADDED
@@ -0,0 +1,162 @@
1
+ """Settling what ``connect()`` reaches, shared by the sync and asyncio clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ import grpc
10
+
11
+ from reef import _channel, _paths, _remote
12
+ from reef._build import CARGO_VERSION
13
+ from reef.errors import (
14
+ DETAIL_TYPE_URL,
15
+ DaemonNotRunningError,
16
+ IncompatibleError,
17
+ ReefError,
18
+ RemoteConnectError,
19
+ RemoteMisdirectedError,
20
+ detail_of,
21
+ from_status,
22
+ )
23
+
24
+ V0_STATUS = "/reef.daemon.v0.Daemon/Status"
25
+ DAEMON_STATUS = "/reef.api.v1.DaemonService/GetDaemonStatus"
26
+ UNSERVED_REASONS = ("UNKNOWN_METHOD", "NOT_IMPLEMENTED")
27
+ # The listener's HTTP 421, as grpcio reports it ("Received http2 header with status: 421"), or as
28
+ # tonic does ("... HTTP status code 421").
29
+ MISDIRECTED = re.compile(r"(header with status: |HTTP status code )421$")
30
+
31
+ __all__ = ["DETAIL_TYPE_URL", "spec_for"]
32
+
33
+
34
+ def spec_for(
35
+ *,
36
+ socket: str | os.PathLike[str] | None,
37
+ remote: str | None,
38
+ token: str | None,
39
+ fingerprint: str | None,
40
+ state_dir: str | os.PathLike[str] | None,
41
+ config: str | os.PathLike[str] | None,
42
+ connect_timeout: float,
43
+ root_certificates: bytes | None,
44
+ ) -> _channel.Spec:
45
+ """The socket, or the remote, that ``connect()``'s arguments and the environment name.
46
+
47
+ Raises:
48
+ TypeError: Both ``socket`` and ``remote``.
49
+ ConfigError: The paths, or the remote's profile, cannot be settled.
50
+ RemoteConnectError: Nothing answered at the remote.
51
+ RemoteTlsError: The remote's certificate was refused.
52
+ """
53
+ if socket is not None and remote is not None:
54
+ raise TypeError("connect() takes a socket or a remote, not both")
55
+ if socket is None and remote is None:
56
+ from_env = os.environ.get(_remote.REMOTE_ENV, "")
57
+ remote = from_env.strip() or None
58
+ if remote is not None:
59
+ state = _paths.settle_state_dir(state_dir=state_dir)
60
+ target = _remote.choose(remote, state_dir=state, token=token, fingerprint=fingerprint)
61
+ return _channel.remote(
62
+ target, connect_timeout=connect_timeout, root_certificates=root_certificates
63
+ )
64
+ settled = _paths.settle(socket=socket, state_dir=state_dir, config=config)
65
+ return _channel.local(settled.socket)
66
+
67
+
68
+ def connect_error(spec: _channel.Spec, failed: bool, timeout: float) -> ReefError:
69
+ """Why ``connect()`` could not reach the daemon: the connection failed (``failed``), or
70
+ nothing settled within ``timeout``.
71
+ """
72
+ cause = "the connection failed" if failed else f"no connection within {timeout:g} s"
73
+ if spec.remote is not None:
74
+ return RemoteConnectError(spec.label, cause)
75
+ return DaemonNotRunningError(spec.label, cause)
76
+
77
+
78
+ def is_unserved(error: grpc.RpcError) -> bool:
79
+ """Whether an error may say the daemon cannot serve the method: a bare ``UNIMPLEMENTED``,
80
+ or one whose reason is ``UNKNOWN_METHOD`` or ``NOT_IMPLEMENTED``.
81
+ """
82
+ if error.code() != grpc.StatusCode.UNIMPLEMENTED:
83
+ return False
84
+ detail = detail_of(error.trailing_metadata())
85
+ return detail is None or detail.reason in UNSERVED_REASONS
86
+
87
+
88
+ def misdirected(spec: _channel.Spec, error: grpc.RpcError) -> ReefError | None:
89
+ """A remote daemon's HTTP 421 (a host name it does not serve), as its error."""
90
+ if (
91
+ spec.remote is not None
92
+ and error.code() == grpc.StatusCode.UNKNOWN
93
+ and MISDIRECTED.search(error.details() or "")
94
+ and detail_of(error.trailing_metadata()) is None
95
+ ):
96
+ return RemoteMisdirectedError(spec.remote.url.origin, spec.remote.url.host)
97
+ return None
98
+
99
+
100
+ def needs_repin(spec: _channel.Spec, error: grpc.RpcError) -> bool:
101
+ """Whether a failure on a pinned channel may be a renewed certificate."""
102
+ return (
103
+ spec.leaf is not None
104
+ and error.code() == grpc.StatusCode.UNAVAILABLE
105
+ and detail_of(error.trailing_metadata()) is None
106
+ )
107
+
108
+
109
+ def incompatible(
110
+ spec: _channel.Spec,
111
+ method: str,
112
+ error: grpc.RpcError,
113
+ v1: Any | None,
114
+ v0: Any | None,
115
+ ) -> ReefError:
116
+ """What a daemon that could not serve ``method`` is, from its answers to the two status
117
+ calls (``v1``: ``GetDaemonStatusResponse`` or None; ``v0``: 0.1's ``StatusResponse``).
118
+ """
119
+ original = from_status(error.code(), error.details() or "", error.trailing_metadata(), method)
120
+ if v1 is not None:
121
+ detail = detail_of(error.trailing_metadata())
122
+ same_version = v1.version == CARGO_VERSION
123
+ if same_version and detail is not None and detail.reason == "NOT_IMPLEMENTED":
124
+ return original
125
+ return IncompatibleError(
126
+ spec.label,
127
+ method,
128
+ daemon_version=v1.version or "unknown",
129
+ daemon_api="v1",
130
+ daemon_revision=v1.api_revision,
131
+ active_workspaces=v1.active_workspace_count,
132
+ sdk_version=CARGO_VERSION,
133
+ )
134
+ if v0 is not None:
135
+ return IncompatibleError(
136
+ spec.label,
137
+ method,
138
+ daemon_version=v0.version or "unknown",
139
+ daemon_api="v0",
140
+ active_workspaces=v0.active_workspaces,
141
+ sdk_version=CARGO_VERSION,
142
+ )
143
+ return original
144
+
145
+
146
+ def require(spec: _channel.Spec, revision: int, status: Any) -> None:
147
+ """Raises :class:`IncompatibleError` when the daemon's API revision is below ``revision``."""
148
+ if status.api_revision >= revision:
149
+ return
150
+ raise IncompatibleError(
151
+ spec.label,
152
+ f"API revision {revision}",
153
+ daemon_version=status.version or "unknown",
154
+ daemon_api="v1",
155
+ daemon_revision=status.api_revision,
156
+ active_workspaces=status.active_workspace_count,
157
+ sdk_version=CARGO_VERSION,
158
+ detail=(
159
+ f"the daemon at {spec.label} is reef {status.version} at API revision "
160
+ f"{status.api_revision}; this needs revision {revision}"
161
+ ),
162
+ )
reef/_env.py ADDED
@@ -0,0 +1,12 @@
1
+ """Process settings applied before grpc is imported.
2
+
3
+ grpc's fork support logs two lines at INFO from every ``subprocess`` call made while a channel is
4
+ open, which an agent makes all the time, and a child process cannot use the parent's channels
5
+ anyway (create clients after forking). So the SDK turns it off unless the program chose:
6
+ ``GRPC_ENABLE_FORK_SUPPORT`` set beforehand wins, and grpc imported before reef keeps its own.
7
+ grpc reads it when it starts, so it stays set, and the program's child processes inherit it.
8
+ """
9
+
10
+ import os
11
+
12
+ os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "false")