Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: pdm publish command #1107

Merged
merged 7 commits into from
Jun 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/docs/usage/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,56 @@ If `-g/--global` option is used, the first item will be replaced by `~/.pdm/glob

You can find all available configuration items in [Configuration Page](/configuration/).

## Publish the project to PyPI

With PDM, you can build and then upload your project to PyPI in one step.

```bash
pdm publish
```

You can specify which repository you would like to publish:

```bash
pdm publish -r pypi
```

PDM will look for the repository named `pypi` from the configuration and use the URL for upload.
You can also give the URL directly with `-r/--repository` option:

```bash
pdm publish -r https://test.pypi.org/simple
```

See all supported options by typing `pdm publish --help`.

### Configure the repository secrets for upload

When using the `pdm publish` command, it reads the repository secrets from the *global* config file(`~/.pdm/config.toml`). The content of the config is as follows:

```toml
[repository.pypi]
username = "frostming"
password = "<secret>"

[repository.company]
url = "https://pypi.company.org/legacy/"
username = "frostming"
password = "<secret>"
```

!!! NOTE
You don't need to configure the `url` for `pypi` and `testpypi` repositories, they are filled by default values.

To change the repository config from the command line, use the `pdm config` command:

```bash
pdm config repository.pypi.username "__token__"
pdm config repository.pypi.password "my-pypi-token"

pdm config repository.company.url "https://pypi.company.org/legacy/"
```

## Cache the installation of wheels

If a package is required by many projects on the system, each project has to keep its own copy. This may become a waste of disk space especially for data science and machine learning libraries.
Expand Down
1 change: 1 addition & 0 deletions news/1107.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a new command `publish` to PDM since it is required for so many people and it will make the workflow easier.
16 changes: 14 additions & 2 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion pdm/cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ def _get_config(self, project: Project, options: argparse.Namespace) -> None:
err=True,
)
options.key = project.project_config.deprecated[options.key]
project.core.ui.echo(project.config[options.key])
if options.key.split(".")[0] == "repository":
value = project.global_config[options.key]
else:
value = project.config[options.key]
project.core.ui.echo(value)

def _set_config(self, project: Project, options: argparse.Namespace) -> None:
config = project.project_config if options.local else project.global_config
Expand Down
165 changes: 165 additions & 0 deletions pdm/cli/commands/publish/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from __future__ import annotations

import argparse
import os

import requests
from rich.progress import (
BarColumn,
DownloadColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)

from pdm.cli import actions
from pdm.cli.commands.base import BaseCommand
from pdm.cli.commands.publish.package import PackageFile
from pdm.cli.commands.publish.repository import Repository
from pdm.cli.options import project_option, verbose_option
from pdm.exceptions import PdmUsageError, PublishError
from pdm.project import Project
from pdm.termui import logger


class Command(BaseCommand):
"""Build and publish the project to PyPI"""

arguments = [verbose_option, project_option]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-r",
"--repository",
help="The repository name or url to publish the package to"
" [env var: PDM_PUBLISH_REPO]",
)
parser.add_argument(
"-u",
"--username",
help="The username to access the repository"
" [env var: PDM_PUBLISH_USERNAME]",
)
parser.add_argument(
"-P",
"--password",
help="The password to access the repository"
" [env var: PDM_PUBLISH_PASSWORD]",
)
parser.add_argument(
"-S",
"--sign",
action="store_true",
help="Upload the package with PGP signature",
)
parser.add_argument(
"-i",
"--identity",
help="GPG identity used to sign files.",
)
parser.add_argument(
"-c",
"--comment",
help="The comment to include with the distribution file.",
)
parser.add_argument(
"--no-build",
action="store_false",
dest="build",
help="Don't build the package before publishing",
)

@staticmethod
def _make_package(
filename: str, signatures: dict[str, str], options: argparse.Namespace
) -> PackageFile:
p = PackageFile.from_filename(filename, options.comment)
if p.base_filename in signatures:
p.add_gpg_signature(signatures[p.base_filename], p.base_filename + ".asc")
elif options.sign:
p.sign(options.identity)
return p

@staticmethod
def _check_response(response: requests.Response) -> None:
message = ""
if response.status_code == 410 and "pypi.python.org" in response.url:
message = (
"Uploading to these sites is deprecated. "
"Try using https://upload.pypi.org/legacy/ "
"(or https://test.pypi.org/legacy/) instead."
)
elif response.status_code == 405 and "pypi.org" in response.url:
message = (
"It appears you're trying to upload to pypi.org but have an "
"invalid URL."
)
else:
try:
response.raise_for_status()
except requests.HTTPError as err:
message = str(err)
if message:
raise PublishError(message)

@staticmethod
def get_repository(project: Project, options: argparse.Namespace) -> Repository:
repository = options.repository or os.getenv("PDM_PUBLISH_REPO", "pypi")
username = options.username or os.getenv("PDM_PUBLISH_USERNAME")
password = options.password or os.getenv("PDM_PUBLISH_PASSWORD")

config = project.global_config.get_repository_config(repository)
if config is None:
raise PdmUsageError(f"Missing repository config of {repository}")
if username is not None:
config.username = username
if password is not None:
config.password = password
return Repository(project, config.url, config.username, config.password)

def handle(self, project: Project, options: argparse.Namespace) -> None:
if options.build:
actions.do_build(project)

package_files = [
str(p)
for p in project.root.joinpath("dist").iterdir()
if not p.name.endswith(".asc")
]
signatures = {
p.stem: str(p)
for p in project.root.joinpath("dist").iterdir()
if p.name.endswith(".asc")
}

repository = self.get_repository(project, options)
uploaded: list[PackageFile] = []
with project.core.ui.make_progress(
" [progress.percentage]{task.percentage:>3.0f}%",
BarColumn(),
DownloadColumn(),
"•",
TimeRemainingColumn(
compact=True,
elapsed_when_finished=True,
),
"•",
TransferSpeedColumn(),
) as progress, project.core.ui.logging("publish"):
packages = sorted(
(self._make_package(p, signatures, options) for p in package_files),
# Upload wheels first if they exist.
key=lambda p: not p.base_filename.endswith(".whl"),
)
for package in packages:
resp = repository.upload(package, progress)
logger.debug(
"Response from %s:\n%s %s", resp.url, resp.status_code, resp.reason
)
self._check_response(resp)
uploaded.append(package)

release_urls = repository.get_release_urls(uploaded)
if release_urls:
project.core.ui.echo("\n[green]View at:")
for url in release_urls:
project.core.ui.echo(url)
Loading