pypi_recipe.py 6.21 KB
Newer Older
Stefan Scherfke's avatar
Stefan Scherfke committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203
"""
Create a conda recipe for a package on PyPI.

.. code-block:: console

   $ ownconda pypi-recipe click

For some packages, this command fails to correctly collect their dependencies.
You can manually specify them in this case:

.. code-block:: console

   $ ownconda pypi-recipe flask --requirements=click,itsdangerous,jinja2,werkzeug

"""
import os
import pathlib
import subprocess

import click
import ruamel.yaml

from .. import click_util, pypi, recipes


META_YAML = """package:
  name: None
  version: None

source:
  url: None
  sha256: None

build:
  number: 0
  entry_points: []

requirements:
  build: []
  run: []

test:
  imports: []
  commands: []

about:
  summary: None
  home: None
  license: unknown

extra:
  changelog_url: None
"""
BUILD_SH = """$PYTHON -m pip install -I --no-deps .
"""


def create_recipe(recipe_name, pkg_info, dry_run, update=False):
    """Create a conda recipe named *recipe_name* and populated it with the data
    from *pkg_info*.

    If the recipe already exists, its being overriden (which should be no
    problem if you've got your recipes under version control).

    It uses :const:`META_YAML` as template.

    """
    if update:
        with open(os.path.join(recipe_name, 'meta.yaml')) as f:
            meta_yaml = recipes.load_yaml(f.read())
        assert meta_yaml['package']['name'] == pkg_info['conda_pkg']
    else:
        meta_yaml = recipes.load_yaml(META_YAML)

    _update_meta_yaml(meta_yaml, pkg_info)
    meta_yaml = recipes.dump_yaml(meta_yaml)

    if dry_run:
        # Early return if we only want to print the recipe
        click_util.echo(meta_yaml)
        return

    existed = update
    if not update:
        try:
            os.mkdir(recipe_name)
        except FileExistsError:
            existed = True
            click_util.warning(f'WARNING: Overriding recipe "{recipe_name}".')

        with open(os.path.join(recipe_name, 'build.sh'), 'w') as f:
            f.write(BUILD_SH)

        with open(os.path.join(recipe_name, 'update_check.py'), 'w') as f:
            f.write("checker = 'pypi'\n")
            if pkg_info['pypi_pkg'] != pkg_info['conda_pkg']:
                f.write(f"package_name = '{pkg_info['pypi_pkg']}'\n")

    with open(os.path.join(recipe_name, 'meta.yaml'), 'w') as f:
        f.write(meta_yaml)

    if existed:
        subprocess.run(['git', '--no-pager', 'diff', recipe_name])
    else:
        for f in pathlib.Path(recipe_name).iterdir():
            subprocess.run(['git', '--no-pager', 'diff', '--no-index',
                            '/dev/null', str(f)])


def _update_meta_yaml(meta_yaml, pkg_info):
    if not pkg_info['packages']:
        click_util.warning('WARNING: Could not autodetect requirements and packages!')

    meta_yaml['package']['name'] = pkg_info['conda_pkg']
    meta_yaml['package']['version'] = pkg_info['version']

    meta_yaml['source']['url'] = pkg_info['url']
    meta_yaml['source']['sha256'] = pkg_info['sha256']

    meta_yaml['build']['number'] = 0
    if pkg_info['entry_points']:
        meta_yaml['build']['entry_points'] = pkg_info['entry_points']
        _add_empty_line_before('requirements', meta_yaml)
    elif 'entry_points' in meta_yaml['build']:
        del meta_yaml['build']['entry_points']

    reqs = meta_yaml['requirements']
    bld_reqs = pkg_info['build_requirements']
    run_reqs = pkg_info['run_requirements']
    # Keep existing requirements if new requirements could not be parsed:
    if not reqs['build'] or bld_reqs:
        reqs['build'] = ['python', 'pip'] + bld_reqs
    if not reqs['run'] or run_reqs:
        reqs['run'] = ['python'] + run_reqs
        _add_empty_line_before('test', meta_yaml)

    # If no Python packages could be found, use the distribution name:
    meta_yaml['test']['imports'] = pkg_info['packages'] or [pkg_info['pkg']]
    if pkg_info['entry_points']:
        commands = [f'{ep.split("=")[0].strip()} --help'
                    for ep in pkg_info['entry_points']]
        meta_yaml['test']['commands'] = commands
    elif 'commands' in meta_yaml['test']:
        del meta_yaml['test']['commands']
    _add_empty_line_before('about', meta_yaml)

    meta_yaml['about']['summary'] = pkg_info['summary']
    meta_yaml['about']['home'] = pkg_info['home']
    if pkg_info['license']:
        meta_yaml['about']['license'] = pkg_info['license']

    # Improve rendering of multi-line strings
    ruamel.yaml.scalarstring.walk_tree(meta_yaml['about'])


def _add_empty_line_before(key, data):
    """Add an empty line before *key* in *data*.

    This is hack to fix empty lines getting lost in the ruamla.yaml round trip
    loader.

    """
    ct = ruamel.yaml.tokens.CommentToken(
        '\n', ruamel.yaml.error.CommentMark(0), None)
    data.ca.items[key] = [None, [ct], None, None]


@click.command()
@click.argument('pypi_pkg')
@click.option('-f', '--force-version',
              help='Enforce a specified version.')
@click_util.option.dry_run()
@click.option('-u', '--update', is_flag=True, default=False,
              help='Update an existing package.')
@click.option('-w', '--use-wheel', is_flag=True, default=False,
              help='Use a Wheel instead of a source distribution.')
@click.option('-p', '--pre-release', is_flag=True, default=False,
              help='Allow pre-release.')
def cli(pypi_pkg, force_version, dry_run, update, use_wheel, pre_release):
    """Create or update recipes for PyPI packages."""
    # We need 3(!) names for the package:
    # - pypy_pkg: The name on PyPI
    # - conda_pkg: The name of the Conda package
    # - recipe_name: The name of the recipe directory
    conda_pkg = pypi_pkg.lower()
    recipe_name = conda_pkg
    if not recipe_name.startswith('python-'):
        recipe_name = f'python-{recipe_name}'

    if force_version is None:
        pkg_info = pypi.get_latest_release(
            pypi_pkg, use_wheel=use_wheel, allow_prerelease=pre_release
        )
    else:
        pkg_info = pypi.get_release(pypi_pkg, force_version, use_wheel=use_wheel)
    pkg_info['pypi_pkg'] = pypi_pkg
    pkg_info['conda_pkg'] = conda_pkg
    pkg_info['pkg'] = conda_pkg.replace('-', '_')  # Guess main package name

    if update and not os.path.isdir(recipe_name):
        update = False  # No update if recipe does not exist

    create_recipe(recipe_name, pkg_info, dry_run, update=update)