Merge ppa-dev-tools:add-ppa-credentials-command into ppa-dev-tools:main

Proposed by Bryce Harrington
Status: Merged
Merge reported by: Bryce Harrington
Merged at revision: 3fed215c7ab95c1753c3136e2d278aa1d195a1c4
Proposed branch: ppa-dev-tools:add-ppa-credentials-command
Merge into: ppa-dev-tools:main
Diff against target: 397 lines (+185/-31)
6 files modified
ppa/constants.py (+2/-0)
ppa/lp.py (+37/-16)
scripts/ppa (+77/-10)
tests/helpers.py (+12/-1)
tests/test_lp.py (+4/-4)
tests/test_scripts_ppa.py (+53/-0)
Reviewer Review Type Date Requested Status
Christian Ehrhardt  (community) Approve
PpaDevTools Developers Pending
Canonical Server packageset reviewers Pending
Canonical Server Reporter Pending
Review via email: mp+450981@code.launchpad.net

Description of the change

This adds a 'ppa credentials' command that dumps your Launchpad creds to a file, that can be passed back in via LP_CREDENTIALS.

This enables a workaround for LP: #2022363, but may be handy for any case where either you can't log in via the website, or the automatically stored credentials aren't getting read for some reason.

This is not a complete solution for LP: #2022363, since the snap should be handling this automatically, but that will require separate investigation.

I imagine this command could benefit from some options, like specifying how to name the credentials file, and it might be nice to be able to refer to it via --credentials-file=<filename> instead of via the env variable. But for this first pass I'm keeping it simple, as I just want the functionality available for the release, which I want to get out the door ASAP.

To post a comment you must log in.
Revision history for this message
Christian Ehrhardt  (paelzer) wrote :

A few minor comments and suggestions to improve before merging as we might never look back to it otherwise.

The "allow argument to specify output file" seems so easy that it should IMHO be added.
I mean any snap magic that needs to be research, yes that is later.
But allowing where to store, that should be so easy that we should not hold back on that.

And TBH a global arg (not per subcommand, but generally available) to pass a filename.
"--use-credential" (if no arg given also using the default path) and "--use-credential foo" which would do not more but load that into the environment variable - that also seems rather close. Like <1h of work.
So again, unless you already released by time pressure, please consider adding that right away.

review: Needs Fixing
629848e... by Bryce Harrington

Add a --credentials option to use the stored creds for auth

Modify the Lp class to accept credentials on creation, and refactor the
existing LP_CREDENTIALS envvar handling code to use this instead. This
moves the envvar handling from the ppa.lp module to the ppa script
itself; add a new wrapper create_lp() to encapsulate this and to add
support for loading credentials from a given file, which can be
specified by a newly introduced --credentials global argument.

3fed215... by Bryce Harrington

ppa: Include name of the default filename in help text

Revision history for this message
Bryce Harrington (bryce) wrote :

Unfortunately this was kind of a "pull a loose thread from a sweater" problem, and ended up actually involving a more extensive amount of work than apparent. The credentials handling is pretty early on in the process (obviously) which made it tricky to tweak without breaking other things, but the good news is that the testsuite is extensive and thorough enough now that it was super helpful in spotting problems.

I've refactored the env var handling to be more general purpose, and layered in functionality to allow it to come from the specified file. I opted to name the option --credentials rather than --use-credentials to match the similar option --config.

I had initially hoped to split the review changes out separately to make it easier for you to check, but due to needing to add more fixes it was simpler to just merge all the rework into the main commits. Hopefully you can see what changed via range-diff, although since so much changed it may be worth just doing a fresh review from scratch.

Revision history for this message
Christian Ehrhardt  (paelzer) wrote :

+1 on refactoring the argparse and help text later in a separate PR - just make sure it is not forgotten

This MP LGMT now, the few things that came to my mind were too small to mention or known and can be done in follow up iterations.

review: Approve
Revision history for this message
Bryce Harrington (bryce) wrote :

Thanks, change landed to main:

