diff --git a/PKG-INFO b/PKG-INFO
index 1942719b08e3ebbf416fe0fa9e031538eebede0e..fdec0535197b444fdd81adc7545efc0e228c7d79 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: swh.model
-Version: 2.3.0
+Version: 2.4.0
 Summary: Software Heritage data model
 Home-page: https://forge.softwareheritage.org/diffusion/DMOD/
 Author: Software Heritage developers
@@ -38,4 +38,5 @@ Classifier: Development Status :: 5 - Production/Stable
 Requires-Python: >=3.7
 Description-Content-Type: text/markdown
 Provides-Extra: cli
+Provides-Extra: testing-minimal
 Provides-Extra: testing
diff --git a/debian/changelog b/debian/changelog
index ff4be10a8d6d3de4aa895592821410e80336a730..900d3c2c2083666f07176593c491dbf83d50e86f 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,10 @@
-swh-model (2.3.0-1~swh1~bpo10+1) buster-swh; urgency=medium
+swh-model (2.4.0-1~swh1) unstable-swh; urgency=medium
 
-  * Rebuild for buster-swh
+  * New upstream release 2.4.0     - (tagged by Antoine Lambert
+    <antoine.lambert@inria.fr> on 2021-04-13 15:26:51 +0200)
+  * Upstream changes:     - version 2.4.0
 
- -- Software Heritage autobuilder (on jenkins-debian1) <jenkins@jenkins-debian1.internal.softwareheritage.org>  Fri, 19 Mar 2021 16:18:56 +0000
+ -- Software Heritage autobuilder (on jenkins-debian1) <jenkins@jenkins-debian1.internal.softwareheritage.org>  Tue, 13 Apr 2021 13:31:21 +0000
 
 swh-model (2.3.0-1~swh1) unstable-swh; urgency=medium
 
diff --git a/docs/persistent-identifiers.rst b/docs/persistent-identifiers.rst
index bb8078437632f3f0bb9b958aa0dfaf08a49c491c..2f20cf18b51dc5e05ab4f18e9c67d40f38330e18 100644
--- a/docs/persistent-identifiers.rst
+++ b/docs/persistent-identifiers.rst
@@ -300,7 +300,7 @@ third party.  An implementation of SWHID that allows to do so locally is the
 `swh identify <https://docs.softwareheritage.org/devel/swh-model/cli.html>`_
 tool, available from the `swh.model <https://pypi.org/project/swh.model/>`_
 Python package under the GPL license. This package can be installed via the ``pip``
-package manager with the one liner ``pip3 install swh.model`` on any machine with
+package manager with the one liner ``pip3 install swh.model[cli]`` on any machine with
 Python  (at least version 3.7) and ``pip`` installed (on a Debian or Ubuntu system a simple ``apt install python3 python3-pip``
 will suffice, see `the general instructions <https://packaging.python.org/tutorials/installing-packages/>`_ for other platforms).
 
diff --git a/pytest.ini b/pytest.ini
index 9fa2d75a39cb3c34a52416c06d11f80d11abb1fd..b15e082c739bb4a8d62e9b2be811a076339b9e0b 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -3,3 +3,4 @@ addopts = --doctest-modules -p no:pytest_swh_core
 norecursedirs = docs .*
 markers =
     fs: tests that involve filesystem ios
+    requires_optional_deps: tests in test_cli.py that should not run if optional dependencies are not installed
diff --git a/requirements-test.txt b/requirements-test.txt
index f906d8ae9a901078112c21d99324e3fca99cdb92..984743d9b5d31b47b5cda7114b22dd0218ee86fb 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,4 +1,3 @@
 Click
-dulwich
 pytest
 pytz
diff --git a/setup.py b/setup.py
index 8f9d32fdcec61b2ac22ea9928cfe8ebabca8cd8a..a44dcbf60b851ec2b7936b2c43a341096f718d3d 100755
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,7 @@ setup(
     ),
     extras_require={
         "cli": parse_requirements("cli"),
+        "testing-minimal": parse_requirements("test"),
         "testing": parse_requirements("test") + parse_requirements("cli"),
     },
     include_package_data=True,
