gitd 0.7.0__tar.gz → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitd
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Client for gitd: instant, secretless git checkouts for agent sandboxes
5
5
  Project-URL: Homepage, https://github.com/dooservice/gitd
6
6
  Project-URL: Source, https://github.com/dooservice/gitd/tree/main/sdk/python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gitd"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "Client for gitd: instant, secretless git checkouts for agent sandboxes"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,86 @@
1
+ """Client for gitd: instant, secretless git checkouts for agent sandboxes."""
2
+
3
+ from .changes import ChangedFile, ChangeKind, Changes, Diff
4
+ from .client import Gitd
5
+ from .comparison import Comparison, Conflict, Event, EventKind, Side
6
+ from .connection import DEFAULT_SOCKET, SOCKET_VARIABLE
7
+ from .endpoint_calls import Endpoint
8
+ from .endpoint_tokens import IssuedToken, TokenInfo
9
+ from .errors import GitdError
10
+ from .kept_credentials import AppToCreate, Connected, CredentialKind, KeptCredential, SignIn
11
+ from .models import (
12
+ Access,
13
+ AuditEntry,
14
+ ConfigurationChange,
15
+ Description,
16
+ Info,
17
+ Layers,
18
+ PendingSubmodule,
19
+ Probe,
20
+ Progress,
21
+ Published,
22
+ PublishedRepository,
23
+ Refusal,
24
+ Strategy,
25
+ Submodule,
26
+ Workspace,
27
+ )
28
+ from .publishing_settings import (
29
+ OpenedPullRequest,
30
+ Opening,
31
+ Preset,
32
+ PresetOrigin,
33
+ Provenance,
34
+ PublishSettings,
35
+ PullRequestOptions,
36
+ PullRequestSettings,
37
+ )
38
+ from .warm_layers import WarmLayer
39
+
40
+ __all__ = [
41
+ "Comparison",
42
+ "Conflict",
43
+ "Event",
44
+ "EventKind",
45
+ "Side",
46
+ "ChangeKind",
47
+ "ChangedFile",
48
+ "Changes",
49
+ "Diff",
50
+ "AppToCreate",
51
+ "ConfigurationChange",
52
+ "Connected",
53
+ "CredentialKind",
54
+ "KeptCredential",
55
+ "OpenedPullRequest",
56
+ "Opening",
57
+ "Preset",
58
+ "PresetOrigin",
59
+ "Provenance",
60
+ "PublishSettings",
61
+ "PullRequestOptions",
62
+ "PullRequestSettings",
63
+ "SignIn",
64
+ "DEFAULT_SOCKET",
65
+ "SOCKET_VARIABLE",
66
+ "Access",
67
+ "AuditEntry",
68
+ "Description",
69
+ "Gitd",
70
+ "GitdError",
71
+ "Info",
72
+ "Layers",
73
+ "PendingSubmodule",
74
+ "Probe",
75
+ "Progress",
76
+ "Published",
77
+ "PublishedRepository",
78
+ "Refusal",
79
+ "Strategy",
80
+ "Submodule",
81
+ "Workspace",
82
+ "WarmLayer",
83
+ "Endpoint",
84
+ "IssuedToken",
85
+ "TokenInfo",
86
+ ]
@@ -0,0 +1,77 @@
1
+ """What was changed in a workspace, and its diff, as plain dataclasses built from the
2
+ messages gitd answers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from typing import Any
9
+
10
+ from . import messages
11
+
12
+ KIND_PREFIX = "KIND_"
13
+
14
+
15
+ class ChangeKind(StrEnum):
16
+ """How a file changed: added, modified or deleted."""
17
+
18
+ ADDED = "added"
19
+ MODIFIED = "modified"
20
+ DELETED = "deleted"
21
+
22
+ @classmethod
23
+ def from_message(cls, number: int) -> ChangeKind:
24
+ name = messages.CHANGE_KIND.name(number).removeprefix(KIND_PREFIX).lower()
25
+ return cls(name) if name in {member.value for member in cls} else cls.MODIFIED
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ChangedFile:
30
+ """A file changed, relative to the workspace, with its lines; a binary one counts none."""
31
+
32
+ path: str
33
+ kind: ChangeKind
34
+ added_lines: int
35
+ removed_lines: int
36
+ binary: bool
37
+
38
+ @classmethod
39
+ def from_message(cls, file: Any) -> ChangedFile:
40
+ return cls(
41
+ path=file.path,
42
+ kind=ChangeKind.from_message(file.kind),
43
+ added_lines=file.added_lines,
44
+ removed_lines=file.removed_lines,
45
+ binary=file.binary,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Changes:
51
+ """What was changed in a workspace since gitd put it on base, committed or not.
52
+ from_layer says it was read from the agent's own layer, with no git run in the workspace."""
53
+
54
+ base: str
55
+ from_layer: bool
56
+ files: list[ChangedFile] = field(default_factory=list)
57
+
58
+ @classmethod
59
+ def from_message(cls, response: Any) -> Changes:
60
+ return cls(
61
+ base=response.base,
62
+ from_layer=response.from_layer,
63
+ files=[ChangedFile.from_message(file) for file in response.files],
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Diff:
69
+ """The unified diff of a workspace's changes; truncated says it was cut short."""
70
+
71
+ base: str
72
+ diff: str
73
+ truncated: bool
74
+
75
+ @classmethod
76
+ def from_message(cls, response: Any) -> Diff:
77
+ return cls(base=response.base, diff=response.diff, truncated=response.truncated)
@@ -0,0 +1,238 @@
1
+ """The gitd client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from . import messages, methods
10
+ from .changes import Changes, Diff
11
+ from .comparison import Comparison, Conflict, Event, Side
12
+ from .connection import Connection, OnProgress
13
+ from .credentials_calls import Credentials
14
+ from .endpoint_calls import Endpoint
15
+ from .models import AuditEntry, Description, Info, Probe, Published, Strategy, Workspace
16
+ from .publishing_calls import Publishing
17
+ from .publishing_settings import Provenance, PullRequestOptions
18
+ from .timestamps import Timestamp
19
+ from .warm_layers import WarmLayer
20
+
21
+
22
+ class Gitd:
23
+ """A gitd on its Unix socket, or at url on the network with key naming the caller's
24
+ tenant. No call on workspaces takes a credential: gitd reads and publishes with the ones
25
+ it keeps, which a platform manages through gitd.credentials, and publishing follows the
26
+ rules it sets through gitd.publishing. Sandboxes elsewhere get tokens of gitd's own
27
+ through gitd.endpoint.
28
+
29
+ with Gitd() as gitd:
30
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
31
+
32
+ with Gitd(url="https://gitd.internal:9420", key=open("/run/secrets/acme").read()) as gitd:
33
+ ...
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ socket: str | None = None,
39
+ *,
40
+ timeout: float | None = None,
41
+ url: str | None = None,
42
+ key: str | None = None,
43
+ ca_file: str | None = None,
44
+ ) -> None:
45
+ self.connection = Connection(socket, timeout, url=url, key=key, ca_file=ca_file)
46
+ self.credentials = Credentials(self.connection)
47
+ self.publishing = Publishing(self.connection)
48
+ self.endpoint = Endpoint(self.connection)
49
+
50
+ def __enter__(self) -> Gitd:
51
+ return self
52
+
53
+ def __exit__(self, *exception: object) -> None:
54
+ self.close()
55
+
56
+ def close(self) -> None:
57
+ self.connection.close()
58
+
59
+ def info(self) -> Info:
60
+ """Which gitd answers, and how it makes workspaces here and why."""
61
+ return Info.from_message(self.connection.call(methods.INFO, messages.InfoRequest()))
62
+
63
+ def probe(self, url: str) -> Probe:
64
+ """Whether a repository can be read with the credentials gitd keeps, and its branches."""
65
+ return Probe.from_message(self.connection.call(methods.PROBE, messages.ProbeRequest(url=url)))
66
+
67
+ def prepare(
68
+ self,
69
+ url: str,
70
+ path: str,
71
+ *,
72
+ branch: str | None = None,
73
+ commit: str | None = None,
74
+ submodules: bool = False,
75
+ full_history: bool = False,
76
+ strategy: Strategy | None = None,
77
+ warm: str = "",
78
+ on_progress: OnProgress | None = None,
79
+ ) -> Workspace:
80
+ """Makes a workspace of a repository at a path under gitd's roots, at a commit, a
81
+ branch, or the default branch. strategy is a preference: gitd falls back to copy when
82
+ it cannot make one that way here. warm names a warm layer to stand on, its
83
+ dependencies installed already: the workspace is then made by overlay, at the commit
84
+ the layer was sealed at, or not at all."""
85
+ if commit:
86
+ revision = messages.Revision(commit=commit)
87
+ elif branch:
88
+ revision = messages.Revision(branch=branch)
89
+ else:
90
+ revision = None
91
+ request = messages.PrepareRequest(
92
+ url=url,
93
+ path=path,
94
+ revision=revision,
95
+ with_submodules=submodules,
96
+ full_history=full_history,
97
+ strategy=Strategy.preference(strategy),
98
+ warm=warm,
99
+ )
100
+ return Workspace.from_message(path, self.connection.follow(methods.PREPARE, request, on_progress))
101
+
102
+ def update(
103
+ self,
104
+ path: str,
105
+ *,
106
+ branch: str | None = None,
107
+ submodules: bool = False,
108
+ on_progress: OnProgress | None = None,
109
+ ) -> Workspace:
110
+ """Brings a workspace to the tip of its branch, or of the branch given."""
111
+ request = messages.UpdateRequest(path=path, branch=branch or "", with_submodules=submodules)
112
+ return Workspace.from_message(path, self.connection.follow(methods.UPDATE, request, on_progress))
113
+
114
+ def checkout(
115
+ self,
116
+ path: str,
117
+ commit: str,
118
+ *,
119
+ submodules: bool = False,
120
+ on_progress: OnProgress | None = None,
121
+ ) -> Workspace:
122
+ """Moves a workspace to a commit, as a rollback does."""
123
+ request = messages.CheckoutRequest(path=path, commit=commit, with_submodules=submodules)
124
+ return Workspace.from_message(path, self.connection.follow(methods.CHECKOUT, request, on_progress))
125
+
126
+ def describe(self, path: str) -> Description:
127
+ """What a workspace is on disk, without the network."""
128
+ response = self.connection.call(methods.DESCRIBE, messages.DescribeRequest(path=path))
129
+ return Description.from_message(response)
130
+
131
+ def publish(
132
+ self,
133
+ path: str,
134
+ branch: str,
135
+ *,
136
+ requested_by: str = "",
137
+ pull_request: PullRequestOptions | None = None,
138
+ provenance: Provenance | None = None,
139
+ sandbox: str = "",
140
+ ) -> Published:
141
+ """Pushes what was committed in a workspace to a new branch, with the credential gitd
142
+ keeps, and opens its pull request when the publishing settings or pull_request say
143
+ so. requested_by is who asked for the work, as their account on the host. With
144
+ sandbox, and path empty, publishes the branch a sandbox pushed through the endpoint."""
145
+ request: Any = messages.PublishRequest(
146
+ path=path, branch=branch, requested_by=requested_by, sandbox=sandbox
147
+ )
148
+ if pull_request is not None:
149
+ request.pull_request.CopyFrom(pull_request.to_message())
150
+ if provenance is not None:
151
+ request.provenance.CopyFrom(provenance.to_message())
152
+ return Published.from_message(self.connection.call(methods.PUBLISH, request))
153
+
154
+ def changes(self, path: str, *, include_ignored: bool = False) -> Changes:
155
+ """What was changed in a workspace since gitd put it on its commit, committed or not."""
156
+ request = messages.ChangesRequest(path=path, include_ignored=include_ignored)
157
+ return Changes.from_message(self.connection.call(methods.CHANGES, request))
158
+
159
+ def diff(
160
+ self,
161
+ path: str,
162
+ files: list[str] | None = None,
163
+ *,
164
+ max_bytes: int = 0,
165
+ include_ignored: bool = False,
166
+ ) -> Diff:
167
+ """The unified diff of a workspace's changes, of some files or all, cut short at
168
+ max_bytes (512 KiB by default)."""
169
+ request = messages.DiffRequest(
170
+ path=path, files=files or [], max_bytes=max_bytes, include_ignored=include_ignored
171
+ )
172
+ return Diff.from_message(self.connection.call(methods.DIFF, request))
173
+
174
+ def compare(
175
+ self,
176
+ first: Side,
177
+ second: Side,
178
+ *,
179
+ url: str = "",
180
+ with_diff: bool = False,
181
+ max_bytes: int = 0,
182
+ include_ignored: bool = False,
183
+ ) -> Comparison:
184
+ """How two works of one repository differ, from first to second; url is the
185
+ repository when both are branches."""
186
+ request = messages.CompareRequest(
187
+ first=first.to_message(),
188
+ second=second.to_message(),
189
+ url=url,
190
+ with_diff=with_diff,
191
+ max_bytes=max_bytes,
192
+ include_ignored=include_ignored,
193
+ )
194
+ return Comparison.from_message(self.connection.call(methods.COMPARE, request))
195
+
196
+ def conflicts(self, sides: list[Side], *, url: str = "") -> list[Conflict]:
197
+ """Which pairs of works do not merge cleanly, and on which files; a workspace stands
198
+ for what it committed."""
199
+ request = messages.ConflictsRequest(sides=[side.to_message() for side in sides], url=url)
200
+ response = self.connection.call(methods.CONFLICTS, request)
201
+ return [Conflict.from_message(conflict) for conflict in response.conflicts]
202
+
203
+ def watch(self, paths: list[str] | None = None) -> Iterator[Event]:
204
+ """What happens to the workspaces at paths, or to every one, as it happens."""
205
+ request = messages.WatchRequest(paths=paths or [])
206
+ for event in self.connection.stream(methods.WATCH, request):
207
+ yield Event.from_message(event)
208
+
209
+ def seal(self, path: str, name: str) -> WarmLayer:
210
+ """Seals what was installed in an overlay workspace as the warm layer name of its
211
+ repository, for workspaces made later at the same commit to stand on. The layer is
212
+ moved, not copied: the workspace is removed."""
213
+ response = self.connection.call(methods.SEAL, messages.SealRequest(path=path, name=name))
214
+ return WarmLayer.from_message(response.warm)
215
+
216
+ def warm_layers(self, url: str) -> list[WarmLayer]:
217
+ """The warm layers of a repository."""
218
+ response = self.connection.call(methods.LIST_WARM, messages.ListWarmRequest(url=url))
219
+ return [WarmLayer.from_message(warm) for warm in response.warm]
220
+
221
+ def remove(self, path: str) -> None:
222
+ """Deletes a workspace; its pool stays."""
223
+ self.connection.call(methods.REMOVE, messages.RemoveRequest(path=path))
224
+
225
+ def audit(
226
+ self,
227
+ *,
228
+ since: datetime | None = None,
229
+ url: str | None = None,
230
+ limit: int | None = None,
231
+ ) -> list[AuditEntry]:
232
+ """The audit log: every publish, done or refused, newest first; from since on, of
233
+ the repository at url, at most limit of them (100 by default, never more than 1000)."""
234
+ request: Any = messages.AuditRequest(url=url or "", limit=limit or 0)
235
+ if since is not None:
236
+ Timestamp.fill(request.since, since)
237
+ response = self.connection.call(methods.AUDIT, request)
238
+ return [AuditEntry.from_message(entry) for entry in response.entries]
@@ -0,0 +1,111 @@
1
+ """Two works compared, their conflicts, and what happens to workspaces, as plain
2
+ dataclasses that write the messages gitd takes and read those it answers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ from . import messages
12
+ from .changes import ChangedFile
13
+ from .timestamps import Timestamp
14
+
15
+ KIND_PREFIX = "KIND_"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Side:
20
+ """One work: a workspace, by its path, or a branch gitd published or its pool holds."""
21
+
22
+ workspace: str = ""
23
+ branch: str = ""
24
+
25
+ @classmethod
26
+ def of_workspace(cls, path: str) -> Side:
27
+ return cls(workspace=path)
28
+
29
+ @classmethod
30
+ def of_branch(cls, name: str) -> Side:
31
+ return cls(branch=name)
32
+
33
+ @classmethod
34
+ def from_message(cls, side: Any) -> Side:
35
+ return cls(workspace=side.workspace, branch=side.branch)
36
+
37
+ def to_message(self) -> Any:
38
+ if self.workspace:
39
+ return messages.Side(workspace=self.workspace)
40
+ return messages.Side(branch=self.branch)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Comparison:
45
+ """How two works differ, from the first to the second, and the diff when asked."""
46
+
47
+ files: list[ChangedFile] = field(default_factory=list)
48
+ diff: str = ""
49
+ truncated: bool = False
50
+
51
+ @classmethod
52
+ def from_message(cls, response: Any) -> Comparison:
53
+ return cls(
54
+ files=[ChangedFile.from_message(file) for file in response.files],
55
+ diff=response.diff,
56
+ truncated=response.truncated,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Conflict:
62
+ """Two works that do not merge cleanly, and the files they conflict on."""
63
+
64
+ first: Side
65
+ second: Side
66
+ paths: list[str]
67
+
68
+ @classmethod
69
+ def from_message(cls, conflict: Any) -> Conflict:
70
+ return cls(
71
+ first=Side.from_message(conflict.first),
72
+ second=Side.from_message(conflict.second),
73
+ paths=list(conflict.paths),
74
+ )
75
+
76
+
77
+ class EventKind(StrEnum):
78
+ """What happened to a workspace."""
79
+
80
+ MADE = "made"
81
+ CHANGED = "changed"
82
+ PUBLISHED = "published"
83
+ REFUSED = "refused"
84
+ REMOVED = "removed"
85
+
86
+ @classmethod
87
+ def from_message(cls, number: int) -> EventKind:
88
+ name = messages.EVENT_KIND.name(number).removeprefix(KIND_PREFIX).lower()
89
+ return cls(name) if name in {member.value for member in cls} else cls.CHANGED
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Event:
94
+ """What happened to a workspace, when; a publish's branch, and its pull request or why it
95
+ was refused."""
96
+
97
+ kind: EventKind
98
+ path: str
99
+ time: datetime
100
+ branch: str
101
+ detail: str
102
+
103
+ @classmethod
104
+ def from_message(cls, event: Any) -> Event:
105
+ return cls(
106
+ kind=EventKind.from_message(event.kind),
107
+ path=event.path,
108
+ time=Timestamp.to_datetime(event.time),
109
+ branch=event.branch,
110
+ detail=event.detail,
111
+ )
@@ -0,0 +1,128 @@
1
+ """The channel to gitd — its Unix socket, or its address on the network — and how a call on
2
+ it is made and read."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from collections.abc import Callable, Iterator
8
+ from typing import Any
9
+
10
+ import grpc
11
+ from google.protobuf.message import Message
12
+
13
+ from .errors import GitdError
14
+ from .methods import Method
15
+ from .models import Progress
16
+
17
+ DEFAULT_SOCKET = "/run/gitd/gitd.sock"
18
+ SOCKET_VARIABLE = "GITD_SOCKET"
19
+ URL_VARIABLE = "GITD_URL"
20
+ KEY_VARIABLE = "GITD_KEY"
21
+ KEY_FILE_VARIABLE = "GITD_KEY_FILE"
22
+ CA_FILE_VARIABLE = "GITD_CA_FILE"
23
+
24
+ OnProgress = Callable[[Progress], None]
25
+
26
+
27
+ class Connection:
28
+ """A channel to gitd: at url on the network (default $GITD_URL: https://host:port, or
29
+ http:// on a private network), else on its socket (default $GITD_SOCKET, then
30
+ /run/gitd/gitd.sock). key names the caller's tenant (default the file $GITD_KEY_FILE names,
31
+ then $GITD_KEY); ca_file is the authority of a certificate the system does not trust
32
+ (default $GITD_CA_FILE). The timeout, in seconds, bounds every call, which gitd otherwise
33
+ bounds itself."""
34
+
35
+ def __init__(
36
+ self,
37
+ socket: str | None,
38
+ timeout: float | None,
39
+ url: str | None = None,
40
+ key: str | None = None,
41
+ ca_file: str | None = None,
42
+ ) -> None:
43
+ self.socket = socket or os.environ.get(SOCKET_VARIABLE) or DEFAULT_SOCKET
44
+ self.timeout = timeout
45
+ key = key or self.key_from_environment()
46
+ self.metadata = (("authorization", f"Bearer {key}"),) if key else ()
47
+ url = url or os.environ.get(URL_VARIABLE)
48
+ if not url:
49
+ self.channel = grpc.insecure_channel(f"unix:{self.socket}")
50
+ return
51
+ secure, target = self.target_of(url)
52
+ if not secure:
53
+ self.channel = grpc.insecure_channel(target)
54
+ return
55
+ ca_file = ca_file or os.environ.get(CA_FILE_VARIABLE)
56
+ roots = open(ca_file, "rb").read() if ca_file else None # noqa: SIM115
57
+ self.channel = grpc.secure_channel(target, grpc.ssl_channel_credentials(root_certificates=roots))
58
+
59
+ @staticmethod
60
+ def key_from_environment() -> str:
61
+ key_file = os.environ.get(KEY_FILE_VARIABLE)
62
+ if key_file:
63
+ with open(key_file) as opened:
64
+ return opened.read().strip()
65
+ return os.environ.get(KEY_VARIABLE, "").strip()
66
+
67
+ @staticmethod
68
+ def target_of(url: str) -> tuple[bool, str]:
69
+ """Whether url is served over TLS, and the host:port gRPC dials."""
70
+ for scheme, secure, port in (("https://", True, 443), ("http://", False, 80)):
71
+ if url.startswith(scheme):
72
+ host = url[len(scheme) :].rstrip("/")
73
+ return secure, host if ":" in host else f"{host}:{port}"
74
+ raise GitdError("BAD_ADDRESS", f"{url!r} is not the address of a gitd: https://host:port")
75
+
76
+ def close(self) -> None:
77
+ self.channel.close()
78
+
79
+ @staticmethod
80
+ def serialize(message: Message) -> bytes:
81
+ return message.SerializeToString()
82
+
83
+ def call(self, method: Method, request: Message) -> Any:
84
+ """Makes a call that answers once."""
85
+ invoke: grpc.UnaryUnaryMultiCallable[Message, Any] = self.channel.unary_unary(
86
+ method.path,
87
+ request_serializer=self.serialize,
88
+ response_deserializer=method.response.FromString,
89
+ )
90
+ try:
91
+ return invoke(request, timeout=self.timeout, metadata=self.metadata)
92
+ except grpc.RpcError as error:
93
+ raise GitdError.from_rpc_error(error) from None
94
+
95
+ def stream(self, method: Method, request: Message) -> Iterator[Any]:
96
+ """Makes a call that streams messages until gitd or the caller ends it."""
97
+ invoke: grpc.UnaryStreamMultiCallable[Message, Any] = self.channel.unary_stream(
98
+ method.path,
99
+ request_serializer=self.serialize,
100
+ response_deserializer=method.response.FromString,
101
+ )
102
+ try:
103
+ yield from invoke(request, timeout=self.timeout, metadata=self.metadata)
104
+ except grpc.RpcError as error:
105
+ raise GitdError.from_rpc_error(error) from None
106
+
107
+ def follow(
108
+ self,
109
+ method: Method,
110
+ request: Message,
111
+ on_progress: OnProgress | None,
112
+ ) -> Any:
113
+ """Makes a call that streams its progress, and reads it to its result."""
114
+ invoke: grpc.UnaryStreamMultiCallable[Message, Any] = self.channel.unary_stream(
115
+ method.path,
116
+ request_serializer=self.serialize,
117
+ response_deserializer=method.response.FromString,
118
+ )
119
+ try:
120
+ for response in invoke(request, timeout=self.timeout, metadata=self.metadata):
121
+ event = response.WhichOneof("event")
122
+ if event == "progress" and on_progress is not None:
123
+ on_progress(Progress.from_message(response.progress))
124
+ elif event == "result":
125
+ return response.result
126
+ except grpc.RpcError as error:
127
+ raise GitdError.from_rpc_error(error) from None
128
+ raise GitdError("", "gitd ended the call without a result")