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 @@
- Added the ability to specify packages on a per-format basis.
- Added support for custom urls in metadata.
- 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
......@@ -24,6 +27,9 @@
- The `debug:resolve` command has been renamed to `debug resolve`.
- The `self:update` command has been renamed to `self update`.
- 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
......
......@@ -182,18 +182,42 @@ poetry will choose a suitable one based on the available package versions.
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:
```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:
```bash
poetry add my-package --path ../my-package/
poetry add my-package --path ../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/
poetry add ../my-package/dist/my-package-0.1.0.tar.gz
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").
......@@ -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:
```
```toml
[tool.poetry.dependencies]
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
* `--dev (-D)`: Add package as development dependency.
* `--git`: The url of the Git repository.
* `--path`: The path to a dependency.
* `--extras (-E)`: Extras to activate for the dependency.
* `--optional` : Add as an optional dependency.
* `--dry-run` : Outputs the operations but will not execute anything (implicitly enables --verbose).
......
......@@ -13,8 +13,6 @@ class AddCommand(EnvCommand, InitCommand):
arguments = [argument("name", "Packages to add.", multiple=True)]
options = [
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(
"extras",
"E",
......@@ -58,17 +56,11 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
packages = self.argument("name")
is_dev = self.option("dev")
if (self.option("git") or self.option("path") or self.option("extras")) and len(
packages
) > 1:
if self.option("extras") and len(packages) > 1:
raise ValueError(
"You can only specify one package "
"when using the --git or --path options"
"You can only specify one package " "when using the --extras option"
)
if self.option("git") and self.option("path"):
raise RuntimeError("--git and --path cannot be used at the same time")
section = "dependencies"
if is_dev:
section = "dev-dependencies"
......@@ -83,32 +75,27 @@ If you do not specify a version constraint, poetry will choose a suitable one ba
for name in packages:
for key in poetry_content[section]:
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))
if self.option("git") or self.option("path"):
requirements = {packages[0]: ""}
else:
requirements = self._determine_requirements(
packages, allow_prereleases=self.option("allow-prereleases")
)
requirements = self._format_requirements(requirements)
# validate requirements format
for constraint in requirements.values():
parse_constraint(constraint)
for _constraint in requirements:
if "version" in _constraint:
# Validate version constraint
parse_constraint(_constraint["version"])
for name, _constraint in requirements.items():
constraint = inline_table()
constraint["version"] = _constraint
if self.option("git"):
del constraint["version"]
constraint["git"] = self.option("git")
elif self.option("path"):
del constraint["version"]
for name, value in _constraint.items():
if name == "name":
continue
constraint["path"] = self.option("path")
constraint[name] = value
if self.option("optional"):
constraint["optional"] = True
......@@ -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:
constraint = constraint["version"]
poetry_content[section][name] = constraint
poetry_content[section][_constraint["name"]] = constraint
# Write new 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
installer.dry_run(self.option("dry-run"))
installer.update(True)
installer.whitelist(requirements)
installer.whitelist([r["name"] for r in requirements])
try:
status = installer.run()
......
......@@ -26,8 +26,9 @@ class VersionSelector(object):
constraint = parse_constraint("*")
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:
return False
......@@ -37,7 +38,12 @@ class VersionSelector(object):
# Select highest version if we have many
package = candidates[0]
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
# Select highest version of the two
......@@ -52,24 +58,20 @@ class VersionSelector(object):
return self._transform_version(version.text, package.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:
parsed = Version.parse(version)
parts = [parsed.major, parsed.minor, parsed.patch]
except ValueError:
return pretty_version
# check to see if we have a semver-looking version
if len(parts) == 3:
# remove the last parts (the patch version number and any extra)
if parts[0] != 0:
del parts[2]
parts = parts[: parsed.precision]
# 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)
if parsed.is_prerelease():
version += "-{}".format(".".join(str(p) for p in parsed.prerelease))
else:
return pretty_version
return "^{}".format(version)
import sys
import pytest
from cleo.testers import CommandTester
from poetry.utils._compat import Path
from tests.helpers import get_dependency
from tests.helpers import get_package
......@@ -39,14 +42,14 @@ Package operations: 1 install, 0 updates, 0 removals
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")
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")
tester.execute("cachy==0.1.0")
expected = """\
......@@ -66,6 +69,67 @@ Package operations: 1 install, 0 updates, 0 removals
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):
command = app.find("add")
tester = CommandTester(command)
......@@ -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("cleo", "0.6.5"))
tester.execute("demo --git https://github.com/demo/demo.git")
tester.execute("git+https://github.com/demo/demo.git")
expected = """\
......@@ -140,7 +204,7 @@ def test_add_git_constraint_with_poetry(app, repo, installer):
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 = """\
......@@ -161,13 +225,121 @@ Package operations: 2 installs, 0 updates, 0 removals
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")
tester = CommandTester(command)
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 = """\
......@@ -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")
tester = CommandTester(command)
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 = """\
......@@ -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")
tester = CommandTester(command)
......@@ -407,3 +582,72 @@ Package operations: 1 install, 0 updates, 0 removals
assert "pyyaml" in content["dependencies"]
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"
[tool.poetry.dependencies]
python = "~2.7 || ^3.6"
pendulum = "^2.0"
pendulum = "^2.0.0"
[tool.poetry.dev-dependencies]
pytest = "^3.6"
pytest = "^3.6.0"
"""
assert expected in tester.io.fetch_output()
......@@ -135,3 +135,305 @@ python = "^{python}"
)
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