diff --git a/swh.model.egg-info/PKG-INFO b/swh.model.egg-info/PKG-INFO
index 1942719b08e3ebbf416fe0fa9e031538eebede0e..fdec0535197b444fdd81adc7545efc0e228c7d79 100644
--- a/swh.model.egg-info/PKG-INFO
+++ b/swh.model.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: swh.model
-Version: 2.3.0
+Version: 2.4.0
 Summary: Software Heritage data model
 Home-page: https://forge.softwareheritage.org/diffusion/DMOD/
 Author: Software Heritage developers
@@ -38,4 +38,5 @@ Classifier: Development Status :: 5 - Production/Stable
 Requires-Python: >=3.7
 Description-Content-Type: text/markdown
 Provides-Extra: cli
+Provides-Extra: testing-minimal
 Provides-Extra: testing
diff --git a/swh.model.egg-info/requires.txt b/swh.model.egg-info/requires.txt
index 919e9687732fb52e32fa2fd42085d88933a043db..d478e8e380b52d3700261fa3e6a071bc015c4c61 100644
--- a/swh.model.egg-info/requires.txt
+++ b/swh.model.egg-info/requires.txt
@@ -16,9 +16,13 @@ dulwich
 
 [testing]
 Click
-dulwich
 pytest
 pytz
 swh.core>=0.3
 Click
 dulwich
+
+[testing-minimal]
+Click
+pytest
+pytz
diff --git a/swh/model/cli.py b/swh/model/cli.py
index 8ac925079d9442cfa0b75afb13eae4bb43b019fe..effdf8a86334046840917a96a3debb954360f580 100644
--- a/swh/model/cli.py
+++ b/swh/model/cli.py
@@ -9,9 +9,22 @@ from typing import Dict, List, Optional
 
 # WARNING: do not import unnecessary things here to keep cli startup time under
 # control
-import click
+try:
+    import click
+except ImportError:
+    print(
+        "Cannot run swh-identify; the Click package is not installed."
+        "Please install 'swh.model[cli]' for full functionality.",
+        file=sys.stderr,
+    )
+    exit(1)
+
+try:
+    from swh.core.cli import swh as swh_cli_group
+except ImportError:
+    # stub so that swh-identify can be used when swh-core isn't installed
+    swh_cli_group = click  # type: ignore
 
-from swh.core.cli import swh as swh_cli_group
 from swh.model.identifiers import CoreSWHID, ObjectType
 
 CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@@ -96,7 +109,13 @@ def swhid_of_origin(url):
 
 
 def swhid_of_git_repo(path) -> CoreSWHID:
-    import dulwich.repo
+    try:
+        import dulwich.repo
+    except ImportError:
+        raise click.ClickException(
+            "Cannot compute snapshot identifier; the Dulwich package is not installed. "
+            "Please install 'swh.model[cli]' for full functionality.",
+        )
 
     from swh.model import hashutil
     from swh.model.identifiers import snapshot_identifier
diff --git a/swh/model/identifiers.py b/swh/model/identifiers.py
index c9f20dcb7b52b83dbd13f42a633822f87c961222..563da9e6944f40a0db8909d69b62ef30c93f9b40 100644
--- a/swh/model/identifiers.py
+++ b/swh/model/identifiers.py
@@ -925,7 +925,7 @@ class _BaseSWHID(Generic[_TObjectType]):
             return cls(**parts)
         except ValueError as e:
             raise ValidationError(
-                "ValueError: %(args)", params={"args": e.args}
+                "ValueError: %(args)s", params={"args": e.args}
             ) from None
 
 