$ git merge --ff-only add-ppa-credentials-command
Updating 06e6152..20533a7
Fast-forward
 ppa/constants.py | 2 ++
 ppa/lp.py | 53 +++++++++++++++++++++++++++++++++++----------------
 scripts/ppa | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 tests/helpers.py | 13 ++++++++++++-
 tests/test_lp.py | 8 ++++----
 tests/test_scripts_ppa.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 185 insertions(+), 31 deletions(-)
$ git push origin
Enumerating objects: 33, done.
Counting objects: 100% (33/33), done.
Delta compression using up to 12 threads
Compressing objects: 100% (20/20), done.
Writing objects: 100% (23/23), 4.83 KiB | 1.21 MiB/s, done.
Total 23 (delta 17), reused 0 (delta 0), pack-reused 0
To git+ssh://git.launchpad.net/ppa-dev-tools
   06e6152..20533a7 main -> main

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/ppa/constants.py b/ppa/constants.py
2index da05046..4362562 100644
3--- a/ppa/constants.py
4+++ b/ppa/constants.py
5@@ -17,6 +17,8 @@ ARCHES_PPA_ALL = ["amd64", "arm64", "armhf", "i386", "powerpc", "ppc64el", "s390
6 ARCHES_PPA_EXTRA = ["riscv64"]
7 ARCHES_AUTOPKGTEST = ["amd64", "arm64", "armhf", "i386", "ppc64el", "s390x"]
8
9+CREDENTIALS_FILENAME_DEFAULT = "credentials.oauth"
10+
11 URL_LPAPI = "https://api.launchpad.net/devel"
12 URL_AUTOPKGTEST = "https://autopkgtest.ubuntu.com"
13
14diff --git a/ppa/lp.py b/ppa/lp.py
15index c31d4dc..5ede47a 100644
16--- a/ppa/lp.py
17+++ b/ppa/lp.py
18@@ -11,7 +11,6 @@
19
20 """Launchpad Interface."""
21
22-import os
23 from contextlib import suppress
24 from functools import lru_cache
25
26@@ -40,10 +39,34 @@ class Lp:
27
28 _real_instance = None
29
30- def __init__(self, application_name, service=Launchpad, staging=False):
31- """Create a Launchpad service object."""
32+ def __init__(self, application_name, service=Launchpad, staging=False, credentials=None):
33+ """Create a Launchpad service object.
34+
35+ Authentication with Launchpad is done lazily, not at object
36+ initialization but at the point it first needs to actually use
37+ Launchpad functionality. This permits adjustment of the
38+ object's credentials or other properties as needed.
39+
40+ If the `$LP_CREDENTIAL` environment variable is defined, its
41+ contents will be loaded as the credentials to pass to the
42+ Credentials.from_string() function. Stored credentials must
43+ be formatted according to the requirements of launchpadlib's
44+ Credentials class. For more information on this class see:
45+ https://git.launchpad.net/launchpadlib/tree/src/launchpadlib/credentials.py
46+
47+ :param str application_name: The text name of the software using
48+ this class.
49+ :param Launchpad service: The launchpadlib service class or
50+ object to wrapper.
51+ :param bool staging: When true, operate against a test instance
52+ of Launchpad instead of the real one.
53+ :param str credentials: (Optional) Formatted OAuth information
54+ to use when authenticating with Launchpad. If not provided,
55+ will automatically login to Launchpad as needed.
56+ """
57 self._app_name = application_name
58 self._service = service
59+ self._credentials = credentials
60 if staging:
61 self._service_root = 'qastaging'
62 self.ROOT_URL = 'https://qastaging.launchpad.net/'
63@@ -53,18 +76,20 @@ class Lp:
64 else:
65 self._service_root = 'production'
66
67- def _get_instance_from_env(self) -> 'Launchpad | None':
68+ def _get_instance_from_creds(self) -> 'Launchpad | None':
69 """
70- Get an instance of _service using `$LP_CREDENTIAL` environment
71- variable if it is defined, else return None
72+ Get an instance of _service using stored credentials if defined,
73+ else return None.
74+
75+ For more information on Launchpad credentials-based authentication see
76+ https://help.launchpad.net/API/launchpadlib#Authenticated_access_for_website_integration
77
78 :rtype: Launchpad | None
79- :returns: Logged in Launchpad instance if LP_CREDENTIALS is in env
80+ :returns: Logged in Launchpad instance if credentials available,
81 else None
82 """
83- lp_cred = os.getenv("LP_CREDENTIALS")
84- if lp_cred:
85- cred = Credentials.from_string(lp_cred)
86+ if self._credentials:
87+ cred = Credentials.from_string(self._credentials)
88 return self._service(
89 cred, None, None,
90 service_root=self._service_root,
91@@ -77,11 +102,6 @@ class Lp:
92 Prompts the user to authorize the login of a new credential
93 or use the cached one if it is available and valid
94
95- Note: This uses Launchpad.login_with if service is left to
96- default, if you have a credential file you can provide
97- the path to it via the LP_CREDENTIALS_FILE environment
98- variable and this call will pass without prompting the
99- user
100 :rtype: launchpadlib.launchpad.Launchpad
101 :returns: Logged in Launchpad instance
102 """
103@@ -97,7 +117,8 @@ class Lp:
104 """Cache LP object."""
105 if not self._real_instance:
106 self._real_instance = (
107- self._get_instance_from_env() or self._get_instance_from_login()
108+ self._get_instance_from_creds() or
109+ self._get_instance_from_login()
110 )
111 return self._real_instance
112
113diff --git a/scripts/ppa b/scripts/ppa
114index 9dbb15a..12eab8f 100755
115--- a/scripts/ppa
116+++ b/scripts/ppa
117@@ -71,6 +71,7 @@ from ppa.constants import (
118 ARCHES_PPA_ALL,
119 ARCHES_PPA_DEFAULT,
120 ARCHES_AUTOPKGTEST,
121+ CREDENTIALS_FILENAME_DEFAULT,
122 LOCAL_REPOSITORY_PATH,
123 LOCAL_REPOSITORY_MIRRORING_DIRECTIONS,
124 )
125@@ -117,9 +118,14 @@ def add_global_options(parser: argparse.ArgumentParser) -> None:
126
127 :param argparse.ArgumentParser parser: A parser or subparser.
128 """
129+ parser.add_argument('-A', '--credentials',
130+ dest='credentials_filename', action='store',
131+ metavar='FILE',
132+ help="Location of oauth credentials file")
133 parser.add_argument('-C', '--config',
134 dest='config_filename', action='store',
135 default="~/.config/ppa-dev-tools/config.yml",
136+ metavar="FILE",
137 help="Location of config file")
138 parser.add_argument('-D', '--debug',
139 dest='debug', action='store_true',
140@@ -263,6 +269,24 @@ def create_arg_parser() -> argparse.ArgumentParser:
141 help="Person or team to create PPA under, if not specified via the ppa address (defaults to current LP user)")
142 add_basic_config_options(create_parser)
143
144+ # Credentials Command
145+ credentials_parser = subparser.add_parser(
146+ 'credentials',
147+ argument_default=argparse.SUPPRESS,
148+ help=(
149+ "Store the Launchpad credentials to a file for manual use. "
150+ f"(Default: '{CREDENTIALS_FILENAME_DEFAULT}')"
151+ ),
152+ prog=progname,
153+ )
154+ add_global_options(credentials_parser)
155+ credentials_parser.add_argument(
156+ 'ppa_name',
157+ action='store',
158+ nargs='?',
159+ default='me',
160+ help="Name of the PPA (optional)")
161+
162 # Desc Command
163 desc_parser = subparser.add_parser(
164 'desc',
165@@ -399,6 +423,27 @@ def create_arg_parser() -> argparse.ArgumentParser:
166 return parser
167
168
169+def create_lp(app_name: str, args: argparse.Namespace) -> Lp:
170+ """Instantiate the Lp Launchpad Interface object.
171+
172+ Use credentials from the file file specified by --credentials , if
173+ provided. If not, next try to use contents from the LP_CREDENTIALS
174+ environment variable. Last, leave creds undefined, and Lp will
175+ handle website login and credentials caching automatically.
176+
177+ :param argparse.Namespace args: Command line arguments.
178+ :rtype: Lp
179+ :returns: Interface object for accessing the launchpad service.
180+ """
181+ if args.credentials_filename:
182+ with open(args.credentials_filename, 'r') as f:
183+ creds = f.read()
184+ else:
185+ creds = os.getenv("LP_CREDENTIALS")
186+
187+ return Lp(app_name, credentials=creds)
188+
189+
190 DEFAULT_CONFIG = {
191 'debug': False,
192 'ppa_name': None,
193@@ -554,6 +599,28 @@ def command_create(lp: Lp, config: dict[str, str]) -> int:
194 return 1
195
196
197+def command_credentials(lp: Lp, config: dict[str, str]) -> int:
198+ """Saves login credentials to a file.
199+
200+ :param Lp lp: The Launchpad wrapper object.
201+ :param dict[str, str] config: Configuration param:value map.
202+ :rtype: int
203+ :returns: Status code OK (0) on success, non-zero on error.
204+ """
205+ try:
206+ credentials_filename = config.get(
207+ 'credentials_filename',
208+ CREDENTIALS_FILENAME_DEFAULT
209+ )
210+ lp.credentials.save_to_path(credentials_filename)
211+ print(f"Launchpad credentials written to {credentials_filename}")
212+ return os.EX_OK
213+ except KeyboardInterrupt:
214+ return 2
215+ print("Unhandled error")
216+ return 1
217+
218+
219 def command_desc(lp: Lp, config: dict[str, str]) -> int:
220 """Sets the description for a PPA.
221
222@@ -962,14 +1029,15 @@ def command_tests(lp: Lp, config: dict[str, str]) -> int:
223
224
225 COMMANDS = {
226- 'create': (command_create, None),
227- 'desc': (command_desc, None),
228- 'destroy': (command_destroy, None),
229- 'list': (command_list, None),
230- 'set': (command_set, None),
231- 'show': (command_show, None),
232- 'tests': (command_tests, None),
233- 'wait': (command_wait, None),
234+ 'create': (command_create, None),
235+ 'credentials': (command_credentials, None),
236+ 'desc': (command_desc, None),
237+ 'destroy': (command_destroy, None),
238+ 'list': (command_list, None),
239+ 'set': (command_set, None),
240+ 'show': (command_show, None),
241+ 'tests': (command_tests, None),
242+ 'wait': (command_wait, None),
243 }
244
245
246@@ -984,9 +1052,8 @@ def main(args: argparse.Namespace) -> int:
247 error("No command given.")
248 return os.EX_USAGE
249
250- lp = Lp('ppa-dev-tools')
251-
252 try:
253+ lp = create_lp('ppa-dev-tools', args)
254 config = create_config(lp, args)
255 except KeyboardInterrupt:
256 return 2
257diff --git a/tests/helpers.py b/tests/helpers.py
258index 3510b35..63b5756 100644
259--- a/tests/helpers.py
260+++ b/tests/helpers.py
261@@ -12,6 +12,11 @@
262
263 import os
264 import sys
265+from functools import lru_cache
266+
267+from mock import Mock
268+
269+from launchpadlib.credentials import Credentials
270
271 sys.path.insert(0, os.path.realpath(
272 os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))
273@@ -113,8 +118,14 @@ class LpServiceMock:
274 BUGS_ROOT_URL = 'https://bugs.mocklaunchpad.net/'
275 CODE_ROOT_URL = 'https://code.mocklaunchpad.net/'
276
277- def __init__(self):
278+ def __init__(self, credentials=None):
279 self.launchpad = LaunchpadMock()
280+ self._credentials = credentials
281+
282+ @property
283+ @lru_cache
284+ def credentials(self):
285+ return Mock(Credentials)
286
287 @property
288 def me(self):
289diff --git a/tests/test_lp.py b/tests/test_lp.py
290index b863107..d1f2696 100644
291--- a/tests/test_lp.py
292+++ b/tests/test_lp.py
293@@ -76,15 +76,15 @@ def test_new_connection():
294 )
295
296
297-@patch.dict(os.environ, {"LP_CREDENTIALS": "..."})
298 @patch("ppa.lp.Credentials")
299-def test_new_connection_envvar(credentials_mock):
300- """Verifies the Lp object will login via envvar if provided."""
301+def test_new_connection_creds(credentials_mock):
302+ """Verifies the Lp object will login if credentials are provided."""
303 mock_launchpad = Mock(spec=Launchpad)
304
305 lp = Lp(
306 application_name=APPNAME,
307- service=mock_launchpad)
308+ service=mock_launchpad,
309+ credentials='...')
310
311 # Cause the lp._instance() internal property to be triggered
312 logging.debug(lp.me)
313diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py
314index 5eb55f5..0f190a3 100644
315--- a/tests/test_scripts_ppa.py
316+++ b/tests/test_scripts_ppa.py
317@@ -117,6 +117,14 @@ def test_create_arg_parser():
318 args = parser.parse_args(['status', 'test-ppa'])
319 assert args.command == 'status'
320
321+ # Check -A, --credentials
322+ args = parser.parse_args(['-A', 'my-creds', 'status', 'test-ppa'])
323+ assert args.credentials_filename == "my-creds"
324+ args.credentials_filename = None
325+ args = parser.parse_args(['--credentials', 'my-creds', 'status', 'test-ppa'])
326+ assert args.credentials_filename == "my-creds"
327+ args.credentials_filename = None
328+
329 # Check -D, --debug
330 args = parser.parse_args(['-D', 'status', 'test-ppa'])
331 assert args.debug is True
332@@ -156,6 +164,7 @@ def test_create_arg_parser():
333 subparsers.append(choice)
334 assert subparsers == [
335 'create',
336+ 'credentials',
337 'desc',
338 'destroy',
339 'list',
340@@ -485,6 +494,36 @@ def test_create_config_from_args_error(args, expected_exception):
341 script.create_config(lp, args)
342
343
344+def test_create_lp_via_file(tmp_path):
345+ '''Checks loading of credentials provided by --credentials arg.'''
346+ expected_credentials = "[1]\na: 1\nb: 2\n"
347+ credentials_file = tmp_path / 'x'
348+ credentials_file.write_text(expected_credentials)
349+
350+ lp = LpServiceMock()
351+ parser = script.create_arg_parser()
352+ args = parser.parse_args([
353+ '--credentials', str(credentials_file),
354+ 'status', 'test-ppa',
355+ ])
356+ lp = script.create_lp('x', args)
357+
358+ assert lp._credentials == expected_credentials
359+
360+
361+@patch.dict(os.environ, {"LP_CREDENTIALS": "[1]\na: 1\nb: 2\n"})
362+def test_create_lp_via_envvar(tmp_path):
363+ '''Checks loading of credentials provided by $LP_CREDENTIALS.'''
364+ lp = LpServiceMock()
365+ parser = script.create_arg_parser()
366+ args = parser.parse_args([
367+ 'status', 'test-ppa',
368+ ])
369+ lp = script.create_lp('x', args)
370+
371+ assert lp._credentials == os.environ['LP_CREDENTIALS']
372+
373+
374 @pytest.mark.parametrize('stdin, params, expected_ppa_config', [
375 # Defaults
376 (None, {}, {'description': ''}),
377@@ -564,6 +603,20 @@ def test_command_create_with_architectures(monkeypatch, fake_config, architectur
378 assert [proc.name for proc in lp_ppa.processors] == expected_processors
379
380
381+@pytest.mark.parametrize('params, expected_filename', [
382+ ({}, 'credentials.oauth'),
383+ ({'credentials_filename': 'a'}, 'a'),
384+ ({'credentials_filename': '~/.x/creds.oauth'}, '~/.x/creds.oauth'),
385+])
386+def test_command_credentials(fake_config, params, expected_filename):
387+ """Check that credentials command saves LP creds to file."""
388+ lp = LpServiceMock()
389+ config = {**fake_config, **params}
390+
391+ assert script.command_credentials(lp, config) == 0
392+ lp.credentials.save_to_path.assert_called_once()
393+
394+
395 @pytest.mark.xfail(reason="Unimplemented")
396 def test_command_desc(fake_config):
397 lp = LpServiceMock()

Subscribers

People subscribed via source and target branches

to all changes: