Commit c6a2d6e1 by Sébastien Eustace Committed by GitHub

Improve the add and init commands (#1221)

* Add the ability to specify path and git dependencies directly in add

* Add the ability to add git and path dependencies via the init command

* Automatically select latest prereleases if only prereleases are available

* Update documentation for the add command

* Add the ability to add complete dependencies in one go
parent 6f4aa21c
...@@ -15,6 +15,9 @@ ...@@ -15,6 +15,9 @@
- Added the ability to specify packages on a per-format basis. - Added the ability to specify packages on a per-format basis.
- Added support for custom urls in metadata. - Added support for custom urls in metadata.
- Full environment markers are now supported for dependencies via the `markers` property. - Full environment markers are now supported for dependencies via the `markers` property.
- Added the ability to specify git dependencies directly in `add`, it no longer requires the `--git` option.
- Added the ability to specify path dependencies directly in `add`, it no longer requires the `--path` option.
- Added the ability to add git and path dependencies via the `init` command.
### Changed ### Changed
...@@ -24,6 +27,9 @@ ...@@ -24,6 +27,9 @@
- The `debug:resolve` command has been renamed to `debug resolve`. - The `debug:resolve` command has been renamed to `debug resolve`.
- The `self:update` command has been renamed to `self update`. - The `self:update` command has been renamed to `self update`.
- Changed the way virtualenvs are stored (names now depend on the project's path). - Changed the way virtualenvs are stored (names now depend on the project's path).
- The `--git` option of the `add` command has been removed.
- The `--path` option of the `add` command has been removed.
- The `add` command will now automatically select the latest prerelease if only prereleases are available.
### Fixed ### Fixed
......
...@@ -182,18 +182,42 @@ poetry will choose a suitable one based on the available package versions. ...@@ -182,18 +182,42 @@ poetry will choose a suitable one based on the available package versions.
poetry add requests pendulum poetry add requests pendulum
``` ```
You also can specify a constraint when adding a package, like so:
```bash
poetry add pendulum@^2.0.5
poetry add "pendulum>=2.0.5"
```
If you try to add a package that is already present, you will get an error.
However, if you specify a constraint, like above, the dependency will be updated
by using the specified constraint. If you want to get the latest version of an already
present dependency you can use the special `latest` constraint:
```bash
poetry add pendulum@latest
```
You can also add `git` dependencies: You can also add `git` dependencies:
```bash ```bash
poetry add pendulum --git https://github.com/sdispater/pendulum.git poetry add git+https://github.com/sdispater/pendulum.git
```
If you need to checkout a specific branch, tag or revision,
you can specify it when using `add`:
```bash
poetry add git+https://github.com/sdispater/pendulum.git@develop
poetry add git+https://github.com/sdispater/pendulum.git@2.0.5
``` ```
or make them point to a local directory or file: or make them point to a local directory or file:
```bash ```bash
poetry add my-package --path ../my-package/ poetry add ./my-package/
poetry add my-package --path ../my-package/dist/my-package-0.1.0.tar.gz poetry add ../my-package/dist/my-package-0.1.0.tar.gz
poetry add my-package --path ../my-package/dist/my_package-0.1.0.whl poetry add ../my-package/dist/my_package-0.1.0.whl
``` ```
Path dependencies pointing to a local directory will be installed in editable mode (i.e. setuptools "develop mode"). Path dependencies pointing to a local directory will be installed in editable mode (i.e. setuptools "develop mode").
...@@ -201,17 +225,24 @@ It means that changes in the local directory will be reflected directly in envir ...@@ -201,17 +225,24 @@ It means that changes in the local directory will be reflected directly in envir
If you don't want the dependency to be installed in editable mode you can specify it in the `pyproject.toml` file: If you don't want the dependency to be installed in editable mode you can specify it in the `pyproject.toml` file:
``` ```toml
[tool.poetry.dependencies] [tool.poetry.dependencies]
my-package = {path = "../my/path", develop = false} my-package = {path = "../my/path", develop = false}
``` ```
If the package(s) you want to install provide extras, you can specify them
when adding the package:
```bash
poetry add requests[security,socks]
poetry add "requests[security,socks]~=2.22.0"
poetry add "git+https://github.com/pallets/flask.git@1.1.1[dotenv,dev]"
```
### Options ### Options
* `--dev (-D)`: Add package as development dependency. * `--dev (-D)`: Add package as development dependency.
* `--git`: The url of the Git repository.
* `--path`: The path to a dependency. * `--path`: The path to a dependency.
* `--extras (-E)`: Extras to activate for the dependency.
* `--optional` : Add as an optional dependency. * `--optional` : Add as an optional dependency.
* `--dry-run` : Outputs the operations but will not execute anything (implicitly enables --verbose). * `--dry-run` : Outputs the operations but will not execute anything (implicitly enables --verbose).
......
...@@ -13,8 +13,6 @@ class AddCommand(EnvCommand, InitCommand): ...@@ -13,8 +13,6 @@ class AddCommand(EnvCommand, InitCommand):
arguments = [argument("name", "Packages to add.", multiple=True)] arguments = [argument("name", "Packages to add.", multiple=True)]
options = [ options = [
option("dev", "D", "Add package as development dependency."), option("dev", "D", "Add package as development dependency."),
option("git", None, "The url of the Git repository.", flag=False),
option("path", None, "The path to a dependency.", flag=False),
option( option(
"extras", "extras",
"E", "E",
...@@ -58,17 +56,11 @@ If you do not specify a version constraint, poetry will choose a suitable one ba ...@@ -58,17 +56,11 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
packages = self.argument("name") packages = self.argument("name")
is_dev = self.option("dev") is_dev = self.option("dev")
if (self.option("git") or self.option("path") or self.option("extras")) and len( if self.option("extras") and len(packages) > 1:
packages
) > 1:
raise ValueError( raise ValueError(
"You can only specify one package " "You can only specify one package " "when using the --extras option"
"when using the --git or --path options"
) )
if self.option("git") and self.option("path"):
raise RuntimeError("--git and --path cannot be used at the same time")
section = "dependencies" section = "dependencies"
if is_dev: if is_dev:
section = "dev-dependencies" section = "dev-dependencies"
...@@ -83,32 +75,27 @@ If you do not specify a version constraint, poetry will choose a suitable one ba ...@@ -83,32 +75,27 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
for name in packages: for name in packages:
for key in poetry_content[section]: for key in poetry_content[section]:
if key.lower() == name.lower(): if key.lower() == name.lower():
pair = self._parse_requirements([name])[0]
if "git" in pair or pair.get("version") == "latest":
continue
raise ValueError("Package {} is already present".format(name)) raise ValueError("Package {} is already present".format(name))
if self.option("git") or self.option("path"):
requirements = {packages[0]: ""}
else:
requirements = self._determine_requirements( requirements = self._determine_requirements(
packages, allow_prereleases=self.option("allow-prereleases") packages, allow_prereleases=self.option("allow-prereleases")
) )
requirements = self._format_requirements(requirements)
# validate requirements format for _constraint in requirements:
for constraint in requirements.values(): if "version" in _constraint:
parse_constraint(constraint) # Validate version constraint
parse_constraint(_constraint["version"])
for name, _constraint in requirements.items():
constraint = inline_table() constraint = inline_table()
constraint["version"] = _constraint for name, value in _constraint.items():
if name == "name":
if self.option("git"): continue
del constraint["version"]
constraint["git"] = self.option("git")
elif self.option("path"):
del constraint["version"]
constraint["path"] = self.option("path") constraint[name] = value
if self.option("optional"): if self.option("optional"):
constraint["optional"] = True constraint["optional"] = True
...@@ -135,7 +122,7 @@ If you do not specify a version constraint, poetry will choose a suitable one ba ...@@ -135,7 +122,7 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
if len(constraint) == 1 and "version" in constraint: if len(constraint) == 1 and "version" in constraint:
constraint = constraint["version"] constraint = constraint["version"]
poetry_content[section][name] = constraint poetry_content[section][_constraint["name"]] = constraint
# Write new content # Write new content
self.poetry.file.write(content) self.poetry.file.write(content)
...@@ -152,7 +139,7 @@ If you do not specify a version constraint, poetry will choose a suitable one ba ...@@ -152,7 +139,7 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
installer.dry_run(self.option("dry-run")) installer.dry_run(self.option("dry-run"))
installer.update(True) installer.update(True)
installer.whitelist(requirements) installer.whitelist([r["name"] for r in requirements])
try: try:
status = installer.run() status = installer.run()
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import os
import re import re
from typing import Dict
from typing import List from typing import List
from typing import Tuple from typing import Tuple
from typing import Union
from cleo import option from cleo import option
from tomlkit import inline_table
from poetry.utils._compat import Path
from poetry.utils._compat import OrderedDict
from .command import Command from .command import Command
from .env_command import EnvCommand from .env_command import EnvCommand
...@@ -133,10 +140,20 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -133,10 +140,20 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
requirements = {} requirements = {}
question = ( question = "Would you like to define your main dependencies interactively?"
"Would you like to define your dependencies" " (require) interactively?" help_message = (
"You can specify a package in the following forms:\n"
" - A single name (<b>requests</b>)\n"
" - A name and a constraint (<b>requests ^2.23.0</b>)\n"
" - A git url (<b>https://github.com/sdispater/poetry.git</b>)\n"
" - A git url with a revision (<b>https://github.com/sdispater/poetry.git@develop</b>)\n"
" - A file path (<b>../my-package/my-package.whl</b>)\n"
" - A directory (<b>../my-package/</b>)\n"
) )
help_displayed = False
if self.confirm(question, True): if self.confirm(question, True):
self.line(help_message)
help_displayed = True
requirements = self._format_requirements( requirements = self._format_requirements(
self._determine_requirements(self.option("dependency")) self._determine_requirements(self.option("dependency"))
) )
...@@ -149,6 +166,9 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -149,6 +166,9 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
" (require-dev) interactively" " (require-dev) interactively"
) )
if self.confirm(question, True): if self.confirm(question, True):
if not help_displayed:
self.line(help_message)
dev_requirements = self._format_requirements( dev_requirements = self._format_requirements(
self._determine_requirements(self.option("dev-dependency")) self._determine_requirements(self.option("dev-dependency"))
) )
...@@ -182,13 +202,24 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -182,13 +202,24 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
def _determine_requirements( def _determine_requirements(
self, requires, allow_prereleases=False self, requires, allow_prereleases=False
): # type: (List[str], bool) -> List[str] ): # type: (List[str], bool) -> List[Dict[str, str]]
if not requires: if not requires:
requires = [] requires = []
package = self.ask("Search for package:") package = self.ask("Add a package:")
while package is not None: while package is not None:
matches = self._get_pool().search(package) constraint = self._parse_requirements([package])[0]
if (
"git" in constraint
or "path" in constraint
or "version" in constraint
):
self.line("Adding <info>{}</info>".format(package))
requires.append(constraint)
package = self.ask("\nAdd a package:")
continue
matches = self._get_pool().search(constraint["name"])
if not matches: if not matches:
self.line("<error>Unable to find package</error>") self.line("<error>Unable to find package</error>")
...@@ -212,7 +243,7 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -212,7 +243,7 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
) )
# no constraint yet, determine the best version automatically # no constraint yet, determine the best version automatically
if package is not False and " " not in package: if package is not False and "version" not in constraint:
question = self.create_question( question = self.create_question(
"Enter the version constraint to require " "Enter the version constraint to require "
"(or leave blank to use the latest version):" "(or leave blank to use the latest version):"
...@@ -220,30 +251,35 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -220,30 +251,35 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
question.attempts = 3 question.attempts = 3
question.validator = lambda x: (x or "").strip() or False question.validator = lambda x: (x or "").strip() or False
constraint = self.ask(question) package_constraint = self.ask(question)
if constraint is None: if package_constraint is None:
_, constraint = self._find_best_version_for_package(package) _, package_constraint = self._find_best_version_for_package(
package
)
self.line( self.line(
"Using version <info>{}</info> for <info>{}</info>".format( "Using version <info>{}</info> for <info>{}</info>".format(
constraint, package package_constraint, package
) )
) )
package += " {}".format(constraint) constraint["version"] = package_constraint
if package is not False: if package is not False:
requires.append(package) requires.append(constraint)
package = self.ask("\nSearch for a package:") package = self.ask("\nAdd a package:")
return requires return requires
requires = self._parse_name_version_pairs(requires) requires = self._parse_requirements(requires)
result = [] result = []
for requirement in requires: for requirement in requires:
if "version" not in requirement: if "git" in requirement or "path" in requirement:
result.append(requirement)
continue
elif "version" not in requirement:
# determine the best version automatically # determine the best version automatically
name, version = self._find_best_version_for_package( name, version = self._find_best_version_for_package(
requirement["name"], allow_prereleases=allow_prereleases requirement["name"], allow_prereleases=allow_prereleases
...@@ -265,7 +301,7 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -265,7 +301,7 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
requirement["name"] = name requirement["name"] = name
result.append("{} {}".format(requirement["name"], requirement["version"])) result.append(requirement)
return result return result
...@@ -285,28 +321,123 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in ...@@ -285,28 +321,123 @@ The <info>init</info> command creates a basic <comment>pyproject.toml</> file in
"Could not find a matching version of package {}".format(name) "Could not find a matching version of package {}".format(name)
) )
return (package.pretty_name, selector.find_recommended_require_version(package)) return package.pretty_name, selector.find_recommended_require_version(package)
def _parse_requirements(
self, requirements
): # type: (List[str]) -> List[Dict[str, str]]
from poetry.puzzle.provider import Provider
def _parse_name_version_pairs(self, pairs): # type: (list) -> list
result = [] result = []
for i in range(len(pairs)): try:
pair = re.sub("^([^=: ]+)[=: ](.*)$", "\\1 \\2", pairs[i].strip()) cwd = self.poetry.file.parent
except RuntimeError:
cwd = Path.cwd()
for requirement in requirements:
requirement = requirement.strip()
extras = []
extras_m = re.search(r"\[([\w\d,-_]+)\]$", requirement)
if extras_m:
extras = [e.strip() for e in extras_m.group(1).split(",")]
requirement, _ = requirement.split("[")
if requirement.startswith(("git+https://", "git+ssh://")):
url = requirement.lstrip("git+")
rev = None
if "@" in url:
url, rev = url.split("@")
pair = OrderedDict(
[("name", url.split("/")[-1].rstrip(".git")), ("git", url)]
)
if rev:
pair["rev"] = rev
if extras:
pair["extras"] = extras
package = Provider.get_package_from_vcs(
"git", url, reference=pair.get("rev")
)
pair["name"] = package.name
result.append(pair)
continue
elif (os.path.sep in requirement or "/" in requirement) and cwd.joinpath(
requirement
).exists():
path = cwd.joinpath(requirement)
if path.is_file():
package = Provider.get_package_from_file(path.resolve())
else:
package = Provider.get_package_from_directory(path)
result.append(
OrderedDict(
[
("name", package.name),
("path", path.relative_to(cwd).as_posix()),
]
+ ([("extras", extras)] if extras else [])
)
)
continue
pair = re.sub(
"^([^@=: ]+)(?:@|==|(?<![<>~!])=|:| )(.*)$", "\\1 \\2", requirement
)
pair = pair.strip() pair = pair.strip()
require = OrderedDict()
if " " in pair: if " " in pair:
name, version = pair.split(" ", 2) name, version = pair.split(" ", 2)
result.append({"name": name, "version": version}) require["name"] = name
require["version"] = version
else: else:
result.append({"name": pair}) m = re.match(
"^([^><=!: ]+)((?:>=|<=|>|<|!=|~=|~|\^).*)$", requirement.strip()
)
if m:
name, constraint = m.group(1), m.group(2)
extras_m = re.search(r"\[([\w\d,-_]+)\]$", name)
if extras_m:
extras = [e.strip() for e in extras_m.group(1).split(",")]
name, _ = name.split("[")
require["name"] = name
require["version"] = constraint
else:
extras_m = re.search(r"\[([\w\d,-_]+)\]$", pair)
if extras_m:
extras = [e.strip() for e in extras_m.group(1).split(",")]
pair, _ = pair.split("[")
require["name"] = pair
if extras:
require["extras"] = extras
result.append(require)
return result return result
def _format_requirements(self, requirements): # type: (List[str]) -> dict def _format_requirements(
self, requirements
): # type: (List[Dict[str, str]]) -> Dict[str, Union[str, Dict[str, str]]]
requires = {} requires = {}
requirements = self._parse_name_version_pairs(requirements)
for requirement in requirements: for requirement in requirements:
requires[requirement["name"]] = requirement["version"] name = requirement.pop("name")
if "version" in requirement and len(requirement) == 1:
constraint = requirement["version"]
else:
constraint = inline_table()
constraint.trivia.trail = "\n"
constraint.update(requirement)
requires[name] = constraint
return requires return requires
......
...@@ -9,6 +9,7 @@ from clikit.ui.components import ProgressIndicator ...@@ -9,6 +9,7 @@ from clikit.ui.components import ProgressIndicator
from contextlib import contextmanager from contextlib import contextmanager
from tempfile import mkdtemp from tempfile import mkdtemp
from typing import List from typing import List
from typing import Optional
from poetry.packages import Dependency from poetry.packages import Dependency
from poetry.packages import DependencyPackage from poetry.packages import DependencyPackage
...@@ -34,6 +35,7 @@ from poetry.utils.helpers import safe_rmtree ...@@ -34,6 +35,7 @@ from poetry.utils.helpers import safe_rmtree
from poetry.utils.env import EnvManager from poetry.utils.env import EnvManager
from poetry.utils.env import EnvCommandError from poetry.utils.env import EnvCommandError
from poetry.utils.setup_reader import SetupReader from poetry.utils.setup_reader import SetupReader
from poetry.utils.toml_file import TomlFile
from poetry.version.markers import MarkerUnion from poetry.version.markers import MarkerUnion
from poetry.vcs.git import Git from poetry.vcs.git import Git
...@@ -56,10 +58,7 @@ class Provider: ...@@ -56,10 +58,7 @@ class Provider:
UNSAFE_PACKAGES = {"setuptools", "distribute", "pip"} UNSAFE_PACKAGES = {"setuptools", "distribute", "pip"}
def __init__( def __init__(
self, self, package, pool, io # type: Package # type: Pool
package, # type: Package
pool, # type: Pool
io,
): # type: (...) -> None ): # type: (...) -> None
self._package = package self._package = package
self._pool = pool self._pool = pool
...@@ -158,59 +157,92 @@ class Provider: ...@@ -158,59 +157,92 @@ class Provider:
Basically, we clone the repository in a temporary directory Basically, we clone the repository in a temporary directory
and get the information we need by checking out the specified reference. and get the information we need by checking out the specified reference.
""" """
if dependency.vcs != "git": package = self.get_package_from_vcs(
raise ValueError("Unsupported VCS dependency {}".format(dependency.vcs)) dependency.vcs,
dependency.source,
dependency.reference,
name=dependency.name,
)
tmp_dir = Path(mkdtemp(prefix="pypoetry-git-{}".format(dependency.name))) if dependency.tag or dependency.rev:
package.source_reference = dependency.reference
try: for extra in dependency.extras:
git = Git() if extra in package.extras:
git.clone(dependency.source, tmp_dir) for dep in package.extras[extra]:
git.checkout(dependency.reference, tmp_dir) dep.activate()
revision = git.rev_parse(dependency.reference, tmp_dir).strip()
if dependency.tag or dependency.rev: package.requires += package.extras[extra]
revision = dependency.reference
directory_dependency = DirectoryDependency( return [package]
dependency.name,
tmp_dir, @classmethod
category=dependency.category, def get_package_from_vcs(
optional=dependency.is_optional(), cls, vcs, url, reference=None, name=None
): # type: (str, str, Optional[str], Optional[str]) -> Package
if vcs != "git":
raise ValueError("Unsupported VCS dependency {}".format(vcs))
tmp_dir = Path(
mkdtemp(prefix="pypoetry-git-{}".format(url.split("/")[-1].rstrip(".git")))
) )
for extra in dependency.extras:
directory_dependency.extras.append(extra)
package = self.search_for_directory(directory_dependency)[0] try:
git = Git()
git.clone(url, tmp_dir)
if reference is not None:
git.checkout(reference, tmp_dir)
else:
reference = "HEAD"
revision = git.rev_parse(reference, tmp_dir).strip()
package = cls.get_package_from_directory(tmp_dir, name=name)
package.source_type = "git" package.source_type = "git"
package.source_url = dependency.source package.source_url = url
package.source_reference = revision package.source_reference = revision
except Exception: except Exception:
raise raise
finally: finally:
safe_rmtree(str(tmp_dir)) safe_rmtree(str(tmp_dir))
return [package] return package
def search_for_file(self, dependency): # type: (FileDependency) -> List[Package] def search_for_file(self, dependency): # type: (FileDependency) -> List[Package]
if dependency.path.suffix == ".whl": package = self.get_package_from_file(dependency.full_path)
meta = pkginfo.Wheel(str(dependency.full_path))
else:
# Assume sdist
meta = pkginfo.SDist(str(dependency.full_path))
if dependency.name != meta.name: if dependency.name != package.name:
# For now, the dependency's name must match the actual package's name # For now, the dependency's name must match the actual package's name
raise RuntimeError( raise RuntimeError(
"The dependency name for {} does not match the actual package's name: {}".format( "The dependency name for {} does not match the actual package's name: {}".format(
dependency.name, meta.name dependency.name, package.name
) )
) )
package.source_url = dependency.path.as_posix()
package.hashes = [dependency.hash()]
for extra in dependency.extras:
if extra in package.extras:
for dep in package.extras[extra]:
dep.activate()
package.requires += package.extras[extra]
return [package]
@classmethod
def get_package_from_file(cls, file_path): # type: (Path) -> Package
if file_path.suffix == ".whl":
meta = pkginfo.Wheel(str(file_path))
else:
# Assume sdist
meta = pkginfo.SDist(str(file_path))
package = Package(meta.name, meta.version) package = Package(meta.name, meta.version)
package.source_type = "file" package.source_type = "file"
package.source_url = dependency.path.as_posix() package.source_url = file_path.as_posix()
package.description = meta.summary package.description = meta.summary
for req in meta.requires_dist: for req in meta.requires_dist:
...@@ -227,7 +259,16 @@ class Provider: ...@@ -227,7 +259,16 @@ class Provider:
if meta.requires_python: if meta.requires_python:
package.python_versions = meta.requires_python package.python_versions = meta.requires_python
package.hashes = [dependency.hash()] return package
def search_for_directory(
self, dependency
): # type: (DirectoryDependency) -> List[Package]
package = self.get_package_from_directory(
dependency.full_path, name=dependency.name
)
package.source_url = dependency.path.as_posix()
for extra in dependency.extras: for extra in dependency.extras:
if extra in package.extras: if extra in package.extras:
...@@ -238,13 +279,23 @@ class Provider: ...@@ -238,13 +279,23 @@ class Provider:
return [package] return [package]
def search_for_directory( @classmethod
self, dependency def get_package_from_directory(
): # type: (DirectoryDependency) -> List[Package] cls, directory, name=None
if dependency.supports_poetry(): ): # type: (Path, Optional[str]) -> Package
supports_poetry = False
pyproject = directory.joinpath("pyproject.toml")
if pyproject.exists():
pyproject = TomlFile(pyproject)
pyproject_content = pyproject.read()
supports_poetry = (
"tool" in pyproject_content and "poetry" in pyproject_content["tool"]
)
if supports_poetry:
from poetry.poetry import Poetry from poetry.poetry import Poetry
poetry = Poetry.create(dependency.full_path) poetry = Poetry.create(directory)
pkg = poetry.package pkg = poetry.package
package = Package(pkg.name, pkg.version) package = Package(pkg.name, pkg.version)
...@@ -264,25 +315,25 @@ class Provider: ...@@ -264,25 +315,25 @@ class Provider:
else: else:
# Execute egg_info # Execute egg_info
current_dir = os.getcwd() current_dir = os.getcwd()
os.chdir(str(dependency.full_path)) os.chdir(str(directory))
try: try:
cwd = dependency.full_path cwd = directory
venv = EnvManager().get(cwd) venv = EnvManager().get(cwd)
venv.run("python", "setup.py", "egg_info") venv.run("python", "setup.py", "egg_info")
except EnvCommandError: except EnvCommandError:
result = SetupReader.read_from_directory(dependency.full_path) result = SetupReader.read_from_directory(directory)
if not result["name"]: if not result["name"]:
# The name could not be determined # The name could not be determined
# We use the dependency name # We use the dependency name
result["name"] = dependency.name result["name"] = name
if not result["version"]: if not result["version"]:
# The version could not be determined # The version could not be determined
# so we raise an error since it is mandatory # so we raise an error since it is mandatory
raise RuntimeError( raise RuntimeError(
"Unable to retrieve the package version for {}".format( "Unable to retrieve the package version for {}".format(
dependency.path directory
) )
) )
...@@ -321,12 +372,12 @@ class Provider: ...@@ -321,12 +372,12 @@ class Provider:
egg_info = next( egg_info = next(
Path(p) Path(p)
for p in glob.glob( for p in glob.glob(
os.path.join(str(dependency.full_path), "**", "*.egg-info"), os.path.join(str(directory), "**", "*.egg-info"),
recursive=True, recursive=True,
) )
) )
else: else:
egg_info = next(dependency.full_path.glob("**/*.egg-info")) egg_info = next(directory.glob("**/*.egg-info"))
meta = pkginfo.UnpackedSDist(str(egg_info)) meta = pkginfo.UnpackedSDist(str(egg_info))
package_name = meta.name package_name = meta.name
...@@ -345,16 +396,16 @@ class Provider: ...@@ -345,16 +396,16 @@ class Provider:
finally: finally:
os.chdir(current_dir) os.chdir(current_dir)
package = Package(package_name, package_version) if name and name != package_name:
if dependency.name != package.name:
# For now, the dependency's name must match the actual package's name # For now, the dependency's name must match the actual package's name
raise RuntimeError( raise RuntimeError(
"The dependency name for {} does not match the actual package's name: {}".format( "The dependency name for {} does not match the actual package's name: {}".format(
dependency.name, package.name name, package_name
) )
) )
package = Package(package_name, package_version)
package.description = package_summary package.description = package_summary
for req in reqs: for req in reqs:
...@@ -373,16 +424,9 @@ class Provider: ...@@ -373,16 +424,9 @@ class Provider:
package.python_versions = python_requires package.python_versions = python_requires
package.source_type = "directory" package.source_type = "directory"
package.source_url = dependency.path.as_posix() package.source_url = directory.as_posix()
for extra in dependency.extras: return package
if extra in package.extras:
for dep in package.extras[extra]:
dep.activate()
package.requires += package.extras[extra]
return [package]
def incompatibilities_for( def incompatibilities_for(
self, package self, package
......
...@@ -26,8 +26,9 @@ class VersionSelector(object): ...@@ -26,8 +26,9 @@ class VersionSelector(object):
constraint = parse_constraint("*") constraint = parse_constraint("*")
candidates = self._pool.find_packages( candidates = self._pool.find_packages(
package_name, constraint, allow_prereleases=allow_prereleases package_name, constraint, allow_prereleases=True
) )
only_prereleases = all([c.version.is_prerelease() for c in candidates])
if not candidates: if not candidates:
return False return False
...@@ -37,7 +38,12 @@ class VersionSelector(object): ...@@ -37,7 +38,12 @@ class VersionSelector(object):
# Select highest version if we have many # Select highest version if we have many
package = candidates[0] package = candidates[0]
for candidate in candidates: for candidate in candidates:
if candidate.is_prerelease() and not dependency.allows_prereleases(): if (
candidate.is_prerelease()
and not dependency.allows_prereleases()
and not allow_prereleases
and not only_prereleases
):
continue continue
# Select highest version of the two # Select highest version of the two
...@@ -52,24 +58,20 @@ class VersionSelector(object): ...@@ -52,24 +58,20 @@ class VersionSelector(object):
return self._transform_version(version.text, package.pretty_version) return self._transform_version(version.text, package.pretty_version)
def _transform_version(self, version, pretty_version): def _transform_version(self, version, pretty_version):
# attempt to transform 2.1.1 to 2.1
# this allows you to upgrade through minor versions
try: try:
parsed = Version.parse(version) parsed = Version.parse(version)
parts = [parsed.major, parsed.minor, parsed.patch] parts = [parsed.major, parsed.minor, parsed.patch]
except ValueError: except ValueError:
return pretty_version return pretty_version
# check to see if we have a semver-looking version parts = parts[: parsed.precision]
if len(parts) == 3:
# remove the last parts (the patch version number and any extra)
if parts[0] != 0:
del parts[2]
# check to see if we have a semver-looking version
if len(parts) < 3:
version = pretty_version
else:
version = ".".join(str(p) for p in parts) version = ".".join(str(p) for p in parts)
if parsed.is_prerelease(): if parsed.is_prerelease():
version += "-{}".format(".".join(str(p) for p in parsed.prerelease)) version += "-{}".format(".".join(str(p) for p in parsed.prerelease))
else:
return pretty_version
return "^{}".format(version) return "^{}".format(version)
import sys import sys
import pytest
from cleo.testers import CommandTester from cleo.testers import CommandTester
from poetry.utils._compat import Path
from tests.helpers import get_dependency from tests.helpers import get_dependency
from tests.helpers import get_package from tests.helpers import get_package
...@@ -39,14 +42,14 @@ Package operations: 1 install, 0 updates, 0 removals ...@@ -39,14 +42,14 @@ Package operations: 1 install, 0 updates, 0 removals
assert content["dependencies"]["cachy"] == "^0.2.0" assert content["dependencies"]["cachy"] == "^0.2.0"
def test_add_constraint(app, repo, installer): def test_add_equal_constraint(app, repo, installer):
command = app.find("add") command = app.find("add")
tester = CommandTester(command) tester = CommandTester(command)
repo.add_package(get_package("cachy", "0.1.0")) repo.add_package(get_package("cachy", "0.1.0"))
repo.add_package(get_package("cachy", "0.2.0")) repo.add_package(get_package("cachy", "0.2.0"))
tester.execute("cachy=0.1.0") tester.execute("cachy==0.1.0")
expected = """\ expected = """\
...@@ -66,6 +69,67 @@ Package operations: 1 install, 0 updates, 0 removals ...@@ -66,6 +69,67 @@ Package operations: 1 install, 0 updates, 0 removals
assert len(installer.installs) == 1 assert len(installer.installs) == 1
def test_add_greater_constraint(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("cachy", "0.1.0"))
repo.add_package(get_package("cachy", "0.2.0"))
tester.execute("cachy>=0.1.0")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 1 install, 0 updates, 0 removals
- Installing cachy (0.2.0)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 1
def test_add_constraint_with_extras(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
cachy1 = get_package("cachy", "0.1.0")
cachy1.extras = {"msgpack": [get_dependency("msgpack-python")]}
msgpack_dep = get_dependency("msgpack-python", ">=0.5 <0.6", optional=True)
cachy1.requires = [msgpack_dep]
repo.add_package(get_package("cachy", "0.2.0"))
repo.add_package(cachy1)
repo.add_package(get_package("msgpack-python", "0.5.3"))
tester.execute("cachy[msgpack]^0.1.0")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 2 installs, 0 updates, 0 removals
- Installing msgpack-python (0.5.3)
- Installing cachy (0.1.0)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 2
def test_add_constraint_dependencies(app, repo, installer): def test_add_constraint_dependencies(app, repo, installer):
command = app.find("add") command = app.find("add")
tester = CommandTester(command) tester = CommandTester(command)
...@@ -106,7 +170,7 @@ def test_add_git_constraint(app, repo, installer): ...@@ -106,7 +170,7 @@ def test_add_git_constraint(app, repo, installer):
repo.add_package(get_package("pendulum", "1.4.4")) repo.add_package(get_package("pendulum", "1.4.4"))
repo.add_package(get_package("cleo", "0.6.5")) repo.add_package(get_package("cleo", "0.6.5"))
tester.execute("demo --git https://github.com/demo/demo.git") tester.execute("git+https://github.com/demo/demo.git")
expected = """\ expected = """\
...@@ -140,7 +204,7 @@ def test_add_git_constraint_with_poetry(app, repo, installer): ...@@ -140,7 +204,7 @@ def test_add_git_constraint_with_poetry(app, repo, installer):
repo.add_package(get_package("pendulum", "1.4.4")) repo.add_package(get_package("pendulum", "1.4.4"))
tester.execute("demo --git https://github.com/demo/pyproject-demo.git") tester.execute("git+https://github.com/demo/pyproject-demo.git")
expected = """\ expected = """\
...@@ -161,13 +225,121 @@ Package operations: 2 installs, 0 updates, 0 removals ...@@ -161,13 +225,121 @@ Package operations: 2 installs, 0 updates, 0 removals
assert len(installer.installs) == 2 assert len(installer.installs) == 2
def test_add_file_constraint_wheel(app, repo, installer): def test_add_git_constraint_with_extras(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4"))
repo.add_package(get_package("cleo", "0.6.5"))
repo.add_package(get_package("tomlkit", "0.5.5"))
tester.execute("git+https://github.com/demo/demo.git[foo,bar]")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 4 installs, 0 updates, 0 removals
- Installing cleo (0.6.5)
- Installing pendulum (1.4.4)
- Installing tomlkit (0.5.5)
- Installing demo (0.1.2 9cf87a2)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 4
content = app.poetry.file.read()["tool"]["poetry"]
assert "demo" in content["dependencies"]
assert content["dependencies"]["demo"] == {
"git": "https://github.com/demo/demo.git",
"extras": ["foo", "bar"],
}
def test_add_directory_constraint(app, repo, installer, mocker):
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__) / ".."
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4"))
repo.add_package(get_package("cleo", "0.6.5"))
tester.execute("../git/github.com/demo/demo")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 2 installs, 0 updates, 0 removals
- Installing pendulum (1.4.4)
- Installing demo (0.1.2 ../git/github.com/demo/demo)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 2
content = app.poetry.file.read()["tool"]["poetry"]
assert "demo" in content["dependencies"]
assert content["dependencies"]["demo"] == {"path": "../git/github.com/demo/demo"}
def test_add_directory_with_poetry(app, repo, installer, mocker):
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__) / ".."
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4"))
tester.execute("../git/github.com/demo/pyproject-demo")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 2 installs, 0 updates, 0 removals
- Installing pendulum (1.4.4)
- Installing demo (0.1.2 ../git/github.com/demo/pyproject-demo)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 2
def test_add_file_constraint_wheel(app, repo, installer, mocker):
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__) / ".."
command = app.find("add") command = app.find("add")
tester = CommandTester(command) tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4")) repo.add_package(get_package("pendulum", "1.4.4"))
tester.execute("demo --path ../distributions/demo-0.1.0-py2.py3-none-any.whl") tester.execute("../distributions/demo-0.1.0-py2.py3-none-any.whl")
expected = """\ expected = """\
...@@ -195,13 +367,16 @@ Package operations: 2 installs, 0 updates, 0 removals ...@@ -195,13 +367,16 @@ Package operations: 2 installs, 0 updates, 0 removals
} }
def test_add_file_constraint_sdist(app, repo, installer): def test_add_file_constraint_sdist(app, repo, installer, mocker):
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__) / ".."
command = app.find("add") command = app.find("add")
tester = CommandTester(command) tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4")) repo.add_package(get_package("pendulum", "1.4.4"))
tester.execute("demo --path ../distributions/demo-0.1.0.tar.gz") tester.execute("../distributions/demo-0.1.0.tar.gz")
expected = """\ expected = """\
...@@ -229,7 +404,7 @@ Package operations: 2 installs, 0 updates, 0 removals ...@@ -229,7 +404,7 @@ Package operations: 2 installs, 0 updates, 0 removals
} }
def test_add_constraint_with_extras(app, repo, installer): def test_add_constraint_with_extras_option(app, repo, installer):
command = app.find("add") command = app.find("add")
tester = CommandTester(command) tester = CommandTester(command)
...@@ -407,3 +582,72 @@ Package operations: 1 install, 0 updates, 0 removals ...@@ -407,3 +582,72 @@ Package operations: 1 install, 0 updates, 0 removals
assert "pyyaml" in content["dependencies"] assert "pyyaml" in content["dependencies"]
assert content["dependencies"]["pyyaml"] == "^3.13" assert content["dependencies"]["pyyaml"] == "^3.13"
def test_add_should_display_an_error_when_adding_existing_package_with_no_constraint(
app, repo, installer
):
content = app.poetry.file.read()
content["tool"]["poetry"]["dependencies"]["foo"] = "^1.0"
app.poetry.file.write(content)
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("foo", "1.1.2"))
with pytest.raises(ValueError) as e:
tester.execute("foo")
assert "Package foo is already present" == str(e.value)
def test_add_chooses_prerelease_if_only_prereleases_are_available(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("foo", "1.2.3b0"))
repo.add_package(get_package("foo", "1.2.3b1"))
tester.execute("foo")
expected = """\
Using version ^1.2.3-beta.1 for foo
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 1 install, 0 updates, 0 removals
- Installing foo (1.2.3b1)
"""
assert expected in tester.io.fetch_output()
def test_add_preferes_stable_releases(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("foo", "1.2.3"))
repo.add_package(get_package("foo", "1.2.4b1"))
tester.execute("foo")
expected = """\
Using version ^1.2.3 for foo
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 1 install, 0 updates, 0 removals
- Installing foo (1.2.3)
"""
assert expected in tester.io.fetch_output()
...@@ -88,10 +88,10 @@ license = "MIT" ...@@ -88,10 +88,10 @@ license = "MIT"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "~2.7 || ^3.6" python = "~2.7 || ^3.6"
pendulum = "^2.0" pendulum = "^2.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^3.6" pytest = "^3.6.0"
""" """
assert expected in tester.io.fetch_output() assert expected in tester.io.fetch_output()
...@@ -135,3 +135,305 @@ python = "^{python}" ...@@ -135,3 +135,305 @@ python = "^{python}"
) )
assert expected in tester.io.fetch_output() assert expected in tester.io.fetch_output()
def test_interactive_with_git_dependencies(app, repo, mocker, poetry):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"git+https://github.com/demo/demo.git", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {git = "https://github.com/demo/demo.git"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
def test_interactive_with_git_dependencies_with_reference(app, repo, mocker, poetry):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"git+https://github.com/demo/demo.git@develop", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {git = "https://github.com/demo/demo.git", rev = "develop"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
def test_interactive_with_git_dependencies_and_other_name(app, repo, mocker, poetry):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"git+https://github.com/demo/pyproject-demo.git", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {git = "https://github.com/demo/pyproject-demo.git"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
def test_interactive_with_directory_dependency(app, repo, mocker, poetry):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"../../fixtures/git/github.com/demo/demo", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {path = "../../fixtures/git/github.com/demo/demo"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
def test_interactive_with_directory_dependency_and_other_name(
app, repo, mocker, poetry
):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"../../fixtures/git/github.com/demo/pyproject-demo", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {path = "../../fixtures/git/github.com/demo/pyproject-demo"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
def test_interactive_with_file_dependency(app, repo, mocker, poetry):
repo.add_package(get_package("pendulum", "2.0.0"))
repo.add_package(get_package("pytest", "3.6.0"))
command = app.find("init")
command._pool = poetry.pool
mocker.patch("poetry.utils._compat.Path.open")
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__).parent
tester = CommandTester(command)
inputs = [
"my-package", # Package name
"1.2.3", # Version
"This is a description", # Description
"n", # Author
"MIT", # License
"~2.7 || ^3.6", # Python
"", # Interactive packages
"../../fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl", # Search for package
"", # Stop searching for packages
"", # Interactive dev packages
"pytest", # Search for package
"0",
"",
"",
"\n", # Generate
]
tester.execute(inputs="\n".join(inputs))
expected = """\
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "This is a description"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
demo = {path = "../../fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl"}
[tool.poetry.dev-dependencies]
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment