Commit 1da3e729 by Sébastien Eustace Committed by GitHub

Merge pull request #4336 from python-poetry/improve-operations-transactions

Improve package operations management
parents df77c800 ea8fb8c6
......@@ -163,11 +163,19 @@ The `--dev-only` option is now deprecated. You should use the `--only dev` notat
See [Dependency groups]({{< relref "managing-dependencies#dependency-groups" >}}) for more information
about dependency groups.
If you want to remove old dependencies no longer present in the lock file, use the
`--remove-untracked` option.
If you want to synchronize your environment – and ensure it matches the lock file – use the
`--sync` option.
```bash
poetry install --remove-untracked
poetry install --sync
```
The `--sync` can be combined with group-related options:
```bash
poetry install --without dev --sync
poetry install --with docs --sync
poetry install --only dev
```
You can also specify the extras you want installed
......@@ -204,12 +212,14 @@ option is used.
* `--with`: The optional dependency groups to include for installation.
* `--only`: The only dependency groups to install.
* `--default`: Only install the default dependencies.
* `--no-dev`: Do not install dev dependencies. (**Deprecated**)
* `--dev-only`: Only install dev dependencies. (**Deprecated**)
* `--sync`: Synchronize the environment with the locked packages and the specified groups.
* `--no-root`: Do not install the root package (your project).
* `--dry-run`: Output the operations but do not execute anything (implicitly enables --verbose).
* `--remove-untracked`: Remove dependencies not presented in the lock file
* `--extras (-E)`: Features to install (multiple values allowed).
* `--no-dev`: Do not install dev dependencies. (**Deprecated**)
* `--dev-only`: Only install dev dependencies. (**Deprecated**)
* `--remove-untracked`: Remove dependencies not presented in the lock file. (**Deprecated**)
## update
......
......@@ -146,3 +146,29 @@ to remove packages from a specific group:
```bash
poetry remove mkdocs --group docs
```
## Synchronizing dependencies
Poetry supports what's called dependency synchronization. What this does is ensuring
that the locked dependencies in the `poetry.lock` file are the only ones present
in the environment, removing anything that's not necessary.
This is done by using the `--sync` option of the `install` command:
```bash
poetry install --sync
```
The `--sync` option can be combined with any [dependency groups]({{< relref "#dependency-groups" >}}) related options
to synchronize the environment with specific groups.
```bash
poetry install --without dev --sync
poetry install --with docs --sync
poetry install --only dev
```
{{% note %}}
The `--sync` option replaces the `--remove-untracked` option which is now deprecated.
{{% /note %}}
......@@ -86,7 +86,7 @@ class DebugResolveCommand(InitCommand):
solver = Solver(package, pool, Repository(), Repository(), self._io)
ops = solver.solve()
ops = solver.solve().calculate_operations()
self.line("")
self.line("Resolution results:")
......@@ -123,7 +123,7 @@ class DebugResolveCommand(InitCommand):
solver = Solver(package, pool, Repository(), Repository(), NullIO())
with solver.use_environment(env):
ops = solver.solve()
ops = solver.solve().calculate_operations()
for op in ops:
if self.option("install") and op.skipped:
......
......@@ -42,6 +42,11 @@ class InstallCommand(InstallerCommand):
"Only install the development dependencies. (<warning>Deprecated</warning>)",
),
option(
"sync",
None,
"Synchronize the environment with the locked packages and the specified groups.",
),
option(
"no-root", None, "Do not install the root package (the current project)."
),
option(
......@@ -138,11 +143,20 @@ dependencies and not including the current project, run the command with the
if self.option("default"):
only_groups.append("default")
with_synchronization = self.option("sync")
if self.option("remove-untracked"):
self.line(
"<warning>The `<fg=yellow;options=bold>--remove-untracked</>` option is deprecated,"
"use the `<fg=yellow;options=bold>--sync</>` option instead.</warning>"
)
with_synchronization = True
self._installer.only_groups(only_groups)
self._installer.without_groups(excluded_groups)
self._installer.with_groups(included_groups)
self._installer.dry_run(self.option("dry-run"))
self._installer.remove_untracked(self.option("remove-untracked"))
self._installer.requires_synchronization(with_synchronization)
self._installer.verbose(self._io.is_verbose())
return_code = self._installer.run()
......
......@@ -161,7 +161,7 @@ lists all packages available."""
)
solver.provider.load_deferred(False)
with solver.use_environment(self.env):
ops = solver.solve()
ops = solver.solve().calculate_operations()
required_locked_packages = set([op.package for op in ops if not op.skipped])
......
......@@ -50,7 +50,7 @@ class Installer:
self._pool = pool
self._dry_run = False
self._remove_untracked = False
self._requires_synchronization = False
self._update = False
self._verbose = False
self._write_lock = True
......@@ -122,14 +122,13 @@ class Installer:
def is_dry_run(self) -> bool:
return self._dry_run
def remove_untracked(self, remove_untracked: bool = True) -> "Installer":
self._remove_untracked = remove_untracked
def requires_synchronization(
self, requires_synchronization: bool = True
) -> "Installer":
self._requires_synchronization = requires_synchronization
return self
def is_remove_untracked(self) -> bool:
return self._remove_untracked
def verbose(self, verbose: bool = True) -> "Installer":
self._verbose = verbose
self._executor.verbose(verbose)
......@@ -212,7 +211,7 @@ class Installer:
self._io,
)
ops = solver.solve(use_latest=[])
ops = solver.solve(use_latest=[]).calculate_operations()
local_repo = Repository()
self._populate_local_repo(local_repo, ops)
......@@ -247,10 +246,9 @@ class Installer:
self._installed_repository,
locked_repository,
self._io,
remove_untracked=self._remove_untracked,
)
ops = solver.solve(use_latest=self._whitelist)
ops = solver.solve(use_latest=self._whitelist).calculate_operations()
else:
self._io.write_line("<info>Installing dependencies from lock file</>")
......@@ -318,19 +316,35 @@ class Installer:
pool.add_repository(repo)
solver = Solver(
root,
pool,
self._installed_repository,
locked_repository,
NullIO(),
remove_untracked=self._remove_untracked,
root, pool, self._installed_repository, locked_repository, NullIO()
)
# Everything is resolved at this point, so we no longer need
# to load deferred dependencies (i.e. VCS, URL and path dependencies)
solver.provider.load_deferred(False)
with solver.use_environment(self._env):
ops = solver.solve(use_latest=self._whitelist)
ops = solver.solve(use_latest=self._whitelist).calculate_operations(
with_uninstalls=self._requires_synchronization,
synchronize=self._requires_synchronization,
)
if not self._requires_synchronization:
# If no packages synchronisation has been requested we need
# to calculate the uninstall operations
from poetry.puzzle.transaction import Transaction
transaction = Transaction(
locked_repository.packages,
[(package, 0) for package in local_repo.packages],
installed_packages=self._installed_repository.packages,
root_package=root,
)
ops = [
op
for op in transaction.calculate_operations(with_uninstalls=True)
if op.job_type == "uninstall"
] + ops
# We need to filter operations so that packages
# not compatible with the current system,
......
......@@ -16,9 +16,6 @@ from cleo.io.io import IO
from poetry.core.packages.package import Package
from poetry.core.packages.project_package import ProjectPackage
from poetry.installation.operations import Install
from poetry.installation.operations import Uninstall
from poetry.installation.operations import Update
from poetry.mixology import resolve_version
from poetry.mixology.failure import SolveFailure
from poetry.packages import DependencyPackage
......@@ -37,7 +34,8 @@ if TYPE_CHECKING:
from poetry.core.packages.file_dependency import FileDependency
from poetry.core.packages.url_dependency import URLDependency
from poetry.core.packages.vcs_dependency import VCSDependency
from poetry.installation.operations import OperationTypes
from .transaction import Transaction
class Solver:
......@@ -48,7 +46,6 @@ class Solver:
installed: Repository,
locked: Repository,
io: IO,
remove_untracked: bool = False,
provider: Optional[Provider] = None,
):
self._package = package
......@@ -62,39 +59,19 @@ class Solver:
self._provider = provider
self._overrides = []
self._remove_untracked = remove_untracked
self._preserved_package_names = None
@property
def provider(self) -> Provider:
return self._provider
@property
def preserved_package_names(self):
if self._preserved_package_names is None:
self._preserved_package_names = {
self._package.name,
*Provider.UNSAFE_PACKAGES,
}
deps = {package.name for package in self._locked.packages}
# preserve pip/setuptools/wheel when not managed by poetry, this is so
# to avoid externally managed virtual environments causing unnecessary
# removals.
for name in {"pip", "wheel", "setuptools"}:
if name not in deps:
self._preserved_package_names.add(name)
return self._preserved_package_names
@contextmanager
def use_environment(self, env: Env) -> None:
with self.provider.use_environment(env):
yield
def solve(self, use_latest: List[str] = None) -> List["OperationTypes"]:
def solve(self, use_latest: List[str] = None) -> "Transaction":
from .transaction import Transaction
with self._provider.progress():
start = time.time()
packages, depths = self._solve(use_latest=use_latest)
......@@ -110,121 +87,11 @@ class Solver:
f"Resolved with overrides: {', '.join(f'({b})' for b in self._overrides)}"
)
operations = []
for i, package in enumerate(packages):
installed = False
for pkg in self._installed.packages:
if package.name == pkg.name:
installed = True
if pkg.source_type == "git" and package.source_type == "git":
from poetry.core.vcs.git import Git
# Trying to find the currently installed version
pkg_source_url = Git.normalize_url(pkg.source_url)
package_source_url = Git.normalize_url(package.source_url)
for locked in self._locked.packages:
if locked.name != pkg.name or locked.source_type != "git":
continue
locked_source_url = Git.normalize_url(locked.source_url)
if (
locked.name == pkg.name
and locked.source_type == pkg.source_type
and locked_source_url == pkg_source_url
and locked.source_reference == pkg.source_reference
and locked.source_resolved_reference
== pkg.source_resolved_reference
):
pkg = Package(
pkg.name,
locked.version,
source_type="git",
source_url=locked.source_url,
source_reference=locked.source_reference,
source_resolved_reference=locked.source_resolved_reference,
)
break
if pkg_source_url != package_source_url or (
(
not pkg.source_resolved_reference
or not package.source_resolved_reference
)
and pkg.source_reference != package.source_reference
and not pkg.source_reference.startswith(
package.source_reference
)
or (
pkg.source_resolved_reference
and package.source_resolved_reference
and pkg.source_resolved_reference
!= package.source_resolved_reference
and not pkg.source_resolved_reference.startswith(
package.source_resolved_reference
)
)
):
operations.append(Update(pkg, package, priority=depths[i]))
else:
operations.append(
Install(package).skip("Already installed")
)
elif package.version != pkg.version:
# Checking version
operations.append(Update(pkg, package, priority=depths[i]))
elif pkg.source_type and package.source_type != pkg.source_type:
operations.append(Update(pkg, package, priority=depths[i]))
else:
operations.append(
Install(package, priority=depths[i]).skip(
"Already installed"
)
)
break
if not installed:
operations.append(Install(package, priority=depths[i]))
# Checking for removals
for pkg in self._locked.packages:
remove = True
for package in packages:
if pkg.name == package.name:
remove = False
break
if remove:
skip = True
for installed in self._installed.packages:
if installed.name == pkg.name:
skip = False
break
op = Uninstall(pkg)
if skip:
op.skip("Not currently installed")
operations.append(op)
if self._remove_untracked:
locked_names = {locked.name for locked in self._locked.packages}
for installed in self._installed.packages:
if installed.name in self.preserved_package_names:
continue
if installed.name not in locked_names:
operations.append(Uninstall(installed))
return sorted(
operations,
key=lambda o: (
-o.priority,
o.package.name,
o.package.version,
),
return Transaction(
self._locked.packages,
list(zip(packages, depths)),
installed_packages=self._installed.packages,
root_package=self._package,
)
def solve_in_compatibility_mode(
......
from typing import TYPE_CHECKING
from typing import List
from typing import Optional
from typing import Tuple
if TYPE_CHECKING:
from poetry.core.packages.package import Package
from poetry.installation.operations import OperationTypes
class Transaction:
def __init__(
self,
current_packages: List["Package"],
result_packages: List[Tuple["Package", int]],
installed_packages: Optional[List["Package"]] = None,
root_package: Optional["Package"] = None,
) -> None:
self._current_packages = current_packages
self._result_packages = result_packages
if installed_packages is None:
installed_packages = []
self._installed_packages = installed_packages
self._root_package = root_package
def calculate_operations(
self, with_uninstalls: bool = True, synchronize: bool = False
) -> List["OperationTypes"]:
from poetry.installation.operations.install import Install
from poetry.installation.operations.uninstall import Uninstall
from poetry.installation.operations.update import Update
operations = []
for result_package, priority in self._result_packages:
installed = False
for installed_package in self._installed_packages:
if result_package.name == installed_package.name:
installed = True
if result_package.version != installed_package.version:
operations.append(
Update(installed_package, result_package, priority=priority)
)
elif (
installed_package.source_type
or result_package.source_type != "legacy"
) and not result_package.is_same_package_as(installed_package):
operations.append(
Update(installed_package, result_package, priority=priority)
)
else:
operations.append(
Install(result_package).skip("Already installed")
)
break
if not installed:
operations.append(Install(result_package, priority=priority))
if with_uninstalls:
for current_package in self._current_packages:
found = False
for result_package, _ in self._result_packages:
if current_package.name == result_package.name:
found = True
break
if not found:
for installed_package in self._installed_packages:
if installed_package.name == current_package.name:
operations.append(Uninstall(current_package))
if synchronize:
current_package_names = {
current_package.name for current_package in self._current_packages
}
# We preserve pip/setuptools/wheel when not managed by poetry, this is done
# to avoid externally managed virtual environments causing unnecessary
# removals.
preserved_package_names = {
"pip",
"setuptools",
"wheel",
} - current_package_names
for installed_package in self._installed_packages:
if (
self._root_package
and installed_package.name == self._root_package.name
):
continue
if installed_package.name in preserved_package_names:
continue
if installed_package.name not in current_package_names:
operations.append(Uninstall(installed_package))
return sorted(
operations,
key=lambda o: (
-o.priority,
o.package.name,
o.package.version,
),
)
......@@ -17,3 +17,14 @@ def test_group_options_are_passed_to_the_installer(tester, mocker):
assert tester.command.installer._with_groups == ["foo", "bar"]
assert tester.command.installer._without_groups == ["baz", "bim"]
assert tester.command.installer._only_groups == ["bam"]
def test_sync_option_is_passed_to_the_installer(tester, mocker):
"""
The --sync option is passed properly to the installer.
"""
mocker.patch.object(tester.command.installer, "run", return_value=1)
tester.execute("--sync")
assert tester.command.installer._requires_synchronization
......@@ -351,7 +351,7 @@ def test_run_install_no_group(installer, locker, repo, package, installed):
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 1 == installer.executor.removals_count
assert 0 == installer.executor.removals_count
def test_run_install_group_only(installer, locker, repo, package, installed):
......@@ -362,7 +362,7 @@ def test_run_install_group_only(installer, locker, repo, package, installed):
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 2 == installer.executor.removals_count
assert 0 == installer.executor.removals_count
def test_run_install_with_optional_group_not_selected(
......@@ -376,7 +376,207 @@ def test_run_install_with_optional_group_not_selected(
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 1 == installer.executor.removals_count
assert 0 == installer.executor.removals_count
def test_run_install_does_not_remove_locked_packages_if_installed_but_not_required(
installer, locker, repo, package, installed
):
package_a = get_package("a", "1.0")
package_b = get_package("b", "1.1")
package_c = get_package("c", "1.2")
repo.add_package(package_a)
installed.add_package(package_a)
repo.add_package(package_b)
installed.add_package(package_b)
repo.add_package(package_c)
installed.add_package(package_c)
installed.add_package(package) # Root package never removed.
package.add_dependency(Factory.create_dependency(package_a.name, package_a.version))
locker.locked(True)
locker.mock_lock_data(
{
"package": [
{
"name": package_a.name,
"version": package_a.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_b.name,
"version": package_b.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_c.name,
"version": package_c.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
],
"metadata": {
"python-versions": "*",
"platform": "*",
"content-hash": "123456789",
"hashes": {package_a.name: [], package_b.name: [], package_c.name: []},
},
}
)
installer.run()
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 0 == installer.executor.removals_count
def test_run_install_removes_locked_packages_if_installed_and_synchronization_is_required(
installer, locker, repo, package, installed
):
package_a = get_package("a", "1.0")
package_b = get_package("b", "1.1")
package_c = get_package("c", "1.2")
repo.add_package(package_a)
installed.add_package(package_a)
repo.add_package(package_b)
installed.add_package(package_b)
repo.add_package(package_c)
installed.add_package(package_c)
installed.add_package(package) # Root package never removed.
package.add_dependency(Factory.create_dependency(package_a.name, package_a.version))
locker.locked(True)
locker.mock_lock_data(
{
"package": [
{
"name": package_a.name,
"version": package_a.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_b.name,
"version": package_b.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_c.name,
"version": package_c.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
],
"metadata": {
"python-versions": "*",
"platform": "*",
"content-hash": "123456789",
"hashes": {package_a.name: [], package_b.name: [], package_c.name: []},
},
}
)
installer.requires_synchronization(True)
installer.run()
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 2 == installer.executor.removals_count
def test_run_install_removes_no_longer_locked_packages_if_installed(
installer, locker, repo, package, installed
):
package_a = get_package("a", "1.0")
package_b = get_package("b", "1.1")
package_c = get_package("c", "1.2")
repo.add_package(package_a)
installed.add_package(package_a)
repo.add_package(package_b)
installed.add_package(package_b)
repo.add_package(package_c)
installed.add_package(package_c)
installed.add_package(package) # Root package never removed.
package.add_dependency(Factory.create_dependency(package_a.name, package_a.version))
locker.locked(True)
locker.mock_lock_data(
{
"package": [
{
"name": package_a.name,
"version": package_a.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_b.name,
"version": package_b.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
{
"name": package_c.name,
"version": package_c.version.text,
"category": "main",
"optional": False,
"platform": "*",
"python-versions": "*",
"checksum": [],
},
],
"metadata": {
"python-versions": "*",
"platform": "*",
"content-hash": "123456789",
"hashes": {package_a.name: [], package_b.name: [], package_c.name: []},
},
}
)
installer.update(True)
installer.run()
assert 0 == installer.executor.installations_count
assert 0 == installer.executor.updates_count
assert 2 == installer.executor.removals_count
def test_run_install_with_optional_group_selected(
......@@ -406,7 +606,7 @@ def test_run_install_with_optional_group_selected(
)
],
)
def test_run_install_remove_untracked(
def test_run_install_with_synchronization(
managed_reserved_package_names, installer, locker, repo, package, installed
):
package_a = get_package("a", "1.0")
......@@ -462,7 +662,7 @@ def test_run_install_remove_untracked(
}
)
installer.remove_untracked(True)
installer.requires_synchronization(True)
installer.run()
assert 0 == installer.executor.installations_count
......
......@@ -293,7 +293,7 @@ def test_run_install_no_group(installer, locker, repo, package, installed):
assert len(updates) == 0
removals = installer.installer.removals
assert len(removals) == 1
assert len(removals) == 0
@pytest.mark.parametrize(
......@@ -308,7 +308,7 @@ def test_run_install_no_group(installer, locker, repo, package, installed):
)
],
)
def test_run_install_remove_untracked(
def test_run_install_with_synchronization(
managed_reserved_package_names, installer, locker, repo, package, installed
):
package_a = get_package("a", "1.0")
......@@ -364,7 +364,7 @@ def test_run_install_remove_untracked(
}
)
installer.remove_untracked(True)
installer.requires_synchronization(True)
installer.run()
installs = installer.installer.installs
......@@ -374,6 +374,7 @@ def test_run_install_remove_untracked(
assert len(updates) == 0
removals = installer.installer.removals
expected_removals = {
package_b.name,
package_c.name,
......
from poetry.core.packages.package import Package
from poetry.puzzle.transaction import Transaction
def check_operations(ops, expected):
for e in expected:
if "skipped" not in e:
e["skipped"] = False
result = []
for op in ops:
if "update" == op.job_type:
result.append(
{
"job": "update",
"from": op.initial_package,
"to": op.target_package,
"skipped": op.skipped,
}
)
else:
job = "install"
if op.job_type == "uninstall":
job = "remove"
result.append({"job": job, "package": op.package, "skipped": op.skipped})
assert expected == result
def test_it_should_calculate_operations_in_correct_order():
transaction = Transaction(
[Package("a", "1.0.0"), Package("b", "2.0.0"), Package("c", "3.0.0")],
[
(Package("a", "1.0.0"), 1),
(Package("b", "2.1.0"), 2),
(Package("d", "4.0.0"), 0),
],
)
check_operations(
transaction.calculate_operations(),
[
{"job": "install", "package": Package("b", "2.1.0")},
{"job": "install", "package": Package("a", "1.0.0")},
{"job": "install", "package": Package("d", "4.0.0")},
],
)
def test_it_should_calculate_operations_for_installed_packages():
transaction = Transaction(
[Package("a", "1.0.0"), Package("b", "2.0.0"), Package("c", "3.0.0")],
[
(Package("a", "1.0.0"), 1),
(Package("b", "2.1.0"), 2),
(Package("d", "4.0.0"), 0),
],
installed_packages=[
Package("a", "1.0.0"),
Package("b", "2.0.0"),
Package("c", "3.0.0"),
Package("e", "5.0.0"),
],
)
check_operations(
transaction.calculate_operations(),
[
{"job": "remove", "package": Package("c", "3.0.0")},
{
"job": "update",
"from": Package("b", "2.0.0"),
"to": Package("b", "2.1.0"),
},
{"job": "install", "package": Package("a", "1.0.0"), "skipped": True},
{"job": "install", "package": Package("d", "4.0.0")},
],
)
def test_it_should_remove_installed_packages_if_required():
transaction = Transaction(
[Package("a", "1.0.0"), Package("b", "2.0.0"), Package("c", "3.0.0")],
[
(Package("a", "1.0.0"), 1),
(Package("b", "2.1.0"), 2),
(Package("d", "4.0.0"), 0),
],
installed_packages=[
Package("a", "1.0.0"),
Package("b", "2.0.0"),
Package("c", "3.0.0"),
Package("e", "5.0.0"),
],
)
check_operations(
transaction.calculate_operations(synchronize=True),
[
{"job": "remove", "package": Package("c", "3.0.0")},
{"job": "remove", "package": Package("e", "5.0.0")},
{
"job": "update",
"from": Package("b", "2.0.0"),
"to": Package("b", "2.1.0"),
},
{"job": "install", "package": Package("a", "1.0.0"), "skipped": True},
{"job": "install", "package": Package("d", "4.0.0")},
],
)
def test_it_should_update_installed_packages_if_sources_are_different():
transaction = Transaction(
[Package("a", "1.0.0")],
[
(
Package(
"a",
"1.0.0",
source_url="https://github.com/demo/demo.git",
source_type="git",
source_reference="main",
source_resolved_reference="123456",
),
1,
)
],
installed_packages=[Package("a", "1.0.0")],
)
check_operations(
transaction.calculate_operations(synchronize=True),
[
{
"job": "update",
"from": Package("a", "1.0.0"),
"to": Package(
"a",
"1.0.0",
source_url="https://github.com/demo/demo.git",
source_type="git",
source_reference="main",
source_resolved_reference="123456",
),
}
],
)
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