Merge ~cjwatson/lpci:release-multi-arch into lpci:main

Proposed by Colin Watson
Status: Merged
Merged at revision: 1dd9ae1b0bee3e802a3fe7983f3fff1b361d215c
Proposed branch: ~cjwatson/lpci:release-multi-arch
Merge into: lpci:main
Diff against target: 606 lines (+294/-37)
8 files modified
.mypy.ini (+1/-1)
NEWS.rst (+7/-0)
docs/cli-interface.rst (+3/-0)
lpci/commands/release.py (+47/-8)
lpci/commands/tests/filter-wadl.py (+1/-1)
lpci/commands/tests/launchpad-wadl.xml (+27/-20)
lpci/commands/tests/test_release.py (+207/-7)
setup.cfg (+1/-0)
Reviewer Review Type Date Requested Status
Simone Pelosi Approve
Review via email: mp+452398@code.launchpad.net

Commit message

Release the latest build of each architecture

Description of the change

`lpci release` incorrectly released the latest build regardless of architecture; this approach was OK when builds were typically only dispatched for a single architecture, but now that we need to handle releasing for (e.g.) both amd64 and arm64, it doesn't work so well. Release the latest build for each architecture for which builds exist instead, and add a new `--architecture` option for the case where people want to override this behaviour and only release the latest build for a single architecture.

To post a comment you must log in.
Revision history for this message
Simone Pelosi (pelpsi) wrote :

