gitd 0.7.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.
gitd/contract.py ADDED
@@ -0,0 +1,50 @@
1
+ """gitd's contract, read at run time from contract.json, which `make generate` builds from
2
+ proto/gitd/v1/gitd.proto. Only protobuf's public API is used: no code is generated."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from importlib import resources
7
+
8
+ from google.protobuf import descriptor_pb2, descriptor_pool, json_format, message_factory
9
+ from google.protobuf.descriptor import EnumDescriptor
10
+ from google.protobuf.message import Message
11
+
12
+ PACKAGE = "gitd.v1"
13
+ CONTRACT_FILE = "contract.json"
14
+
15
+
16
+ class Enumeration:
17
+ """An enumeration of the contract, by the names and numbers of its values."""
18
+
19
+ def __init__(self, descriptor: EnumDescriptor) -> None:
20
+ self.descriptor = descriptor
21
+
22
+ def number(self, name: str) -> int:
23
+ return self.descriptor.values_by_name[name].number
24
+
25
+ def name(self, number: int) -> str:
26
+ """The value's name, or an empty one for a value newer than this client."""
27
+ value = self.descriptor.values_by_number.get(number)
28
+ return value.name if value else ""
29
+
30
+
31
+ class Contract:
32
+ """The messages and enumerations of gitd's API."""
33
+
34
+ def __init__(self, pool: descriptor_pool.DescriptorPool) -> None:
35
+ self.pool = pool
36
+
37
+ @classmethod
38
+ def load(cls) -> Contract:
39
+ text = resources.files(__package__).joinpath(CONTRACT_FILE).read_text(encoding="utf-8")
40
+ files = json_format.Parse(text, descriptor_pb2.FileDescriptorSet())
41
+ pool = descriptor_pool.DescriptorPool()
42
+ for file in files.file:
43
+ pool.Add(file)
44
+ return cls(pool)
45
+
46
+ def message(self, name: str) -> type[Message]:
47
+ return message_factory.GetMessageClass(self.pool.FindMessageTypeByName(f"{PACKAGE}.{name}"))
48
+
49
+ def enumeration(self, name: str) -> Enumeration:
50
+ return Enumeration(self.pool.FindEnumTypeByName(f"{PACKAGE}.{name}"))
gitd/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """A refusal from gitd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import grpc
8
+ from grpc_status import rpc_status
9
+
10
+ from . import messages
11
+ from .models import Refusal
12
+
13
+
14
+ class GitdError(Exception):
15
+ """A refusal from gitd: its reason (AUTH_REQUIRED, NOT_FOUND, PATH_REFUSED, BUSY, …) and
16
+ what git said. Tell users what happened from the reason, never by parsing the message."""
17
+
18
+ def __init__(self, reason: str, said: str, code: grpc.StatusCode | None = None) -> None:
19
+ super().__init__(f"{reason}: {said}" if reason else said)
20
+ self.reason = reason
21
+ self.said = said
22
+ self.code = code
23
+
24
+ @classmethod
25
+ def from_rpc_error(cls, error: grpc.RpcError) -> GitdError:
26
+ """Reads the GitFailure gitd attaches to a refused call, if there is one."""
27
+ if not isinstance(error, grpc.Call):
28
+ return cls("", str(error))
29
+ status = rpc_status.from_call(error)
30
+ details = list(status.details) if status else []
31
+ for detail in details:
32
+ if detail.Is(messages.GitFailure.DESCRIPTOR):
33
+ failure: Any = messages.GitFailure()
34
+ detail.Unpack(failure)
35
+ return cls(Refusal.reason_of(failure), failure.git_said, error.code())
36
+ return cls("", error.details() or "", error.code())
gitd/messages.py ADDED
@@ -0,0 +1,30 @@
1
+ """The messages and enumerations of gitd's contract that this client uses."""
2
+
3
+ from .contract import Contract
4
+
5
+ contract = Contract.load()
6
+
7
+ InfoRequest = contract.message("InfoRequest")
8
+ InfoResponse = contract.message("InfoResponse")
9
+ ProbeRequest = contract.message("ProbeRequest")
10
+ ProbeResponse = contract.message("ProbeResponse")
11
+ Revision = contract.message("Revision")
12
+ PrepareRequest = contract.message("PrepareRequest")
13
+ PrepareResponse = contract.message("PrepareResponse")
14
+ UpdateRequest = contract.message("UpdateRequest")
15
+ UpdateResponse = contract.message("UpdateResponse")
16
+ CheckoutRequest = contract.message("CheckoutRequest")
17
+ CheckoutResponse = contract.message("CheckoutResponse")
18
+ DescribeRequest = contract.message("DescribeRequest")
19
+ DescribeResponse = contract.message("DescribeResponse")
20
+ PublishRequest = contract.message("PublishRequest")
21
+ PublishResponse = contract.message("PublishResponse")
22
+ RemoveRequest = contract.message("RemoveRequest")
23
+ RemoveResponse = contract.message("RemoveResponse")
24
+ AuditRequest = contract.message("AuditRequest")
25
+ AuditResponse = contract.message("AuditResponse")
26
+ GitFailure = contract.message("GitFailure")
27
+
28
+ STRATEGY = contract.enumeration("Strategy")
29
+ ACCESS = contract.enumeration("ProbeResponse.Access")
30
+ REASON = contract.enumeration("GitFailure.Reason")
gitd/methods.py ADDED
@@ -0,0 +1,32 @@
1
+ """The gitd methods this client calls, by their gRPC path and the message each answers with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from google.protobuf.message import Message
8
+
9
+ from . import messages
10
+ from .contract import PACKAGE
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Method:
15
+ service: str
16
+ name: str
17
+ response: type[Message]
18
+
19
+ @property
20
+ def path(self) -> str:
21
+ return f"/{PACKAGE}.{self.service}/{self.name}"
22
+
23
+
24
+ INFO = Method("ServerService", "Info", messages.InfoResponse)
25
+ AUDIT = Method("ServerService", "Audit", messages.AuditResponse)
26
+ PROBE = Method("RepositoryService", "Probe", messages.ProbeResponse)
27
+ PREPARE = Method("WorkspaceService", "Prepare", messages.PrepareResponse)
28
+ UPDATE = Method("WorkspaceService", "Update", messages.UpdateResponse)
29
+ CHECKOUT = Method("WorkspaceService", "Checkout", messages.CheckoutResponse)
30
+ DESCRIBE = Method("WorkspaceService", "Describe", messages.DescribeResponse)
31
+ PUBLISH = Method("WorkspaceService", "Publish", messages.PublishResponse)
32
+ REMOVE = Method("WorkspaceService", "Remove", messages.RemoveResponse)
gitd/models.py ADDED
@@ -0,0 +1,285 @@
1
+ """What gitd answers, as plain dataclasses, each built from the message gitd sends. The
2
+ messages are made from the contract at run time, so their fields are typed here as Any."""
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 .timestamps import Timestamp
13
+
14
+ STRATEGY_PREFIX = "STRATEGY_"
15
+ ACCESS_PREFIX = "ACCESS_"
16
+ REASON_PREFIX = "REASON_"
17
+
18
+
19
+ class Strategy(StrEnum):
20
+ """How a workspace is made: a layer on a shared template, a block clone, or every file written."""
21
+
22
+ OVERLAY = "overlay"
23
+ REFLINK = "reflink"
24
+ COPY = "copy"
25
+
26
+ @classmethod
27
+ def from_message(cls, number: int) -> Strategy:
28
+ name = messages.STRATEGY.name(number).removeprefix(STRATEGY_PREFIX).lower()
29
+ return cls(name) if name in {member.value for member in cls} else cls.COPY
30
+
31
+ @classmethod
32
+ def preference(cls, strategy: Strategy | None) -> int:
33
+ """The number a request carries: the strategy asked for, or none, for gitd to choose."""
34
+ name = strategy.value.upper() if strategy else "UNSPECIFIED"
35
+ return messages.STRATEGY.number(STRATEGY_PREFIX + name)
36
+
37
+
38
+ class Access(StrEnum):
39
+ """Whether a repository can be read: by anyone, with a credential gitd keeps, or not at all."""
40
+
41
+ PUBLIC = "public"
42
+ REACHABLE = "reachable"
43
+ DENIED = "denied"
44
+
45
+ @classmethod
46
+ def from_message(cls, number: int) -> Access:
47
+ name = messages.ACCESS.name(number).removeprefix(ACCESS_PREFIX).lower()
48
+ return cls(name) if name in {member.value for member in cls} else cls.DENIED
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Progress:
53
+ """How far a fetch or checkout has gone; repository is the submodule's URL, empty for the
54
+ workspace's own repository."""
55
+
56
+ phase: str
57
+ percent: int
58
+ repository: str
59
+
60
+ @classmethod
61
+ def from_message(cls, progress: Any) -> Progress:
62
+ return cls(phase=progress.phase, percent=progress.percent, repository=progress.repository)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Layers:
67
+ """An overlay workspace's template (lower) and its own layer (upper, work)."""
68
+
69
+ lower: str
70
+ upper: str
71
+ work: str
72
+
73
+ @classmethod
74
+ def from_message(cls, layers: Any) -> Layers:
75
+ return cls(lower=layers.lower, upper=layers.upper, work=layers.work)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PendingSubmodule:
80
+ """A submodule that could not be checked out, and what git said."""
81
+
82
+ path: str
83
+ url: str
84
+ reason: str
85
+
86
+ @classmethod
87
+ def from_message(cls, submodule: Any) -> PendingSubmodule:
88
+ return cls(path=submodule.path, url=submodule.url, reason=submodule.failure.git_said)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Workspace:
93
+ """Where a workspace ended up. branch is empty when it is detached at a commit;
94
+ lfs_patterns are the files the repository keeps in git LFS, which are pointers here, as
95
+ gitd does not download them."""
96
+
97
+ path: str
98
+ commit: str
99
+ branch: str
100
+ strategy: Strategy
101
+ layers: Layers | None = None
102
+ pending_submodules: list[PendingSubmodule] = field(default_factory=list)
103
+ lfs_patterns: list[str] = field(default_factory=list)
104
+
105
+ @classmethod
106
+ def from_message(cls, path: str, result: Any) -> Workspace:
107
+ return cls(
108
+ path=path,
109
+ commit=result.commit,
110
+ branch=result.branch,
111
+ strategy=Strategy.from_message(result.strategy),
112
+ layers=Layers.from_message(result.layers) if result.HasField("layers") else None,
113
+ pending_submodules=[
114
+ PendingSubmodule.from_message(pending) for pending in result.pending_submodules
115
+ ],
116
+ lfs_patterns=list(result.lfs_patterns),
117
+ )
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class Submodule:
122
+ path: str
123
+ url: str
124
+ commit: str
125
+ checked_out: bool
126
+
127
+ @classmethod
128
+ def from_message(cls, submodule: Any) -> Submodule:
129
+ return cls(
130
+ path=submodule.path,
131
+ url=submodule.url,
132
+ commit=submodule.commit,
133
+ checked_out=submodule.checked_out,
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Description:
139
+ """What a workspace is on disk. modified says whether it has uncommitted changes;
140
+ lfs_patterns are the files kept in git LFS, which are pointers here."""
141
+
142
+ url: str
143
+ commit: str
144
+ branch: str
145
+ modified: bool
146
+ strategy: Strategy
147
+ submodules: list[Submodule] = field(default_factory=list)
148
+ lfs_patterns: list[str] = field(default_factory=list)
149
+
150
+ @classmethod
151
+ def from_message(cls, response: Any) -> Description:
152
+ return cls(
153
+ url=response.url,
154
+ commit=response.commit,
155
+ branch=response.branch,
156
+ modified=response.modified,
157
+ strategy=Strategy.from_message(response.strategy),
158
+ submodules=[Submodule.from_message(submodule) for submodule in response.submodules],
159
+ lfs_patterns=list(response.lfs_patterns),
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class PublishedRepository:
165
+ """A repository a publish pushed. path is the submodule's, empty for the workspace's own
166
+ repository; subjects are the new commits' subjects, newest first."""
167
+
168
+ url: str
169
+ path: str
170
+ commit: str
171
+ subjects: list[str]
172
+
173
+ @classmethod
174
+ def from_message(cls, repository: Any) -> PublishedRepository:
175
+ return cls(
176
+ url=repository.url,
177
+ path=repository.path,
178
+ commit=repository.commit,
179
+ subjects=list(repository.subjects),
180
+ )
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class Refusal:
185
+ """Why gitd refused a call: its reason (BRANCH_REFUSED, AUTH_REQUIRED, …) and what git said."""
186
+
187
+ reason: str
188
+ said: str
189
+
190
+ @classmethod
191
+ def from_message(cls, failure: Any) -> Refusal:
192
+ return cls(reason=cls.reason_of(failure), said=failure.git_said)
193
+
194
+ @staticmethod
195
+ def reason_of(failure: Any) -> str:
196
+ return messages.REASON.name(failure.reason).removeprefix(REASON_PREFIX)
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class Published:
201
+ """What a publish pushed: the workspace's repository first, then each submodule."""
202
+
203
+ branch: str
204
+ repositories: list[PublishedRepository]
205
+
206
+ @classmethod
207
+ def from_message(cls, response: Any) -> Published:
208
+ pushed = [response.repository, *response.submodules] if response.HasField("repository") else []
209
+ return cls(
210
+ branch=response.branch,
211
+ repositories=[PublishedRepository.from_message(repository) for repository in pushed],
212
+ )
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class Info:
217
+ """Which gitd answers, and how it makes workspaces on its machine and why."""
218
+
219
+ version: str
220
+ git_version: str
221
+ pools: int
222
+ pools_bytes: int
223
+ templates: int
224
+ templates_bytes: int
225
+ strategy: Strategy
226
+ strategy_reason: str
227
+
228
+ @classmethod
229
+ def from_message(cls, response: Any) -> Info:
230
+ return cls(
231
+ version=response.version,
232
+ git_version=response.git_version,
233
+ pools=response.pools,
234
+ pools_bytes=response.pools_bytes,
235
+ templates=response.templates,
236
+ templates_bytes=response.templates_bytes,
237
+ strategy=Strategy.from_message(response.strategy),
238
+ strategy_reason=response.strategy_reason,
239
+ )
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class Probe:
244
+ """Whether a repository can be read, its default branch, and each branch's commit.
245
+ denied says why it cannot be read, when it cannot."""
246
+
247
+ access: Access
248
+ default_branch: str
249
+ branches: dict[str, str]
250
+ denied: str | None = None
251
+
252
+ @classmethod
253
+ def from_message(cls, response: Any) -> Probe:
254
+ return cls(
255
+ access=Access.from_message(response.access),
256
+ default_branch=response.default_branch,
257
+ branches=dict(response.commits),
258
+ denied=response.failure.git_said if response.HasField("failure") else None,
259
+ )
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class AuditEntry:
264
+ """One publish, done or refused, as the audit log keeps it. url is the workspace's
265
+ repository, empty when the workspace could not be opened; repositories is what was
266
+ pushed, the workspace's repository first, empty when refused."""
267
+
268
+ time: datetime
269
+ workspace: str
270
+ url: str
271
+ branch: str
272
+ repositories: list[PublishedRepository] = field(default_factory=list)
273
+ refused: Refusal | None = None
274
+
275
+ @classmethod
276
+ def from_message(cls, entry: Any) -> AuditEntry:
277
+ pushed = [entry.repository, *entry.submodules] if entry.HasField("repository") else []
278
+ return cls(
279
+ time=Timestamp.to_datetime(entry.time),
280
+ workspace=entry.workspace,
281
+ url=entry.url,
282
+ branch=entry.branch,
283
+ repositories=[PublishedRepository.from_message(repository) for repository in pushed],
284
+ refused=Refusal.from_message(entry.refusal) if entry.HasField("refusal") else None,
285
+ )
gitd/timestamps.py ADDED
@@ -0,0 +1,22 @@
1
+ """Between the contract's google.protobuf.Timestamp and Python's datetime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ NANOSECONDS_PER_SECOND = 1_000_000_000
9
+
10
+
11
+ class Timestamp:
12
+ """A Timestamp message read as a datetime, or written from one, always in UTC."""
13
+
14
+ @staticmethod
15
+ def to_datetime(message: Any) -> datetime:
16
+ return datetime.fromtimestamp(message.seconds + message.nanos / NANOSECONDS_PER_SECOND, tz=UTC)
17
+
18
+ @staticmethod
19
+ def fill(message: Any, moment: datetime) -> None:
20
+ seconds = moment.timestamp()
21
+ message.seconds = int(seconds // 1)
22
+ message.nanos = int(round((seconds % 1) * NANOSECONDS_PER_SECOND))
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: gitd
3
+ Version: 0.7.0
4
+ Summary: Client for gitd: instant, secretless git checkouts for agent sandboxes
5
+ Project-URL: Homepage, https://github.com/dooservice/gitd
6
+ Project-URL: Source, https://github.com/dooservice/gitd/tree/main/sdk/python
7
+ License-Expression: Apache-2.0
8
+ Keywords: ai-agents,git,grpc,sandbox,workspace
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Version Control :: Git
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: grpcio-status>=1.84.0
14
+ Requires-Dist: grpcio>=1.84.0
15
+ Requires-Dist: protobuf>=7.36.2
16
+ Description-Content-Type: text/markdown
17
+
18
+ # gitd for Python
19
+
20
+ A client for [gitd](https://github.com/dooservice/gitd): instant, secretless git checkouts
21
+ for agent sandboxes. Your agents work in their own copy and publish a branch; they never
22
+ see your token.
23
+
24
+ ```sh
25
+ pip install gitd
26
+ ```
27
+
28
+ ```python
29
+ from gitd import Gitd, GitdError
30
+
31
+ with Gitd() as gitd: # $GITD_SOCKET, then /run/gitd/gitd.sock
32
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
33
+ print(workspace.path, workspace.commit, workspace.strategy)
34
+
35
+ # … the agent works in workspace.path with plain git, and commits …
36
+
37
+ published = gitd.publish(workspace.path, "agents/fix-login")
38
+ gitd.remove(workspace.path)
39
+ ```
40
+
41
+ No call takes a credential: gitd reads and publishes with the ones it keeps.
42
+
43
+ | Method | What it does |
44
+ |---|---|
45
+ | `prepare(url, path, *, branch, commit, submodules, full_history, strategy, on_progress)` | A workspace of a repository at a path under gitd's roots |
46
+ | `update(path, *, branch, submodules, on_progress)` | To the tip of its branch, or of the branch given |
47
+ | `checkout(path, commit, *, submodules, on_progress)` | To a commit, as a rollback does |
48
+ | `describe(path)` | Its repository, commit, branch, uncommitted changes and submodules |
49
+ | `publish(path, branch)` | Its commits pushed to a new branch |
50
+ | `remove(path)` | Deleted; its pool stays |
51
+ | `probe(url)` | Whether it can be read, its default branch and branches |
52
+ | `info()` | Which gitd answers, and how it makes workspaces here and why |
53
+ | `audit(*, since, url, limit)` | Every publish, done or refused, newest first |
54
+
55
+ A refusal raises `GitdError`, with `reason` (`AUTH_REQUIRED`, `NOT_FOUND`, `PATH_REFUSED`,
56
+ `BRANCH_REFUSED`, `BUSY`, …) and `said`, what git said. Decide from the reason, never from
57
+ the message.
58
+
59
+ [`examples/agent.py`](examples/agent.py) is an agent's whole round trip.
60
+
61
+ ## Layout
62
+
63
+ | Path | What it is |
64
+ |---|---|
65
+ | `src/gitd/client.py` | `Gitd`, the client |
66
+ | `src/gitd/models.py` | What gitd answers, as dataclasses |
67
+ | `src/gitd/errors.py` | `GitdError` |
68
+ | `src/gitd/timestamps.py` | Between the contract's timestamps and `datetime` |
69
+ | `src/gitd/connection.py` | The channel to the socket, and how calls are made |
70
+ | `src/gitd/methods.py` | The gitd methods called, by their gRPC path |
71
+ | `src/gitd/messages.py` | The messages of the contract the client uses |
72
+ | `src/gitd/contract.py` | Reads the contract at run time, with protobuf's public API |
73
+ | `src/gitd/contract.json` | The contract, built from [`proto/gitd/v1/gitd.proto`](../../proto/gitd/v1/gitd.proto) by `make generate` |
74
+
75
+ No code is generated: the client reads gitd's contract when it starts.
76
+
77
+ Python 3.11 or later, on Linux. Apache-2.0.
@@ -0,0 +1,13 @@
1
+ gitd/__init__.py,sha256=ryECu7i_jkjA4sn0A3mqEKVNN-iLlSSafMJ_Ei153WE,753
2
+ gitd/client.py,sha256=J0eLNka23N4F1kd_zbV5FmyEZUPr9GXWHOMyp5w3mcY,4831
3
+ gitd/connection.py,sha256=gAQl4Ov3CIIHdmmrZ0vXAOCH6S0VjXOjPFT-TNDgJLY,2548
4
+ gitd/contract.json,sha256=_mw_aEfMoU77syjO0EPDEYpdHF1pxiIu4dpdvIi9IZs,35828
5
+ gitd/contract.py,sha256=_hvrks2Lu4Ugcp53aYSxiO83n8EsaoA2BTqmF7r5lPc,1826
6
+ gitd/errors.py,sha256=QU8NWOyfh5Z6I4irwUds6c6DsouRWDyB_sERdOvGK0M,1326
7
+ gitd/messages.py,sha256=oqI_PyUJ6YqBN3FcbB6u9Rcb5gLrW-wnwodCj0KpUi0,1299
8
+ gitd/methods.py,sha256=_coa3-ZugO2ejmHuEH9VlbMSbhY5ad1b_vln_NySNbA,1105
9
+ gitd/models.py,sha256=Xs-wN0tOf5DSHPdpAjfqH98rLFvNr2Kilra1IvjL5rk,8918
10
+ gitd/timestamps.py,sha256=mroL2g5H6WN5gWxSCrtGG-xo5sznqsFrB5-LUYZ2xIY,717
11
+ gitd-0.7.0.dist-info/METADATA,sha256=UMQ1AhNTl2R18LKFZmVe0lVrwTN-WNEd9NWpaVyQu2E,3384
12
+ gitd-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ gitd-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any