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.
- {gitd-0.7.0 → gitd-0.9.0}/PKG-INFO +1 -1
- {gitd-0.7.0 → gitd-0.9.0}/pyproject.toml +1 -1
- gitd-0.9.0/src/gitd/__init__.py +86 -0
- gitd-0.9.0/src/gitd/changes.py +77 -0
- gitd-0.9.0/src/gitd/client.py +238 -0
- gitd-0.9.0/src/gitd/comparison.py +111 -0
- gitd-0.9.0/src/gitd/connection.py +128 -0
- gitd-0.9.0/src/gitd/contract.json +3113 -0
- gitd-0.9.0/src/gitd/credentials_calls.py +69 -0
- gitd-0.9.0/src/gitd/endpoint_calls.py +52 -0
- gitd-0.9.0/src/gitd/endpoint_tokens.py +55 -0
- gitd-0.9.0/src/gitd/kept_credentials.py +107 -0
- gitd-0.9.0/src/gitd/messages.py +92 -0
- gitd-0.9.0/src/gitd/methods.py +58 -0
- {gitd-0.7.0 → gitd-0.9.0}/src/gitd/models.py +56 -6
- gitd-0.9.0/src/gitd/publishing_calls.py +47 -0
- gitd-0.9.0/src/gitd/publishing_settings.py +211 -0
- gitd-0.9.0/src/gitd/warm_layers.py +34 -0
- gitd-0.7.0/src/gitd/__init__.py +0 -42
- gitd-0.7.0/src/gitd/client.py +0 -124
- gitd-0.7.0/src/gitd/connection.py +0 -71
- gitd-0.7.0/src/gitd/contract.json +0 -1291
- gitd-0.7.0/src/gitd/messages.py +0 -30
- gitd-0.7.0/src/gitd/methods.py +0 -32
- {gitd-0.7.0 → gitd-0.9.0}/.gitignore +0 -0
- {gitd-0.7.0 → gitd-0.9.0}/README.md +0 -0
- {gitd-0.7.0 → gitd-0.9.0}/examples/agent.py +0 -0
- {gitd-0.7.0 → gitd-0.9.0}/src/gitd/contract.py +0 -0
- {gitd-0.7.0 → gitd-0.9.0}/src/gitd/errors.py +0 -0
- {gitd-0.7.0 → gitd-0.9.0}/src/gitd/timestamps.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitd
|
|
3
|
-
Version: 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
|
|
@@ -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")
|