Skip to content

Commit 7018bd3

Browse files
authored
Add an export command (python-poetry#675)
1 parent 832e8fe commit 7018bd3

File tree

10 files changed

+684
-2
lines changed

10 files changed

+684
-2
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Added an `export` command to export the lock file to other formats (only `requirements.txt` is currently supported).
8+
59
### Changed
610

711
- Slightly changed the lock file, making it potentially incompatible with previous Poetry versions.

docs/docs/cli.md

+15
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,18 @@ and writes the new version back to `pyproject.toml`
360360

361361
The new version should ideally be a valid semver string or a valid bump rule:
362362
`patch`, `minor`, `major`, `prepatch`, `preminor`, `premajor`, `prerelease`.
363+
364+
365+
## export
366+
367+
This command exports the lock file to other formats.
368+
369+
If the lock file does not exist, it will be created automatically.
370+
371+
```bash
372+
poetry export -f requirements.txt
373+
```
374+
375+
!!!note
376+
377+
Only the `requirements.txt` format is currently supported.

poetry/console/application.py

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .commands import CheckCommand
2020
from .commands import ConfigCommand
2121
from .commands import DevelopCommand
22+
from .commands import ExportCommand
2223
from .commands import InitCommand
2324
from .commands import InstallCommand
2425
from .commands import LockCommand
@@ -111,6 +112,7 @@ def get_default_commands(self): # type: () -> list
111112
CheckCommand(),
112113
ConfigCommand(),
113114
DevelopCommand(),
115+
ExportCommand(),
114116
InitCommand(),
115117
InstallCommand(),
116118
LockCommand(),

poetry/console/commands/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .check import CheckCommand
55
from .config import ConfigCommand
66
from .develop import DevelopCommand
7+
from .export import ExportCommand
78
from .init import InitCommand
89
from .install import InstallCommand
910
from .lock import LockCommand

poetry/console/commands/export.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from poetry.utils.exporter import Exporter
2+
3+
from .command import Command
4+
5+
6+
class ExportCommand(Command):
7+
"""
8+
Exports the lock file to alternative formats.
9+
10+
export
11+
{--f|format= : Format to export to.}
12+
{--without-hashes : Exclude hashes from the exported file.}
13+
{--dev : Include development dependencies.}
14+
"""
15+
16+
def handle(self):
17+
fmt = self.option("format")
18+
19+
if fmt not in Exporter.ACCEPTED_FORMATS:
20+
raise ValueError("Invalid export format: {}".format(fmt))
21+
22+
locker = self.poetry.locker
23+
if not locker.is_locked():
24+
self.line("<comment>The lock file does not exist. Locking.</comment>")
25+
options = []
26+
if self.output.is_debug():
27+
options.append(("-vvv", None))
28+
elif self.output.is_very_verbose():
29+
options.append(("-vv", None))
30+
elif self.output.is_verbose():
31+
options.append(("-v", None))
32+
33+
self.call("lock", options)
34+
35+
if not locker.is_fresh():
36+
self.line(
37+
"<warning>"
38+
"Warning: The lock file is not up to date with "
39+
"the latest changes in pyproject.toml. "
40+
"You may be getting outdated dependencies. "
41+
"Run update to update them."
42+
"</warning>"
43+
)
44+
45+
exporter = Exporter(self.poetry.locker)
46+
exporter.export(
47+
fmt,
48+
self.poetry.file.parent,
49+
with_hashes=not self.option("without-hashes"),
50+
dev=self.option("dev"),
51+
)

poetry/packages/locker.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from poetry.version.markers import parse_marker
1313

1414

15-
class Locker:
15+
class Locker(object):
1616

1717
_relevant_keys = ["dependencies", "dev-dependencies", "source", "extras"]
1818

poetry/utils/exporter.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from poetry.packages.locker import Locker
2+
from poetry.utils._compat import Path
3+
from poetry.utils._compat import decode
4+
5+
6+
class Exporter(object):
7+
"""
8+
Exporter class to export a lock file to alternative formats.
9+
"""
10+
11+
ACCEPTED_FORMATS = ("requirements.txt",)
12+
13+
def __init__(self, lock): # type: (Locker) -> None
14+
self._lock = lock
15+
16+
def export(
17+
self, fmt, cwd, with_hashes=True, dev=False
18+
): # type: (str, Path, bool, bool) -> None
19+
if fmt not in self.ACCEPTED_FORMATS:
20+
raise ValueError("Invalid export format: {}".format(fmt))
21+
22+
getattr(self, "_export_{}".format(fmt.replace(".", "_")))(
23+
cwd, with_hashes=with_hashes, dev=dev
24+
)
25+
26+
def _export_requirements_txt(
27+
self, cwd, with_hashes=True, dev=False
28+
): # type: (Path, bool, bool) -> None
29+
filepath = cwd / "requirements.txt"
30+
content = ""
31+
32+
for package in sorted(
33+
self._lock.locked_repository(dev).packages, key=lambda p: p.name
34+
):
35+
if package.source_type == "git":
36+
line = "-e git+{}@{}#egg={}".format(
37+
package.source_url, package.source_reference, package.name
38+
)
39+
elif package.source_type in ["directory", "file"]:
40+
line = ""
41+
if package.develop:
42+
line += "-e "
43+
44+
line += package.source_url
45+
else:
46+
line = "{}=={}".format(package.name, package.version.text)
47+
48+
if package.source_type == "legacy" and package.source_url:
49+
line += " \\\n"
50+
line += " --index-url {}".format(package.source_url)
51+
52+
if package.hashes and with_hashes:
53+
line += " \\\n"
54+
for i, h in enumerate(package.hashes):
55+
line += " --hash=sha256:{}{}".format(
56+
h, " \\\n" if i < len(package.hashes) - 1 else ""
57+
)
58+
59+
line += "\n"
60+
content += line
61+
62+
with filepath.open("w", encoding="utf-8") as f:
63+
f.write(decode(content))

tests/console/commands/test_export.py

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
import pytest
5+
6+
from cleo.testers import CommandTester
7+
8+
from tests.helpers import get_package
9+
10+
from ..conftest import Application
11+
from ..conftest import Path
12+
from ..conftest import Poetry
13+
14+
15+
PYPROJECT_CONTENT = """\
16+
[tool.poetry]
17+
name = "simple-project"
18+
version = "1.2.3"
19+
description = "Some description."
20+
authors = [
21+
"Sébastien Eustace <[email protected]>"
22+
]
23+
license = "MIT"
24+
25+
readme = "README.rst"
26+
27+
homepage = "https://poetry.eustace.io"
28+
repository = "https://github.com/sdispater/poetry"
29+
documentation = "https://poetry.eustace.io/docs"
30+
31+
keywords = ["packaging", "dependency", "poetry"]
32+
33+
classifiers = [
34+
"Topic :: Software Development :: Build Tools",
35+
"Topic :: Software Development :: Libraries :: Python Modules"
36+
]
37+
38+
# Requirements
39+
[tool.poetry.dependencies]
40+
python = "~2.7 || ^3.6"
41+
foo = "^1.0"
42+
"""
43+
44+
45+
@pytest.fixture
46+
def poetry(repo, tmp_dir):
47+
with (Path(tmp_dir) / "pyproject.toml").open("w", encoding="utf-8") as f:
48+
f.write(PYPROJECT_CONTENT)
49+
50+
p = Poetry.create(Path(tmp_dir))
51+
52+
p.pool.remove_repository("pypi")
53+
p.pool.add_repository(repo)
54+
p._locker.write()
55+
56+
yield p
57+
58+
59+
@pytest.fixture
60+
def app(poetry):
61+
return Application(poetry)
62+
63+
64+
def test_export_exports_requirements_txt_file_locks_if_no_lock_file(app, repo):
65+
command = app.find("export")
66+
tester = CommandTester(command)
67+
68+
assert not app.poetry.locker.lock.exists()
69+
70+
repo.add_package(get_package("foo", "1.0.0"))
71+
72+
tester.execute([("command", command.get_name()), ("--format", "requirements.txt")])
73+
74+
requirements = app.poetry.file.parent / "requirements.txt"
75+
assert requirements.exists()
76+
77+
with requirements.open(encoding="utf-8") as f:
78+
content = f.read()
79+
80+
assert app.poetry.locker.lock.exists()
81+
82+
expected = """\
83+
foo==1.0.0
84+
"""
85+
86+
assert expected == content
87+
assert "The lock file does not exist. Locking." in tester.get_display(True)
88+
89+
90+
def test_export_exports_requirements_txt_uses_lock_file(app, repo):
91+
repo.add_package(get_package("foo", "1.0.0"))
92+
93+
command = app.find("lock")
94+
tester = CommandTester(command)
95+
tester.execute([("command", "lock")])
96+
97+
assert app.poetry.locker.lock.exists()
98+
99+
command = app.find("export")
100+
tester = CommandTester(command)
101+
102+
tester.execute([("command", command.get_name()), ("--format", "requirements.txt")])
103+
104+
requirements = app.poetry.file.parent / "requirements.txt"
105+
assert requirements.exists()
106+
107+
with requirements.open(encoding="utf-8") as f:
108+
content = f.read()
109+
110+
assert app.poetry.locker.lock.exists()
111+
112+
expected = """\
113+
foo==1.0.0
114+
"""
115+
116+
assert expected == content
117+
assert "The lock file does not exist. Locking." not in tester.get_display(True)
118+
119+
120+
def test_export_fails_on_invalid_format(app, repo):
121+
repo.add_package(get_package("foo", "1.0.0"))
122+
123+
command = app.find("lock")
124+
tester = CommandTester(command)
125+
tester.execute([("command", "lock")])
126+
127+
assert app.poetry.locker.lock.exists()
128+
129+
command = app.find("export")
130+
tester = CommandTester(command)
131+
132+
with pytest.raises(ValueError):
133+
tester.execute([("command", command.get_name()), ("--format", "invalid")])

tests/console/conftest.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from poetry.repositories import Pool
1818
from poetry.repositories import Repository
1919
from poetry.utils._compat import Path
20-
from poetry.utils.env import Env
2120
from poetry.utils.env import MockEnv
2221
from poetry.utils.toml_file import TomlFile
2322

@@ -107,6 +106,10 @@ def __init__(self, lock, local_config):
107106
self._content_hash = self._get_content_hash()
108107
self._locked = False
109108
self._lock_data = None
109+
self._write = False
110+
111+
def write(self, write=True):
112+
self._write = write
110113

111114
def is_locked(self):
112115
return self._locked
@@ -125,6 +128,11 @@ def is_fresh(self):
125128
return True
126129

127130
def _write_lock_data(self, data):
131+
if self._write:
132+
super(Locker, self)._write_lock_data(data)
133+
self._locked = True
134+
return
135+
128136
self._lock_data = None
129137

130138

0 commit comments

Comments
 (0)