Commit 1bc9cdcb by Sébastien Eustace

Add the publish command

parent 63bb882e
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
- Added support for extras definition. - Added support for extras definition.
- Added support for dependencies extras specification. - Added support for dependencies extras specification.
- Added the `config` command. - Added the `config` command.
- Added the `publish` command.
### Changed ### Changed
......
...@@ -12,6 +12,7 @@ from .commands import ConfigCommand ...@@ -12,6 +12,7 @@ from .commands import ConfigCommand
from .commands import InstallCommand from .commands import InstallCommand
from .commands import LockCommand from .commands import LockCommand
from .commands import NewCommand from .commands import NewCommand
from .commands import PublishCommand
from .commands import RemoveCommand from .commands import RemoveCommand
from .commands import ShowCommand from .commands import ShowCommand
from .commands import UpdateCommand from .commands import UpdateCommand
...@@ -52,6 +53,7 @@ class Application(BaseApplication): ...@@ -52,6 +53,7 @@ class Application(BaseApplication):
InstallCommand(), InstallCommand(),
LockCommand(), LockCommand(),
NewCommand(), NewCommand(),
PublishCommand(),
RemoveCommand(), RemoveCommand(),
ShowCommand(), ShowCommand(),
UpdateCommand(), UpdateCommand(),
......
...@@ -5,6 +5,7 @@ from .config import ConfigCommand ...@@ -5,6 +5,7 @@ from .config import ConfigCommand
from .install import InstallCommand from .install import InstallCommand
from .lock import LockCommand from .lock import LockCommand
from .new import NewCommand from .new import NewCommand
from .publish import PublishCommand
from .remove import RemoveCommand from .remove import RemoveCommand
from .show import ShowCommand from .show import ShowCommand
from .update import UpdateCommand from .update import UpdateCommand
...@@ -19,7 +19,6 @@ class BuildCommand(Command): ...@@ -19,7 +19,6 @@ class BuildCommand(Command):
package = self.poetry.package package = self.poetry.package
self.line(f'Building <info>{package.pretty_name}</> ' self.line(f'Building <info>{package.pretty_name}</> '
f'(<comment>{package.version}</>)') f'(<comment>{package.version}</>)')
self.line('')
builder = Builder(self.poetry, self.output) builder = Builder(self.poetry, self.output)
builder.build(fmt) builder.build(fmt)
from cleo import Command as BaseCommand from cleo import Command as BaseCommand
from cleo.inputs import ListInput
from poetry.poetry import Poetry from poetry.poetry import Poetry
...@@ -14,6 +15,21 @@ class Command(BaseCommand): ...@@ -14,6 +15,21 @@ class Command(BaseCommand):
def reset_poetry(self) -> None: def reset_poetry(self) -> None:
self.get_application().reset_poetry() self.get_application().reset_poetry()
def call(self, name, options=None):
"""
Call another command.
Fixing style being passed rather than an output
"""
if options is None:
options = []
command = self.get_application().find(name)
options = [('command', command.get_name())] + options
return command.run(ListInput(options), self.output.output)
def run(self, i, o) -> int: def run(self, i, o) -> int:
""" """
Initialize command. Initialize command.
......
from poetry.masonry.publisher import Publisher
from .command import Command
class PublishCommand(Command):
"""
Publishes a package to a remote repository.
publish
{ --r|repository= : The repository to publish the package to. }
{ --no-build : Do not build the package before publishing. }
"""
help = """The publish command builds and uploads the package to a remote repository.
By default, it will upload to PyPI but if you pass the --repository option it will
upload to it instead.
The --repository option should match the name of a configured repository using
the config command.
"""
def handle(self):
# Building package first, unless told otherwise
if not self.option('no-build'):
self.call('build')
self.line('')
publisher = Publisher(self.poetry, self.output)
publisher.publish(self.option('repository'))
import hashlib
import toml
import requests
from pathlib import Path
from poetry.locations import CONFIG_DIR
from poetry.semver.constraints import Constraint
from poetry.semver.constraints import MultiConstraint
from .builders.builder import Builder
class Publisher:
def __init__(self, poetry, io):
self._poetry = poetry
self._package = poetry.package
self._io = io
def publish(self, repository_name):
if repository_name:
self._io.writeln(
f'Publishing <info>{self._package.pretty_name}</info> '
f'(<comment>{self._package.pretty_version}</comment>) '
f'to <fg=cyan>{repository_name}</>'
)
else:
self._io.writeln(
f'Publishing <info>{self._package.pretty_name}</info> '
f'(<comment>{self._package.pretty_version}</comment>) '
f'to <fg=cyan>PyPI</>'
)
if not repository_name:
url = 'https://upload.pypi.org/legacy/'
else:
# Retrieving config information
config_file = Path(CONFIG_DIR) / 'config.toml'
if not config_file.exists():
raise RuntimeError(
'Config file does not exist. '
'Unable to get repository information'
)
with config_file.open() as f:
config = toml.loads(f.read())
if (
'repositories' not in config
or repository_name not in config['repositories']
):
raise RuntimeError(
f'Repository {repository_name} is not defined'
)
url = config['repositories'][repository_name]['url']
username = None
password = None
auth_file = Path(CONFIG_DIR) / 'auth.toml'
if not auth_file.exists():
# No auth file, we will ask for info later
auth_config = {}
else:
with auth_file.open() as f:
auth_config = toml.loads(f.read())
if 'http-basic' in auth_config and repository_name in auth_config['http-basic']:
config = auth_config['http-basic'][repository_name]
username = config.get('username')
password = config.get('password')
return self.upload(url, username=username, password=password)
def upload(self, url, username=None, password=None):
data = self.build_post_data('file_upload')
def upload_file(self, file, url, username, password):
data = self.build_post_data('file_upload')
data['protocol_version'] = '1'
if file.suffix == '.whl':
data['filetype'] = 'bdist_wheel'
py2_support = self._package.python_constraint.matches(
MultiConstraint([
Constraint('>=', '2.0.0'),
Constraint('<', '3.0.0')
])
)
data['pyversion'] = ('py2.' if py2_support else '') + 'py3'
else:
data['filetype'] = 'sdist'
with file.open('rb') as f:
content = f.read()
files = {'content': (file.name, content)}
data['md5_digest'] = hashlib.md5(content).hexdigest()
log.info('Uploading %s...', file)
resp = requests.post(repo['url'],
data=data,
files=files,
auth=(repo['username'], repo['password']),
)
resp.raise_for_status()
def build_post_data(self, action):
builder = Builder(self._poetry, self._io)
d = {
":action": action,
"name": self._package.name,
"version": self._package.version,
# additional meta-data
"metadata_version": '1.2',
"summary": self._package.description,
"home_page": self._package.homepage or self._package.repository_url,
"author": self._package.author_name,
"author_email": self._package.author_email,
"maintainer": self._package.author_name,
"maintainer_email": self._package.author_email,
"license": self._package.license,
"description": self._package.readme,
"keywords": ','.join(self._package.keywords),
"platform": None if self._package.platform == '*' else self._package.platform,
"classifiers": builder.get_classifers(),
"download_url": None,
"supported_platform": None if self._package.platform == '*' else self._package.platform,
"project_urls": [],
"provides_dist": [],
"obsoletes_dist": [],
"requires_dist": [d.to_pep_508() for d in self._package.requires],
"requires_external": [],
"requires_python": builder.convert_python_version(),
}
return {k: v for k, v in d.items() if v}
from poetry.semver.constraints import MultiConstraint
from .builders import CompleteBuilder from .builders import CompleteBuilder
from .builders import SdistBuilder from .builders import SdistBuilder
from .builders import WheelBuilder from .builders import WheelBuilder
...@@ -19,6 +21,20 @@ class Builder: ...@@ -19,6 +21,20 @@ class Builder:
if fmt not in self._FORMATS: if fmt not in self._FORMATS:
raise ValueError(f'Invalid format: {fmt}') raise ValueError(f'Invalid format: {fmt}')
self.check()
builder = self._FORMATS[fmt](self._poetry, self._io) builder = self._FORMATS[fmt](self._poetry, self._io)
return builder.build() return builder.build()
def check(self) -> None:
package = self._poetry.package
# Checking for disjunctive python versions
if isinstance(package.python_constraint, MultiConstraint):
if package.python_constraint.is_disjunctive():
raise RuntimeError(
'Disjunctive python versions are not yet supported '
'when building packages. Rewrite your python requirements '
'in a conjunctive way.'
)
...@@ -5,6 +5,7 @@ from collections import defaultdict ...@@ -5,6 +5,7 @@ from collections import defaultdict
from pathlib import Path from pathlib import Path
from poetry.semver.constraints import Constraint from poetry.semver.constraints import Constraint
from poetry.semver.constraints import MultiConstraint
from poetry.semver.version_parser import VersionParser from poetry.semver.version_parser import VersionParser
from poetry.vcs import get_vcs from poetry.vcs import get_vcs
...@@ -144,3 +145,14 @@ class Builder: ...@@ -144,3 +145,14 @@ class Builder:
classifiers.append(f'Programming Language :: Python :: {version}') classifiers.append(f'Programming Language :: Python :: {version}')
return classifiers return classifiers
def convert_python_version(self):
constraint = self._package.python_constraint
if isinstance(constraint, MultiConstraint):
python_requires = ','.join(
[str(c).replace(' ', '') for c in constraint.constraints]
)
else:
python_requires = str(constraint).replace(' ', '')
return python_requires
...@@ -24,6 +24,7 @@ from setuptools import setup ...@@ -24,6 +24,7 @@ from setuptools import setup
{before} {before}
setup( setup(
name={name!r}, name={name!r},
version={version!r}
description={description!r}, description={description!r},
author={author!r}, author={author!r},
author_email={author_email!r}, author_email={author_email!r},
...@@ -50,7 +51,7 @@ class SdistBuilder(Builder): ...@@ -50,7 +51,7 @@ class SdistBuilder(Builder):
super().__init__(poetry, io) super().__init__(poetry, io)
def build(self, target_dir: Path = None) -> Path: def build(self, target_dir: Path = None) -> Path:
self._io.writeln('Building <info>sdist</info>') self._io.writeln(' - Building <info>sdist</info>')
if target_dir is None: if target_dir is None:
target_dir = self._path / 'dist' target_dir = self._path / 'dist'
...@@ -104,7 +105,7 @@ class SdistBuilder(Builder): ...@@ -104,7 +105,7 @@ class SdistBuilder(Builder):
tar.close() tar.close()
gz.close() gz.close()
self._io.writeln(f'Built <comment>{target.name}</>') self._io.writeln(f' - Built <comment>{target.name}</>')
return target return target
......
...@@ -69,7 +69,7 @@ class WheelBuilder(Builder): ...@@ -69,7 +69,7 @@ class WheelBuilder(Builder):
return cls.make_in(poetry, io, dist_dir) return cls.make_in(poetry, io, dist_dir)
def build(self) -> None: def build(self) -> None:
self._io.writeln('Building <info>wheel</info>') self._io.writeln(' - Building <info>wheel</info>')
try: try:
self.copy_module() self.copy_module()
self.write_metadata() self.write_metadata()
...@@ -77,7 +77,7 @@ class WheelBuilder(Builder): ...@@ -77,7 +77,7 @@ class WheelBuilder(Builder):
finally: finally:
self._wheel_zip.close() self._wheel_zip.close()
self._io.writeln(f'Built <comment>{self.wheel_filename}</>') self._io.writeln(f' - Built <comment>{self.wheel_filename}</>')
def copy_module(self) -> None: def copy_module(self) -> None:
if self._module.is_package(): if self._module.is_package():
......
# -*- coding: utf-8 -*-
import toml
import twine.utils
from pathlib import Path
from requests.exceptions import HTTPError
from twine.commands.upload import find_dists, skip_upload
from twine.repository import Repository as BaseRepository
from twine.exceptions import PackageNotFound, RedirectDetected
from twine.package import PackageFile
from requests_toolbelt.multipart import (
MultipartEncoder, MultipartEncoderMonitor
)
from poetry.locations import CONFIG_DIR
class Repository(BaseRepository):
def __init__(self, io, url, username, password):
self._io = io
super(Repository, self).__init__(url, username, password)
def register(self, package):
data = package.metadata_dictionary()
data.update({
":action": "submit",
"protocol_version": "1",
})
self._io.writeln(
" - Registering <info>{0}</>".format(package.basefilename)
)
data_to_send = self._convert_data_to_list_of_tuples(data)
encoder = MultipartEncoder(data_to_send)
resp = self.session.post(
self.url,
data=encoder,
allow_redirects=False,
headers={'Content-Type': encoder.content_type}
)
# Bug 28. Try to silence a ResourceWarning by releasing the socket.
resp.close()
return resp
def _upload(self, package):
data = package.metadata_dictionary()
data.update({
# action
":action": "file_upload",
"protocol_version": "1",
})
data_to_send = self._convert_data_to_list_of_tuples(data)
with open(package.filename, "rb") as fp:
data_to_send.append((
"content",
(package.basefilename, fp, "application/octet-stream"),
))
encoder = MultipartEncoder(data_to_send)
bar = self._io.create_progress_bar(encoder.len)
bar.set_format(
" - Uploading <info>{0}</> <comment>%percent%%</>".format(
package.basefilename
)
)
monitor = MultipartEncoderMonitor(
encoder, lambda monitor: bar.set_progress(monitor.bytes_read)
)
bar.start()
resp = self.session.post(
self.url,
data=monitor,
allow_redirects=False,
headers={'Content-Type': monitor.content_type}
)
if resp.ok:
bar.finish()
self._io.writeln('')
else:
self._io.overwrite('')
return resp
class Publisher(object):
"""
Registers and publishes packages to remote repositories.
"""
def __init__(self, poetry, io):
self._poetry = poetry
self._package = poetry.package
self._io = io
def publish(self, repository_name):
if repository_name:
self._io.writeln(
f'Publishing <info>{self._package.pretty_name}</info> '
f'(<comment>{self._package.pretty_version}</comment>) '
f'to <fg=cyan>{repository_name}</>'
)
else:
self._io.writeln(
f'Publishing <info>{self._package.pretty_name}</info> '
f'(<comment>{self._package.pretty_version}</comment>) '
f'to <fg=cyan>PyPI</>'
)
if not repository_name:
url = 'https://upload.pypi.org/legacy/'
else:
# Retrieving config information
config_file = Path(CONFIG_DIR) / 'config.toml'
if not config_file.exists():
raise RuntimeError(
'Config file does not exist. '
'Unable to get repository information'
)
with config_file.open() as f:
config = toml.loads(f.read())
if (
'repositories' not in config
or repository_name not in config['repositories']
):
raise RuntimeError(
f'Repository {repository_name} is not defined'
)
url = config['repositories'][repository_name]['url']
username = None
password = None
auth_file = Path(CONFIG_DIR) / 'auth.toml'
if auth_file.exists():
with auth_file.open() as f:
auth_config = toml.loads(f.read())
if 'http-basic' in auth_config and repository_name in auth_config['http-basic']:
config = auth_config['http-basic'][repository_name]
username = config.get('username')
password = config.get('password')
# Requesting missing credentials
if not username:
username = self._io.ask('Username:')
if not password:
password = self._io.ask_hidden('Password:')
repository = Repository(self._io, url, username, password)
# TODO: handle certificates
self.upload(repository)
def register(self, repository):
"""
Register a package to a repository.
"""
dist = self._poetry.file.parent / 'dist'
package = dist / f'{self._package.name}-{self._package.version}.tar.gz'
if package.exists():
raise PackageNotFound(
'"{0}" does not exist on the file system.'.format(package)
)
resp = repository.register(
PackageFile.from_filename(str(package), None)
)
repository.close()
if resp.is_redirect:
raise RedirectDetected(
('"{0}" attempted to redirect to "{1}" during registration.'
' Aborting...').format(repository.url,
resp.headers["location"]))
resp.raise_for_status()
def upload(self, repository):
"""
Upload packages for the current project.
"""
try:
self._upload(repository)
except HTTPError as e:
if (
e.response.status_code not in (403, 400)
or e.response.status_code == 400 and 'was ever registered' not in e.response.text
):
raise
# It may be the first time we publish the package
# We'll try to register it and go from there
try:
self.register(repository)
except HTTPError:
raise
def _upload(self, repository):
skip_existing = False
dist = self._poetry.file.parent / 'dist'
packages = list(dist.glob(f'{self._package.name}-{self._package.version}*'))
dists = find_dists([str(p) for p in packages])
uploads = [i for i in dists if not i.endswith(".asc")]
for filename in uploads:
package = PackageFile.from_filename(filename, None)
skip_message = (
" - Skipping <comment>{0}</> because it appears to already exist"
.format(
package.basefilename
)
)
# Note: The skip_existing check *needs* to be first, because otherwise
# we're going to generate extra HTTP requests against a hardcoded
# URL for no reason.
if skip_existing and repository.package_is_uploaded(package):
self._io.writeln(skip_message)
continue
resp = repository.upload(package)
# Bug 92. If we get a redirect we should abort because something seems
# funky. The behaviour is not well defined and redirects being issued
# by PyPI should never happen in reality. This should catch malicious
# redirects as well.
if resp.is_redirect:
raise RedirectDetected(
('"{0}" attempted to redirect to "{1}" during upload.'
' Aborting...').format(repository.url,
resp.headers["location"]))
if skip_upload(resp, skip_existing, package):
self._io.writeln(skip_message)
continue
twine.utils.check_status_code(resp)
# Bug 28. Try to silence a ResourceWarning by clearing the connection
# pool.
repository.close()
import re
from typing import Union from typing import Union
from poetry.semver.helpers import parse_stability from poetry.semver.helpers import parse_stability
...@@ -7,6 +8,8 @@ from poetry.version import parse as parse_version ...@@ -7,6 +8,8 @@ from poetry.version import parse as parse_version
from .dependency import Dependency from .dependency import Dependency
from .vcs_dependency import VCSDependency from .vcs_dependency import VCSDependency
AUTHOR_REGEX = re.compile('(?u)^(?P<name>[- .,\w\d\'’"()]+) <(?P<email>.+?)>$')
class Package: class Package:
...@@ -121,6 +124,31 @@ class Package: ...@@ -121,6 +124,31 @@ class Package:
return self._authors return self._authors
@property @property
def author_name(self) -> str:
return self._get_author()['name']
@property
def author_email(self) -> str:
return self._get_author()['email']
def _get_author(self) -> dict:
if not self._authors:
return {
'name': None,
'email': None
}
m = AUTHOR_REGEX.match(self._authors[0])
name = m.group('name')
email = m.group('email')
return {
'name': name,
'email': email
}
@property
def python_versions(self): def python_versions(self):
return self._python_versions return self._python_versions
......
...@@ -23,6 +23,8 @@ requests = "^2.18" ...@@ -23,6 +23,8 @@ requests = "^2.18"
toml = "^0.9" toml = "^0.9"
cachy = "^0.1.0" cachy = "^0.1.0"
pip-tools = "^1.11" pip-tools = "^1.11"
twine = "^1.10"
requests-toolbelt = "^0.8.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "~3.4" pytest = "~3.4"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment