gitd 0.7.0__tar.gz → 0.8.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.8.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.8.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,81 @@
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 .errors import GitdError
8
+ from .kept_credentials import AppToCreate, Connected, CredentialKind, KeptCredential, SignIn
9
+ from .models import (
10
+ Access,
11
+ AuditEntry,
12
+ ConfigurationChange,
13
+ Description,
14
+ Info,
15
+ Layers,
16
+ PendingSubmodule,
17
+ Probe,
18
+ Progress,
19
+ Published,
20
+ PublishedRepository,
21
+ Refusal,
22
+ Strategy,
23
+ Submodule,
24
+ Workspace,
25
+ )
26
+ from .publishing_settings import (
27
+ OpenedPullRequest,
28
+ Opening,
29
+ Preset,
30
+ PresetOrigin,
31
+ Provenance,
32
+ PublishSettings,
33
+ PullRequestOptions,
34
+ PullRequestSettings,
35
+ )
36
+ from .warm_layers import WarmLayer
37
+
38
+ __all__ = [
39
+ "Comparison",
40
+ "Conflict",
41
+ "Event",
42
+ "EventKind",
43
+ "Side",
44
+ "ChangeKind",
45
+ "ChangedFile",
46
+ "Changes",
47
+ "Diff",
48
+ "AppToCreate",
49
+ "ConfigurationChange",
50
+ "Connected",
51
+ "CredentialKind",
52
+ "KeptCredential",
53
+ "OpenedPullRequest",
54
+ "Opening",
55
+ "Preset",
56
+ "PresetOrigin",
57
+ "Provenance",
58
+ "PublishSettings",
59
+ "PullRequestOptions",
60
+ "PullRequestSettings",
61
+ "SignIn",
62
+ "DEFAULT_SOCKET",
63
+ "SOCKET_VARIABLE",
64
+ "Access",
65
+ "AuditEntry",
66
+ "Description",
67
+ "Gitd",
68
+ "GitdError",
69
+ "Info",
70
+ "Layers",
71
+ "PendingSubmodule",
72
+ "Probe",
73
+ "Progress",
74
+ "Published",
75
+ "PublishedRepository",
76
+ "Refusal",
77
+ "Strategy",
78
+ "Submodule",
79
+ "Workspace",
80
+ "WarmLayer",
81
+ ]
@@ -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,219 @@
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 .models import AuditEntry, Description, Info, Probe, Published, Strategy, Workspace
15
+ from .publishing_calls import Publishing
16
+ from .publishing_settings import Provenance, PullRequestOptions
17
+ from .timestamps import Timestamp
18
+ from .warm_layers import WarmLayer
19
+
20
+
21
+ class Gitd:
22
+ """A gitd on its Unix socket. No call on workspaces takes a credential: gitd reads and
23
+ publishes with the ones it keeps, which a platform manages through gitd.credentials, and
24
+ publishing follows the rules it sets through gitd.publishing.
25
+
26
+ with Gitd() as gitd:
27
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
28
+ """
29
+
30
+ def __init__(self, socket: str | None = None, *, timeout: float | None = None) -> None:
31
+ self.connection = Connection(socket, timeout)
32
+ self.credentials = Credentials(self.connection)
33
+ self.publishing = Publishing(self.connection)
34
+
35
+ def __enter__(self) -> Gitd:
36
+ return self
37
+
38
+ def __exit__(self, *exception: object) -> None:
39
+ self.close()
40
+
41
+ def close(self) -> None:
42
+ self.connection.close()
43
+
44
+ def info(self) -> Info:
45
+ """Which gitd answers, and how it makes workspaces here and why."""
46
+ return Info.from_message(self.connection.call(methods.INFO, messages.InfoRequest()))
47
+
48
+ def probe(self, url: str) -> Probe:
49
+ """Whether a repository can be read with the credentials gitd keeps, and its branches."""
50
+ return Probe.from_message(self.connection.call(methods.PROBE, messages.ProbeRequest(url=url)))
51
+
52
+ def prepare(
53
+ self,
54
+ url: str,
55
+ path: str,
56
+ *,
57
+ branch: str | None = None,
58
+ commit: str | None = None,
59
+ submodules: bool = False,
60
+ full_history: bool = False,
61
+ strategy: Strategy | None = None,
62
+ warm: str = "",
63
+ on_progress: OnProgress | None = None,
64
+ ) -> Workspace:
65
+ """Makes a workspace of a repository at a path under gitd's roots, at a commit, a
66
+ branch, or the default branch. strategy is a preference: gitd falls back to copy when
67
+ it cannot make one that way here. warm names a warm layer to stand on, its
68
+ dependencies installed already: the workspace is then made by overlay, at the commit
69
+ the layer was sealed at, or not at all."""
70
+ if commit:
71
+ revision = messages.Revision(commit=commit)
72
+ elif branch:
73
+ revision = messages.Revision(branch=branch)
74
+ else:
75
+ revision = None
76
+ request = messages.PrepareRequest(
77
+ url=url,
78
+ path=path,
79
+ revision=revision,
80
+ with_submodules=submodules,
81
+ full_history=full_history,
82
+ strategy=Strategy.preference(strategy),
83
+ warm=warm,
84
+ )
85
+ return Workspace.from_message(path, self.connection.follow(methods.PREPARE, request, on_progress))
86
+
87
+ def update(
88
+ self,
89
+ path: str,
90
+ *,
91
+ branch: str | None = None,
92
+ submodules: bool = False,
93
+ on_progress: OnProgress | None = None,
94
+ ) -> Workspace:
95
+ """Brings a workspace to the tip of its branch, or of the branch given."""
96
+ request = messages.UpdateRequest(path=path, branch=branch or "", with_submodules=submodules)
97
+ return Workspace.from_message(path, self.connection.follow(methods.UPDATE, request, on_progress))
98
+
99
+ def checkout(
100
+ self,
101
+ path: str,
102
+ commit: str,
103
+ *,
104
+ submodules: bool = False,
105
+ on_progress: OnProgress | None = None,
106
+ ) -> Workspace:
107
+ """Moves a workspace to a commit, as a rollback does."""
108
+ request = messages.CheckoutRequest(path=path, commit=commit, with_submodules=submodules)
109
+ return Workspace.from_message(path, self.connection.follow(methods.CHECKOUT, request, on_progress))
110
+
111
+ def describe(self, path: str) -> Description:
112
+ """What a workspace is on disk, without the network."""
113
+ response = self.connection.call(methods.DESCRIBE, messages.DescribeRequest(path=path))
114
+ return Description.from_message(response)
115
+
116
+ def publish(
117
+ self,
118
+ path: str,
119
+ branch: str,
120
+ *,
121
+ requested_by: str = "",
122
+ pull_request: PullRequestOptions | None = None,
123
+ provenance: Provenance | None = None,
124
+ ) -> Published:
125
+ """Pushes what was committed in a workspace to a new branch, with the credential gitd
126
+ keeps, and opens its pull request when the publishing settings or pull_request say
127
+ so. requested_by is who asked for the work, as their account on the host."""
128
+ request: Any = messages.PublishRequest(path=path, branch=branch, requested_by=requested_by)
129
+ if pull_request is not None:
130
+ request.pull_request.CopyFrom(pull_request.to_message())
131
+ if provenance is not None:
132
+ request.provenance.CopyFrom(provenance.to_message())
133
+ return Published.from_message(self.connection.call(methods.PUBLISH, request))
134
+
135
+ def changes(self, path: str, *, include_ignored: bool = False) -> Changes:
136
+ """What was changed in a workspace since gitd put it on its commit, committed or not."""
137
+ request = messages.ChangesRequest(path=path, include_ignored=include_ignored)
138
+ return Changes.from_message(self.connection.call(methods.CHANGES, request))
139
+
140
+ def diff(
141
+ self,
142
+ path: str,
143
+ files: list[str] | None = None,
144
+ *,
145
+ max_bytes: int = 0,
146
+ include_ignored: bool = False,
147
+ ) -> Diff:
148
+ """The unified diff of a workspace's changes, of some files or all, cut short at
149
+ max_bytes (512 KiB by default)."""
150
+ request = messages.DiffRequest(
151
+ path=path, files=files or [], max_bytes=max_bytes, include_ignored=include_ignored
152
+ )
153
+ return Diff.from_message(self.connection.call(methods.DIFF, request))
154
+
155
+ def compare(
156
+ self,
157
+ first: Side,
158
+ second: Side,
159
+ *,
160
+ url: str = "",
161
+ with_diff: bool = False,
162
+ max_bytes: int = 0,
163
+ include_ignored: bool = False,
164
+ ) -> Comparison:
165
+ """How two works of one repository differ, from first to second; url is the
166
+ repository when both are branches."""
167
+ request = messages.CompareRequest(
168
+ first=first.to_message(),
169
+ second=second.to_message(),
170
+ url=url,
171
+ with_diff=with_diff,
172
+ max_bytes=max_bytes,
173
+ include_ignored=include_ignored,
174
+ )
175
+ return Comparison.from_message(self.connection.call(methods.COMPARE, request))
176
+
177
+ def conflicts(self, sides: list[Side], *, url: str = "") -> list[Conflict]:
178
+ """Which pairs of works do not merge cleanly, and on which files; a workspace stands
179
+ for what it committed."""
180
+ request = messages.ConflictsRequest(sides=[side.to_message() for side in sides], url=url)
181
+ response = self.connection.call(methods.CONFLICTS, request)
182
+ return [Conflict.from_message(conflict) for conflict in response.conflicts]
183
+
184
+ def watch(self, paths: list[str] | None = None) -> Iterator[Event]:
185
+ """What happens to the workspaces at paths, or to every one, as it happens."""
186
+ request = messages.WatchRequest(paths=paths or [])
187
+ for event in self.connection.stream(methods.WATCH, request):
188
+ yield Event.from_message(event)
189
+
190
+ def seal(self, path: str, name: str) -> WarmLayer:
191
+ """Seals what was installed in an overlay workspace as the warm layer name of its
192
+ repository, for workspaces made later at the same commit to stand on. The layer is
193
+ moved, not copied: the workspace is removed."""
194
+ response = self.connection.call(methods.SEAL, messages.SealRequest(path=path, name=name))
195
+ return WarmLayer.from_message(response.warm)
196
+
197
+ def warm_layers(self, url: str) -> list[WarmLayer]:
198
+ """The warm layers of a repository."""
199
+ response = self.connection.call(methods.LIST_WARM, messages.ListWarmRequest(url=url))
200
+ return [WarmLayer.from_message(warm) for warm in response.warm]
201
+
202
+ def remove(self, path: str) -> None:
203
+ """Deletes a workspace; its pool stays."""
204
+ self.connection.call(methods.REMOVE, messages.RemoveRequest(path=path))
205
+
206
+ def audit(
207
+ self,
208
+ *,
209
+ since: datetime | None = None,
210
+ url: str | None = None,
211
+ limit: int | None = None,
212
+ ) -> list[AuditEntry]:
213
+ """The audit log: every publish, done or refused, newest first; from since on, of
214
+ the repository at url, at most limit of them (100 by default, never more than 1000)."""
215
+ request: Any = messages.AuditRequest(url=url or "", limit=limit or 0)
216
+ if since is not None:
217
+ Timestamp.fill(request.since, since)
218
+ response = self.connection.call(methods.AUDIT, request)
219
+ 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
+ )
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Iterator
7
7
  from typing import Any
8
8
 
9
9
  import grpc
@@ -47,6 +47,18 @@ class Connection:
47
47
  except grpc.RpcError as error:
48
48
  raise GitdError.from_rpc_error(error) from None
49
49
 
50
+ def stream(self, method: Method, request: Message) -> Iterator[Any]:
51
+ """Makes a call that streams messages until gitd or the caller ends it."""
52
+ invoke: grpc.UnaryStreamMultiCallable[Message, Any] = self.channel.unary_stream(
53
+ method.path,
54
+ request_serializer=self.serialize,
55
+ response_deserializer=method.response.FromString,
56
+ )
57
+ try:
58
+ yield from invoke(request, timeout=self.timeout)
59
+ except grpc.RpcError as error:
60
+ raise GitdError.from_rpc_error(error) from None
61
+
50
62
  def follow(
51
63
  self,
52
64
  method: Method,