LGTM! Thank you Colin for this fix

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.mypy.ini b/.mypy.ini
2index 2d0ab77..d749745 100644
3--- a/.mypy.ini
4+++ b/.mypy.ini
5@@ -6,7 +6,7 @@ disallow_subclassing_any = false
6 disallow_untyped_calls = false
7 disallow_untyped_defs = false
8
9-[mypy-fixtures.*,launchpadlib.*,systemfixtures.*,testtools.*,pluggy.*,wadllib.*]
10+[mypy-fixtures.*,launchpadlib.*,lazr.restfulclient.*,systemfixtures.*,testtools.*,pluggy.*,wadllib.*]
11 ignore_missing_imports = true
12
13 [mypy-craft_cli.*]
14diff --git a/NEWS.rst b/NEWS.rst
15index 8c9132a..2748244 100644
16--- a/NEWS.rst
17+++ b/NEWS.rst
18@@ -2,6 +2,13 @@
19 Version history
20 ===============
21
22+0.2.4 (unreleased)
23+==================
24+
25+- Fix ``lpci release`` to release the latest build of each architecture (or
26+ a single architecture selected by the new ``--architecture`` option),
27+ rather than only releasing the latest build regardless of architecture.
28+
29 0.2.3 (2023-07-20)
30 ==================
31
32diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst
33index bab45d1..eb0fafc 100644
34--- a/docs/cli-interface.rst
35+++ b/docs/cli-interface.rst
36@@ -135,3 +135,6 @@ lpci release optional arguments
37 - ``--commit ID`` to specify the source Git branch name, tag name, or commit
38 ID (defaults to the tip commit found for the current branch in the
39 upstream repository).
40+
41+- ``--architecture NAME`` to only release the builds for this architecture
42+ (defaults to the latest build for each built architecture).
43diff --git a/lpci/commands/release.py b/lpci/commands/release.py
44index d51e239..fd523eb 100644
45--- a/lpci/commands/release.py
46+++ b/lpci/commands/release.py
47@@ -3,11 +3,14 @@
48
49 import re
50 from argparse import ArgumentParser, Namespace
51+from collections import defaultdict
52 from operator import attrgetter
53+from typing import Dict, List
54 from urllib.parse import urlparse
55
56 from craft_cli import BaseCommand, emit
57 from launchpadlib.launchpad import Launchpad
58+from lazr.restfulclient.resource import Entry
59
60 from lpci.errors import CommandError
61 from lpci.git import get_current_branch, get_current_remote_url
62@@ -52,13 +55,21 @@ class ReleaseCommand(BaseCommand):
63 ),
64 )
65 parser.add_argument(
66+ "-a",
67+ "--architecture",
68+ help=(
69+ "Only release the latest build for this architecture "
70+ "(defaults to the latest build for each built architecture)"
71+ ),
72+ )
73+ parser.add_argument(
74 "archive", help="Target archive, e.g. ppa:OWNER/DISTRIBUTION/NAME"
75 )
76 parser.add_argument("suite", help="Target suite, e.g. focal")
77 parser.add_argument("channel", help="Target channel, e.g. edge")
78
79- def run(self, args: Namespace) -> int:
80- """Run the command."""
81+ def _check_args(self, args: Namespace) -> None:
82+ """Check and process arguments."""
83 if args.repository is None:
84 current_remote_url = get_current_remote_url()
85 if current_remote_url is None:
86@@ -86,9 +97,9 @@ class ReleaseCommand(BaseCommand):
87 "branch."
88 )
89
90- launchpad = Launchpad.login_with(
91- "lpci", args.launchpad_instance, version="devel"
92- )
93+ def _find_builds(
94+ self, launchpad: Launchpad, args: Namespace
95+ ) -> Dict[str, List[Entry]]:
96 repository = launchpad.git_repositories.getByPath(path=args.repository)
97 if repository is None:
98 raise CommandError(
99@@ -107,6 +118,10 @@ class ReleaseCommand(BaseCommand):
100 report.ci_build
101 for report in reports
102 if report.ci_build is not None
103+ and (
104+ args.architecture is None
105+ or report.ci_build.arch_tag == args.architecture
106+ )
107 and report.ci_build.buildstate == "Successfully built"
108 and report.getArtifactURLs(artifact_type="Binary")
109 ]
110@@ -115,20 +130,44 @@ class ReleaseCommand(BaseCommand):
111 f"{args.repository}:{args.commit} has no completed CI "
112 f"builds with attached files."
113 )
114- latest_build = sorted(builds, key=attrgetter("datebuilt"))[-1]
115+ builds_by_arch = defaultdict(list)
116+ for build in builds:
117+ builds_by_arch[build.arch_tag].append(build)
118+ return builds_by_arch
119+
120+ def _release_build(
121+ self, launchpad: Launchpad, build: Entry, args: Namespace
122+ ) -> None:
123 archive = launchpad.archives.getByReference(reference=args.archive)
124 description = (
125- f"build of {args.repository}:{args.commit} to "
126+ f"{build.arch_tag} build of {args.repository}:{args.commit} to "
127 f"{args.archive} {args.suite} {args.channel}"
128 )
129 if args.dry_run:
130 emit.message(f"Would release {description}.")
131 else:
132 archive.uploadCIBuild(
133- ci_build=latest_build,
134+ ci_build=build,
135 to_series=args.suite,
136 to_pocket="Release",
137 to_channel=args.channel,
138 )
139 emit.message(f"Released {description}.")
140+
141+ def run(self, args: Namespace) -> int:
142+ """Run the command."""
143+ self._check_args(args)
144+ launchpad = Launchpad.login_with(
145+ "lpci", args.launchpad_instance, version="devel"
146+ )
147+ builds_by_arch = self._find_builds(launchpad, args)
148+ if args.architecture is not None:
149+ arch_tags = [args.architecture]
150+ else:
151+ arch_tags = sorted(builds_by_arch)
152+ for arch_tag in arch_tags:
153+ latest_build = sorted(
154+ builds_by_arch[arch_tag], key=attrgetter("datebuilt")
155+ )[-1]
156+ self._release_build(launchpad, latest_build, args)
157 return 0
158diff --git a/lpci/commands/tests/filter-wadl.py b/lpci/commands/tests/filter-wadl.py
159index 91ddfbc..89705f4 100755
160--- a/lpci/commands/tests/filter-wadl.py
161+++ b/lpci/commands/tests/filter-wadl.py
162@@ -23,7 +23,7 @@ keep_collections = {
163
164 keep_entries = {
165 "archive": ["uploadCIBuild"],
166- "ci_build": ["buildstate", "datebuilt", "get"],
167+ "ci_build": ["arch_tag", "buildstate", "datebuilt", "get"],
168 "git_ref": ["commit_sha1", "get"],
169 "git_repository": ["getRefByPath", "getStatusReports"],
170 "revision_status_report": [
171diff --git a/lpci/commands/tests/launchpad-wadl.xml b/lpci/commands/tests/launchpad-wadl.xml
172index 836fdd6..d61b793 100644
173--- a/lpci/commands/tests/launchpad-wadl.xml
174+++ b/lpci/commands/tests/launchpad-wadl.xml
175@@ -263,6 +263,12 @@ A build record for a pipeline of CI jobs.
176 The value of the HTTP ETag for this resource.
177 </wadl:doc>
178 </wadl:param>
179+ <wadl:param style="plain" required="true" name="arch_tag" path="$['arch_tag']">
180+ <wadl:doc>
181+Architecture tag
182+</wadl:doc>
183+
184+ </wadl:param>
185 <wadl:param style="plain" required="true" name="buildstate" path="$['buildstate']">
186 <wadl:doc>
187 <html:p>Status</html:p>
188@@ -281,6 +287,7 @@ A build record for a pipeline of CI jobs.
189 <wadl:option value="Uploading build" />
190 <wadl:option value="Cancelling build" />
191 <wadl:option value="Cancelled build" />
192+ <wadl:option value="Gathering build output" />
193 </wadl:param>
194 <wadl:param style="plain" required="true" name="datebuilt" path="$['datebuilt']" type="xsd:dateTime">
195 <wadl:doc>
196@@ -425,29 +432,27 @@ A reference in a Git repository.
197 <wadl:doc>
198 A Git repository.
199 </wadl:doc>
200- <wadl:method id="git_repository-getStatusReports" name="GET">
201+ <wadl:method id="git_repository-getRefByPath" name="GET">
202 <wadl:doc>
203-<html:p>Retrieves the list of reports that exist for a commit.</html:p>
204-<html:blockquote>
205+<html:p>Look up a single reference in this repository by path.</html:p>
206 <html:table class="rst-docutils field-list" frame="void" rules="none">
207 <html:col class="field-name" />
208 <html:col class="field-body" />
209 <html:tbody valign="top">
210-<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param commit_sha1:</html:th></html:tr>
211-<html:tr class="rst-field"><html:td>\&#160;</html:td><html:td class="rst-field-body">The commit sha1 for the report.</html:td>
212+<html:tr class="rst-field"><html:th class="rst-field-name">param path:</html:th><html:td class="rst-field-body">A string to look up as a path.</html:td>
213+</html:tr>
214+<html:tr class="rst-field"><html:th class="rst-field-name">return:</html:th><html:td class="rst-field-body">An IGitRef, or None.</html:td>
215 </html:tr>
216 </html:tbody>
217 </html:table>
218-</html:blockquote>
219-<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
220
221 </wadl:doc>
222 <wadl:request>
223
224- <wadl:param style="query" name="ws.op" required="true" fixed="getStatusReports" />
225- <wadl:param style="query" name="commit_sha1" required="true">
226+ <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
227+ <wadl:param style="query" name="path" required="true">
228 <wadl:doc>
229-The Git commit for which this report is built.
230+A string to look up as a path.
231 </wadl:doc>
232
233 </wadl:param>
234@@ -455,30 +460,32 @@ The Git commit for which this report is built.
235 </wadl:request>
236 <wadl:response>
237
238- <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page" />
239+ <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full" />
240 </wadl:response>
241 </wadl:method>
242- <wadl:method id="git_repository-getRefByPath" name="GET">
243+ <wadl:method id="git_repository-getStatusReports" name="GET">
244 <wadl:doc>
245-<html:p>Look up a single reference in this repository by path.</html:p>
246+<html:p>Retrieves the list of reports that exist for a commit.</html:p>
247+<html:blockquote>
248 <html:table class="rst-docutils field-list" frame="void" rules="none">
249 <html:col class="field-name" />
250 <html:col class="field-body" />
251 <html:tbody valign="top">
252-<html:tr class="rst-field"><html:th class="rst-field-name">param path:</html:th><html:td class="rst-field-body">A string to look up as a path.</html:td>
253-</html:tr>
254-<html:tr class="rst-field"><html:th class="rst-field-name">return:</html:th><html:td class="rst-field-body">An IGitRef, or None.</html:td>
255+<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param commit_sha1:</html:th></html:tr>
256+<html:tr class="rst-field"><html:td>\&#160;</html:td><html:td class="rst-field-body">The commit sha1 for the report.</html:td>
257 </html:tr>
258 </html:tbody>
259 </html:table>
260+</html:blockquote>
261+<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
262
263 </wadl:doc>
264 <wadl:request>
265
266- <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
267- <wadl:param style="query" name="path" required="true">
268+ <wadl:param style="query" name="ws.op" required="true" fixed="getStatusReports" />
269+ <wadl:param style="query" name="commit_sha1" required="true">
270 <wadl:doc>
271-A string to look up as a path.
272+The Git commit for which this report is built.
273 </wadl:doc>
274
275 </wadl:param>
276@@ -486,7 +493,7 @@ A string to look up as a path.
277 </wadl:request>
278 <wadl:response>
279
280- <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full" />
281+ <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page" />
282 </wadl:response>
283 </wadl:method>
284 </wadl:resource_type>
285diff --git a/lpci/commands/tests/test_release.py b/lpci/commands/tests/test_release.py
286index 96649f4..46b5883 100644
287--- a/lpci/commands/tests/test_release.py
288+++ b/lpci/commands/tests/test_release.py
289@@ -269,6 +269,7 @@ class TestRelease(CommandBaseTestCase):
290 "entries": [
291 {
292 "ci_build": {
293+ "arch_tag": "amd64",
294 "buildstate": "Successfully built",
295 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
296 },
297@@ -301,7 +302,7 @@ class TestRelease(CommandBaseTestCase):
298 MatchesStructure.byEquality(
299 exit_code=0,
300 messages=[
301- f"Would release build of example:{commit_sha1} to "
302+ f"Would release amd64 build of example:{commit_sha1} to "
303 f"ppa:owner/ubuntu/name focal edge."
304 ],
305 ),
306@@ -318,6 +319,7 @@ class TestRelease(CommandBaseTestCase):
307 "entries": [
308 {
309 "ci_build": {
310+ "arch_tag": "amd64",
311 "buildstate": "Successfully built",
312 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
313 },
314@@ -325,6 +327,7 @@ class TestRelease(CommandBaseTestCase):
315 },
316 {
317 "ci_build": {
318+ "arch_tag": "amd64",
319 "buildstate": "Successfully built",
320 "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
321 },
322@@ -356,7 +359,7 @@ class TestRelease(CommandBaseTestCase):
323 MatchesStructure.byEquality(
324 exit_code=0,
325 messages=[
326- f"Released build of example:{commit_sha1} to "
327+ f"Released amd64 build of example:{commit_sha1} to "
328 f"ppa:owner/ubuntu/name focal edge."
329 ],
330 ),
331@@ -366,8 +369,9 @@ class TestRelease(CommandBaseTestCase):
332 upload,
333 MatchesListwise(
334 [
335- MatchesStructure(
336- datebuilt=Equals(datetime(2022, 1, 1, 12, 0, 0))
337+ MatchesStructure.byEquality(
338+ arch_tag="amd64",
339+ datebuilt=datetime(2022, 1, 1, 12, 0, 0),
340 ),
341 Equals("focal"),
342 Equals("Release"),
343@@ -385,6 +389,7 @@ class TestRelease(CommandBaseTestCase):
344 "entries": [
345 {
346 "ci_build": {
347+ "arch_tag": "amd64",
348 "buildstate": "Successfully built",
349 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
350 },
351@@ -392,6 +397,7 @@ class TestRelease(CommandBaseTestCase):
352 },
353 {
354 "ci_build": {
355+ "arch_tag": "amd64",
356 "buildstate": "Successfully built",
357 "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
358 },
359@@ -423,7 +429,7 @@ class TestRelease(CommandBaseTestCase):
360 MatchesStructure.byEquality(
361 exit_code=0,
362 messages=[
363- f"Released build of example:{commit_sha1} to "
364+ f"Released amd64 build of example:{commit_sha1} to "
365 f"ppa:owner/ubuntu/name focal edge."
366 ],
367 ),
368@@ -442,6 +448,7 @@ class TestRelease(CommandBaseTestCase):
369 "entries": [
370 {
371 "ci_build": {
372+ "arch_tag": "amd64",
373 "buildstate": "Successfully built",
374 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
375 },
376@@ -469,7 +476,7 @@ class TestRelease(CommandBaseTestCase):
377 MatchesStructure.byEquality(
378 exit_code=0,
379 messages=[
380- f"Released build of example:{commit_sha1} to "
381+ f"Released amd64 build of example:{commit_sha1} to "
382 f"ppa:owner/ubuntu/name focal edge."
383 ],
384 ),
385@@ -488,6 +495,7 @@ class TestRelease(CommandBaseTestCase):
386 "entries": [
387 {
388 "ci_build": {
389+ "arch_tag": "amd64",
390 "buildstate": "Successfully built",
391 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
392 },
393@@ -515,8 +523,200 @@ class TestRelease(CommandBaseTestCase):
394 MatchesStructure.byEquality(
395 exit_code=0,
396 messages=[
397- f"Released build of example:{commit_sha1} to "
398+ f"Released amd64 build of example:{commit_sha1} to "
399 f"ppa:owner/ubuntu/name focal edge."
400 ],
401 ),
402 )
403+
404+ def test_release_multiple_architectures(self):
405+ lp = self.make_fake_launchpad()
406+ commit_sha1 = "1" * 40
407+ lp.git_repositories = {
408+ "getByPath": lambda path: {
409+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
410+ "getStatusReports": lambda commit_sha1: {
411+ "entries": [
412+ {
413+ "ci_build": {
414+ "arch_tag": "amd64",
415+ "buildstate": "Successfully built",
416+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
417+ },
418+ "getArtifactURLs": lambda artifact_type: ["url"],
419+ },
420+ {
421+ "ci_build": {
422+ "arch_tag": "arm64",
423+ "buildstate": "Successfully built",
424+ "datebuilt": datetime(2022, 1, 1, 1, 0, 0),
425+ },
426+ "getArtifactURLs": lambda artifact_type: ["url"],
427+ },
428+ {
429+ "ci_build": {
430+ "arch_tag": "amd64",
431+ "buildstate": "Successfully built",
432+ "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
433+ },
434+ "getArtifactURLs": lambda artifact_type: ["url"],
435+ },
436+ {
437+ "ci_build": {
438+ "arch_tag": "arm64",
439+ "buildstate": "Successfully built",
440+ "datebuilt": datetime(2022, 1, 1, 13, 0, 0),
441+ },
442+ "getArtifactURLs": lambda artifact_type: ["url"],
443+ },
444+ ]
445+ },
446+ }
447+ }
448+ lp.archives = {
449+ "getByReference": lambda reference: {
450+ "uploadCIBuild": self.fake_upload
451+ }
452+ }
453+
454+ result = self.run_command(
455+ "release",
456+ "--repository",
457+ "example",
458+ "--commit",
459+ "branch",
460+ "ppa:owner/ubuntu/name",
461+ "focal",
462+ "edge",
463+ )
464+
465+ self.assertThat(
466+ result,
467+ MatchesStructure.byEquality(
468+ exit_code=0,
469+ messages=[
470+ f"Released amd64 build of example:{commit_sha1} to "
471+ f"ppa:owner/ubuntu/name focal edge.",
472+ f"Released arm64 build of example:{commit_sha1} to "
473+ f"ppa:owner/ubuntu/name focal edge.",
474+ ],
475+ ),
476+ )
477+ self.assertThat(
478+ self.uploads,
479+ MatchesListwise(
480+ [
481+ MatchesListwise(
482+ [
483+ MatchesStructure.byEquality(
484+ arch_tag="amd64",
485+ datebuilt=datetime(2022, 1, 1, 12, 0, 0),
486+ ),
487+ Equals("focal"),
488+ Equals("Release"),
489+ Equals("edge"),
490+ ]
491+ ),
492+ MatchesListwise(
493+ [
494+ MatchesStructure.byEquality(
495+ arch_tag="arm64",
496+ datebuilt=datetime(2022, 1, 1, 13, 0, 0),
497+ ),
498+ Equals("focal"),
499+ Equals("Release"),
500+ Equals("edge"),
501+ ],
502+ ),
503+ ]
504+ ),
505+ )
506+
507+ def test_release_select_single_architecture(self):
508+ lp = self.make_fake_launchpad()
509+ commit_sha1 = "1" * 40
510+ lp.git_repositories = {
511+ "getByPath": lambda path: {
512+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
513+ "getStatusReports": lambda commit_sha1: {
514+ "entries": [
515+ {
516+ "ci_build": {
517+ "arch_tag": "amd64",
518+ "buildstate": "Successfully built",
519+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
520+ },
521+ "getArtifactURLs": lambda artifact_type: ["url"],
522+ },
523+ {
524+ "ci_build": {
525+ "arch_tag": "arm64",
526+ "buildstate": "Successfully built",
527+ "datebuilt": datetime(2022, 1, 1, 1, 0, 0),
528+ },
529+ "getArtifactURLs": lambda artifact_type: ["url"],
530+ },
531+ {
532+ "ci_build": {
533+ "arch_tag": "amd64",
534+ "buildstate": "Successfully built",
535+ "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
536+ },
537+ "getArtifactURLs": lambda artifact_type: ["url"],
538+ },
539+ {
540+ "ci_build": {
541+ "arch_tag": "arm64",
542+ "buildstate": "Successfully built",
543+ "datebuilt": datetime(2022, 1, 1, 13, 0, 0),
544+ },
545+ "getArtifactURLs": lambda artifact_type: ["url"],
546+ },
547+ ]
548+ },
549+ }
550+ }
551+ lp.archives = {
552+ "getByReference": lambda reference: {
553+ "uploadCIBuild": self.fake_upload
554+ }
555+ }
556+
557+ result = self.run_command(
558+ "release",
559+ "--repository",
560+ "example",
561+ "--commit",
562+ "branch",
563+ "--architecture",
564+ "amd64",
565+ "ppa:owner/ubuntu/name",
566+ "focal",
567+ "edge",
568+ )
569+
570+ self.assertThat(
571+ result,
572+ MatchesStructure.byEquality(
573+ exit_code=0,
574+ messages=[
575+ f"Released amd64 build of example:{commit_sha1} to "
576+ f"ppa:owner/ubuntu/name focal edge.",
577+ ],
578+ ),
579+ )
580+ [upload] = self.uploads
581+ self.assertThat(
582+ upload,
583+ MatchesListwise(
584+ [
585+ MatchesStructure.byEquality(
586+ arch_tag="amd64",
587+ datebuilt=datetime(2022, 1, 1, 12, 0, 0),
588+ ),
589+ Equals("focal"),
590+ Equals("Release"),
591+ Equals("edge"),
592+ ]
593+ ),
594+ )
595diff --git a/setup.cfg b/setup.cfg
596index 803ab01..1fe623d 100644
597--- a/setup.cfg
598+++ b/setup.cfg
599@@ -28,6 +28,7 @@ install_requires =
600 craft-providers
601 jinja2
602 launchpadlib[keyring]
603+ lazr.restfulclient
604 pluggy
605 pydantic
606 python-dotenv

Subscribers

People subscribed via source and target branches