diff --git a/docs/docs/usage/project.md b/docs/docs/usage/project.md index 6e90f7739e..ee4682b9d1 100644 --- a/docs/docs/usage/project.md +++ b/docs/docs/usage/project.md @@ -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.md). +## 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 = "" + +[repository.company] +url = "https://pypi.company.org/legacy/" +username = "frostming" +password = "" +``` + +!!! 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. diff --git a/news/1107.feature.md b/news/1107.feature.md new file mode 100644 index 0000000000..3e9b6b6239 --- /dev/null +++ b/news/1107.feature.md @@ -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. diff --git a/pdm.lock b/pdm.lock index 2db0d8ee35..5c4d5d1be0 100644 --- a/pdm.lock +++ b/pdm.lock @@ -46,7 +46,7 @@ extras = ["filecache"] requires_python = ">=3.6" summary = "httplib2 caching for requests" dependencies = [ - "cachecontrol", + "cachecontrol>=0.12.11", "lockfile>=0.9", ] @@ -530,6 +530,14 @@ dependencies = [ "urllib3<1.27,>=1.21.1", ] +[[package]] +name = "requests-toolbelt" +version = "0.9.1" +summary = "A utility belt for advanced users of python-requests" +dependencies = [ + "requests<3.0.0,>=2.0.1", +] + [[package]] name = "resolvelib" version = "0.8.1" @@ -632,7 +640,7 @@ summary = "Backport of pathlib-compatible object wrapper for zip files" [metadata] lock_version = "3.1" -content_hash = "sha256:754d53d71a3b7f32000fbeaab8cfecfc0f630cf37d377aa8e6953673be3afcb1" +content_hash = "sha256:81d85f3e6b79cd961a23c768dbe8c3307c7c7a484ad14db7357ec0d49a663e86" [metadata.files] "arpeggio 1.10.2" = [ @@ -1016,6 +1024,10 @@ content_hash = "sha256:754d53d71a3b7f32000fbeaab8cfecfc0f630cf37d377aa8e6953673b {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, ] +"requests-toolbelt 0.9.1" = [ + {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, + {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, +] "resolvelib 0.8.1" = [ {file = "resolvelib-0.8.1-py2.py3-none-any.whl", hash = "sha256:d9b7907f055c3b3a2cfc56c914ffd940122915826ff5fb5b1de0c99778f4de98"}, {file = "resolvelib-0.8.1.tar.gz", hash = "sha256:c6ea56732e9fb6fca1b2acc2ccc68a0b6b8c566d8f3e78e0443310ede61dbd37"}, diff --git a/pdm/cli/commands/config.py b/pdm/cli/commands/config.py index 9017ee69b8..24ff24ee1b 100644 --- a/pdm/cli/commands/config.py +++ b/pdm/cli/commands/config.py @@ -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 diff --git a/pdm/cli/commands/publish/__init__.py b/pdm/cli/commands/publish/__init__.py new file mode 100644 index 0000000000..b571e50afd --- /dev/null +++ b/pdm/cli/commands/publish/__init__.py @@ -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) diff --git a/pdm/cli/commands/publish/package.py b/pdm/cli/commands/publish/package.py new file mode 100644 index 0000000000..47aba82814 --- /dev/null +++ b/pdm/cli/commands/publish/package.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import email +import email.message +import hashlib +import os +import re +import subprocess +import tarfile +import zipfile +from dataclasses import dataclass +from typing import IO, Any, cast + +from unearth.preparer import has_leading_dir, split_leading_dir + +from pdm.exceptions import PdmUsageError, ProjectError +from pdm.termui import logger +from pdm.utils import normalize_name + +DIST_EXTENSIONS = { + ".whl": "bdist_wheel", + ".tar.bz2": "sdist", + ".tar.gz": "sdist", + ".zip": "sdist", +} +wheel_file_re = re.compile( + r"""^(?P(?P.+?)(-(?P\d.+?))?) + ((-(?P\d.*?))?-(?P.+?)-(?P.+?)-(?P.+?) + \.whl|\.dist-info)$""", + re.VERBOSE, +) + + +@dataclass +class PackageFile: + """A distribution file for upload. + + XXX: currently only supports sdist and wheel. + """ + + filename: str + metadata: email.message.Message + comment: str | None + py_version: str | None + filetype: str + + def __post_init__(self) -> None: + self.base_filename = os.path.basename(self.filename) + self.gpg_signature: tuple[str, bytes] | None = None + + def get_hashes(self) -> dict[str, str]: + hashers = {"sha256_digest": hashlib.sha256()} + try: + hashers["md5_digest"] = hashlib.md5() + except ValueError: + pass + try: + hashers["blake2_256_digest"] = hashlib.blake2b(digest_size=256 // 8) + except (TypeError, ValueError): + pass + with open(self.filename, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + for hasher in hashers.values(): + hasher.update(chunk) + return {k: v.hexdigest() for k, v in hashers.items()} + + @classmethod + def from_filename(cls, filename: str, comment: str | None) -> PackageFile: + filetype = "" + for ext, dtype in DIST_EXTENSIONS.items(): + if filename.endswith(ext): + filetype = dtype + break + else: + raise PdmUsageError(f"Unknown distribution file type: {filename}") + if filetype == "bdist_wheel": + metadata = cls.read_metadata_from_wheel(filename) + match = wheel_file_re.match(os.path.basename(filename)) + if match is None: + py_ver = "any" + else: + py_ver = match.group("pyver") + elif filename.endswith(".zip"): + metadata = cls.read_metadata_from_zip(filename) + py_ver = "source" + else: + metadata = cls.read_metadata_from_tar(filename) + py_ver = "source" + return cls(filename, metadata, comment, py_ver, filetype) + + @staticmethod + def read_metadata_from_tar(filename: str) -> email.message.Message: + if filename.endswith(".gz"): + mode = "r:gz" + elif filename.endswith(".bz2"): + mode = "r:bz2" + else: + logger.warning(f"Can't determine the compression mode for {filename}") + mode = "r:*" + with tarfile.open(filename, mode) as tar: + members = tar.getmembers() + has_leading = has_leading_dir(m.name for m in members) + for m in members: + fn = split_leading_dir(m.name)[1] if has_leading else m.name + if fn == "PKG-INFO": + return email.message_from_binary_file( + cast(IO[bytes], tar.extractfile(m)) + ) + raise ProjectError(f"No PKG-INFO found in {filename}") + + @staticmethod + def read_metadata_from_zip(filename: str) -> email.message.Message: + with zipfile.ZipFile(filename, allowZip64=True) as zip: + filenames = zip.namelist() + has_leading = has_leading_dir(filenames) + for name in filenames: + fn = split_leading_dir(name)[1] if has_leading else name + if fn == "PKG-INFO": + return email.message_from_binary_file(zip.open(name)) + raise ProjectError(f"No PKG-INFO found in {filename}") + + @staticmethod + def read_metadata_from_wheel(filename: str) -> email.message.Message: + with zipfile.ZipFile(filename, allowZip64=True) as zip: + for fn in zip.namelist(): + if fn.replace("\\", "/").endswith(".dist-info/METADATA"): + return email.message_from_binary_file(zip.open(fn)) + raise ProjectError(f"No egg-info is found in {filename}") + + def add_gpg_signature(self, filename: str, signature_name: str) -> None: + if self.gpg_signature is not None: + raise PdmUsageError("GPG signature already added") + with open(filename, "rb") as f: + self.gpg_signature = (signature_name, f.read()) + + def sign(self, identity: str | None) -> None: + logger.info("Signing %s with gpg", self.base_filename) + gpg_args = ["gpg", "--detach-sign"] + if identity is not None: + gpg_args.extend(["--local-user", identity]) + gpg_args.extend(["-a", self.filename]) + self._run_gpg(gpg_args) + self.add_gpg_signature(self.filename + ".asc", self.base_filename + ".asc") + + @staticmethod + def _run_gpg(gpg_args: list[str]) -> None: + try: + subprocess.run(gpg_args, check=True) + return + except FileNotFoundError: + logger.warning("gpg executable not available. Attempting fallback to gpg2.") + + gpg_args[0] = "gpg2" + try: + subprocess.run(gpg_args, check=True) + except FileNotFoundError: + raise PdmUsageError( + "'gpg' or 'gpg2' executables not available.\n" + "Try installing one of these or specifying an executable " + "with the --sign-with flag." + ) + + @property + def metadata_dict(self) -> dict[str, Any]: + meta = self.metadata + data = { + # identify release + "name": normalize_name(meta["Name"]), + "version": meta["Version"], + # file content + "filetype": self.filetype, + "pyversion": self.py_version, + # additional meta-data + "metadata_version": meta["Metadata-Version"], + "summary": meta["Summary"], + "home_page": meta["Home-page"], + "author": meta["Author"], + "author_email": meta["Author-email"], + "maintainer": meta["Maintainer"], + "maintainer_email": meta["Maintainer-email"], + "license": meta["License"], + "description": meta.get_payload(), + "keywords": meta["Keywords"], + "platform": meta.get_all("Platform") or (), + "classifiers": meta.get_all("Classifier") or [], + "download_url": meta["Download-URL"], + "supported_platform": meta.get_all("Supported-Platform") or (), + "comment": self.comment, + # Metadata 1.2 + "project_urls": meta.get_all("Project-URL") or (), + "provides_dist": meta.get_all("Provides-Dist") or (), + "obsoletes_dist": meta.get_all("Obsoletes-Dist") or (), + "requires_dist": meta.get_all("Requires-Dist") or (), + "requires_external": meta.get_all("Requires-External") or (), + "requires_python": meta.get_all("Requires-Python") or (), + # Metadata 2.1 + "provides_extras": meta.get_all("Provides-Extra") or (), + "description_content_type": meta.get("Description-Content-Type"), + # Metadata 2.2 + "dynamic": meta.get_all("Dynamic") or (), + # Hashes + **self.get_hashes(), + } + if self.gpg_signature is not None: + data["gpg_signature"] = self.gpg_signature + return data diff --git a/pdm/cli/commands/publish/repository.py b/pdm/cli/commands/publish/repository.py new file mode 100644 index 0000000000..ccaa5f64ff --- /dev/null +++ b/pdm/cli/commands/publish/repository.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import atexit +from typing import Any, Iterable + +import requests +import requests_toolbelt +import rich.progress + +from pdm.cli.commands.publish.package import PackageFile +from pdm.models.session import PDMSession +from pdm.project import Project +from pdm.project.config import DEFAULT_REPOSITORIES + + +class Repository: + def __init__( + self, project: Project, url: str, username: str | None, password: str | None + ) -> None: + self.url = url + self.session = PDMSession(cache_dir=project.cache("http")) + self.session.auth = ( + (username or "", password or "") if username or password else None + ) + self.ui = project.core.ui + + atexit.register(self.session.close) + + @staticmethod + def _convert_to_list_of_tuples(data: dict[str, Any]) -> list[tuple[str, Any]]: + result: list[tuple[str, Any]] = [] + for key, value in data.items(): + if isinstance(value, (list, tuple)) and key != "gpg_signature": + for item in value: + result.append((key, item)) + else: + result.append((key, value)) + return result + + def get_release_urls(self, packages: list[PackageFile]) -> Iterable[str]: + if self.url.startswith(DEFAULT_REPOSITORIES["pypi"].url.rstrip("/")): + base = "https://pypi.org/" + elif self.url.startswith(DEFAULT_REPOSITORIES["testpypi"].url.rstrip("/")): + base = "https://test.pypi.org/" + else: + return set() + return { + f"{base}project/{package.metadata['name']}/{package.metadata['version']}/" + for package in packages + } + + def upload( + self, package: PackageFile, progress: rich.progress.Progress + ) -> requests.Response: + payload = package.metadata_dict + payload.update( + { + ":action": "file_upload", + "protocol_version": "1", + } + ) + field_parts = self._convert_to_list_of_tuples(payload) + + progress.live.console.print(f"Uploading [green]{package.base_filename}") + + with open(package.filename, "rb") as fp: + field_parts.append( + ("content", (package.base_filename, fp, "application/octet-stream")) + ) + + def on_upload(monitor: requests_toolbelt.MultipartEncoderMonitor) -> None: + progress.update(job, completed=monitor.bytes_read) + + monitor = requests_toolbelt.MultipartEncoderMonitor.from_fields( + field_parts, callback=on_upload + ) + job = progress.add_task("", total=monitor.len) + return self.session.post( + self.url, + data=monitor, + headers={"Content-Type": monitor.content_type}, + allow_redirects=False, + ) diff --git a/pdm/cli/completions/pdm.bash b/pdm/cli/completions/pdm.bash index dd4fae8c0e..4f4af269ab 100644 --- a/pdm/cli/completions/pdm.bash +++ b/pdm/cli/completions/pdm.bash @@ -1,7 +1,7 @@ # BASH completion script for pdm # Generated by pycomplete 0.3.2 -_pdm_f954f1f200cdc31f_complete() +_pdm_25182a7ef85b840e_complete() { local cur script coms opts com COMPREPLY=() @@ -80,6 +80,10 @@ _pdm_f954f1f200cdc31f_complete() opts="--help --verbose" ;; + (publish) + opts="--comment --help --identity --no-build --password --project --repository --sign --username --verbose" + ;; + (remove) opts="--dev --dry-run --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --project --verbose" ;; @@ -118,7 +122,7 @@ _pdm_f954f1f200cdc31f_complete() # completing for a command if [[ $cur == $com ]]; then - coms="add build cache completion config export import info init install list lock plugin remove run search show sync update use" + coms="add build cache completion config export import info init install list lock plugin publish remove run search show sync update use" COMPREPLY=($(compgen -W "${coms}" -- ${cur})) __ltrim_colon_completions "$cur" @@ -127,4 +131,4 @@ _pdm_f954f1f200cdc31f_complete() fi } -complete -o default -F _pdm_f954f1f200cdc31f_complete pdm +complete -o default -F _pdm_25182a7ef85b840e_complete pdm diff --git a/pdm/cli/completions/pdm.fish b/pdm/cli/completions/pdm.fish index 05834ab9e7..0539ad3fcc 100644 --- a/pdm/cli/completions/pdm.fish +++ b/pdm/cli/completions/pdm.fish @@ -1,9 +1,9 @@ # FISH completion script for pdm # Generated by pycomplete 0.3.2 -function __fish_pdm_f954f1f200cdc31f_complete_no_subcommand +function __fish_pdm_7426f3abf02b4bb8_complete_no_subcommand for i in (commandline -opc) - if contains -- $i add build cache completion config export import info init install list lock plugin remove run search show sync update use + if contains -- $i add build cache completion config export import info init install list lock plugin publish remove run search show sync update use return 1 end end @@ -11,34 +11,35 @@ function __fish_pdm_f954f1f200cdc31f_complete_no_subcommand end # global options -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l config -d 'Specify another config file path(env var: PDM_CONFIG_FILE)' -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l help -d 'show this help message and exit' -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l ignore-python -d 'Ignore the Python path saved in the pdm.toml config' -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l pep582 -d 'Print the command line to be eval\'d by the shell' -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l verbose -d '-v for detailed output and -vv for more detailed' -complete -c pdm -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -l version -d 'Show version' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l config -d 'Specify another config file path(env var: PDM_CONFIG_FILE)' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l help -d 'show this help message and exit' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l ignore-python -d 'Ignore the Python path saved in the pdm.toml config' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l pep582 -d 'Print the command line to be eval\'d by the shell' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l verbose -d '-v for detailed output and -vv for more detailed' +complete -c pdm -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -l version -d 'Show version' # commands -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a add -d 'Add package(s) to pyproject.toml and install them' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a build -d 'Build artifacts for distribution' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a cache -d 'Control the caches of PDM' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a completion -d 'Generate completion scripts for the given shell' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a config -d 'Display the current configuration' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a export -d 'Export the locked packages set to other formats' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a import -d 'Import project metadata from other formats' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a info -d 'Show the project information' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a init -d 'Initialize a pyproject.toml for PDM' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a install -d 'Install dependencies from lock file' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a list -d 'List packages installed in the current working set' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a lock -d 'Resolve and lock dependencies' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a plugin -d 'Manage the PDM plugins' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a remove -d 'Remove packages from pyproject.toml' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a run -d 'Run commands or scripts with local packages loaded' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a search -d 'Search for PyPI packages' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a show -d 'Show the package information' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a sync -d 'Synchronize the current working set with lock file' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a update -d 'Update package(s) in pyproject.toml' -complete -c pdm -f -n '__fish_pdm_f954f1f200cdc31f_complete_no_subcommand' -a use -d 'Use the given python version or path as base interpreter' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a add -d 'Add package(s) to pyproject.toml and install them' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a build -d 'Build artifacts for distribution' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a cache -d 'Control the caches of PDM' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a completion -d 'Generate completion scripts for the given shell' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a config -d 'Display the current configuration' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a export -d 'Export the locked packages set to other formats' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a import -d 'Import project metadata from other formats' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a info -d 'Show the project information' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a init -d 'Initialize a pyproject.toml for PDM' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a install -d 'Install dependencies from lock file' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a list -d 'List packages installed in the current working set' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a lock -d 'Resolve and lock dependencies' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a plugin -d 'Manage the PDM plugins' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a publish -d 'Build and publish the project to PyPI' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a remove -d 'Remove packages from pyproject.toml' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a run -d 'Run commands or scripts with local packages loaded' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a search -d 'Search for PyPI packages' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a show -d 'Show the package information' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a sync -d 'Synchronize the current working set with lock file' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a update -d 'Update package(s) in pyproject.toml' +complete -c pdm -f -n '__fish_pdm_7426f3abf02b4bb8_complete_no_subcommand' -a use -d 'Use the given python version or path as base interpreter' # command options @@ -49,7 +50,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from add' -l editable -d 'Specify complete -c pdm -A -n '__fish_seen_subcommand_from add' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from add' -l group -d 'Specify the target dependency group to add into' complete -c pdm -A -n '__fish_seen_subcommand_from add' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from add' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from add' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from add' -l no-editable -d 'Install non-editable versions for all packages' complete -c pdm -A -n '__fish_seen_subcommand_from add' -l no-isolation -d 'Do not isolate the build in a clean environment' complete -c pdm -A -n '__fish_seen_subcommand_from add' -l no-self -d 'Don\'t install the project itself' @@ -98,7 +99,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from export' -l format -d 'Specify complete -c pdm -A -n '__fish_seen_subcommand_from export' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from export' -l group -d 'Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species.' complete -c pdm -A -n '__fish_seen_subcommand_from export' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from export' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from export' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from export' -l no-default -d 'Don\'t include dependencies from the default group' complete -c pdm -A -n '__fish_seen_subcommand_from export' -l output -d 'Write output to the given file, or print to stdout if not given' complete -c pdm -A -n '__fish_seen_subcommand_from export' -l production -d 'Unselect dev dependencies' @@ -140,7 +141,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from install' -l dry-run -d 'Show complete -c pdm -A -n '__fish_seen_subcommand_from install' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from install' -l group -d 'Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species.' complete -c pdm -A -n '__fish_seen_subcommand_from install' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from install' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from install' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from install' -l no-default -d 'Don\'t include dependencies from the default group' complete -c pdm -A -n '__fish_seen_subcommand_from install' -l no-editable -d 'Install non-editable versions for all packages' complete -c pdm -A -n '__fish_seen_subcommand_from install' -l no-isolation -d 'Do not isolate the build in a clean environment' @@ -163,7 +164,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from list' -l verbose -d '-v for d # lock complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l no-isolation -d 'Do not isolate the build in a clean environment' complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l project -d 'Specify another path as the project root, which changes the base of pyproject.toml and __pypackages__' complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l refresh -d 'Don\'t update pinned versions, only refresh the lock file' @@ -173,13 +174,25 @@ complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l verbose -d '-v for d complete -c pdm -A -n '__fish_seen_subcommand_from plugin' -l help -d 'show this help message and exit' complete -c pdm -A -n '__fish_seen_subcommand_from plugin' -l verbose -d '-v for detailed output and -vv for more detailed' +# publish +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l comment -d 'The comment to include with the distribution file.' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l help -d 'show this help message and exit' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l identity -d 'GPG identity used to sign files.' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l no-build -d 'Don\'t build the package before publishing' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l password -d 'The password to access the repository [env var: PDM_PUBLISH_PASSWORD]' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l project -d 'Specify another path as the project root, which changes the base of pyproject.toml and __pypackages__' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l repository -d 'The repository name or url to publish the package to [env var: PDM_PUBLISH_REPO]' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l sign -d 'Upload the package with PGP signature' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l username -d 'The username to access the repository [env var: PDM_PUBLISH_USERNAME]' +complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l verbose -d '-v for detailed output and -vv for more detailed' + # remove complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l dev -d 'Remove packages from dev dependencies' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l dry-run -d 'Show the difference only and don\'t perform any action' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l group -d 'Specify the target dependency group to remove from' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l no-editable -d 'Install non-editable versions for all packages' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l no-isolation -d 'Do not isolate the build in a clean environment' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l no-self -d 'Don\'t install the project itself' @@ -218,7 +231,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l dry-run -d 'Show the complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l group -d 'Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species.' complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l no-clean -d 'don\'t clean unused packages' complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l no-default -d 'Don\'t include dependencies from the default group' complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l no-editable -d 'Install non-editable versions for all packages' @@ -234,7 +247,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from update' -l dev -d 'Select dev complete -c pdm -A -n '__fish_seen_subcommand_from update' -l global -d 'Use the global project, supply the project root with `-p` option' complete -c pdm -A -n '__fish_seen_subcommand_from update' -l group -d 'Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species.' complete -c pdm -A -n '__fish_seen_subcommand_from update' -l help -d 'show this help message and exit' -complete -c pdm -A -n '__fish_seen_subcommand_from update' -l lockfile -d 'Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock' +complete -c pdm -A -n '__fish_seen_subcommand_from update' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]' complete -c pdm -A -n '__fish_seen_subcommand_from update' -l no-default -d 'Don\'t include dependencies from the default group' complete -c pdm -A -n '__fish_seen_subcommand_from update' -l no-editable -d 'Install non-editable versions for all packages' complete -c pdm -A -n '__fish_seen_subcommand_from update' -l no-isolation -d 'Do not isolate the build in a clean environment' diff --git a/pdm/cli/completions/pdm.ps1 b/pdm/cli/completions/pdm.ps1 index 3f0759020c..17d47efb95 100644 --- a/pdm/cli/completions/pdm.ps1 +++ b/pdm/cli/completions/pdm.ps1 @@ -191,7 +191,7 @@ function TabExpansion($line, $lastWord) { if ($lastBlock -match "^pdm ") { [string[]]$words = $lastBlock.Split()[1..$lastBlock.Length] - [string[]]$AllCommands = ("add", "build", "cache", "completion", "config", "export", "import", "info", "init", "install", "list", "lock", "plugin", "remove", "run", "search", "show", "sync", "update", "use") + [string[]]$AllCommands = ("add", "build", "cache", "completion", "config", "export", "import", "info", "init", "install", "list", "lock", "plugin", "publish", "remove", "run", "search", "show", "sync", "update", "use") [string[]]$commands = $words.Where( { $_ -notlike "-*" }) $command = $commands[0] $completer = [Completer]::new() @@ -313,6 +313,14 @@ function TabExpansion($line, $lastWord) { } break } + "publish" { + $completer.AddOpts( + @( + [Option]::new(@("-r", "--repository", "-u", "--username", "-P", "--password", "-S", "--sign", "-i", "--identity", "-c", "--comment", "--no-build")), + $projectOption + )) + break + } "remove" { $completer.AddOpts( @( diff --git a/pdm/cli/completions/pdm.zsh b/pdm/cli/completions/pdm.zsh index 0eb7f7def1..694b14c277 100644 --- a/pdm/cli/completions/pdm.zsh +++ b/pdm/cli/completions/pdm.zsh @@ -28,6 +28,7 @@ _pdm() { 'list:List packages installed in the current working set' 'lock:Resolve and lock dependencies' 'plugin:Manage the PDM plugins' + 'publish:Build and publish the project to PyPI' 'remove:Remove packages from pyproject.toml' 'run:Run commands or scripts with local packages loaded' 'search:Search for PyPI packages' @@ -106,7 +107,7 @@ _pdm() { args) case $words[1] in clear) - compadd -X type 'hashes' 'http' 'wheels' 'metadata' && ret=0 + compadd -X type 'hashes' 'http' 'wheels' 'metadata' 'packages' && ret=0 ;; *) _message "pattern" && ret=0 @@ -237,6 +238,17 @@ _pdm() { esac return $ret ;; + publish) + arguments+=( + {-r,--repository}'[The repository name or url to publish the package to }[env var: PDM_PUBLISH_REPO]]:repository:' + {-u,--username}'[The username to access the repository [env var: PDM_PUBLISH_USERNAME]]:username:' + {-P,--password}'[The password to access the repository [env var: PDM_PUBLISH_PASSWORD]]:password:' + {-S,--sign}'[Upload the package with PGP signature]' + {-i,--identity}'[GPG identity used to sign files.]:gpg identity:' + {-c,--comment}'[The comment to include with the distribution file.]:comment:' + "--no-build[Don't build the package before publishing]" + ) + ;; remove) arguments+=( {-g,--global}'[Use the global project, supply the project root with `-p` option]' diff --git a/pdm/cli/options.py b/pdm/cli/options.py index c25d79d723..978fd9dbe6 100644 --- a/pdm/cli/options.py +++ b/pdm/cli/options.py @@ -117,8 +117,7 @@ def wrapped_type(obj: Any) -> Any: "-L", "--lockfile", default=os.getenv("PDM_LOCKFILE"), - help="Specify another lockfile path, or use `PDM_LOCKFILE` env variable. " - "Default: pdm.lock", + help="Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]", ) pep582_option = Option( diff --git a/pdm/exceptions.py b/pdm/exceptions.py index d011273b96..a018f63242 100644 --- a/pdm/exceptions.py +++ b/pdm/exceptions.py @@ -14,15 +14,19 @@ class PdmUsageError(PdmException): pass -class RequirementError(PdmException, ValueError): +class RequirementError(PdmUsageError, ValueError): pass -class InvalidPyVersion(PdmException, ValueError): +class PublishError(PdmUsageError): pass -class CorruptedCacheError(PdmException): +class InvalidPyVersion(PdmUsageError, ValueError): + pass + + +class CorruptedCacheError(PdmUsageError): pass @@ -57,12 +61,12 @@ class UninstallError(PdmException): pass -class NoConfigError(PdmException, KeyError): +class NoConfigError(PdmUsageError, KeyError): def __init__(self, key: str) -> None: super().__init__("No such config item: {}".format(key)) -class NoPythonVersion(PdmException): +class NoPythonVersion(PdmUsageError): pass diff --git a/pdm/installers/synchronizers.py b/pdm/installers/synchronizers.py index 5e3ed6e584..f3a5ce6c80 100644 --- a/pdm/installers/synchronizers.py +++ b/pdm/installers/synchronizers.py @@ -6,6 +6,8 @@ from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING, Any, Callable, Collection, TypeVar +from rich.progress import SpinnerColumn + from pdm import termui from pdm.exceptions import InstallationError from pdm.installers.manager import InstallManager @@ -371,7 +373,11 @@ def update_progress(future: Future | DummyFuture, kind: str, key: str) -> None: ) # get rich progess and live handler to deal with multiple spinners - with self.ui.logging("install"), self.ui.make_progress() as progress: + with self.ui.logging("install"), self.ui.make_progress( + " ", + SpinnerColumn(termui.SPINNER, speed=1, style="bold cyan"), + "{task.description}", + ) as progress: live = progress.live for kind, key in sequential_jobs: handlers[kind](key, progress) diff --git a/pdm/models/session.py b/pdm/models/session.py index fd8acb55fe..cffdc5b695 100644 --- a/pdm/models/session.py +++ b/pdm/models/session.py @@ -4,8 +4,11 @@ from cachecontrol.adapter import CacheControlAdapter from cachecontrol.caches import FileCache +from requests_toolbelt.utils import user_agent from unearth.session import InsecureMixin, PyPISession +from pdm.__version__ import __version__ + class InsecureCacheControlAdapter(InsecureMixin, CacheControlAdapter): pass @@ -20,3 +23,11 @@ def __init__(self, *, cache_dir: Path, **kwargs: Any) -> None: InsecureCacheControlAdapter, cache=FileCache(str(cache_dir)) ) super().__init__(**kwargs) + self.headers["User-Agent"] = self._make_user_agent() + + def _make_user_agent(self) -> str: + return ( + user_agent.UserAgentBuilder("pdm", __version__) + .include_implementation() + .build() + ) diff --git a/pdm/project/config.py b/pdm/project/config.py index f707fdff9a..0795ea8a83 100644 --- a/pdm/project/config.py +++ b/pdm/project/config.py @@ -1,28 +1,54 @@ +from __future__ import annotations + +import collections import dataclasses import os from pathlib import Path -from typing import Any, Callable, Dict, Iterator, MutableMapping, Optional, Set, TypeVar +from typing import Any, Callable, Iterator, Mapping, MutableMapping, TypeVar import platformdirs import tomlkit from pdm import termui -from pdm.exceptions import NoConfigError +from pdm.exceptions import NoConfigError, PdmUsageError T = TypeVar("T") ui = termui.UI() +REPOSITORY = "repository" + + +@dataclasses.dataclass +class RepositoryConfig: + url: str + username: str | None = None + password: str | None = None + + def __rich__(self) -> str: + lines = [f"[cyan]url[/] = {self.url}"] + if self.username: + lines.append(f"[cyan]username[/] = {self.username}") + if self.password: + lines.append("[cyan]password[/] = ") + return "\n".join(lines) + + +DEFAULT_REPOSITORIES = { + "pypi": RepositoryConfig("https://upload.pypi.org/legacy/"), + "testpypi": RepositoryConfig("https://test.pypi.org/legacy/"), +} + -def load_config(file_path: Path) -> Dict[str, Any]: +def load_config(file_path: Path) -> dict[str, Any]: """Load a nested TOML document into key-value paires E.g. ["python"]["path"] will be loaded as "python.path" key. """ - def get_item(sub_data: Dict[str, Any]) -> Dict[str, Any]: + def get_item(sub_data: Mapping[str, Any]) -> Mapping[str, Any]: result = {} for k, v in sub_data.items(): - if getattr(v, "items", None) is not None: + if k != REPOSITORY and isinstance(v, Mapping): result.update( {f"{k}.{sub_k}": sub_v for sub_k, sub_v in get_item(v).items()} ) @@ -64,9 +90,9 @@ class ConfigItem: description: str default: Any = _NOT_SET global_only: bool = False - env_var: Optional[str] = None + env_var: str | None = None coerce: Callable = str - replace: Optional[str] = None + replace: str | None = None def should_show(self) -> bool: return self.default is not self._NOT_SET @@ -75,7 +101,7 @@ def should_show(self) -> bool: class Config(MutableMapping[str, str]): """A dict-like object for configuration key and values""" - _config_map: Dict[str, ConfigItem] = { + _config_map: dict[str, ConfigItem] = { "cache_dir": ConfigItem( "The root directory of cached files", platformdirs.user_cache_dir("pdm"), @@ -175,7 +201,7 @@ class Config(MutableMapping[str, str]): } @classmethod - def get_defaults(cls) -> Dict[str, Any]: + def get_defaults(cls) -> dict[str, Any]: return {k: v.default for k, v in cls._config_map.items() if v.should_show()} @classmethod @@ -184,22 +210,20 @@ def add_config(cls, name: str, item: ConfigItem) -> None: cls._config_map[name] = item def __init__(self, config_file: Path, is_global: bool = False): - self._data = {} - if is_global: - self._data.update(self.get_defaults()) - self.is_global = is_global self.config_file = config_file.resolve() self._file_data = load_config(self.config_file) self.deprecated = { v.replace: k for k, v in self._config_map.items() if v.replace } - self._data.update(self._file_data) + self._data = collections.ChainMap( + self._file_data, self.get_defaults() if is_global else {} + ) def _save_config(self) -> None: """Save the changed to config file.""" self.config_file.parent.mkdir(parents=True, exist_ok=True) - toml_data: Dict[str, Any] = {} + toml_data: dict[str, Any] = {} for key, value in self._file_data.items(): *parts, last = key.split(".") temp = toml_data @@ -213,6 +237,19 @@ def _save_config(self) -> None: tomlkit.dump(toml_data, fp) # type: ignore def __getitem__(self, key: str) -> Any: + parts = key.split(".") + if parts[0] == REPOSITORY: + if len(parts) < 2: + raise PdmUsageError("Must specify a repository name") + repo = self.get_repository_config(parts[1]) + if repo is None: + raise KeyError(f"No repository named {parts[1]}") + + value = getattr(repo, parts[2]) if len(parts) >= 3 else repo + if len(parts) >= 3 and parts[2] == "password" and value: + return "" + return value + if key not in self._config_map and key not in self.deprecated: raise NoConfigError(key) config_key = self.deprecated.get(key, key) @@ -230,6 +267,17 @@ def __getitem__(self, key: str) -> Any: return config.coerce(result) def __setitem__(self, key: str, value: Any) -> None: + parts = key.split(".") + if parts[0] == REPOSITORY: + if len(parts) < 3: + raise PdmUsageError( + "Set repository config with [green]repository.{name}.{attr}" + ) + self._file_data.setdefault(parts[0], {}).setdefault( + parts[1], {} + ).setdefault(parts[2], value) + self._save_config() + return if key not in self._config_map and key not in self.deprecated: raise NoConfigError(key) config_key = self.deprecated.get(key, key) @@ -247,10 +295,8 @@ def __setitem__(self, key: str, value: Any) -> None: "the value set won't take effect.".format(env_var), style="yellow", ) - self._data[config_key] = value self._file_data[config_key] = value if config.replace: - self._data.pop(config.replace, None) self._file_data.pop(config.replace, None) self._save_config() @@ -258,7 +304,7 @@ def __len__(self) -> int: return len(self._data) def __iter__(self) -> Iterator[str]: - keys: Set[str] = set() + keys: set[str] = set() for key in self._data: if key in self._config_map: keys.add(key) @@ -267,14 +313,20 @@ def __iter__(self) -> Iterator[str]: return iter(keys) def __delitem__(self, key: str) -> None: + parts = key.split(".") + if parts[0] == REPOSITORY: + if len(parts) < 2: + raise PdmUsageError("Should specify the name of repository") + if len(parts) >= 3: + del self._file_data.get(REPOSITORY, {}).get(parts[1], {})[parts[2]] + else: + del self._file_data.get(REPOSITORY, {})[parts[1]] + self._save_config() + return config_key = self.deprecated.get(key, key) config = self._config_map[config_key] - self._data.pop(config_key, None) self._file_data.pop(config_key, None) - if self.is_global and config.should_show(): - self._data[config_key] = config.default if config.replace: - self._data.pop(config.replace, None) self._file_data.pop(config.replace, None) env_var = config.env_var @@ -285,3 +337,29 @@ def __delitem__(self, key: str) -> None: style="yellow", ) self._save_config() + + def get_repository_config(self, name_or_url: str) -> RepositoryConfig | None: + """Get a repository by name or url.""" + if not self.is_global: # pragma: no cover + raise PdmUsageError("No repository config in project config.") + repositories: Mapping[str, Mapping[str, str | None]] = self._data.get( + REPOSITORY, {} + ) + repo: RepositoryConfig | None = None + if "://" in name_or_url: + config: Mapping[str, str | None] = next( + (v for v in repositories.values() if v.get("url") == name_or_url), {} + ) + repo = next( + (r for r in DEFAULT_REPOSITORIES.values() if r.url == name_or_url), + RepositoryConfig(name_or_url), + ) + else: + config = repositories.get(name_or_url, {}) + if name_or_url in DEFAULT_REPOSITORIES: + repo = DEFAULT_REPOSITORIES[name_or_url] + if repo: + return dataclasses.replace(repo, **config) + if not config: + return None + return RepositoryConfig(**config) # type: ignore diff --git a/pdm/termui.py b/pdm/termui.py index 2747a1cf1a..f4fdcd8931 100644 --- a/pdm/termui.py +++ b/pdm/termui.py @@ -10,7 +10,7 @@ from rich.box import ROUNDED from rich.console import Console -from rich.progress import Progress, SpinnerColumn +from rich.progress import Progress, ProgressColumn from rich.prompt import Confirm, IntPrompt, Prompt from rich.table import Table @@ -230,11 +230,11 @@ def open_spinner(self, title: str) -> Spinner: else: return _console.status(title, spinner=SPINNER, spinner_style="bold cyan") - def make_progress(self) -> Progress: + def make_progress(self, *columns: str | ProgressColumn, **kwargs: Any) -> Progress: """create a progress instance for indented spinners""" return Progress( - " ", - SpinnerColumn(SPINNER, speed=1, style="bold cyan"), - "{task.description}", + *columns, + console=_console, disable=self.verbosity >= Verbosity.DETAIL, + **kwargs, ) diff --git a/pyproject.toml b/pyproject.toml index e6e571f4ec..aed98fe5c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "typing-extensions; python_version < \"3.8\"", "unearth>=0.3.0,<0.4.0", "cachecontrol[filecache]>=0.12.11", + "requests-toolbelt", ] name = "pdm" description = "Python Development Master" diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 9a79bc5b92..80e4269368 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -2,6 +2,7 @@ import pytest +from pdm.exceptions import PdmUsageError from pdm.utils import cd @@ -112,3 +113,97 @@ def test_specify_config_file(tmp_path, invoke): result = invoke(["config", "project_max_depth"]) assert result.exit_code == 0 assert result.output.strip() == "9" + + +def test_default_repository_setting(project): + repository = project.global_config.get_repository_config("pypi") + assert repository.url == "https://upload.pypi.org/legacy/" + assert repository.username is None + assert repository.password is None + + repository = project.global_config.get_repository_config("testpypi") + assert repository.url == "https://test.pypi.org/legacy/" + + repository = project.global_config.get_repository_config("nonexist") + assert repository is None + + +def test_repository_config_not_available_on_project(project): + with pytest.raises(PdmUsageError): + project.project_config.get_repository_config("pypi") + + +def test_repository_config_key_short(project): + with pytest.raises(PdmUsageError): + project.global_config["repository.test"] = {"url": "https://example.org/simple"} + + with pytest.raises(PdmUsageError): + project.global_config["repository"] = "123" + + with pytest.raises(PdmUsageError): + del project.global_config["repository"] + + +def test_repostory_overwrite_default(project): + project.global_config["repository.pypi.username"] = "foo" + project.global_config["repository.pypi.password"] = "bar" + repository = project.global_config.get_repository_config("pypi") + assert repository.url == "https://upload.pypi.org/legacy/" + assert repository.username == "foo" + assert repository.password == "bar" + + project.global_config["repository.pypi.url"] = "https://example.pypi.org/legacy/" + repository = project.global_config.get_repository_config("pypi") + assert repository.url == "https://example.pypi.org/legacy/" + + +def test_hide_password_in_output(project, invoke): + assert project.global_config["repository.pypi.password"] is None + project.global_config["repository.pypi.username"] = "testuser" + project.global_config["repository.pypi.password"] = "secret" + result = invoke(["config", "repository.pypi"], obj=project, strict=True) + assert "password = " in result.output + result = invoke(["config", "repository.pypi.password"], obj=project, strict=True) + assert "" == result.output.strip() + + +def test_config_get_repository(project, invoke): + config = project.global_config["repository.pypi"] + assert config == project.global_config.get_repository_config("pypi") + assert ( + project.global_config["repository.pypi.url"] + == "https://upload.pypi.org/legacy/" + ) + + result = invoke(["config", "repository.pypi"], obj=project, strict=True) + assert result.stdout.strip() == "url = https://upload.pypi.org/legacy/" + + assert ( + project.global_config.get_repository_config( + "https://example.pypi.org/legacy/" + ).url + == "https://example.pypi.org/legacy/" + ) + + result = invoke(["config", "repository.pypi.url"], obj=project, strict=True) + assert result.stdout.strip() == "https://upload.pypi.org/legacy/" + + +def test_config_set_repository(project): + project.global_config["repository.pypi.url"] = "https://example.pypi.org/legacy/" + project.global_config["repository.pypi.username"] = "foo" + assert ( + project.global_config["repository.pypi.url"] + == "https://example.pypi.org/legacy/" + ) + assert project.global_config["repository.pypi.username"] == "foo" + del project.global_config["repository.pypi.username"] + assert project.global_config["repository.pypi.username"] is None + + +def test_config_del_repository(project): + project.global_config["repository.test.url"] = "https://example.org/simple" + assert project.global_config.get_repository_config("test") is not None + + del project.global_config["repository.test"] + assert project.global_config.get_repository_config("test") is None diff --git a/tests/cli/test_publish.py b/tests/cli/test_publish.py new file mode 100644 index 0000000000..9b500f1e6b --- /dev/null +++ b/tests/cli/test_publish.py @@ -0,0 +1,189 @@ +import os +import shutil +from argparse import Namespace + +import pytest +import requests + +from pdm.cli.commands.publish import Command as PublishCommand +from pdm.cli.commands.publish.package import PackageFile +from pdm.cli.commands.publish.repository import Repository +from tests import FIXTURES + + +@pytest.fixture(autouse=True) +def mock_run_gpg(mocker): + def mock_run_gpg(args): + signature_file = args[-1] + ".asc" + with open(signature_file, "wb") as f: + f.write(b"fake signature") + + mocker.patch.object(PackageFile, "_run_gpg", side_effect=mock_run_gpg) + + +@pytest.fixture() +def prepare_packages(tmp_path): + dist_path = tmp_path / "dist" + dist_path.mkdir() + for filename in [ + "demo-0.0.1-py2.py3-none-any.whl", + "demo-0.0.1.tar.gz", + "demo-0.0.1.zip", + ]: + shutil.copy2(FIXTURES / "artifacts" / filename, dist_path) + + +@pytest.fixture() +def mock_pypi(mocker): + def post(url, *, data, **kwargs): + # consume the data body to make the progress complete + data.read() + resp = requests.Response() + resp.status_code = 200 + resp.reason = "OK" + resp.url = url + return resp + + return mocker.patch("pdm.models.session.PDMSession.post", side_effect=post) + + +@pytest.fixture() +def uploaded(mocker): + packages = [] + + def fake_upload(package, progress): + packages.append(package) + resp = requests.Response() + resp.status_code = 200 + resp.reason = "OK" + resp.url = "https://upload.pypi.org/legacy/" + return resp + + mocker.patch.object(Repository, "upload", side_effect=fake_upload) + return packages + + +@pytest.mark.parametrize( + "filename", + ["demo-0.0.1-py2.py3-none-any.whl", "demo-0.0.1.tar.gz", "demo-0.0.1.zip"], +) +def test_package_parse_metadata(filename): + fullpath = FIXTURES / "artifacts" / filename + package = PackageFile.from_filename(str(fullpath), None) + assert package.base_filename == filename + meta = package.metadata_dict + assert meta["name"] == "demo" + assert meta["version"] == "0.0.1" + assert all( + f"{hash_name}_digest" in meta for hash_name in ["md5", "sha256", "blake2_256"] + ) + + if filename.endswith(".whl"): + assert meta["pyversion"] == "py2.py3" + assert meta["filetype"] == "bdist_wheel" + else: + assert meta["pyversion"] == "source" + assert meta["filetype"] == "sdist" + + +def test_package_add_signature(tmp_path): + package = PackageFile.from_filename( + str(FIXTURES / "artifacts/demo-0.0.1-py2.py3-none-any.whl"), None + ) + tmp_path.joinpath("signature.asc").write_bytes(b"test gpg signature") + package.add_gpg_signature(str(tmp_path / "signature.asc"), "signature.asc") + assert package.gpg_signature == ("signature.asc", b"test gpg signature") + + +def test_package_call_gpg_sign(): + package = PackageFile.from_filename( + str(FIXTURES / "artifacts/demo-0.0.1-py2.py3-none-any.whl"), None + ) + try: + package.sign(None) + finally: + try: + os.unlink(package.filename + ".asc") + except OSError: + pass + assert package.gpg_signature == (package.base_filename + ".asc", b"fake signature") + + +def test_repository_get_release_urls(project): + package_files = [ + PackageFile.from_filename(str(FIXTURES / "artifacts" / fn), None) + for fn in [ + "demo-0.0.1-py2.py3-none-any.whl", + "demo-0.0.1.tar.gz", + "demo-0.0.1.zip", + ] + ] + repository = Repository(project, "https://upload.pypi.org/legacy/", None, None) + assert repository.get_release_urls(package_files) == { + "https://pypi.org/project/demo/0.0.1/" + } + + repository = Repository(project, "https://example.pypi.org/legacy/", None, None) + assert not repository.get_release_urls(package_files) + + +@pytest.mark.usefixtures("prepare_packages") +def test_publish_pick_up_asc_files(project, uploaded, invoke): + for p in list(project.root.joinpath("dist").iterdir()): + with open(str(p) + ".asc", "w") as f: + f.write("fake signature") + + invoke(["publish", "--no-build"], obj=project, strict=True) + # Test wheels are uploaded first + assert uploaded[0].base_filename.endswith(".whl") + for package in uploaded: + assert package.gpg_signature == ( + package.base_filename + ".asc", + b"fake signature", + ) + + +@pytest.mark.usefixtures("prepare_packages") +def test_publish_package_with_signature(project, uploaded, invoke): + invoke(["publish", "--no-build", "-S"], obj=project, strict=True) + for package in uploaded: + assert package.gpg_signature == ( + package.base_filename + ".asc", + b"fake signature", + ) + + +@pytest.mark.usefixtures("local_finder") +def test_publish_and_build_in_one_run(fixture_project, invoke, mock_pypi): + project = fixture_project("demo-module") + result = invoke(["publish"], obj=project, strict=True).output + + mock_pypi.assert_called() + assert "Uploading demo_module-0.1.0-py3-none-any.whl" in result + assert "Uploading demo-module-0.1.0.tar.gz" in result + assert "https://pypi.org/project/demo-module/0.1.0/" in result + + +def test_publish_cli_args_and_env_var_precedence(project, monkeypatch): + repo = PublishCommand.get_repository( + project, Namespace(repository=None, username="foo", password="bar") + ) + assert repo.url == "https://upload.pypi.org/legacy/" + assert repo.session.auth == ("foo", "bar") + + with monkeypatch.context() as m: + m.setenv("PDM_PUBLISH_USERNAME", "bar") + m.setenv("PDM_PUBLISH_PASSWORD", "secret") + m.setenv("PDM_PUBLISH_REPO", "testpypi") + + repo = PublishCommand.get_repository( + project, Namespace(repository=None, username=None, password=None) + ) + assert repo.url == "https://test.pypi.org/legacy/" + assert repo.session.auth == ("bar", "secret") + + repo = PublishCommand.get_repository( + project, Namespace(repository="pypi", username="foo", password=None) + ) + assert repo.url == "https://upload.pypi.org/legacy/" + assert repo.session.auth == ("foo", "secret")