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,96 @@ 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 keyring .add_credential ("example.com" , "username" , "hunter2" ):
418
+ with keyring .add_credential ("http://example.com/path2/" , "username" , "hunter3" ):
419
+ assert (
420
+ auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True )
421
+ == expect
422
+ )
366
423
367
424
368
425
class KeyringModuleBroken :
@@ -393,7 +450,7 @@ def test_broken_keyring_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> Non
393
450
assert keyring_broken ._call_count == 1
394
451
395
452
396
- class KeyringSubprocessResult (KeyringModuleV1 ):
453
+ class KeyringSubprocessResult (KeyringModuleV2 ):
397
454
"""Represents the subprocess call to keyring"""
398
455
399
456
returncode = 0 # Default to zero retcode
@@ -408,33 +465,85 @@ def __call__(
408
465
input : Optional [bytes ] = None ,
409
466
check : Optional [bool ] = None ,
410
467
) -> 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 ))
468
+ parsed_cmd = list (cmd )
469
+ assert parsed_cmd .pop (0 ) == "keyring"
470
+ subcommand = [
471
+ arg
472
+ for arg in parsed_cmd
473
+ # Skip past all the --whatever options until we get to the subcommand.
474
+ if not arg .startswith ("--" )
475
+ ][0 ]
476
+ subcommand_func = {
477
+ "get" : self ._get_subcommand ,
478
+ "set" : self ._set_subcommand ,
479
+ }[subcommand ]
480
+
481
+ subcommand_func (
482
+ parsed_cmd ,
483
+ env = env ,
484
+ stdin = stdin ,
485
+ stdout = stdout ,
486
+ input = input ,
487
+ check = check ,
488
+ )
435
489
436
490
return self
437
491
492
+ def _get_subcommand (
493
+ self ,
494
+ cmd : List [str ],
495
+ * ,
496
+ env : Dict [str , str ],
497
+ stdin : Optional [Any ] = None ,
498
+ stdout : Optional [Any ] = None ,
499
+ input : Optional [bytes ] = None ,
500
+ check : Optional [bool ] = None ,
501
+ ) -> None :
502
+ assert cmd .pop (0 ) == "--mode=creds"
503
+ assert cmd .pop (0 ) == "--output=json"
504
+ assert stdin == - 3 # subprocess.DEVNULL
505
+ assert stdout == subprocess .PIPE
506
+ assert env ["PYTHONIOENCODING" ] == "utf-8"
507
+ assert check is None
508
+ assert cmd .pop (0 ) == "get"
509
+
510
+ service = cmd .pop (0 )
511
+ username = cmd .pop (0 ) if len (cmd ) > 0 else None
512
+ creds = self .get_credential (service , username )
513
+ if creds is None :
514
+ # Expect non-zero returncode if no creds present
515
+ self .returncode = 1
516
+ else :
517
+ # Passwords are returned encoded with a newline appended
518
+ self .returncode = 0
519
+ self .stdout = json .dumps (
520
+ {
521
+ "username" : creds .username ,
522
+ "password" : creds .password ,
523
+ }
524
+ ).encode ("utf-8" )
525
+
526
+ def _set_subcommand (
527
+ self ,
528
+ cmd : List [str ],
529
+ * ,
530
+ env : Dict [str , str ],
531
+ stdin : Optional [Any ] = None ,
532
+ stdout : Optional [Any ] = None ,
533
+ input : Optional [bytes ] = None ,
534
+ check : Optional [bool ] = None ,
535
+ ) -> None :
536
+ assert cmd .pop (0 ) == "set"
537
+ assert stdin is None
538
+ assert stdout is None
539
+ assert env ["PYTHONIOENCODING" ] == "utf-8"
540
+ assert input is not None
541
+ assert check
542
+
543
+ # Input from stdin is encoded
544
+ system , username = cmd
545
+ self .set_password (system , username , input .decode ("utf-8" ).strip (os .linesep ))
546
+
438
547
def check_returncode (self ) -> None :
439
548
if self .returncode :
440
549
raise Exception ()
@@ -443,31 +552,42 @@ def check_returncode(self) -> None:
443
552
@pytest .mark .parametrize (
444
553
"url, expect" ,
445
554
[
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" )),
555
+ # It's not obvious, but this url ultimately resolves to index url
556
+ # http://example.com/path2, so we get the creds for that index.
557
+ ("http://example.com/path1" , ("saved-user1" , "pw1" )),
558
+ ("http://[email protected] /path2" , ("saved-user1" , "pw1" )),
559
+ ("http://[email protected] /path2" , ("saved-user2" , "pw2" )),
560
+ ("http://[email protected] /path2" , ("new-user" , None )),
561
+ ("http://example.com/path2/path3" , ("saved-user1" , "pw1" )),
562
+ ("http://[email protected] /path2/path3" , ("foo" , None )),
453
563
],
454
564
)
455
565
def test_keyring_cli_get_password (
456
566
monkeypatch : pytest .MonkeyPatch ,
457
567
url : str ,
458
568
expect : Tuple [Optional [str ], Optional [str ]],
459
569
) -> None :
570
+ keyring_subprocess = KeyringSubprocessResult ()
460
571
monkeypatch .setattr (pip ._internal .network .auth .shutil , "which" , lambda x : "keyring" )
461
572
monkeypatch .setattr (
462
- pip ._internal .network .auth .subprocess , "run" , KeyringSubprocessResult ()
573
+ pip ._internal .network .auth .subprocess , "run" , keyring_subprocess
463
574
)
464
575
auth = MultiDomainBasicAuth (
465
576
index_urls = ["http://example.com/path2" , "http://example.com/path3" ],
466
577
keyring_provider = "subprocess" ,
467
578
)
468
579
469
- actual = auth ._get_new_credentials (url , allow_netrc = False , allow_keyring = True )
470
- assert actual == expect
580
+ with keyring_subprocess .add_credential ("example.com" , "example" , "!netloc" ):
581
+ with keyring_subprocess .add_credential (
582
+ "http://example.com/path2/" , "saved-user1" , "pw1"
583
+ ):
584
+ with keyring_subprocess .add_credential (
585
+ "http://example.com/path2/" , "saved-user2" , "pw2"
586
+ ):
587
+ actual = auth ._get_new_credentials (
588
+ url , allow_netrc = False , allow_keyring = True
589
+ )
590
+ assert actual == expect
471
591
472
592
473
593
@pytest .mark .parametrize (
@@ -492,13 +612,14 @@ def test_keyring_cli_set_password(
492
612
creds : Tuple [str , str , bool ],
493
613
expect_save : bool ,
494
614
) -> None :
615
+ expected_username , expected_password , save = creds
495
616
monkeypatch .setattr (pip ._internal .network .auth .shutil , "which" , lambda x : "keyring" )
496
617
keyring = KeyringSubprocessResult ()
497
618
monkeypatch .setattr (pip ._internal .network .auth .subprocess , "run" , keyring )
498
619
auth = MultiDomainBasicAuth (prompting = True , keyring_provider = "subprocess" )
499
620
monkeypatch .setattr (auth , "_get_url_and_credentials" , lambda u : (u , None , None ))
500
621
monkeypatch .setattr (auth , "_prompt_for_password" , lambda * a : creds )
501
- if creds [ 2 ] :
622
+ if save :
502
623
# when _prompt_for_password indicates to save, we should save
503
624
def should_save_password_to_keyring (* a : Any ) -> bool :
504
625
return True
@@ -535,6 +656,12 @@ def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse:
535
656
auth .handle_401 (resp )
536
657
537
658
if expect_save :
538
- assert keyring .saved_passwords == [("example.com" , creds [0 ], creds [1 ])]
659
+ assert keyring .saved_credential_by_username_by_system == {
660
+ "example.com" : {
661
+ expected_username : KeyringModuleV2 .Credential (
662
+ expected_username , expected_password
663
+ ),
664
+ },
665
+ }
539
666
else :
540
- assert keyring .saved_passwords == []
667
+ assert keyring .saved_credential_by_username_by_system == {}
0 commit comments