diff --git a/.github/workflows/check-master.yml b/.github/workflows/check-master.yml index 3203a694..2f5afdd8 100644 --- a/.github/workflows/check-master.yml +++ b/.github/workflows/check-master.yml @@ -34,6 +34,7 @@ jobs: with: auto-update-conda: true python-version: ${{ matrix.python-version }} + miniconda-version: "latest" - name: Install latest conda run: | @@ -41,7 +42,7 @@ jobs: - name: Install dependencies run: | - conda install python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt + conda install -c defaults -c anaconda-cloud python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt pip install -r requirements-dev.txt python setup.py develop --no-deps @@ -79,7 +80,7 @@ jobs: - name: Export reports if: ${{ always() && steps.conda_environment_information.outputs.exit_status == 'success' }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: report-lint-${{ matrix.python-version }}-${{ matrix.os }} path: .artifacts/reports @@ -100,13 +101,14 @@ jobs: # unfortunately, as of June 2021 - GitHub doesn't support anchors for action scripts - name: Checkout project - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup Miniconda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 with: auto-update-conda: true python-version: ${{ matrix.python-version }} + miniconda-version: "latest" - name: Install latest conda run: | @@ -114,7 +116,7 @@ jobs: - name: Install dependencies run: | - conda install python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt + conda install -c defaults -c anaconda-cloud python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt pip install -r requirements-dev.txt python setup.py develop --no-deps @@ -132,7 +134,7 @@ jobs: - name: Export reports if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: report-test-${{ matrix.python-version }}-${{ matrix.os }} path: .artifacts/reports diff --git a/.gitignore b/.gitignore index 5c3b77c5..beca9abd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pip-log.txt # Unit test / coverage reports .coveragerc .coverage +.coverage.* .tox nosetests.xml @@ -48,3 +49,6 @@ __conda_*__.txt # Additional files /.artifacts/ + +# Local dev environment +env/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cdf1f9..45286d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ We [keep a changelog.](http://keepachangelog.com/) +## 1.13.0 + +As part of extending our CLI, we have been migrating to a plugin-based system. +This release removes the `anaconda` entrypoint from `anaconda-client`, which is now located instead in `anaconda-cli-base`. +When these changes are installed, we don't intend any breaking behavior for the user. + +Any changes are generally dependent on the presence of other Anaconda CLI plugins. +In the case that another plugin is installed (like `anaconda-cloud-auth`), the following changes may be observed: + +* All existing `anaconda-client` subcommands are available +* All existing `anaconda-client` subcommands are ALSO available with the `org` prefix, e.g. `anaconda org upload`. + It is recommended to adopt this format, as it is more explicit. +* The help text will be modified (i.e. type `anaconda`). + Not all `anaconda-client` subcommands will be shown. +* The `anaconda notebooks` subcommand will explicitly be listed as deprecated in the CLI help. + +In order to maintain backwards compatibility for all subcommands, we include logic to mount all existing subcommands from `anaconda-client` as top-level subcommands. +`anaconda-client` subcommands continue to work until overridden by new plugins, but may not be displayed in the global help output. +We also include all subcommands under the namespace `anaconda org`, e.g. `anaconda org upload`. +The help for all nested subcommands can be accessed with `anaconda org --help`. + +Users may disable the new plugin system by setting the environment variable `ANACONDA_CLIENT_FORCE_STANDALONE=1`. + +In addition to automated testing, this release has undergone an extensive internal QA process. +However, if any unintended regressions do occur, please file a bug in our issue tracker. + +### Pull requests merged + +* [PR 717](https://github.com/anaconda/anaconda-client/pull/717) - Drop dependency on `six` +* [PR 718](https://github.com/anaconda/anaconda-client/pull/718) - Fix local development setup via `make init` +* [PR 719](https://github.com/anaconda/anaconda-client/pull/719) - Migrate `anaconda-client` to become a plugin of `anaconda-cli-base` +* [PR 711](https://github.com/anaconda/anaconda-client/pull/711) - Remove duplicate import +* [PR 714](https://github.com/anaconda/anaconda-client/pull/714) - Updated links in upload help text +* [PR 724](https://github.com/anaconda/anaconda-client/pull/724) - Use conda package streaming to fix bug in upload of packages with bad permissions + ## 1.12.3 - 2024-02-22 ### Tickets closed diff --git a/Makefile b/Makefile index f9bc029d..107e565d 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,13 @@ Commands: endef export HELP +# Conda-related paths +conda_env_dir ?= ./env + +# Command aliases +CONDA_EXE ?= conda +CONDA_RUN := $(CONDA_EXE) run --prefix $(conda_env_dir) --no-capture-output + .PHONY: help init lint lint-bandit lint-mypy lint-pycodestyle lint-pylint test test-pytest help: @@ -22,9 +29,22 @@ help: init: @if [ -z "$${CONDA_SHLVL:+x}" ]; then echo "Conda is not installed." && exit 1; fi - @conda create -y -n anaconda_client python=3.12 --file requirements.txt --file requirements-extra.txt - @conda run -n anaconda_client pip install -r requirements-dev.txt - @echo "\n\nConda environment has been created. To activate run \"conda activate anaconda_client\"." + @conda create \ + --channel defaults \ + --channel anaconda-cloud \ + --yes \ + --prefix $(conda_env_dir) \ + python=3.11 \ + pip \ + --file requirements.txt \ + --file requirements-extra.txt + @conda run \ + --prefix $(conda_env_dir) \ + pip install -r requirements-dev.txt + @conda run \ + --prefix $(conda_env_dir) \ + pip install -e . --no-deps + @echo "\n\nConda environment has been created. To activate run \"conda activate $(conda_env_dir)\"." check: lint test diff --git a/autotest/data/test_env.yml b/autotest/data/test_env.yml index 0f25464b..808e7e0e 100644 --- a/autotest/data/test_env.yml +++ b/autotest/data/test_env.yml @@ -88,7 +88,6 @@ dependencies: - ripgrep=12.1.1=0 - ruamel_yaml=0.15.100=py38h27cfd23_0 - setuptools=52.0.0=py38h06a4308_0 - - six=1.16.0=pyhd3eb1b0_0 - soupsieve=2.2.1=pyhd3eb1b0_0 - sqlite=3.35.4=hdfb4753_0 - tk=8.6.10=hbc83047_0 diff --git a/binstar_client/__about__.py b/binstar_client/__about__.py index 301d5b71..932207c3 100644 --- a/binstar_client/__about__.py +++ b/binstar_client/__about__.py @@ -4,4 +4,4 @@ __all__ = ['__version__'] -__version__ = '1.12.3' +__version__ = '1.13.0' diff --git a/binstar_client/__init__.py b/binstar_client/__init__.py index 363f08a6..e76bdf95 100644 --- a/binstar_client/__init__.py +++ b/binstar_client/__init__.py @@ -9,11 +9,11 @@ import logging import os import platform as _platform +from urllib.parse import quote import defusedxml.ElementTree as ET import requests from pkg_resources import parse_version as pv -from six.moves.urllib.parse import quote from tqdm import tqdm from . import errors diff --git a/binstar_client/commands/authorizations.py b/binstar_client/commands/authorizations.py index 390ef50d..200b95af 100644 --- a/binstar_client/commands/authorizations.py +++ b/binstar_client/commands/authorizations.py @@ -22,7 +22,6 @@ import pytz from dateutil.parser import parse as parse_date -from six.moves import input from binstar_client import errors from binstar_client.utils import get_server_api diff --git a/binstar_client/commands/download.py b/binstar_client/commands/download.py index 18bb7da8..0100476e 100644 --- a/binstar_client/commands/download.py +++ b/binstar_client/commands/download.py @@ -2,8 +2,8 @@ """ Usage: - anaconda download notebook - anaconda download user/notebook + anaconda download + anaconda download / """ from __future__ import unicode_literals @@ -14,14 +14,14 @@ from binstar_client import errors from binstar_client.utils import get_server_api from binstar_client.utils.config import PackageType -from binstar_client.utils.notebook import parse, has_environment +from binstar_client.utils.notebook import parse from binstar_client.utils.notebook.downloader import Downloader logger = logging.getLogger('binstar.download') def add_parser(subparsers): - description = 'Download notebooks from your Anaconda repository' + description = 'Download packages from your Anaconda repository' parser = subparsers.add_parser( 'download', formatter_class=argparse.RawDescriptionHelpFormatter, @@ -32,7 +32,7 @@ def add_parser(subparsers): parser.add_argument( 'handle', - help='user/notebook', + help='/', action='store' ) @@ -58,9 +58,9 @@ def add_parser(subparsers): def main(args): aserver_api = get_server_api(args.token, args.site) - username, notebook = parse(args.handle) + username, package_name = parse(args.handle) username = username or aserver_api.user()['login'] - downloader = Downloader(aserver_api, username, notebook) + downloader = Downloader(aserver_api, username, package_name) packages_types = list(map(PackageType, args.package_type) if args.package_type else PackageType) try: @@ -68,10 +68,5 @@ def main(args): for download_file, download_dist in download_files.items(): downloader.download(download_dist) logger.info('%s has been downloaded as %s', args.handle, download_file) - if has_environment(download_file): - logger.info('%s has an environment embedded.', download_file) - logger.info('Run:') - logger.info(' conda env create %s', download_file) - logger.info('To install the environment in your system') except (errors.DestinationPathExists, errors.NotFound, errors.BinstarError, OSError) as err: logger.info(err) diff --git a/binstar_client/commands/login.py b/binstar_client/commands/login.py index a78429ca..65b70b1a 100644 --- a/binstar_client/commands/login.py +++ b/binstar_client/commands/login.py @@ -9,9 +9,7 @@ import platform import socket import sys - -from six.moves import input -from six.moves.urllib.parse import urlparse +from urllib.parse import urlparse from binstar_client import errors from binstar_client.utils import get_config, get_server_api, store_token, bool_input diff --git a/binstar_client/commands/notebook.py b/binstar_client/commands/notebook.py index 49e6dff4..105c3d30 100644 --- a/binstar_client/commands/notebook.py +++ b/binstar_client/commands/notebook.py @@ -6,18 +6,19 @@ use `anaconda upload/download` instead """ -from __future__ import unicode_literals import argparse import logging -from binstar_client import errors -from binstar_client.utils import get_server_api -from binstar_client.utils.notebook import parse, notebook_url, has_environment -from binstar_client.utils.notebook.uploader import Uploader -from binstar_client.utils.notebook.downloader import Downloader +import sys +from binstar_client.deprecations import DEPRECATION_MESSAGE_NOTEBOOKS_PROJECTS_ENVIRONMENTS_REMOVED logger = logging.getLogger('binstar.notebook') +def main(args): # pylint: disable=unused-argument + logger.error(DEPRECATION_MESSAGE_NOTEBOOKS_PROJECTS_ENVIRONMENTS_REMOVED) + return sys.exit(1) + + def add_parser(subparsers): description = 'Interact with notebooks in your Anaconda repository' parser = subparsers.add_parser('notebook', @@ -25,116 +26,11 @@ def add_parser(subparsers): help=description, description=description, epilog=__doc__) - - nb_subparsers = parser.add_subparsers() - add_upload_parser(nb_subparsers) - add_download_parser(nb_subparsers) - - -def add_upload_parser(subparsers): - description = 'Upload a notebook to your Anaconda repository' - epilog = ''' - [Deprecation warning] - `anaconda notebook` is going to be deprecated - use `anaconda upload` instead - ''' - parser = subparsers.add_parser('upload', - formatter_class=argparse.RawDescriptionHelpFormatter, - help=description, - description=description, - epilog=epilog) - - mgroup = parser.add_argument_group('metadata options') - mgroup.add_argument('-n', '--name', help='Notebook\'s name (will be parameterized)') - mgroup.add_argument('-v', '--version', help='Notebook\'s version') - mgroup.add_argument('-s', '--summary', help='Set the summary of the notebook') - mgroup.add_argument('-t', '--thumbnail', help='Notebook\'s thumbnail image') - parser.add_argument( - '-u', '--user', - help='User account, defaults to the current user' - ) - - parser.add_argument( - '--force', - help='Force a notebook upload regardless of errors', - action='store_true' - ) - - parser.add_argument( - 'notebook', - help='Notebook to upload', + 'args', + nargs='+', + help='Catch-all for args', action='store' ) - parser.set_defaults(main=upload) - - -def add_download_parser(subparsers): - description = 'Download notebooks from your Anaconda repository' - epilog = ''' - [Deprecation warning] - `anaconda notebook` is going to be deprecated - use `anaconda download` instead - ''' - parser = subparsers.add_parser('download', - formatter_class=argparse.RawDescriptionHelpFormatter, - help=description, - description=description, - epilog=epilog) - - parser.add_argument( - 'handle', - help='user/notebook', - action='store' - ) - - parser.add_argument( - '-f', '--force', - help='Overwrite', - action='store_true' - ) - - parser.add_argument( - '-o', '--output', - help='Download as', - default='.' - ) - - parser.set_defaults(main=download) - - -def upload(args): - aserver_api = get_server_api(args.token, args.site) - - uploader = Uploader(aserver_api, args.notebook, user=args.user, summary=args.summary, - version=args.version, thumbnail=args.thumbnail, name=args.name) - - try: - upload_info = uploader.upload(force=args.force) - logger.warning('`anaconda notebook` is going to be deprecated') - logger.warning('use `anaconda upload` instead.') - logger.info('%s has been uploaded.', args.notebook) - logger.info('You can visit your notebook at %s', notebook_url(upload_info)) - except (errors.BinstarError, IOError) as error: - logger.error(error) - - -def download(args): - aserver_api = get_server_api(token=args.token, site=args.site) - - username, notebook = parse(args.handle) - username = username or aserver_api.user()['login'] - downloader = Downloader(aserver_api, username, notebook) - try: - download_info = downloader(output=args.output, force=args.force) - logger.warning('`anaconda notebook` is going to be deprecated') - logger.warning('use `anaconda download` instead.') - logger.info('%s has been downloaded as %s.', args.handle, download_info[0]) - if has_environment(download_info[0]): - logger.info('%s has an environment embedded.', download_info[0]) - logger.info('Run:') - logger.info(' conda env create %s', download_info[0]) - logger.info('To install the environment in your system') - except (errors.DestinationPathExists, errors.NotFound, OSError) as err: - logger.info(err.msg) + parser.set_defaults(main=main) diff --git a/binstar_client/commands/upload.py b/binstar_client/commands/upload.py index bccab625..40fc6d66 100644 --- a/binstar_client/commands/upload.py +++ b/binstar_client/commands/upload.py @@ -3,17 +3,13 @@ """ anaconda upload CONDA_PACKAGE_1.tar.bz2 - anaconda upload notebook.ipynb - anaconda upload environment.yml -##### See Also +See Also: - * [Uploading a Conda Package]( - https://docs.anaconda.com/anaconda-repository/user-guide/tasks/pkgs/use-pkg-managers/#uploading-a-conda-package) - * [Uploading a Standard Python Package]( - https://docs.anaconda.com/anaconda-repository/user-guide/tasks/pkgs/use-pkg-managers/#uploading-pypi-packages) +* Uploading a Conda Package: https://docs.anaconda.com/free/anacondaorg/user-guide/packages/conda-packages/#cloud-uploading-conda-packages +* Uploading a Standard Python Package: https://docs.anaconda.com/free/anacondaorg/user-guide/packages/standard-python-packages/#uploading-stdpython-packages -""" +""" # noqa: E501, pylint: disable=line-too-long from __future__ import annotations @@ -26,10 +22,9 @@ import os import typing -import nbformat - import binstar_client from binstar_client import errors +from binstar_client.deprecations import DEPRECATION_MESSAGE_NOTEBOOKS_PROJECTS_ENVIRONMENTS_REMOVED from binstar_client.utils import bool_input, DEFAULT_CONFIG, get_config, get_server_api from binstar_client.utils.config import PackageType from binstar_client.utils import detect @@ -505,11 +500,8 @@ def upload(self, filename: str) -> bool: def upload_package(self, filename: str, package_meta: detect.Meta) -> bool: """Upload a package to the server.""" - if ( - package_meta.package_type is PackageType.NOTEBOOK and - self.arguments.mode != 'force' and - not self.validate_notebook(filename) - ): + if package_meta.package_type is PackageType.NOTEBOOK: + logger.error(DEPRECATION_MESSAGE_NOTEBOOKS_PROJECTS_ENVIRONMENTS_REMOVED) return False meta: PackageMeta = PackageMeta(filename=filename, meta=package_meta) @@ -631,19 +623,6 @@ def detect_package_meta(filename: str, package_type: typing.Optional[PackageType result = detect.Meta(package_type=package_type, extension=os.path.splitext(filename)[1]) return result - @staticmethod - def validate_notebook(filename: str) -> bool: - """Check if file is a valid notebook.""" - try: - stream: typing.TextIO - with open(filename, 'rt', encoding='utf8') as stream: - nbformat.read(stream, nbformat.NO_CONVERT) - except Exception as error: # pylint: disable=broad-except - logger.error('Invalid notebook file "%s": %s', filename, error) - logger.info('Use --force to upload the file anyways') - return False - return True - @staticmethod def validate_package_type(package: PackageCacheRecord, package_type: PackageType) -> bool: """Check if file of :code:`package_type` might be uploaded to :code:`package`.""" @@ -717,7 +696,10 @@ def add_parser(subparsers: typing.Any) -> None: parser.add_argument( '--keep-basename', dest='keep_basename', - help='Do not normalize a basename when uploading a conda package.', + help=( + 'Do not normalize a basename when uploading a conda package. ' + 'Note: this parameter only applies to conda, and not standard Python packages.' + ), action='store_true' ) diff --git a/binstar_client/deprecations.py b/binstar_client/deprecations.py new file mode 100644 index 00000000..bdaf3b18 --- /dev/null +++ b/binstar_client/deprecations.py @@ -0,0 +1,8 @@ +"""Re-usable helpers for deprecating functionality.""" + +DEPRECATION_MESSAGE_NOTEBOOKS_PROJECTS_ENVIRONMENTS_REMOVED = " ".join([ + "The Projects, Notebooks, and Environments features have been removed.", + "See our release notes (https://docs.anaconda.com/anacondaorg/release-notes/)", + "for more information.", + "If you have any questions, please contact usercare@anaconda.com.", +]) diff --git a/binstar_client/inspect_package/conda.py b/binstar_client/inspect_package/conda.py index 7743e4f5..b4ea1a74 100644 --- a/binstar_client/inspect_package/conda.py +++ b/binstar_client/inspect_package/conda.py @@ -1,18 +1,20 @@ # pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring -from __future__ import print_function +from __future__ import annotations, print_function -import os.path import json +import os.path import re import sys -import tempfile from pprint import pprint -from shutil import rmtree -from conda_package_handling.api import extract +from typing import Any -from ..utils.notebook.data_uri import data_uri_from +from conda_package_streaming.package_streaming import ( + CondaComponent, + stream_conda_component, +) +from ..utils.notebook.data_uri import data_uri_from_bytes os_map = {'osx': 'darwin', 'win': 'win32'} specs_re = re.compile('^([=><]+)(.*)$') @@ -27,7 +29,7 @@ def transform_conda_deps(deps): dep = dep.strip() name_spec = dep.split(' ', 1) if len(name_spec) == 1: - name, = name_spec + (name,) = name_spec depends.append({'name': name, 'specs': []}) elif len(name_spec) == 2: name, spec = name_spec @@ -52,7 +54,9 @@ def transform_conda_deps(deps): else: operator = '==' - depends.append({'name': name, 'specs': [['==', '%s+%s' % (spec, build_str)]]}) + depends.append( + {'name': name, 'specs': [['==', '%s+%s' % (spec, build_str)]]} + ) return {'depends': depends} @@ -78,12 +82,12 @@ def get_subdir(index): return '%s-%s' % (index.get('platform'), intel_map.get(arch, arch)) -def inspect_conda_info_dir(info_path, basename): # pylint: disable=too-many-locals +def inspect_conda_info_dir(info_contents: dict[str, bytes], basename: str) -> tuple[dict, dict, dict]: + # pylint: disable=too-many-locals def _load(filename, default=None): - file_path = os.path.join(info_path, filename) - if os.path.exists(file_path): - with open(file_path, encoding='utf-8') as file: - return json.load(file) + info_path = f'info/{filename}' + if info_path in info_contents: + return json.loads(info_contents[info_path]) return default index = _load('index.json', None) @@ -92,13 +96,15 @@ def _load(filename, default=None): recipe = _load('recipe.json') about = recipe.get('about', {}) if recipe else _load('about.json', {}) - has_prefix = os.path.exists(os.path.join(info_path, 'has_prefix')) + has_prefix = 'info/has_prefix' in info_contents # Load icon defined in the index.json and file exists inside info folder icon_b64 = index.get('icon', None) - icon_path = os.path.join(info_path, icon_b64) if icon_b64 else None - if icon_path and os.path.exists(icon_path): - icon_b64 = data_uri_from(icon_path) + if index.get('icon'): + for icon_key in (f'info/{index.get("icon", None)}', 'info/icon.png'): + if icon_key in info_contents: + icon_b64 = data_uri_from_bytes(info_contents[icon_key]) + break subdir = get_subdir(index) machine = index.get('arch', None) @@ -134,7 +140,7 @@ def _load(filename, default=None): 'license_url': about.get('license_url'), 'license_family': about.get('license_family'), } - file_data = { + file_data: dict[str, Any] = { 'basename': '%s/%s' % (subdir, basename), 'attrs': { 'operatingsystem': operatingsystem, @@ -152,22 +158,58 @@ def _load(filename, default=None): return package_data, release_data, file_data -def inspect_conda_package(filename, *args, **kwargs): # pylint: disable=unused-argument - tmpdir = tempfile.mkdtemp() - extract(filename, tmpdir, components='info') +def gather_info_dir( + path: os.PathLike, + wanted: frozenset[str] = frozenset( + ( + 'info/index.json', + 'info/recipe.json', + 'info/about.json', + 'info/has_prefix', + ) + ), +) -> dict[str, bytes]: + """Use conda-package-streaming to gather files without extracting to disk.""" + # based on code from conda-index + have: dict[str, bytes] = {} + seeking = set(wanted) + with open(path, mode='rb') as fileobj: + package_stream = stream_conda_component( + path, fileobj, CondaComponent.info + ) + for tar, member in package_stream: + if member.name in wanted: + seeking.remove(member.name) + reader = tar.extractfile(member) + if reader is None: + continue + have[member.name] = reader.read() + + if not seeking: # we got what we wanted + package_stream.close() + + # extremely rare icon case. index.json lists a .png but the icon + # appears to always be info/icon.png. + if b'"icon"' in have.get('info/index.json', b''): + index_json = json.loads(have['info/index.json']) + # this case matters for our unit tests + wanted = frozenset(('info/icon.png', f'info/{index_json["icon"]}')) + have.update(gather_info_dir(path, wanted=wanted)) + + return have - info_dir = os.path.join(tmpdir, 'info') - package_data, release_data, file_data = inspect_conda_info_dir(info_dir, os.path.basename(filename)) - - rmtree(tmpdir) +def inspect_conda_package(filename, *args, **kwargs): # pylint: disable=unused-argument + info_contents = gather_info_dir(filename) + package_data, release_data, file_data = inspect_conda_info_dir( + info_contents, os.path.basename(filename) + ) return package_data, release_data, file_data def main(): filename = sys.argv[1] - with open(filename) as fileobj: # pylint: disable=unspecified-encoding - package_data, release_data, file_data = inspect_conda_package(filename, fileobj) + package_data, release_data, file_data = inspect_conda_package(filename) pprint(package_data) print('--') pprint(release_data) diff --git a/binstar_client/plugins.py b/binstar_client/plugins.py new file mode 100644 index 00000000..73ff371c --- /dev/null +++ b/binstar_client/plugins.py @@ -0,0 +1,235 @@ +"""Defines the subcommand plugins for the new CLI defined in anaconda-cli-base. + +We define a new subcommand called `anaconda org`, which nests all existing +anaconda-client subcommands beneath it. Additionally, we mount all of the +existing subcommands, with the exception of "login" and "logout" at the top +level of the CLI, although some of these are mounted silently. This is done to +maintain backwards compatibility while we work to deprecate some of them. + +Rather than re-write all the CLI code in anaconda-client, we opt to dynamically +register each subcommand in the `load_legacy_subcommands` function. + +Note: This module should not be imported, except as defined as a plugin +entrypoint in setup.py. + +""" + +import logging +import warnings +from argparse import ArgumentParser +from typing import Any +from typing import Callable +from typing import Optional + +import typer +import typer.colors +from anaconda_cli_base import console +from anaconda_cli_base.cli import app as main_app +from typer import Context, Typer + +from binstar_client import commands as command_module +from binstar_client.scripts.cli import ( + _add_subparser_modules as add_subparser_modules, main as binstar_main, +) + +# All subcommands in anaconda-client +ALL_SUBCOMMANDS = { + "auth", + "channel", + "config", + "copy", + "download", + "groups", + "label", + "login", + "logout", + "move", + "notebook", + "package", + "remove", + "search", + "show", + "update", + "upload", + "whoami", +} +# These subcommands will be shown in the top-level help +NON_HIDDEN_SUBCOMMANDS = { + "auth", + "config", + "copy", + "download", + "label", + "move", + "package", + "remove", + "search", + "show", + "update", + "upload", +} +# Any subcommands that should emit deprecation warnings, and show as deprecated in the help +DEPRECATED_SUBCOMMANDS = { + "channel", + "notebook", +} + +# The logger +log = logging.getLogger(__name__) +warnings.simplefilter("always") + +app = Typer( + add_completion=False, + name="org", + help="Interact with anaconda.org", + no_args_is_help=True, +) + + +@app.callback(invoke_without_command=True, no_args_is_help=True) +def main( + ctx: typer.Context, + show_help: Optional[bool] = typer.Option( + False, + "-h", + "--help", + help="Show this message and exit.", + ), +) -> None: + """Add -h and --help options to anaconda org base subcommand.""" + # We do this instead of using context_settings for now so we can fallback + # on the existing help for all subcommands. This callback can go away once + # we are okay with typer recursively adding -h and --help to every subcommand. + if show_help: + console.print(ctx.get_help()) + raise typer.Exit() + + +def _get_help_text(parser: ArgumentParser, name: str) -> str: + """Extract the help text from the anaconda-client CLI Argument Parser.""" + if parser._subparsers is None: # pylint: disable=protected-access + return "" + # MyPy says this was unreachable + # if parser._subparsers._actions is None: # pylint: disable=protected-access + # return "" + if parser._subparsers._actions[1].choices is None: # pylint: disable=protected-access + return "" + subcommand_parser = dict(parser._subparsers._actions[1].choices).get(name) # pylint: disable=protected-access + if subcommand_parser is None: + return "" + description = subcommand_parser.description + if description is None: + return "" + return description.strip() + + +def _deprecate(name: str, func: Callable) -> Callable: + """Mark a named subcommand as deprecated. + + Args: + name: The name of the subcommand. + f: The subcommand callable. + + """ + def new_func(ctx: Context) -> Any: + msg = ( + f"The existing anaconda-client commands will be deprecated. To maintain compatibility, " + f"please either pin `anaconda-client<2` or update your system call with the `org` prefix, " + f'e.g. "anaconda org {name} ..."' + ) + log.warning(msg) + return func(ctx) + + return new_func + + +def _subcommand(ctx: Context) -> None: + """A common function to use for all subcommands. + + In a proper typer/click app, this is the function that is decorated. + + We use the typer.Context object to extract the args passed into the CLI, and then delegate + to the binstar_main function. + + """ + args = [] + # Ensure we capture the subcommand name if there is one + if ctx.info_name is not None: + args.append(ctx.info_name) + args.extend(ctx.args) + binstar_main(args, allow_plugin_main=False) + + +def _mount_subcommand( + *, + name: str, + help_text: str, + is_deprecated: bool, + mount_to_main: bool, + is_hidden_on_main: bool, +) -> None: + """Mount an existing subcommand to the `anaconda org` typer application. + + Args: + name: The name of the subcommand. + help_text: The help text for the subcommand + is_deprecated: If True, mark the subcommand as deprecated. This will cause a warning to be + emitted, and also add "(deprecated)" to the help text. + mount_to_main: If True, also mount the subcommand to the main typer app. + is_hidden_on_main: If True, the subcommand is registered as a hidden subcommand of the main CLI + for backwards-compatibility + + """ + if is_deprecated: + deprecated_text = typer.style("(deprecated)", fg=typer.colors.RED, bold=True) + help_text = f"{deprecated_text} {help_text}" + func = _deprecate(name, _subcommand) + else: + func = _subcommand + + # Mount the subcommand to the `anaconda org` application. + app.command( + name=name, + help=help_text, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, + )(func) + + # Exit early if we are not mounting to the main `anaconda` app + if not mount_to_main: + return + + # Mount some CLI subcommands at the top-level, but optionally emit a deprecation warning + help_text = f"{help_text + ' ' if help_text else ''}(alias for 'anaconda org {name}')" + + main_app.command( + name=name, + help=help_text, + hidden=is_hidden_on_main, + context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True, + }, + )(func) + + +def load_legacy_subcommands() -> None: + """Load each of the legacy subcommands into its own typer subcommand. + + This allows them to be called from the new CLI, without having to manually migrate. + + """ + parser = ArgumentParser() + add_subparser_modules(parser, command_module) + + for name in ALL_SUBCOMMANDS: + # TODO: Can we load the arguments, or at least the docstring to make the help nicer? # pylint: disable=fixme + _mount_subcommand( + name=name, + help_text=_get_help_text(parser, name), + is_deprecated=(name in DEPRECATED_SUBCOMMANDS), + mount_to_main=(name not in {"login", "logout", "whoami"}), + is_hidden_on_main=(name not in NON_HIDDEN_SUBCOMMANDS), + ) + + +load_legacy_subcommands() diff --git a/binstar_client/requests_ext.py b/binstar_client/requests_ext.py index e055adff..d6569e9b 100644 --- a/binstar_client/requests_ext.py +++ b/binstar_client/requests_ext.py @@ -3,17 +3,16 @@ from __future__ import annotations -__all__ = ['NullAuth', 'encode_multipart_formdata_stream', 'stream_multipart'] - from io import BytesIO, StringIO from itertools import chain import logging import typing -import requests -import six +from requests.auth import AuthBase from urllib3.filepost import choose_boundary +__all__ = ['NullAuth', 'encode_multipart_formdata_stream', 'stream_multipart'] + logger = logging.getLogger('binstar.requests_ext') @@ -30,7 +29,7 @@ def iter_fields( return iter(fields) -class NullAuth(requests.auth.AuthBase): # pylint: disable=too-few-public-methods +class NullAuth(AuthBase): # pylint: disable=too-few-public-methods """force requests to ignore the ``.netrc`` Some sites do not support regular authentication, but we still @@ -38,7 +37,7 @@ class NullAuth(requests.auth.AuthBase): # pylint: disable=too-few-public-method as form elements. Without this, requests would otherwise use the .netrc which leads, on some sites, to a 401 error. - https://github.com/kennethreitz/requests/issues/2773 + https://github.com/psf/requests/issues/2773 Use with:: @@ -68,9 +67,9 @@ def encode_multipart_formdata_stream(fields, boundary=None): body = [] def body_write(item): - if isinstance(item, six.binary_type): + if isinstance(item, bytes): item = BytesIO(item) - elif isinstance(item, six.text_type): + elif isinstance(item, str): item = StringIO(item) body.append(item) @@ -102,10 +101,10 @@ def body_write_encode(item): % (fieldname)) body_write(b'\r\n') - if isinstance(data, six.integer_types): - data = six.text_type(data) # Backwards compatibility + if isinstance(data, (int,)): + data = str(data) # Backwards compatibility - if isinstance(data, six.text_type): + if isinstance(data, str): body_write_encode(data) else: body_write(data) diff --git a/binstar_client/utils/config.py b/binstar_client/utils/config.py index 5caca039..5a88834e 100644 --- a/binstar_client/utils/config.py +++ b/binstar_client/utils/config.py @@ -17,11 +17,6 @@ import yaml from platformdirs import PlatformDirs -try: - from conda.gateways import anaconda_client as c_client -except ImportError: - c_client = None - try: from conda.gateways import anaconda_client as c_client except ImportError: diff --git a/binstar_client/utils/notebook/__init__.py b/binstar_client/utils/notebook/__init__.py index 618f546d..62b16147 100644 --- a/binstar_client/utils/notebook/__init__.py +++ b/binstar_client/utils/notebook/__init__.py @@ -1,7 +1,7 @@ # pylint: disable=missing-module-docstring,missing-function-docstring +from urllib.parse import urlparse import nbformat -from six.moves.urllib.parse import urlparse from ...errors import BinstarError diff --git a/binstar_client/utils/notebook/data_uri.py b/binstar_client/utils/notebook/data_uri.py index 62c73f91..d5238630 100644 --- a/binstar_client/utils/notebook/data_uri.py +++ b/binstar_client/utils/notebook/data_uri.py @@ -4,15 +4,14 @@ import io import os import sys +from urllib.parse import urlparse import requests -from six.moves.urllib.parse import urlparse - try: from PIL import Image except ImportError: - Image = None + Image = None # type: ignore from ...errors import PillowNotInstalled @@ -20,28 +19,35 @@ class DataURIConverter: - def __init__(self, location): + def __init__(self, location, data=None): self.check_pillow_installed() self.location = location + self.data = data def check_pillow_installed(self): if Image is None: raise PillowNotInstalled() def __call__(self): - if os.path.exists(self.location): + if self.data: + file = io.BytesIO(self.data) + b64 = self._encode(self.resize_and_convert(file).read()) + elif os.path.exists(self.location): with open(self.location, 'rb') as file: - return self._encode(self.resize_and_convert(file).read()) + b64 = self._encode(self.resize_and_convert(file).read()) elif self.is_url(): content = requests.get(self.location, timeout=10 * 60 * 60).content file = io.BytesIO() file.write(content) file.seek(0) - return self._encode(self.resize_and_convert(file).read()) + b64 = self._encode(self.resize_and_convert(file).read()) else: raise IOError('{} not found'.format(self.location)) + return b64 def resize_and_convert(self, file): + if Image is None: + raise PillowNotInstalled() image = Image.open(file) image.thumbnail(THUMB_SIZE) out = io.BytesIO() @@ -65,3 +71,7 @@ def _encode(self, content): def data_uri_from(location): return DataURIConverter(location)() + + +def data_uri_from_bytes(data): + return DataURIConverter(location=None, data=data)() diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index eacc5775..79221454 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -5,13 +5,13 @@ package: version: {{ data.get('version') }} source: - git_url: ../ + path: .. build: number: 0 script: {{ PYTHON }} -m pip install --no-build-isolation --no-deps . + noarch: python entry_points: - - anaconda = binstar_client.scripts.cli:main - binstar = binstar_client.scripts.cli:main - conda-server = binstar_client.scripts.cli:main @@ -24,6 +24,7 @@ requirements: - python - anaconda-anon-usage >=0.4.0 - conda-package-handling >=1.7.3 + - conda-package-streaming >=0.9.0 - defusedxml >=0.7.1 - nbformat >=4.4.0 - python-dateutil >=2.6.1 @@ -33,9 +34,9 @@ requirements: - requests >=2.20.0 - requests-toolbelt >=0.9.1 - setuptools >=58.0.4 - - six >=1.15.0 - tqdm >=4.56.0 - urllib3 >=1.26.4 + - anaconda-cli-base >=0.4.0 test: requires: diff --git a/requirements-dev.txt b/requirements-dev.txt index deaffff8..8dd791d7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,6 +18,5 @@ types-pytz~=2023.3.0.0 types-pyyaml~=6.0.12.9 types-requests~=2.30.0.0 types-setuptools~=67.8.0.0 -types-six~=1.16.21.8 types-tqdm~=4.65.0.1 typing-extensions~=4.6.0 diff --git a/requirements-extra.txt b/requirements-extra.txt index bf7e7598..64313da9 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -1,4 +1,9 @@ # Additional requirements for complete experience -anaconda-project>=0.9.1 +# Disabling these extras since they break CI and the server doesn't support +# projects anyway. The problem is that anaconda-project has a circular +# dependency back onto anaconda-client. + +# anaconda-project>=0.9.1 +# ruamel.yaml # Required by anaconda-project pillow>=8.2 diff --git a/requirements.txt b/requirements.txt index 5ebf561c..959178c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,6 @@ platformdirs>=3.10.0,<5.0 requests>=2.20.0 requests-toolbelt>=0.9.1 setuptools>=58.0.4 -six>=1.15.0 tqdm>=4.56.0 urllib3>=1.26.4 +anaconda-cli-base>=0.4.0 diff --git a/setup.cfg b/setup.cfg index e48d5823..695c6869 100644 --- a/setup.cfg +++ b/setup.cfg @@ -174,7 +174,7 @@ callbacks=callback_,_callback dummy-variables-rgx=^_+$|^_[a-zA-Z0-9_]*[a-zA-Z0-9]$ ignored-argument-names=_.* init-import=no -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io +redefining-builtins-modules=past.builtins,future.builtins,builtins,io [tool:pytest] addopts=-x --durations 10 --cov=binstar_client --cov-report term-missing diff --git a/setup.py b/setup.py index 5262862e..aa15db46 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,11 @@ requirement.split('#', 1)[0].strip() for requirement in stream ))) +# This is temporarily here so we don't pull in the incompatible dependency in CI +# and during local development as we move to 1.13.0. But to not change the behavior +# around the "full" extra at all. We will soon explicitly drop this dependency. +extras_require.append("anaconda-project>=0.9.1") + __about__ = {} with open(os.path.join(root, 'binstar_client', '__about__.py'), 'rt', encoding='utf-8') as stream: exec(stream.read(), __about__) @@ -46,9 +51,11 @@ packages=setuptools.find_packages(include=['binstar_client', 'binstar_client.*']), entry_points={ 'console_scripts': [ - 'anaconda = binstar_client.scripts.cli:main', 'binstar = binstar_client.scripts.cli:main', 'conda-server = binstar_client.scripts.cli:main', ], + 'anaconda_cli.subcommand': [ + 'org = binstar_client.plugins:app', + ] }, ) diff --git a/tests/inspect_package/test_conda.py b/tests/inspect_package/test_conda.py index f2f72da6..f075b0d4 100644 --- a/tests/inspect_package/test_conda.py +++ b/tests/inspect_package/test_conda.py @@ -4,11 +4,18 @@ # Standard libary imports import unittest +from pathlib import Path # Local imports from binstar_client.inspect_package import conda from binstar_client.utils.notebook.data_uri import data_uri_from -from tests.utils.utils import data_dir + + +HERE = Path(__file__).parent + + +def data_dir(path): + return str(HERE / 'data' / path) expected_package_data = { @@ -72,8 +79,10 @@ }, 'basename': 'osx-64/conda_gc_test-1.2.1-py27_3.tar.bz2', 'dependencies': { - 'depends': [{'name': 'foo', 'specs': [['==', '3']]}, - {'name': 'python', 'specs': [['==', '2.7.8']]}], + 'depends': [ + {'name': 'foo', 'specs': [['==', '3']]}, + {'name': 'python', 'specs': [['==', '2.7.8']]}, + ], }, } @@ -93,8 +102,10 @@ }, 'basename': 'linux-64/conda_gc_test-2.2.1-py27_3.tar.bz2', 'dependencies': { - 'depends': [{'name': 'foo', 'specs': [['==', '3']]}, - {'name': 'python', 'specs': [['==', '2.7.8']]}], + 'depends': [ + {'name': 'foo', 'specs': [['==', '3']]}, + {'name': 'python', 'specs': [['==', '2.7.8']]}, + ], }, } @@ -114,8 +125,10 @@ }, 'basename': 'linux-64/conda_gc_test-2.2.1-py27_3.conda', 'dependencies': { - 'depends': [{'name': 'foo', 'specs': [['==', '3']]}, - {'name': 'python', 'specs': [['==', '2.7.8']]}], + 'depends': [ + {'name': 'foo', 'specs': [['==', '3']]}, + {'name': 'python', 'specs': [['==', '2.7.8']]}, + ], }, } @@ -156,8 +169,7 @@ class Test(unittest.TestCase): def test_conda_old(self): filename = data_dir('conda_gc_test-1.2.1-py27_3.tar.bz2') - with open(filename, 'rb') as file: - package_data, version_data, file_data = conda.inspect_conda_package(filename, file) + package_data, version_data, file_data = conda.inspect_conda_package(filename) self.assertEqual(expected_package_data, package_data) self.assertEqual(expected_version_data_121, version_data) @@ -165,8 +177,7 @@ def test_conda_old(self): def test_conda(self): filename = data_dir('conda_gc_test-2.2.1-py27_3.tar.bz2') - with open(filename, 'rb') as file: - package_data, version_data, file_data = conda.inspect_conda_package(filename, file) + package_data, version_data, file_data = conda.inspect_conda_package(filename) self.assertEqual(expected_package_data, package_data) self.assertEqual(expected_version_data_221, version_data) @@ -174,8 +185,7 @@ def test_conda(self): def test_conda_app_image(self): filename = data_dir('test-app-package-icon-0.1-0.tar.bz2') - with open(filename, 'rb') as file: - package_data, version_data, _ = conda.inspect_conda_package(filename, file) + package_data, version_data, _ = conda.inspect_conda_package(filename) self.assertEqual(app_expected_package_data, package_data) self.assertEqual(app_expected_version_data.pop('icon'), version_data.pop('icon')) @@ -183,8 +193,7 @@ def test_conda_app_image(self): def test_conda_v2_format(self): filename = data_dir('conda_gc_test-2.2.1-py27_3.conda') - with open(filename, 'rb') as file: - package_data, version_data, file_data = conda.inspect_conda_package(filename, file) + package_data, version_data, file_data = conda.inspect_conda_package(filename) self.assertEqual(expected_package_data, package_data) self.assertEqual(expected_version_data_221, version_data) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..8970120b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,102 @@ +"""Test entrypoint to anaconda-cli-base""" + +from importlib import reload +import logging +from typing import Generator + +import pytest +from pytest import LogCaptureFixture +from pytest import MonkeyPatch +from typer.testing import CliRunner +import anaconda_cli_base.cli +import binstar_client.plugins +from binstar_client.plugins import ALL_SUBCOMMANDS, NON_HIDDEN_SUBCOMMANDS, DEPRECATED_SUBCOMMANDS + +BASE_COMMANDS = {"login", "logout", "whoami"} +HIDDEN_SUBCOMMANDS = ALL_SUBCOMMANDS - BASE_COMMANDS - NON_HIDDEN_SUBCOMMANDS + + +@pytest.fixture(autouse=True) +def enable_base_cli_plugin(monkeypatch: MonkeyPatch) -> Generator[None, None, None]: + """Make sure that we get a clean app with plugins loaded""" + + monkeypatch.setenv("ANACONDA_CLI_FORCE_NEW", "1") + monkeypatch.delenv("ANACONDA_CLIENT_FORCE_STANDALONE", raising=False) + reload(anaconda_cli_base.cli) + reload(binstar_client.plugins) + yield + + +def test_entrypoint() -> None: + """Has the entrypoint been loaded?""" + + groups = [grp.name for grp in anaconda_cli_base.cli.app.registered_groups] + assert "org" in groups + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_org_subcommand_help(flag: str) -> None: + """anaconda org -h and anaconda --help are both available""" + + runner = CliRunner() + result = runner.invoke(anaconda_cli_base.cli.app, ["org", flag]) + assert result.exit_code == 0 + + +@pytest.mark.parametrize("cmd", ALL_SUBCOMMANDS) +def test_org_subcommands(cmd: str) -> None: + """anaconda org """ + + org = next((group for group in anaconda_cli_base.cli.app.registered_groups if group.name == "org"), None) + assert org is not None + + assert org.typer_instance + subcmd = next((subcmd for subcmd in org.typer_instance.registered_commands if subcmd.name == cmd), None) + assert subcmd is not None + assert subcmd.hidden is False + + runner = CliRunner() + result = runner.invoke(anaconda_cli_base.cli.app, ["org", cmd, "-h"]) + assert result.exit_code == 0 + assert result.stdout.startswith("usage") + + +@pytest.mark.parametrize("cmd", HIDDEN_SUBCOMMANDS) +def test_hidden_commands(cmd: str) -> None: + """anaconda """ + + subcmd = next((subcmd for subcmd in anaconda_cli_base.cli.app.registered_commands if subcmd.name == cmd), None) + assert subcmd is not None + assert subcmd.hidden is True + assert subcmd.help is not None + + runner = CliRunner() + result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"]) + assert result.exit_code == 0 + assert result.stdout.startswith("usage") + + +@pytest.mark.parametrize("cmd", NON_HIDDEN_SUBCOMMANDS) +def test_non_hidden_commands(cmd: str) -> None: + """anaconda login""" + + subcmd = next((subcmd for subcmd in anaconda_cli_base.cli.app.registered_commands if subcmd.name == cmd), None) + assert subcmd is not None + assert subcmd.hidden is False + assert subcmd.help is not None + + runner = CliRunner() + result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"]) + assert result.exit_code == 0 + assert result.stdout.startswith("usage") + + +@pytest.mark.parametrize("cmd", DEPRECATED_SUBCOMMANDS) +def test_deprecated_message(cmd: str, caplog: LogCaptureFixture) -> None: + """anaconda warning""" + + with caplog.at_level(logging.WARNING): + runner = CliRunner() + result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"]) + assert result.exit_code == 0 + assert "commands will be deprecated" in caplog.records[0].msg diff --git a/tests/test_upload.py b/tests/test_upload.py index 5d96d64d..d98a66da 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -3,10 +3,11 @@ """Tests for package upload commands.""" -import datetime import json import unittest.mock +import pytest + from binstar_client import errors from tests.fixture import CLITestCase, main from tests.urlmock import urlpatch @@ -221,6 +222,7 @@ def test_upload_file(self, registry): registry.assertAllCalled() self.assertIsNotNone(json.loads(staging_response.req.body).get('sha256')) + @pytest.mark.xfail(reason='anaconda-project removed') @urlpatch def test_upload_project(self, registry): # there's redundant work between anaconda-client which checks auth and anaconda-project also checks auth; @@ -241,6 +243,7 @@ def test_upload_project(self, registry): registry.assertAllCalled() + @pytest.mark.xfail(reason='anaconda-project removed') @urlpatch def test_upload_notebook_as_project(self, registry): registry.register(method='HEAD', path='/', status=200) @@ -259,36 +262,7 @@ def test_upload_notebook_as_project(self, registry): registry.assertAllCalled() - @urlpatch - def test_upload_notebook_as_package(self, registry): - test_datetime = datetime.datetime(2022, 5, 19, 15, 29) - mock_version = test_datetime.strftime('%Y.%m.%d.%H%M') - - registry.register(method='HEAD', path='/', status=200) - registry.register(method='GET', path='/user', content='{"login": "eggs"}') - registry.register(method='GET', path='/dist/eggs/foo/2022.05.19.1529/foo.ipynb', status=404) - registry.register(method='GET', path='/package/eggs/foo', content={'package_types': ['ipynb']}) - registry.register(method='GET', path='/release/eggs/foo/{}'.format(mock_version), content='{}') - staging_response = registry.register( - method='POST', - path='/stage/eggs/foo/{}/foo.ipynb'.format(mock_version), - content={'post_url': 'http://s3url.com/s3_url', 'form_data': {}, 'dist_id': 'dist_id'}, - ) - registry.register(method='POST', path='/s3_url', status=201) - registry.register( - method='POST', - path='/commit/eggs/foo/{}/foo.ipynb'.format(mock_version), - status=200, - content={}, - ) - - with unittest.mock.patch('binstar_client.inspect_package.ipynb.datetime') as mock_datetime: - mock_datetime.now.return_value = test_datetime - main(['--show-traceback', 'upload', data_dir('foo.ipynb')]) - - registry.assertAllCalled() - self.assertIsNotNone(json.loads(staging_response.req.body).get('sha256')) - + @pytest.mark.xfail(reason='anaconda-project removed') @urlpatch def test_upload_project_specifying_user(self, registry): registry.register(method='HEAD', path='/', status=200) @@ -306,6 +280,7 @@ def test_upload_project_specifying_user(self, registry): registry.assertAllCalled() + @pytest.mark.xfail(reason='anaconda-project removed') @urlpatch def test_upload_project_specifying_token(self, registry): registry.register(method='HEAD', path='/', status=200) diff --git a/tests/utils/test_spec.py b/tests/utils/test_spec.py new file mode 100644 index 00000000..69f4068d --- /dev/null +++ b/tests/utils/test_spec.py @@ -0,0 +1,12 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring +import binstar_client.utils.spec + + +def test_parse_specs(): + # Reproducing for https://github.com/anaconda/anaconda-client/issues/642 + spec = binstar_client.utils.spec.parse_specs("someuser/foo/1.2.3/blah-1.2.3.tar.bz2?x=1") + assert spec.user == "someuser" + assert spec.name == "foo" + assert spec.version == "1.2.3" + assert spec.basename == "blah-1.2.3.tar.bz2" + assert spec.attrs == {"x": "1"}