Commit 7018bd32 by Sébastien Eustace Committed by GitHub

Add an export command (#675)

parent 832e8fea
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- Added an `export` command to export the lock file to other formats (only `requirements.txt` is currently supported).
### Changed ### Changed
- Slightly changed the lock file, making it potentially incompatible with previous Poetry versions. - Slightly changed the lock file, making it potentially incompatible with previous Poetry versions.
......
...@@ -360,3 +360,18 @@ and writes the new version back to `pyproject.toml` ...@@ -360,3 +360,18 @@ and writes the new version back to `pyproject.toml`
The new version should ideally be a valid semver string or a valid bump rule: The new version should ideally be a valid semver string or a valid bump rule:
`patch`, `minor`, `major`, `prepatch`, `preminor`, `premajor`, `prerelease`. `patch`, `minor`, `major`, `prepatch`, `preminor`, `premajor`, `prerelease`.
## export
This command exports the lock file to other formats.
If the lock file does not exist, it will be created automatically.
```bash
poetry export -f requirements.txt
```
!!!note
Only the `requirements.txt` format is currently supported.
...@@ -19,6 +19,7 @@ from .commands import BuildCommand ...@@ -19,6 +19,7 @@ from .commands import BuildCommand
from .commands import CheckCommand from .commands import CheckCommand
from .commands import ConfigCommand from .commands import ConfigCommand
from .commands import DevelopCommand from .commands import DevelopCommand
from .commands import ExportCommand
from .commands import InitCommand from .commands import InitCommand
from .commands import InstallCommand from .commands import InstallCommand
from .commands import LockCommand from .commands import LockCommand
...@@ -111,6 +112,7 @@ class Application(BaseApplication): ...@@ -111,6 +112,7 @@ class Application(BaseApplication):
CheckCommand(), CheckCommand(),
ConfigCommand(), ConfigCommand(),
DevelopCommand(), DevelopCommand(),
ExportCommand(),
InitCommand(), InitCommand(),
InstallCommand(), InstallCommand(),
LockCommand(), LockCommand(),
......
...@@ -4,6 +4,7 @@ from .build import BuildCommand ...@@ -4,6 +4,7 @@ from .build import BuildCommand
from .check import CheckCommand from .check import CheckCommand
from .config import ConfigCommand from .config import ConfigCommand
from .develop import DevelopCommand from .develop import DevelopCommand
from .export import ExportCommand
from .init import InitCommand from .init import InitCommand
from .install import InstallCommand from .install import InstallCommand
from .lock import LockCommand from .lock import LockCommand
......
from poetry.utils.exporter import Exporter
from .command import Command
class ExportCommand(Command):
"""
Exports the lock file to alternative formats.
export
{--f|format= : Format to export to.}
{--without-hashes : Exclude hashes from the exported file.}
{--dev : Include development dependencies.}
"""
def handle(self):
fmt = self.option("format")
if fmt not in Exporter.ACCEPTED_FORMATS:
raise ValueError("Invalid export format: {}".format(fmt))
locker = self.poetry.locker
if not locker.is_locked():
self.line("<comment>The lock file does not exist. Locking.</comment>")
options = []
if self.output.is_debug():
options.append(("-vvv", None))
elif self.output.is_very_verbose():
options.append(("-vv", None))
elif self.output.is_verbose():
options.append(("-v", None))
self.call("lock", options)
if not locker.is_fresh():
self.line(
"<warning>"
"Warning: The lock file is not up to date with "
"the latest changes in pyproject.toml. "
"You may be getting outdated dependencies. "
"Run update to update them."
"</warning>"
)
exporter = Exporter(self.poetry.locker)
exporter.export(
fmt,
self.poetry.file.parent,
with_hashes=not self.option("without-hashes"),
dev=self.option("dev"),
)
...@@ -12,7 +12,7 @@ from poetry.utils.toml_file import TomlFile ...@@ -12,7 +12,7 @@ from poetry.utils.toml_file import TomlFile
from poetry.version.markers import parse_marker from poetry.version.markers import parse_marker
class Locker: class Locker(object):
_relevant_keys = ["dependencies", "dev-dependencies", "source", "extras"] _relevant_keys = ["dependencies", "dev-dependencies", "source", "extras"]
......
from poetry.packages.locker import Locker
from poetry.utils._compat import Path
from poetry.utils._compat import decode
class Exporter(object):
"""
Exporter class to export a lock file to alternative formats.
"""
ACCEPTED_FORMATS = ("requirements.txt",)
def __init__(self, lock): # type: (Locker) -> None
self._lock = lock
def export(
self, fmt, cwd, with_hashes=True, dev=False
): # type: (str, Path, bool, bool) -> None
if fmt not in self.ACCEPTED_FORMATS:
raise ValueError("Invalid export format: {}".format(fmt))
getattr(self, "_export_{}".format(fmt.replace(".", "_")))(
cwd, with_hashes=with_hashes, dev=dev
)
def _export_requirements_txt(
self, cwd, with_hashes=True, dev=False
): # type: (Path, bool, bool) -> None
filepath = cwd / "requirements.txt"
content = ""
for package in sorted(
self._lock.locked_repository(dev).packages, key=lambda p: p.name
):
if package.source_type == "git":
line = "-e git+{}@{}#egg={}".format(
package.source_url, package.source_reference, package.name
)
elif package.source_type in ["directory", "file"]:
line = ""
if package.develop:
line += "-e "
line += package.source_url
else:
line = "{}=={}".format(package.name, package.version.text)
if package.source_type == "legacy" and package.source_url:
line += " \\\n"
line += " --index-url {}".format(package.source_url)
if package.hashes and with_hashes:
line += " \\\n"
for i, h in enumerate(package.hashes):
line += " --hash=sha256:{}{}".format(
h, " \\\n" if i < len(package.hashes) - 1 else ""
)
line += "\n"
content += line
with filepath.open("w", encoding="utf-8") as f:
f.write(decode(content))
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import pytest
from cleo.testers import CommandTester
from tests.helpers import get_package
from ..conftest import Application
from ..conftest import Path
from ..conftest import Poetry
PYPROJECT_CONTENT = """\
[tool.poetry]
name = "simple-project"
version = "1.2.3"
description = "Some description."
authors = [
"Sébastien Eustace <sebastien@eustace.io>"
]
license = "MIT"
readme = "README.rst"
homepage = "https://poetry.eustace.io"
repository = "https://github.com/sdispater/poetry"
documentation = "https://poetry.eustace.io/docs"
keywords = ["packaging", "dependency", "poetry"]
classifiers = [
"Topic :: Software Development :: Build Tools",
"Topic :: Software Development :: Libraries :: Python Modules"
]
# Requirements
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
foo = "^1.0"
"""
@pytest.fixture
def poetry(repo, tmp_dir):
with (Path(tmp_dir) / "pyproject.toml").open("w", encoding="utf-8") as f:
f.write(PYPROJECT_CONTENT)
p = Poetry.create(Path(tmp_dir))
p.pool.remove_repository("pypi")
p.pool.add_repository(repo)
p._locker.write()
yield p
@pytest.fixture
def app(poetry):
return Application(poetry)
def test_export_exports_requirements_txt_file_locks_if_no_lock_file(app, repo):
command = app.find("export")
tester = CommandTester(command)
assert not app.poetry.locker.lock.exists()
repo.add_package(get_package("foo", "1.0.0"))
tester.execute([("command", command.get_name()), ("--format", "requirements.txt")])
requirements = app.poetry.file.parent / "requirements.txt"
assert requirements.exists()
with requirements.open(encoding="utf-8") as f:
content = f.read()
assert app.poetry.locker.lock.exists()
expected = """\
foo==1.0.0
"""
assert expected == content
assert "The lock file does not exist. Locking." in tester.get_display(True)
def test_export_exports_requirements_txt_uses_lock_file(app, repo):
repo.add_package(get_package("foo", "1.0.0"))
command = app.find("lock")
tester = CommandTester(command)
tester.execute([("command", "lock")])
assert app.poetry.locker.lock.exists()
command = app.find("export")
tester = CommandTester(command)
tester.execute([("command", command.get_name()), ("--format", "requirements.txt")])
requirements = app.poetry.file.parent / "requirements.txt"
assert requirements.exists()
with requirements.open(encoding="utf-8") as f:
content = f.read()
assert app.poetry.locker.lock.exists()
expected = """\
foo==1.0.0
"""
assert expected == content
assert "The lock file does not exist. Locking." not in tester.get_display(True)
def test_export_fails_on_invalid_format(app, repo):
repo.add_package(get_package("foo", "1.0.0"))
command = app.find("lock")
tester = CommandTester(command)
tester.execute([("command", "lock")])
assert app.poetry.locker.lock.exists()
command = app.find("export")
tester = CommandTester(command)
with pytest.raises(ValueError):
tester.execute([("command", command.get_name()), ("--format", "invalid")])
...@@ -17,7 +17,6 @@ from poetry.packages import Locker as BaseLocker ...@@ -17,7 +17,6 @@ from poetry.packages import Locker as BaseLocker
from poetry.repositories import Pool from poetry.repositories import Pool
from poetry.repositories import Repository from poetry.repositories import Repository
from poetry.utils._compat import Path from poetry.utils._compat import Path
from poetry.utils.env import Env
from poetry.utils.env import MockEnv from poetry.utils.env import MockEnv
from poetry.utils.toml_file import TomlFile from poetry.utils.toml_file import TomlFile
...@@ -107,6 +106,10 @@ class Locker(BaseLocker): ...@@ -107,6 +106,10 @@ class Locker(BaseLocker):
self._content_hash = self._get_content_hash() self._content_hash = self._get_content_hash()
self._locked = False self._locked = False
self._lock_data = None self._lock_data = None
self._write = False
def write(self, write=True):
self._write = write
def is_locked(self): def is_locked(self):
return self._locked return self._locked
...@@ -125,6 +128,11 @@ class Locker(BaseLocker): ...@@ -125,6 +128,11 @@ class Locker(BaseLocker):
return True return True
def _write_lock_data(self, data): def _write_lock_data(self, data):
if self._write:
super(Locker, self)._write_lock_data(data)
self._locked = True
return
self._lock_data = None self._lock_data = None
......
import pytest
from poetry.packages import Locker as BaseLocker
from poetry.utils._compat import Path
from poetry.utils.exporter import Exporter
class Locker(BaseLocker):
def __init__(self):
self._locked = True
self._content_hash = self._get_content_hash()
def locked(self, is_locked=True):
self._locked = is_locked
return self
def mock_lock_data(self, data):
self._lock_data = data
def is_locked(self):
return self._locked
def is_fresh(self):
return True
def _get_content_hash(self):
return "123456789"
@pytest.fixture()
def locker():
return Locker()
def test_exporter_can_export_requirements_txt_with_standard_packages(tmp_dir, locker):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "main",
"optional": False,
"python-versions": "*",
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": [], "bar": []},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
bar==4.5.6
foo==1.2.3
"""
assert expected == content
def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes(
tmp_dir, locker
):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "main",
"optional": False,
"python-versions": "*",
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": ["12345"], "bar": ["67890"]},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
bar==4.5.6 \\
--hash=sha256:67890
foo==1.2.3 \\
--hash=sha256:12345
"""
assert expected == content
def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_disabled(
tmp_dir, locker
):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "main",
"optional": False,
"python-versions": "*",
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": ["12345"], "bar": ["67890"]},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir), with_hashes=False)
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
bar==4.5.6
foo==1.2.3
"""
assert expected == content
def test_exporter_exports_requirements_txt_without_dev_packages_by_default(
tmp_dir, locker
):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "dev",
"optional": False,
"python-versions": "*",
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": ["12345"], "bar": ["67890"]},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
foo==1.2.3 \\
--hash=sha256:12345
"""
assert expected == content
def test_exporter_exports_requirements_txt_with_dev_packages_if_opted_in(
tmp_dir, locker
):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "dev",
"optional": False,
"python-versions": "*",
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": ["12345"], "bar": ["67890"]},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir), dev=True)
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
bar==4.5.6 \\
--hash=sha256:67890
foo==1.2.3 \\
--hash=sha256:12345
"""
assert expected == content
def test_exporter_can_export_requirements_txt_with_git_packages(tmp_dir, locker):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
"source": {
"type": "git",
"url": "https://github.com/foo/foo.git",
"reference": "123456",
},
}
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": []},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
-e git+https://github.com/foo/foo.git@123456#egg=foo
"""
assert expected == content
def test_exporter_can_export_requirements_txt_with_directory_packages(tmp_dir, locker):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
"source": {"type": "directory", "url": "../foo", "reference": ""},
}
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": []},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
-e ../foo
"""
assert expected == content
def test_exporter_can_export_requirements_txt_with_file_packages(tmp_dir, locker):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
"source": {"type": "file", "url": "../foo.tar.gz", "reference": ""},
}
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": []},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir))
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
-e ../foo.tar.gz
"""
assert expected == content
def test_exporter_exports_requirements_txt_with_legacy_packages(tmp_dir, locker):
locker.mock_lock_data(
{
"package": [
{
"name": "foo",
"version": "1.2.3",
"category": "main",
"optional": False,
"python-versions": "*",
},
{
"name": "bar",
"version": "4.5.6",
"category": "dev",
"optional": False,
"python-versions": "*",
"source": {
"type": "legacy",
"url": "https://example.com/simple/",
"reference": "",
},
},
],
"metadata": {
"python-versions": "*",
"content-hash": "123456789",
"hashes": {"foo": ["12345"], "bar": ["67890"]},
},
}
)
exporter = Exporter(locker)
exporter.export("requirements.txt", Path(tmp_dir), dev=True)
with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f:
content = f.read()
expected = """\
bar==4.5.6 \\
--index-url https://example.com/simple/ \\
--hash=sha256:67890
foo==1.2.3 \\
--hash=sha256:12345
"""
assert expected == content
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