Skip to content

Commit babbee9

Browse files
authored
Add a generic python driver (#21)
1 parent 722b5f6 commit babbee9

File tree

20 files changed

+438
-315
lines changed

20 files changed

+438
-315
lines changed

doc/index.html

Lines changed: 20 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,7 +1432,7 @@ <h2>Table of Contents</h2>
14321432

14331433

14341434
<li>
1435-
<a href="#metalstack%2fapi%2fv2%2fmethods.proto">metalstack/api/v2/methods.proto</a>
1435+
<a href="#metalstack%2fapi%2fv2%2fmethod.proto">metalstack/api/v2/method.proto</a>
14361436
<ul>
14371437

14381438
<li>
@@ -2700,10 +2700,9 @@ <h3 id="buf.validate.FieldRules">FieldRules</h3>
27002700

27012701
```proto
27022702
message UpdateRequest {
2703-
// The uri rule only applies if the field is populated and not an empty
2704-
// string.
2705-
optional string url = 1 [
2706-
(buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE,
2703+
// The uri rule only applies if the field is not an empty string.
2704+
string url = 1 [
2705+
(buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE,
27072706
(buf.validate.field).string.uri = true
27082707
];
27092708
}
@@ -3815,21 +3814,6 @@ <h3 id="buf.validate.MessageRules">MessageRules</h3>
38153814
</thead>
38163815
<tbody>
38173816

3818-
<tr>
3819-
<td>disabled</td>
3820-
<td><a href="#bool">bool</a></td>
3821-
<td>optional</td>
3822-
<td><p>`disabled` is a boolean flag that, when set to true, nullifies any validation rules for this message.
3823-
This includes any fields within the message that would otherwise support validation.
3824-
3825-
```proto
3826-
message MyMessage {
3827-
// validation will be bypassed for this message
3828-
option (buf.validate.message).disabled = true;
3829-
}
3830-
``` </p></td>
3831-
</tr>
3832-
38333817
<tr>
38343818
<td>cel</td>
38353819
<td><a href="#buf.validate.Rule">Rule</a></td>
@@ -3874,7 +3858,7 @@ <h3 id="buf.validate.MessageRules">MessageRules</h3>
38743858
silently ignored when unmarshalling, with only the last field being set when
38753859
unmarshalling completes.
38763860

3877-
Note that adding a field to a `oneof` will also set the IGNORE_IF_UNPOPULATED on the fields. This means
3861+
Note that adding a field to a `oneof` will also set the IGNORE_IF_ZERO_VALUE on the fields. This means
38783862
only the field that is set will be validated and the unset fields are not validated according to the field rules.
38793863
This behavior can be overridden by setting `ignore` against a field.
38803864

@@ -6076,77 +6060,22 @@ <h3 id="buf.validate.Ignore">Ignore</h3>
60766060
</tr>
60776061

60786062
<tr>
6079-
<td>IGNORE_IF_UNPOPULATED</td>
6063+
<td>IGNORE_IF_ZERO_VALUE</td>
60806064
<td>1</td>
6081-
<td><p>Ignore rules if the field is unset, also for fields that don&#39;t track
6082-
presence.
6083-
6084-
In proto3, repeated fields, map fields, and fields with scalar types don&#39;t
6085-
track presence. Consequently, the following fields are only validated if
6086-
they are set:
6087-
6088-
```proto
6089-
syntax=&#34;proto3&#34;;
6090-
6091-
message RulesApplyIfSet {
6092-
// `string.email` is ignored for the empty string.
6093-
string link = 1 [
6094-
(buf.validate.field).string.email = true,
6095-
(buf.validate.field).ignore = IGNORE_IF_UNPOPULATED
6096-
];
6097-
// `int32.gte` is ignored for the zero value.
6098-
int32 age = 2 [
6099-
(buf.validate.field).int32.gte = 21,
6100-
(buf.validate.field).ignore = IGNORE_IF_UNPOPULATED
6101-
];
6102-
// `repeated.min_items` is ignored if the list is empty.
6103-
repeated string labels = 3 [
6104-
(buf.validate.field).repeated.min_items = 3,
6105-
(buf.validate.field).ignore = IGNORE_IF_UNPOPULATED
6106-
];
6107-
}
6108-
```
6109-
6110-
For fields that don&#39;t track presence, the field&#39;s value determines
6111-
whether the field is set and rules apply:
6112-
6113-
- For string and bytes, an empty value is ignored.
6114-
- For bool, false is ignored.
6115-
- For numeric types, zero is ignored.
6116-
- For enums, the first defined enum value is ignored.
6117-
- For repeated fields, an empty list is ignored.
6118-
- For map fields, an empty map is ignored.
6119-
- For message fields, absence of the message (typically a null-value) is
6120-
ignored.
6065+
<td><p>Ignore rules if the field is unset, or set to the zero value.
6066+
6067+
The zero value depends on the field type:
6068+
- For strings, the zero value is the empty string.
6069+
- For bytes, the zero value is empty bytes.
6070+
- For bool, the zero value is false.
6071+
- For numeric types, the zero value is zero.
6072+
- For enums, the zero value is the first defined enum value.
6073+
- For repeated fields, the zero is an empty list.
6074+
- For map fields, the zero is an empty map.
6075+
- For message fields, absence of the message (typically a null-value) is considered zero value.
61216076

61226077
For fields that track presence (e.g. adding the `optional` label in proto3),
6123-
behavior is the same as the default `IGNORE_UNSPECIFIED`.
6124-
6125-
To learn which fields track presence, see the
6126-
[Field Presence cheat sheet](https://protobuf.dev/programming-guides/field_presence/#cheat).</p></td>
6127-
</tr>
6128-
6129-
<tr>
6130-
<td>IGNORE_IF_DEFAULT_VALUE</td>
6131-
<td>2</td>
6132-
<td><p>Ignore rules if the field is unset, or set to the default value.
6133-
6134-
The default value depends on the field type:
6135-
- For strings, the default value is the empty string.
6136-
- For bytes, the default value is empty bytes.
6137-
- For bool, the default value is false.
6138-
- For numeric types, the default value is zero.
6139-
- For enums, the default value is the first defined enum value.
6140-
- For repeated fields, the default is an empty list.
6141-
- For map fields, the default is an empty map.
6142-
- For message fields, Protovalidate treats the empty message as the
6143-
default value. All rules of the referenced message are ignored as well.
6144-
6145-
For some fields, the default value can be overridden with the Protobuf
6146-
`default` option.
6147-
6148-
For fields that don&#39;t track presence and don&#39;t have the `default` option,
6149-
behavior is the same as the default `IGNORE_UNSPECIFIED`.</p></td>
6078+
this a no-op and behavior is the same as the default `IGNORE_UNSPECIFIED`.</p></td>
61506079
</tr>
61516080

61526081
<tr>
@@ -6159,7 +6088,7 @@ <h3 id="buf.validate.Ignore">Ignore</h3>
61596088

61606089
```proto
61616090
message MyMessage {
6162-
// The field&#39;s rules will always be ignored, including any validation&#39;s
6091+
// The field&#39;s rules will always be ignored, including any validations
61636092
// on value&#39;s fields.
61646093
MyOtherMessage value = 1 [
61656094
(buf.validate.field).ignore = IGNORE_ALWAYS];
@@ -14163,7 +14092,7 @@ <h3 id="metalstack.api.v2.HealthService">HealthService</h3>
1416314092

1416414093

1416514094
<div class="file-heading">
14166-
<h2 id="metalstack/api/v2/methods.proto">metalstack/api/v2/methods.proto</h2><a href="#title">Top</a>
14095+
<h2 id="metalstack/api/v2/method.proto">metalstack/api/v2/method.proto</h2><a href="#title">Top</a>
1416714096
</div>
1416814097
<p></p>
1416914098

examples/python/ip-list.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,34 @@
11
#!/usr/bin/env python
22

3-
from connecpy.context import ClientContext
4-
from connecpy.exceptions import ConnecpyServerException
53
import os
4+
import sys
5+
6+
from connecpy.exceptions import ConnecpyServerException
67

7-
from metalstack.api.v2 import ip_pb2, ip_connecpy
8+
from metalstack.client import client as apiclient
9+
from metalstack.api.v2 import ip_pb2
10+
from metalstack.admin.v2 import network_pb2
811

912
timeout_s = 5
1013
baseurl = os.environ['METAL_APISERVER_URL']
1114
token = os.environ['API_TOKEN']
1215
project = os.environ['PROJECT_ID']
1316

14-
def main():
15-
with ip_connecpy.IPServiceClient(baseurl, timeout=timeout_s) as client:
16-
try:
17-
response = client.List(
18-
ctx=ClientContext(),
19-
request=ip_pb2.IPServiceListRequest(project=project),
20-
headers={
21-
"Authorization": "Bearer " + token,
22-
}
23-
)
24-
for ip in response.ips:
25-
print(ip.ip, ip.name, ip.project, ip.network)
26-
except ConnecpyServerException as e:
27-
print(e.code, e.message, e.to_dict())
28-
29-
30-
if __name__ == "__main__":
31-
main()
17+
client = apiclient.Client(baseurl=baseurl, token=token, timeout=timeout_s)
18+
19+
try:
20+
resp = client.apiv2().ip().List(request=ip_pb2.IPServiceListRequest(
21+
project=project))
22+
except ConnecpyServerException as e:
23+
print(e.code, e.message, e.to_dict())
24+
sys.exit(1)
25+
26+
27+
for ip in resp.ips:
28+
print(ip.ip, ip.name, ip.project, ip.network)
29+
30+
resp = client.adminv2().network().List(
31+
request=network_pb2.NetworkServiceListRequest())
32+
33+
for nw in resp.networks:
34+
print(nw.id, nw.name, nw.project)

generate/generate.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ var (
3333
mockClientTpl string
3434
//go:embed go_client.tpl
3535
clientTpl string
36+
//go:embed python_client.tpl
37+
pythonClientTpl string
3638
)
3739

3840
type api struct {
@@ -70,6 +72,11 @@ func main() {
7072
if err != nil {
7173
panic(err)
7274
}
75+
76+
err = writePythonTemplate("../python/metalstack/client/client.py", pythonClientTpl, svcs)
77+
if err != nil {
78+
panic(err)
79+
}
7380
}
7481

7582
func servicePermissions(root string) (*permissions.ServicePermissions, error) {
@@ -116,22 +123,17 @@ func servicePermissions(root string) (*permissions.ServicePermissions, error) {
116123
}
117124

118125
for _, filename := range files {
119-
filename := filename
120126
fd, err := protoparser.Parse(filename)
121127
if err != nil {
122128
return nil, err
123129
}
124130
for _, serviceDesc := range fd.GetService() {
125-
serviceDesc := serviceDesc
126131
services = append(services, fmt.Sprintf("%s.%s", *fd.Package, *serviceDesc.Name))
127132
for _, method := range serviceDesc.GetMethod() {
128-
method := method
129133
methodName := fmt.Sprintf("/%s.%s/%s", *fd.Package, *serviceDesc.Name, *method.Name)
130134
methodOpts := method.Options.GetUninterpretedOption()
131135
for _, methodOpt := range methodOpts {
132-
methodOpt := methodOpt
133136
for _, namePart := range methodOpt.Name {
134-
namePart := namePart
135137
if !*namePart.IsExtension {
136138
continue
137139
}
@@ -286,3 +288,18 @@ func writeTemplate(dest, text string, data any) error {
286288

287289
return os.WriteFile(dest, p, 0755) // nolint:gosec
288290
}
291+
func writePythonTemplate(dest, text string, data any) error {
292+
t, err := template.New("").Funcs(sprig.FuncMap()).Parse(text)
293+
if err != nil {
294+
return err
295+
}
296+
297+
var buf bytes.Buffer
298+
if err := t.Execute(&buf, data); err != nil {
299+
return err
300+
}
301+
302+
fmt.Println("wrote " + dest)
303+
304+
return os.WriteFile(dest, buf.Bytes(), 0755) // nolint:gosec
305+
}

generate/python_client.tpl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Code generated by generate.go. DO NOT EDIT.
2+
3+
import httpx
4+
5+
{{ range $name, $api := . -}}
6+
{{ range $svc := $api.Services -}}
7+
import metalstack.{{ $name | trimSuffix "v2" }}.v2.{{ $svc | trimSuffix "Service" | lower }}_connecpy as {{ $name | trimSuffix "v2" }}_{{ $svc | trimSuffix "Service" | lower }}_connecpy
8+
{{ end }}
9+
{{ end }}
10+
11+
class Client:
12+
def __init__(self, baseurl: str, token: str, timeout: int = 10):
13+
self._baseurl = baseurl
14+
15+
headers = {}
16+
if token:
17+
headers["Authorization"] = "Bearer " + token
18+
19+
self._session = httpx.Client(headers=headers, timeout=timeout)
20+
21+
{{ range $name, $api := . }}
22+
def {{ $name | lower }}(self):
23+
return self._{{ $name | title }}(baseurl=self._baseurl, session=self._session)
24+
{{ end }}
25+
26+
{{ range $name, $api := . }}
27+
class _{{ $name | title }}:
28+
def __init__(self, baseurl: str, session=None):
29+
self._baseurl = baseurl
30+
self._session = session
31+
32+
{{ range $svc := $api.Services }}
33+
def {{ $svc | trimSuffix "Service" | lower }}(self):
34+
return {{ $name | trimSuffix "v2" }}_{{ $svc | trimSuffix "Service" | lower }}_connecpy.{{ $svc }}Client(address=self._baseurl, session=self._session)
35+
{{ end }}
36+
{{ end }}

go.mod

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ module github.com/metal-stack/api
33
go 1.24.0
44

55
require (
6-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250625184727-c923a0c2a132.1
7-
buf.build/go/protovalidate v0.13.1
6+
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1
7+
buf.build/go/protovalidate v0.14.0
88
connectrpc.com/connect v1.18.1
99
github.com/bufbuild/protocompile v0.14.1
1010
github.com/go-task/slim-sprig/v3 v3.0.0
11-
github.com/golang-jwt/jwt/v5 v5.2.2
11+
github.com/golang-jwt/jwt/v5 v5.3.0
1212
github.com/google/go-cmp v0.7.0
1313
github.com/klauspost/connect-compress/v2 v2.0.0
1414
github.com/stretchr/testify v1.10.0
@@ -25,10 +25,10 @@ require (
2525
github.com/pmezard/go-difflib v1.0.0 // indirect
2626
github.com/stoewer/go-strcase v1.3.1 // indirect
2727
github.com/stretchr/objx v0.5.2 // indirect
28-
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
28+
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
2929
golang.org/x/text v0.27.0 // indirect
30-
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
31-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
30+
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
31+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
3232
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3333
gopkg.in/yaml.v3 v3.0.1 // indirect
3434
)

go.sum

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250625184727-c923a0c2a132.1 h1:6tCo3lsKNLqUjRPhyc8JuYWYUiQkulufxSDOfG1zgWQ=
2-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250625184727-c923a0c2a132.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
3-
buf.build/go/protovalidate v0.13.1 h1:6loHDTWdY/1qmqmt1MijBIKeN4T9Eajrqb9isT1W1s8=
4-
buf.build/go/protovalidate v0.13.1/go.mod h1:C/QcOn/CjXRn5udUwYBiLs8y1TGy7RS+GOSKqjS77aU=
1+
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
2+
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
3+
buf.build/go/protovalidate v0.14.0 h1:kr/rC/no+DtRyYX+8KXLDxNnI1rINz0imk5K44ZpZ3A=
4+
buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA=
55
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
66
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
77
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
@@ -16,8 +16,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1616
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1717
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
1818
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
19-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
20-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
19+
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
20+
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
2121
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
2222
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
2323
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -50,16 +50,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
5050
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
5151
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
5252
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
53-
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
54-
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
53+
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
54+
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
5555
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
5656
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
5757
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
5858
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
59-
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
60-
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
61-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
62-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
59+
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
60+
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
61+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
62+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
6363
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
6464
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
6565
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

0 commit comments

Comments
 (0)