@@ -996,7 +996,7 @@ def _parse_lines_qualifier(
             return (int(lines), None)
     except ValueError:
         raise ValidationError(
-            "Invalid format for the lines qualifier: %(lines)", params={"lines": lines}
+            "Invalid format for the lines qualifier: %(lines)s", params={"lines": lines}
         )
 
 
@@ -1152,7 +1152,7 @@ class QualifiedSWHID(_BaseSWHID[ObjectType]):
         invalid_qualifiers = set(qualifiers) - SWHID_QUALIFIERS
         if invalid_qualifiers:
             raise ValidationError(
-                "Invalid qualifier(s): %(qualifiers)",
+                "Invalid qualifier(s): %(qualifiers)s",
                 params={"qualifiers": ", ".join(invalid_qualifiers)},
             )
         try:
@@ -1228,13 +1228,13 @@ def _parse_swhid(swhid: str) -> Dict[str, Any]:
     if qualifiers_raw:
         for qualifier in qualifiers_raw.split(SWHID_CTXT_SEP):
             try:
-                k, v = qualifier.split("=")
+                k, v = qualifier.split("=", maxsplit=1)
+                parts["qualifiers"][k] = v
             except ValueError:
                 raise ValidationError(
                     "Invalid SWHID: invalid qualifier: %(qualifier)s",
                     params={"qualifier": qualifier},
                 )
-            parts["qualifiers"][k] = v
 
     parts["scheme_version"] = int(parts["scheme_version"])
     parts["object_id"] = hash_to_bytes(parts["object_id"])
diff --git a/swh/model/tests/swh_model_data.py b/swh/model/tests/swh_model_data.py
index c4700cba3475382deffb45160cbdf8a88dfb0176..cd0ef4321ce58b1ac5a8b45476bd378ea0386f05 100644
--- a/swh/model/tests/swh_model_data.py
+++ b/swh/model/tests/swh_model_data.py
@@ -152,6 +152,16 @@ RELEASES = [
         message=b"foo",
         synthetic=False,
     ),
+    Release(
+        id=hash_to_bytes("ee4d20e80af850cc0f417d25dc5073792c5010d2"),
+        name=b"this-is-a/tag/1.0",
+        date=None,
+        author=None,
+        target_type=ObjectType.DIRECTORY,
+        target=b"\x05" * 20,
+        message=b"bar",
+        synthetic=False,
+    ),
 ]
 
 ORIGINS = [
diff --git a/swh/model/tests/test_cli.py b/swh/model/tests/test_cli.py
index 9a006607f15d96be1917ca8a2cbc12aeb763f5f8..de0de4806c7fa2defb55bcbbc705adcbf610847f 100644
--- a/swh/model/tests/test_cli.py
+++ b/swh/model/tests/test_cli.py
@@ -4,9 +4,11 @@
 # See top-level LICENSE file for more information
 
 import os
+import sys
 import tarfile
 import tempfile
 import unittest
+import unittest.mock
 
 from click.testing import CliRunner
 import pytest
@@ -52,6 +54,7 @@ class TestIdentify(DataMixin, unittest.TestCase):
         result = self.runner.invoke(cli.identify, ["--type", "directory", path])
         self.assertSWHID(result, "swh:1:dir:e8b0f1466af8608c8a3fb9879db172b887e80759")
 
+    @pytest.mark.requires_optional_deps
     def test_snapshot_id(self):
         """identify a snapshot"""
         tarball = os.path.join(
@@ -68,6 +71,18 @@ class TestIdentify(DataMixin, unittest.TestCase):
                     result, "swh:1:snp:abc888898124270905a0ef3c67e872ce08e7e0c1"
                 )
 
+    def test_snapshot_without_dulwich(self):
+        """checks swh-identify returns a 'nice' message instead of a traceback
+        when dulwich is not installed"""
+        with unittest.mock.patch.dict(sys.modules, {"dulwich": None}):
+            with tempfile.TemporaryDirectory(prefix="swh.model.cli") as d:
+                result = self.runner.invoke(
+                    cli.identify, ["--type", "snapshot", d], catch_exceptions=False,
+                )
+
+        assert result.exit_code == 1
+        assert "'swh.model[cli]'" in result.output
+
     def test_origin_id(self):
         """identify an origin URL"""
         url = "https://github.com/torvalds/linux"
diff --git a/swh/model/tests/test_identifiers.py b/swh/model/tests/test_identifiers.py
index 3ba570867e6cd529166547978bd948068c3b4a5e..42934ba8d1dec7a022c460350de0d44cebc02eb8 100644
--- a/swh/model/tests/test_identifiers.py
+++ b/swh/model/tests/test_identifiers.py
@@ -1241,6 +1241,12 @@ VALID_SWHIDS = [
         ),
         None,  # Neither does ExtendedSWHID
     ),
