Skip to content

Commit

Permalink
Merge pull request pypa#649 from pypa/keyring-tokens
Browse files Browse the repository at this point in the history
Look for PyPI tokens in keyring when uploading
  • Loading branch information
takluyver authored Nov 8, 2023
2 parents a259482 + 3e58d78 commit 06b9e2e
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 102 deletions.
172 changes: 108 additions & 64 deletions flit/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
import logging
import os
from pathlib import Path
import re
import requests
import sys
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse

from flit_core.common import Metadata
from flit_core.common import make_metadata, Metadata, Module
from .config import read_flit_config

log = logging.getLogger(__name__)

Expand All @@ -27,6 +31,20 @@
"http://upload.pypi.io/",
)


@dataclass
class RepoDetails:
url: str
username: Optional[str] = None
password: Optional[str] = None

@property
def is_pypi(self):
# Main PyPI (pypi.org) or TestPyPI (test.pypi.org)
# This is used to guess the URL for the project's HTML page
return self.url.rstrip('/').endswith('/legacy')


def get_repositories(file="~/.pypirc"):
"""Get the known repositories from a pypirc file.
Expand All @@ -38,9 +56,7 @@ def get_repositories(file="~/.pypirc"):
file = os.path.expanduser(file)

if not os.path.isfile(file):
return {'pypi': {
'url': PYPI, 'username': None, 'password': None,
}}
return {'pypi': RepoDetails(url=PYPI)}

cp.read(file)
else:
Expand All @@ -51,16 +67,16 @@ def get_repositories(file="~/.pypirc"):
repos = {}

for name in names:
repos[name] = {
'url': cp.get(name, 'repository', fallback=PYPI),
'username': cp.get(name, 'username', fallback=None),
'password': cp.get(name, 'password', fallback=None),
}
repos[name] = RepoDetails(
url=cp.get(name, 'repository', fallback=PYPI),
username=cp.get(name, 'username', fallback=None),
password=cp.get(name, 'password', fallback=None),
)

return repos


def get_repository(pypirc_path="~/.pypirc", name=None):
def get_repository(pypirc_path="~/.pypirc", name=None, project_name=None):
"""Get the url, username and password for one repository.
Returns a dict with keys 'url', 'username', 'password'.
Expand All @@ -74,55 +90,63 @@ def get_repository(pypirc_path="~/.pypirc", name=None):
4. Default PyPI (hardcoded)
Username:
1. Command line arg --repository (looked up in .pypirc)
2. $FLIT_USERNAME
3. Repository called 'pypi' from .pypirc
4. Terminal prompt (write to .pypirc if it doesn't exist yet)
1. $FLIT_USERNAME
2. Repository with specified name (default 'pypi') in .pypirc
3. Terminal prompt (write to .pypirc if it doesn't exist yet)
Password:
1. Command line arg --repository (looked up in .pypirc)
2. $FLIT_PASSWORD
3. Repository called 'pypi' from .pypirc
3. keyring
4. Terminal prompt (store to keyring if available)
1. $FLIT_PASSWORD
2. Repository with specified name (default 'pypi') in .pypirc
3. keyring - pypi_token:project:<project_name>
4. keyring - pypi_token:user:<username>
5. keyring - username
6. Terminal prompt (store to keyring if available)
"""
log.debug("Loading repositories config from %r", pypirc_path)
repos_cfg = get_repositories(pypirc_path)

if name is not None:
repo = repos_cfg[name]
if 'FLIT_INDEX_URL' in os.environ:
raise EnvironmentError(
"Use either FLIT_INDEX_URL or --repository, not both"
)
elif 'FLIT_INDEX_URL' in os.environ:
repo = {'url': os.environ['FLIT_INDEX_URL'],
'username': None, 'password': None}
repo = RepoDetails(url=os.environ['FLIT_INDEX_URL'])
elif 'pypi' in repos_cfg:
repo = repos_cfg['pypi']

if 'FLIT_PASSWORD' in os.environ:
repo['password'] = os.environ['FLIT_PASSWORD']
else:
repo = {'url': PYPI, 'username': None, 'password': None}
repo = RepoDetails(url=PYPI)

if repo['url'].startswith(SWITCH_TO_HTTPS):
if repo.url.startswith(SWITCH_TO_HTTPS):
# Use https for PyPI, even if an http URL was given
repo['url'] = 'https' + repo['url'][4:]
elif repo['url'].startswith('http://'):
repo.url = 'https' + repo.url[4:]
elif repo.url.startswith('http://'):
log.warning("Unencrypted connection - credentials may be visible on "
"the network.")
log.info("Using repository at %s", repo['url'])

if ('FLIT_USERNAME' in os.environ) and ((name is None) or (not repo['username'])):
repo['username'] = os.environ['FLIT_USERNAME']
if sys.stdin.isatty():
while not repo['username']:
repo['username'] = input("Username: ")
if repo['url'] == PYPI:
log.info("Using repository at %s", repo.url)

if 'FLIT_USERNAME' in os.environ:
repo.username = os.environ['FLIT_USERNAME']
if not repo.username and sys.stdin.isatty():
while not repo.username:
repo.username = input("Username: ")
if repo.url == PYPI:
write_pypirc(repo, pypirc_path)
elif not repo['username']:
elif not repo.username:
raise Exception("Could not find username for upload.")

repo['password'] = get_password(repo, prefer_env=(name is None))
if 'FLIT_PASSWORD' in os.environ:
repo.password = os.environ['FLIT_PASSWORD']

repo['is_warehouse'] = repo['url'].rstrip('/').endswith('/legacy')
if not repo.password:
token = find_token(repo, project_name)
if token is not None:
repo.username = '__token__'
repo.password = token
else:
repo.password = get_password(repo)

return repo

Expand All @@ -135,23 +159,17 @@ def write_pypirc(repo, file="~/.pypirc"):

with open(file, 'w', encoding='utf-8') as f:
f.write("[pypi]\n"
"username = %s\n" % repo['username'])

def get_password(repo, prefer_env):
if ('FLIT_PASSWORD' in os.environ) and (prefer_env or not repo['password']):
return os.environ['FLIT_PASSWORD']

if repo['password']:
return repo['password']
"username = %s\n" % repo.username)

def get_password(repo: RepoDetails):
try:
import keyring, keyring.errors
except ImportError: # pragma: no cover
log.warning("Install keyring to store passwords securely")
log.warning("Install keyring to store tokens/passwords securely")
keyring = None
else:
try:
stored_pw = keyring.get_password(repo['url'], repo['username'])
stored_pw = keyring.get_password(repo.url, repo.username)
if stored_pw is not None:
return stored_pw
except keyring.errors.KeyringError as e:
Expand All @@ -160,21 +178,44 @@ def get_password(repo, prefer_env):
if sys.stdin.isatty():
pw = None
while not pw:
print('Server :', repo['url'])
print('Username:', repo['username'])
print('Server :', repo.url)
print('Username:', repo.username)
pw = getpass.getpass('Password: ')
else:
raise Exception("Could not find password for upload.")

if keyring is not None:
try:
keyring.set_password(repo['url'], repo['username'], pw)
keyring.set_password(repo.url, repo.username, pw)
log.info("Stored password with keyring")
except keyring.errors.KeyringError as e:
log.warning("Could not store password in keyring (%s)", e)

return pw


def find_token(repo: RepoDetails, project_name: str):
# https://packaging.python.org/en/latest/specifications/name-normalization/
project_name = re.sub(r"[-_.]+", "-", project_name).lower()
candidate_keys = [f"pypi_token:project:{project_name}"]

if repo.username is not None:
candidate_keys.append(f"pypi_token:user:{repo.username}")

try:
import keyring, keyring.errors
except ImportError: # pragma: no cover
pass
else:
try:
for key in candidate_keys:
token = keyring.get_password(repo.url, key)
if token is not None:
return token
except keyring.errors.KeyringError as e:
log.warning("Could not get token from keyring (%s)", e)


def build_post_data(action, metadata:Metadata):
"""Prepare the metadata needed for requests to PyPI.
"""
Expand Down Expand Up @@ -217,7 +258,7 @@ def build_post_data(action, metadata:Metadata):

return {k:v for k,v in d.items() if v}

def upload_file(file:Path, metadata:Metadata, repo):
def upload_file(file:Path, metadata:Metadata, repo: RepoDetails):
"""Upload a file to an index server, given the index server details.
"""
data = build_post_data('file_upload', metadata)
Expand All @@ -237,27 +278,24 @@ def upload_file(file:Path, metadata:Metadata, repo):
data['sha256_digest'] = hashlib.sha256(content).hexdigest()

log.info('Uploading %s...', file)
resp = requests.post(repo['url'],
data=data,
files=files,
auth=(repo['username'], repo['password']),
)
resp = requests.post(
repo.url, data=data, files=files, auth=(repo.username, repo.password),
)
resp.raise_for_status()


def do_upload(file:Path, metadata:Metadata, pypirc_path="~/.pypirc", repo_name=None):
def do_upload(file:Path, metadata:Metadata, repo: RepoDetails):
"""Upload a file to an index server.
"""
repo = get_repository(pypirc_path, repo_name)
upload_file(file, metadata, repo)

if repo['is_warehouse']:
domain = urlparse(repo['url']).netloc
if repo.is_pypi:
domain = urlparse(repo.url).netloc
if domain.startswith('upload.'):
domain = domain[7:]
log.info("Package is at https://%s/project/%s/", domain, metadata.name)
else:
log.info("Package is at %s/%s", repo['url'], metadata.name)
log.info("Package is at %s/%s", repo.url, metadata.name)


def main(ini_path, repo_name, pypirc_path=None, formats=None, gen_setup_py=True,
Expand All @@ -268,12 +306,18 @@ def main(ini_path, repo_name, pypirc_path=None, formats=None, gen_setup_py=True,
elif not os.path.isfile(pypirc_path):
raise FileNotFoundError("The specified pypirc config file does not exist.")

ini_info = read_flit_config(ini_path)
srcdir = ini_path.parent
module = Module(ini_info.module, srcdir)
metadata = make_metadata(module, ini_info)
repo = get_repository(pypirc_path, repo_name, project_name=metadata.name)

from . import build
built = build.main(
ini_path, formats=formats, gen_setup_py=gen_setup_py, use_vcs=use_vcs
)

if built.wheel is not None:
do_upload(built.wheel.file, built.wheel.builder.metadata, pypirc_path, repo_name)
do_upload(built.wheel.file, built.wheel.builder.metadata, repo)
if built.sdist is not None:
do_upload(built.sdist.file, built.sdist.builder.metadata, pypirc_path, repo_name)
do_upload(built.sdist.file, built.sdist.builder.metadata, repo)
Loading

0 comments on commit 06b9e2e

Please sign in to comment.