1
+ import contextlib
1
2
import functools
3
+ import json
2
4
import os
3
5
import subprocess
4
6
import sys
5
- from typing import Any , Dict , Iterable , List , Optional , Tuple
7
+ from dataclasses import dataclass
8
+ from typing import Any , Dict , Generator , Iterable , List , Optional , Tuple
6
9
7
10
import pytest
8
11
@@ -327,42 +330,98 @@ def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse:
327
330
class KeyringModuleV2 :
328
331
"""Represents the current supported API of keyring"""
329
332
333
+ def __init__ (self ) -> None :
334
+ self .saved_credential_by_username_by_system : dict [
335
+ str , dict [str , KeyringModuleV2 .Credential ]
336
+ ] = {}
337
+
338
+ @dataclass
330
339
class Credential :
331
- def __init__ (self , username : str , password : str ) -> None :
332
- self .username = username
333
- self .password = password
340
+ username : str
341
+ password : str
334
342
335
343
def get_password (self , system : str , username : str ) -> None :
336
344
pytest .fail ("get_password should not ever be called" )
337
345
338
- def get_credential (self , system : str , username : str ) -> Optional [Credential ]:
339
- if system == "http://example.com/path2/" :
340
- return self .Credential ("username" , "url" )
341
- if system == "example.com" :
342
- return self .Credential ("username" , "netloc" )
343
- return None
346
+ def get_credential (
347
+ self , system : str , username : Optional [str ]
348
+ ) -> Optional [Credential ]:
349
+ credential_by_username = self .saved_credential_by_username_by_system .get (
350
+ system , {}
351
+ )
352
+ if username is None :
353
+ # Just return the first cred we can find (if
354
+ # there even are any for this service).
355
+ credentials = list (credential_by_username .values ())
356
+ if len (credentials ) == 0 :
357
+ return None
358
+
359
+ # Just pick the first one we can find.
360
+ credential = credentials [0 ]
361
+ return credential
362
+
363
+ return credential_by_username .get (username )
364
+
365
+ def set_password (self , system : str , username : str , password : str ) -> None :
366
+ if system not in self .saved_credential_by_username_by_system :
367
+ self .saved_credential_by_username_by_system [system ] = {}
368
+
369
+ credential_by_username = self .saved_credential_by_username_by_system [system ]
370
+ assert username not in credential_by_username
371
+ credential_by_username [username ] = self .Credential (username , password )
372
+
373
+ def delete_password (self , system : str , username : str ) -> None :
374
+ del self .saved_credential_by_username_by_system [system ][username ]
375
+
376
+ @contextlib .contextmanager
377
+ def add_credential (
378
+ self , system : str , username : str , password : str
379
+ ) -> Generator [None , None , None ]:
380
+ """
381
+ Context manager that adds the given credential to the keyring
382
+ and yields. Once the yield is done, the credential is removed
383
+ from the keyring.
384
+
385
+ This is re-entrant safe: it's ok for one thread to call this while in
386
+ the middle of an existing invocation
387
+
388
+ This is probably not thread safe: it's not ok for multiple threads to
389
+ simultaneously call this method on the exact same instance of KeyringModuleV2.
390
+ """
391
+ self .set_password (system , username , password )
392
+ try :
393
+ yield
394
+ finally :
395
+ # No matter what happened, make sure we clean up after ourselves.
396
+ self .delete_password (system , username )
344
397
345
398
346
399
@pytest .mark .parametrize (
347
400
"url, expect" ,
348
401
[
349
- ("http://example.com/path1" , ("username" , "netloc " )),
350
- ("http://example.com/path2/path3" , ("username" , "url " )),
351
- ("http://[email protected] /path2/path3" , ("username " , "url" )),
402
+ ("http://example.com/path1" , ("username" , "hunter2 " )),
403
+ ("http://example.com/path2/path3" , ("username" , "hunter3 " )),
404
+ ("http://[email protected] /path2/path3" , ("user2 " , None )),
352
405
],
353
406
)
354
407
def test_keyring_get_credential (
355
408
monkeypatch : pytest .MonkeyPatch , url : str , expect : Tuple [str , str ]
356
409
) -> None :
357
- monkeypatch .setitem (sys .modules , "keyring" , KeyringModuleV2 ())
410
+ keyring = KeyringModuleV2 ()
411
+ monkeypatch .setitem (sys .modules , "keyring" , keyring )
358
412
auth = MultiDomainBasicAuth (
359
413
index_urls = ["http://example.com/path1" , "http://example.com/path2" ],
360
414
keyring_provider = "import" ,
361
415
)
362
416
363
- assert (
364
- auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True ) == expect
365
- )
417
+ with (
418
+ keyring .add_credential ("example.com" , "username" , "hunter2" ),
419
+ keyring .add_credential ("http://example.com/path2/" , "username" , "hunter3" ),
420
+ ):
421
+ assert (
422
+ auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True )
423
+ == expect
424
+ )
366
425
367
426
368
427
class KeyringModuleBroken :
@@ -393,7 +452,7 @@ def test_broken_keyring_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> Non
393
452
assert keyring_broken ._call_count == 1
394
453
395
454
396
- class KeyringSubprocessResult (KeyringModuleV1 ):
455
+ class KeyringSubprocessResult (KeyringModuleV2 ):
397
456
"""Represents the subprocess call to keyring"""
398
457
399
458
returncode = 0 # Default to zero retcode
@@ -408,33 +467,85 @@ def __call__(
408
467
input : Optional [bytes ] = None ,
409
468
check : Optional [bool ] = None ,
410
469
) -> Any :
411
- if cmd [1 ] == "get" :
412
- assert stdin == - 3 # subprocess.DEVNULL
413
- assert stdout == subprocess .PIPE
414
- assert env ["PYTHONIOENCODING" ] == "utf-8"
415
- assert check is None
416
-
417
- password = self .get_password (* cmd [2 :])
418
- if password is None :
419
- # Expect non-zero returncode if no password present
420
- self .returncode = 1
421
- else :
422
- # Passwords are returned encoded with a newline appended
423
- self .returncode = 0
424
- self .stdout = (password + os .linesep ).encode ("utf-8" )
425
-
426
- if cmd [1 ] == "set" :
427
- assert stdin is None
428
- assert stdout is None
429
- assert env ["PYTHONIOENCODING" ] == "utf-8"
430
- assert input is not None
431
- assert check
432
-
433
- # Input from stdin is encoded
434
- self .set_password (cmd [2 ], cmd [3 ], input .decode ("utf-8" ).strip (os .linesep ))
470
+ parsed_cmd = list (cmd )
471
+ assert parsed_cmd .pop (0 ) == "keyring"
472
+ subcommand = [
473
+ arg
474
+ for arg in parsed_cmd
475
+ # Skip past all the --whatever options until we get to the subcommand.
476
+ if not arg .startswith ("--" )
477
+ ][0 ]
478
+ subcommand_func = {
479
+ "get" : self ._get_subcommand ,
480
+ "set" : self ._set_subcommand ,
481
+ }[subcommand ]
482
+
483
+ subcommand_func (
484
+ parsed_cmd ,
485
+ env = env ,
486
+ stdin = stdin ,
487
+ stdout = stdout ,
488
+ input = input ,
489
+ check = check ,
490
+ )
435
491
436
492
return self
437
493
494
+ def _get_subcommand (
495
+ self ,
496
+ cmd : List [str ],
497
+ * ,
498
+ env : Dict [str , str ],
499
+ stdin : Optional [Any ] = None ,
500
+ stdout : Optional [Any ] = None ,
501
+ input : Optional [bytes ] = None ,
502
+ check : Optional [bool ] = None ,
503
+ ) -> None :
504
+ assert cmd .pop (0 ) == "--mode=creds"
505
+ assert cmd .pop (0 ) == "--output=json"
506
+ assert stdin == - 3 # subprocess.DEVNULL
507
+ assert stdout == subprocess .PIPE
508
+ assert env ["PYTHONIOENCODING" ] == "utf-8"
509
+ assert check is None
510
+ assert cmd .pop (0 ) == "get"
511
+
512
+ service = cmd .pop (0 )
513
+ username = cmd .pop (0 ) if len (cmd ) > 0 else None
514
+ creds = self .get_credential (service , username )
515
+ if creds is None :
516
+ # Expect non-zero returncode if no creds present
517
+ self .returncode = 1
518
+ else :
519
+ # Passwords are returned encoded with a newline appended
520
+ self .returncode = 0
521
+ self .stdout = json .dumps (
522
+ {
523
+ "username" : creds .username ,
524
+ "password" : creds .password ,
525
+ }
526
+ ).encode ("utf-8" )
527
+
528
+ def _set_subcommand (
529
+ self ,
530
+ cmd : List [str ],
531
+ * ,
532
+ env : Dict [str , str ],
533
+ stdin : Optional [Any ] = None ,
534
+ stdout : Optional [Any ] = None ,
535
+ input : Optional [bytes ] = None ,
536
+ check : Optional [bool ] = None ,
537
+ ) -> None :
538
+ assert cmd .pop (0 ) == "set"
539
+ assert stdin is None
540
+ assert stdout is None
541
+ assert env ["PYTHONIOENCODING" ] == "utf-8"
542
+ assert input is not None
543
+ assert check
544
+
545
+ # Input from stdin is encoded
546
+ system , username = cmd
547
+ self .set_password (system , username , input .decode ("utf-8" ).strip (os .linesep ))
548
+
438
549
def check_returncode (self ) -> None :
439
550
if self .returncode :
440
551
raise Exception ()
@@ -443,31 +554,42 @@ def check_returncode(self) -> None:
443
554
@pytest .mark .parametrize (
444
555
"url, expect" ,
445
556
[
446
- ("http://example.com/path1" , (None , None )),
447
- # path1 URLs will be resolved by netloc
448
- ("http://[email protected] /path3" , ("user" , "user!netloc" )),
449
- ("http://[email protected] /path3" , ("user2" , "user2!netloc" )),
450
- # path2 URLs will be resolved by index URL
451
- ("http://example.com/path2/path3" , (None , None )),
452
- ("http://[email protected] /path2/path3" , ("foo" , "foo!url" )),
557
+ # It's not obvious, but this url ultimately resolves to index url
558
+ # http://example.com/path2, so we get the creds for that index.
559
+ ("http://example.com/path1" , ("saved-user1" , "pw1" )),
560
+ ("http://[email protected] /path2" , ("saved-user1" , "pw1" )),
561
+ ("http://[email protected] /path2" , ("saved-user2" , "pw2" )),
562
+ ("http://[email protected] /path2" , ("new-user" , None )),
563
+ ("http://example.com/path2/path3" , ("saved-user1" , "pw1" )),
564
+ ("http://[email protected] /path2/path3" , ("foo" , None )),
453
565
],
454
566
)
455
567
def test_keyring_cli_get_password (
456
568
monkeypatch : pytest .MonkeyPatch ,
457
569
url : str ,
458
570
expect : Tuple [Optional [str ], Optional [str ]],
459
571
) -> None :
572
+ keyring_subprocess = KeyringSubprocessResult ()
460
573
monkeypatch .setattr (pip ._internal .network .auth .shutil , "which" , lambda x : "keyring" )
461
574
monkeypatch .setattr (
462
- pip ._internal .network .auth .subprocess , "run" , KeyringSubprocessResult ()
575
+ pip ._internal .network .auth .subprocess , "run" , keyring_subprocess
463
576
)
464
577
auth = MultiDomainBasicAuth (
465
578
index_urls = ["http://example.com/path2" , "http://example.com/path3" ],
466
579
keyring_provider = "subprocess" ,
467
580
)
468
581
469
- actual = auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True )
470
- assert actual == expect
582
+ with (
583
+ keyring_subprocess .add_credential ("example.com" , "example" , "!netloc" ),
584
+ keyring_subprocess .add_credential (
585
+ "http://example.com/path2/" , "saved-user1" , "pw1"
586
+ ),
587
+ keyring_subprocess .add_credential (
588
+ "http://example.com/path2/" , "saved-user2" , "pw2"
589
+ ),
590
+ ):
591
+ actual = auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True )
592
+ assert actual == expect
471
593
472
594
473
595
@pytest .mark .parametrize (
@@ -492,13 +614,14 @@ def test_keyring_cli_set_password(
492
614
creds : Tuple [str , str , bool ],
493
615
expect_save : bool ,
494
616
) -> None :
617
+ expected_username , expected_password , save = creds
495
618
monkeypatch .setattr (pip ._internal .network .auth .shutil , "which" , lambda x : "keyring" )
496
619
keyring = KeyringSubprocessResult ()
497
620
monkeypatch .setattr (pip ._internal .network .auth .subprocess , "run" , keyring )
498
621
auth = MultiDomainBasicAuth (prompting = True , keyring_provider = "subprocess" )
499
622
monkeypatch .setattr (auth , "_get_url_and_credentials" , lambda u : (u , None , None ))
500
623
monkeypatch .setattr (auth , "_prompt_for_password" , lambda * a : creds )
501
- if creds [ 2 ] :
624
+ if save :
502
625
# when _prompt_for_password indicates to save, we should save
503
626
def should_save_password_to_keyring (* a : Any ) -> bool :
504
627
return True
@@ -535,6 +658,12 @@ def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse:
535
658
auth .handle_401 (resp )
536
659
537
660
if expect_save :
538
- assert keyring .saved_passwords == [("example.com" , creds [0 ], creds [1 ])]
661
+ assert keyring .saved_credential_by_username_by_system == {
662
+ "example.com" : {
663
+ expected_username : KeyringModuleV2 .Credential (
664
+ expected_username , expected_password
665
+ ),
666
+ },
667
+ }
539
668
else :
540
- assert keyring .saved_passwords == []
669
+ assert keyring .saved_credential_by_username_by_system == {}
0 commit comments