+    (
+        f"swh:1:cnt:{HASH};origin=https://github.com/python/cpython;lines=1-18/",
+        None,  # likewise
+        None,
+        None,  # likewise
+    ),
     (
         f"swh:1:cnt:{HASH};origin=https://github.com/python/cpython;lines=18",
         None,  # likewise
@@ -1298,8 +1304,10 @@ def test_parse_unparse_swhids(string, core, qualified, extended):
     for (cls, parsed_swhid) in zip(classes, [core, qualified, extended]):
         if parsed_swhid is None:
             # This class should not accept this SWHID
-            with pytest.raises(ValidationError):
+            with pytest.raises(ValidationError) as excinfo:
                 cls.from_string(string)
+            # Check string serialization for exception
+            assert str(excinfo.value) is not None
         else:
             # This class should
             assert cls.from_string(string) == parsed_swhid
@@ -1508,6 +1516,14 @@ QUALIFIED_SWHIDS = [
             origin="https://example.org/foo%3Bbar%25baz",
         ),
     ),
+    (
+        f"swh:1:cnt:{HASH};origin=https://example.org?project=test",
+        QualifiedSWHID(
+            object_type=ObjectType.CONTENT,
+            object_id=_x(HASH),
+            origin="https://example.org?project=test",
+        ),
+    ),
     # visit:
     (
         f"swh:1:cnt:{HASH};visit=swh:1:snp:{HASH}",
@@ -1562,6 +1578,12 @@ QUALIFIED_SWHIDS = [
             object_type=ObjectType.CONTENT, object_id=_x(HASH), path=b"/foo%bar"
         ),
     ),
+    (
+        f"swh:1:cnt:{HASH};path=/foo/bar%3Dbaz",
+        QualifiedSWHID(
+            object_type=ObjectType.CONTENT, object_id=_x(HASH), path=b"/foo/bar=baz"
+        ),
+    ),
     # lines
     (
         f"swh:1:cnt:{HASH};lines=1-18",
diff --git a/tox.ini b/tox.ini
index bffcdfafd6924e3f97839d387b9611ab495ebaff..25baa11db70079f1d41f3123066b763f44ed0024 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,16 +1,25 @@
 [tox]
-envlist=black,flake8,mypy,py3
+envlist=black,flake8,mypy,py3-{minimal,full}
 
 [testenv]
 extras =
-  testing
+  full: testing
+  minimal: testing-minimal
 deps =
   pytest-cov
 commands =
   pytest --cov={envsitepackagesdir}/swh/model \
          --doctest-modules \
-         {envsitepackagesdir}/swh/model \
-           --cov-branch {posargs}
+  full:    {envsitepackagesdir}/swh/model \
+  minimal: {envsitepackagesdir}/swh/model/tests/test_cli.py -m 'not requires_optional_deps' \
+         --cov-branch {posargs}
+
+[testenv:py3]
+skip_install = true
+deps = tox
+commands =
+  tox -e py3-full -- {posargs}
+  tox -e py3-minimal -- {posargs}
 
 [testenv:black]
 skip_install = true