Repository: openvinotoolkit/datumaro
Pull request #919 (2023-04-06 07:38:48)

Fix log issue when importing celeba and align celeba dataset
<!-- Contributing guide: https://github.com/openvinotoolkit/datumaro/blob/develop/CONTRIBUTING.md -->

### Summary

<!--
Resolves #111 and #222.
Depends on #1000 (for series of dependent commits).

This PR introduces this capability to make the project better in this and that.

- Added this feature
- Removed that feature
- Fixed the problem #1234
-->
When import `celeba` or `align_celeba` dataset without format like `dm.Dataset.import_from(path=...)`, we met error like below. The example is the case for importing `align_celeba` dataset.
```
ERROR:root:Cannot find celeba's images directory at tests/assets/align_celeba_dataset/dataset/Img/img_celeba
```

### How to test
<!-- Describe the testing procedure for reviewers, if changes are
not fully covered by unit tests or manual testing can be complicated. -->

### Checklist
<!-- Put an 'x' in all the boxes that apply -->
- [ ] I have added unit tests to cover my changes.​
- [ ] I have added integration tests to cover my changes.​
- [X] I have added the description of my changes into [CHANGELOG](https://github.com/openvinotoolkit/datumaro/blob/develop/CHANGELOG.md).​
- [ ] I have updated the [documentation](https://github.com/openvinotoolkit/datumaro/tree/develop/docs) accordingly

### License

- [ ] I submit _my code changes_ under the same [MIT License](https://github.com/openvinotoolkit/datumaro/blob/develop/LICENSE) that covers the project.
  Feel free to contact the maintainers if that's a concern.
- [ ] I have updated the license header for each file (see an example below).

```python
# Copyright (C) 2023 Intel Corporation
#
# SPDX-License-Identifier: MIT
```


codecov-commenter: ## [Codecov](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit) Report
Patch coverage: **`83.33`**% and project coverage change: **`-0.01`** :warning:
> Comparison is base [(`89b2111`)](https://codecov.io/gh/openvinotoolkit/datumaro/commit/89b211156ba613c657fb2c30e30c0213f40d2ad1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit) 78.87% compared to head [(`e423085`)](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit) 78.86%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop     #919      +/-   ##
===========================================
- Coverage    78.87%   78.86%   -0.01%     
===========================================
  Files          210      210              
  Lines        25145    25145              
  Branches      5036     5036              
===========================================
- Hits         19832    19831       -1     
  Misses        4178     4178              
- Partials      1135     1136       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| macos-11_Python-3.8 | `?` | |
| ubuntu-20.04_Python-3.8 | `78.86% <83.33%> (+<0.01%)` | :arrow_up: |
| windows-2019_Python-3.8 | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit) | Coverage Δ | |
|---|---|---|
| [datumaro/plugins/data\_formats/celeba/celeba.py](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit#diff-ZGF0dW1hcm8vcGx1Z2lucy9kYXRhX2Zvcm1hdHMvY2VsZWJhL2NlbGViYS5weQ==) | `74.05% <83.33%> (+0.63%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/openvinotoolkit/datumaro/pull/919?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openvinotoolkit).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de3364112..93ed10c8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,12 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   (<https://github.com/openvinotoolkit/datumaro/pull/901>, <https://github.com/openvinotoolkit/datumaro/pull/906>)
 - Refactor download CLI commands
   (<https://github.com/openvinotoolkit/datumaro/pull/909>)
+- Refactor CLI commands w/ and w/o project
+  (<https://github.com/openvinotoolkit/datumaro/pull/910>)
+- Add tfds:uc_merced and tfds:eurosat download
+  (<https://github.com/openvinotoolkit/datumaro/pull/914>)
 
 ### Bug fixes
 - Add UserWarning if an invalid media_type comes to image statistics computation
   (<https://github.com/openvinotoolkit/datumaro/pull/891>)
 - Fix negated `is_encrypted`
   (<https://github.com/openvinotoolkit/datumaro/pull/907>)
+- Fix log issue when importing celeba and align celeba dataset
+  (<https://github.com/openvinotoolkit/datumaro/pull/919>)
 
 ## 28/03/2023 - Release 1.1.1
 ### Bug fixes
diff --git a/datumaro/cli/__main__.py b/datumaro/cli/__main__.py
index 1ef3d3cf7..6c72099eb 100644
--- a/datumaro/cli/__main__.py
+++ b/datumaro/cli/__main__.py
@@ -16,8 +16,9 @@ from ..util.telemetry_utils import (
     send_command_success_info,
 )
 from ..version import __version__
-from . import commands, contexts
-from .util import add_subparser
+from . import contexts
+from .commands import get_non_project_commands, get_project_commands
+from .util import add_subparser, make_subcommands_help
 from .util.errors import CliException
 
 _log_levels = {
@@ -67,16 +68,7 @@ class _LogManager:
         return parser
 
 
-def _make_subcommands_help(commands, help_line_start=0):
-    desc = ""
-    for command_name, _, command_help in commands:
-        desc += ("  %-" + str(max(0, help_line_start - 2 - 1)) + "s%s\n") % (
-            command_name,
-            command_help,
-        )
-    return desc
-
-
+# TODO: revisit during CLI refactoring
 def _get_known_contexts():
     return [
         ("model", contexts.model, "Actions with models"),
@@ -86,42 +78,9 @@ def _get_known_contexts():
     ]
 
 
-def _get_known_commands():
-    return [
-        ("Project modification:", None, ""),
-        ("add", commands.add, "Add dataset"),
-        ("create", commands.create, "Create empty project"),
-        ("import", commands.import_, "Import dataset"),
-        ("remove", commands.remove, "Remove dataset"),
-        ("", None, ""),
-        ("Project versioning:", None, ""),
-        ("checkout", commands.checkout, "Switch to another branch or revision"),
-        ("commit", commands.commit, "Commit changes in tracked files"),
-        ("log", commands.log, "List history"),
-        ("status", commands.status, "Display current status"),
-        ("", None, ""),
-        ("Dataset operations:", None, ""),
-        ("convert", commands.convert, "Convert dataset between formats"),
-        ("detect-format", commands.detect_format, "Detect the format of a dataset"),
-        ("diff", commands.diff, "Compare datasets"),
-        ("download", commands.download, "Download a publicly available dataset"),
-        ("explain", commands.explain, "Run Explainable AI algorithm for model"),
-        ("export", commands.export, "Export dataset in some format"),
-        ("filter", commands.filter, "Filter dataset items"),
-        ("generate", commands.generate, "Generate synthetic dataset"),
-        ("info", commands.info, "Print dataset info"),
-        ("merge", commands.merge, "Merge datasets"),
-        ("patch", commands.patch, "Update dataset from another one"),
-        ("stats", commands.stats, "Compute dataset statistics"),
-        ("transform", commands.transform, "Modify dataset items"),
-        ("validate", commands.validate, "Validate dataset"),
-        ("search", commands.search, "Search similar datasetitems of query"),
-    ]
-
-
 def _get_sensitive_args():
     known_contexts = _get_known_contexts()
-    known_commands = _get_known_commands()
+    known_commands = get_project_commands() + get_non_project_commands()
 
     res = {}
     for _, command, _ in known_contexts + known_commands:
@@ -142,7 +101,7 @@ def make_parser():
     _LogManager._define_loglevel_option(parser)
 
     known_contexts = _get_known_contexts()
-    known_commands = _get_known_commands()
+    known_commands = get_non_project_commands()
 
     # Argparse doesn't support subparser groups:
     # https://stackoverflow.com/questions/32017020/grouping-argparse-subparser-arguments
@@ -151,12 +110,12 @@ def make_parser():
     subcommands_desc = ""
     if known_contexts:
         subcommands_desc += "Contexts:\n"
-        subcommands_desc += _make_subcommands_help(known_contexts, help_line_start)
+        subcommands_desc += make_subcommands_help(known_contexts, help_line_start)
     if known_commands:
         if subcommands_desc:
             subcommands_desc += "\n"
-        subcommands_desc += "Commands:\n"
-        subcommands_desc += _make_subcommands_help(known_commands, help_line_start)
+        subcommands_desc += "Basic Commands:\n"
+        subcommands_desc += make_subcommands_help(known_commands, help_line_start)
     if subcommands_desc:
         subcommands_desc += (
             "\nRun '%s COMMAND --help' for more information on a command." % parser.prog
diff --git a/datumaro/cli/commands/__init__.py b/datumaro/cli/commands/__init__.py
index 465d6bc25..1840c3c2c 100644
--- a/datumaro/cli/commands/__init__.py
+++ b/datumaro/cli/commands/__init__.py
@@ -4,28 +4,24 @@
 
 # pylint: disable=redefined-builtin
 
-from . import (
-    add,
-    checkout,
-    commit,
-    convert,
-    create,
-    detect_format,
-    diff,
-    explain,
-    export,
-    filter,
-    generate,
-    import_,
-    info,
-    log,
-    merge,
-    patch,
-    remove,
-    search,
-    stats,
-    status,
-    transform,
-    validate,
-)
-from .no_project import download
+from . import convert, detect_format, download, explain, filter, generate, merge, patch, search
+from .require_project import get_project_commands
+
+__all__ = [
+    "get_non_project_commands",
+    "get_project_commands",
+]
+
+
+def get_non_project_commands():
+    return [
+        ("convert", convert, "Convert dataset between formats"),
+        ("detect-format", detect_format, "Detect the format of a dataset"),
+        ("download", download, "Download a publicly available dataset"),
+        ("explain", explain, "Run Explainable AI algorithm for model"),
+        ("filter", filter, "Filter dataset items"),
+        ("generate", generate, "Generate synthetic dataset"),
+        ("merge", merge, "Merge datasets"),
+        ("patch", patch, "Update dataset from another one"),
+        ("search", search, "Search similar datasetitems of query"),
+    ]
diff --git a/datumaro/cli/commands/add.py b/datumaro/cli/commands/add.py
deleted file mode 100644
index 800b37661..000000000
--- a/datumaro/cli/commands/add.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) 2021 Intel Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from ..contexts.source import build_add_parser as build_parser
-from ..contexts.source import get_add_sensitive_args as get_sensitive_args
-
-__all__ = [
-    "build_parser",
-    "get_sensitive_args",
-]
diff --git a/datumaro/cli/commands/convert.py b/datumaro/cli/commands/convert.py
index 0fa7219dc..2d19b00c7 100644
--- a/datumaro/cli/commands/convert.py
+++ b/datumaro/cli/commands/convert.py
@@ -11,10 +11,9 @@ from datumaro.components.dataset import Dataset
 from datumaro.components.project import Environment
 from datumaro.util.os_util import make_file_name
 
-from ..contexts.project import FilterModes
 from ..util import MultilineFormatter
 from ..util.errors import CliException
-from ..util.project import generate_next_file_name
+from ..util.project import FilterModes, generate_next_file_name
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/no_project/download.py b/datumaro/cli/commands/download.py
similarity index 98%
rename from datumaro/cli/commands/no_project/download.py
rename to datumaro/cli/commands/download.py
index ff5e455d4..e67e81805 100644
--- a/datumaro/cli/commands/no_project/download.py
+++ b/datumaro/cli/commands/download.py
@@ -19,9 +19,9 @@ from datumaro.components.project import Environment
 from datumaro.util import dump_json
 from datumaro.util.os_util import make_file_name
 
-from ...util import MultilineFormatter
-from ...util.errors import CliException
-from ...util.project import generate_next_file_name
+from ..util import MultilineFormatter
+from ..util.errors import CliException
+from ..util.project import generate_next_file_name
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/export.py b/datumaro/cli/commands/export.py
deleted file mode 100644
index 578d83d3a..000000000
--- a/datumaro/cli/commands/export.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) 2019-2021 Intel Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from ..contexts.project import build_export_parser as build_parser
-from ..contexts.project import get_export_sensitive_args as get_sensitive_args
-
-__all__ = [
-    "build_parser",
-    "get_sensitive_args",
-]
diff --git a/datumaro/cli/commands/filter.py b/datumaro/cli/commands/filter.py
index 0d7ec2219..c574e8418 100644
--- a/datumaro/cli/commands/filter.py
+++ b/datumaro/cli/commands/filter.py
@@ -13,10 +13,9 @@ from datumaro.components.project import ProjectBuildTargets
 from datumaro.util import str_to_bool
 from datumaro.util.scope import scope_add, scoped
 
-from ..contexts.project import FilterModes
 from ..util import MultilineFormatter
 from ..util.errors import CliException
-from ..util.project import load_project, parse_full_revpath
+from ..util.project import FilterModes, load_project, parse_full_revpath
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/import_.py b/datumaro/cli/commands/import_.py
deleted file mode 100644
index f1cd9a4b9..000000000
--- a/datumaro/cli/commands/import_.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) 2020-2021 Intel Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from ..contexts.source import build_import_parser as build_parser
-from ..contexts.source import get_import_sensitive_args as get_sensitive_args
-
-__all__ = [
-    "build_parser",
-    "get_sensitive_args",
-]
diff --git a/datumaro/cli/commands/remove.py b/datumaro/cli/commands/remove.py
deleted file mode 100644
index 753fe363f..000000000
--- a/datumaro/cli/commands/remove.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) 2019-2021 Intel Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from ..contexts.source import build_remove_parser as build_parser
-from ..contexts.source import get_remove_sensitive_args as get_sensitive_args
-
-__all__ = [
-    "build_parser",
-    "get_sensitive_args",
-]
diff --git a/datumaro/cli/commands/require_project/__init__.py b/datumaro/cli/commands/require_project/__init__.py
new file mode 100644
index 000000000..5face012c
--- /dev/null
+++ b/datumaro/cli/commands/require_project/__init__.py
@@ -0,0 +1,30 @@
+# Copyright (C) 2023 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from . import dataset_operations, modification, versioning
+
+
+def get_project_commands():
+    return [
+        ("Project modification:", None, ""),
+        ("add", modification.add, "Add dataset"),
+        ("create", modification.create, "Create empty project"),
+        ("import", modification.import_, "Import dataset"),
+        ("remove", modification.remove, "Remove dataset"),
+        ("", None, ""),
+        ("Project versioning:", None, ""),
+        ("checkout", versioning.checkout, "Switch to another branch or revision"),
+        ("commit", versioning.commit, "Commit changes in tracked files"),
+        ("log", versioning.log, "List history"),
+        ("status", versioning.status, "Display current status"),
+        ("pinfo", versioning.info, "Print project info"),
+        ("", None, ""),
+        ("Dataset operations:", None, ""),
+        ("diff", dataset_operations.diff, "Compare datasets"),
+        ("export", dataset_operations.export, "Export dataset in some format"),
+        ("stats", dataset_operations.stats, "Compute dataset statistics"),
+        ("transform", dataset_operations.transform, "Modify dataset items"),
+        ("validate", dataset_operations.validate, "Validate dataset"),
+        ("dinfo", dataset_operations.info, "Print dataset info"),
+    ]
diff --git a/datumaro/cli/commands/require_project/dataset_operations/__init__.py b/datumaro/cli/commands/require_project/dataset_operations/__init__.py
new file mode 100644
index 000000000..ff9b4b3b7
--- /dev/null
+++ b/datumaro/cli/commands/require_project/dataset_operations/__init__.py
@@ -0,0 +1,5 @@
+# Copyright (C) 2023 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from . import diff, export, info, stats, transform, validate
diff --git a/datumaro/cli/commands/diff.py b/datumaro/cli/commands/require_project/dataset_operations/diff.py
similarity index 97%
rename from datumaro/cli/commands/diff.py
rename to datumaro/cli/commands/require_project/dataset_operations/diff.py
index 800172df7..905d8577a 100644
--- a/datumaro/cli/commands/diff.py
+++ b/datumaro/cli/commands/require_project/dataset_operations/diff.py
@@ -14,10 +14,10 @@ from datumaro.util import dump_json_file
 from datumaro.util.os_util import rmtree
 from datumaro.util.scope import on_error_do, scope_add, scoped
 
-from ..contexts.project.diff import DiffVisualizer
-from ..util import MultilineFormatter
-from ..util.errors import CliException
-from ..util.project import generate_next_file_name, load_project, parse_full_revpath
+from ....util import MultilineFormatter
+from ....util.diff import DiffVisualizer
+from ....util.errors import CliException
+from ....util.project import generate_next_file_name, load_project, parse_full_revpath
 
 
 class ComparisonMethod(Enum):
diff --git a/datumaro/cli/commands/require_project/dataset_operations/export.py b/datumaro/cli/commands/require_project/dataset_operations/export.py
new file mode 100644
index 000000000..3eefa3bc8
--- /dev/null
+++ b/datumaro/cli/commands/require_project/dataset_operations/export.py
@@ -0,0 +1,181 @@
+# Copyright (C) 2019-2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import logging as log
+import os
+import os.path as osp
+
+from datumaro.components.environment import Environment
+from datumaro.components.errors import ProjectNotFoundError
+from datumaro.components.project import ProjectBuildTargets
+from datumaro.util.os_util import make_file_name
+from datumaro.util.scope import scope_add, scoped
+
+from ....util import MultilineFormatter
+from ....util.errors import CliException
+from ....util.project import FilterModes, generate_next_file_name, load_project
+
+__all__ = [
+    "build_parser",
+    "get_sensitive_args",
+]
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    builtins = sorted(Environment().exporters)
+
+    parser = parser_ctor(
+        help="Export project",
+        description="""
+        Exports a project in some format.|n
+        |n
+        Each dataset format has its own export
+        options, which are passed after the '--' separator (see examples),
+        pass '-- -h' for more info. If not stated otherwise, by default
+        only annotations are exported, to include images pass
+        '--save-images' parameter.|n
+        |n
+        A filter can be passed, check the 'filter' command description for
+        more info.|n
+        |n
+        Formats:|n
+        Datasets come in a wide variety of formats. Each dataset
+        format defines its own data structure and rules on how to
+        interpret the data. Check the user manual for the list of
+        supported formats, examples and documentation.
+        |n
+        The list of supported formats can be extended by plugins.
+        Check the "plugins" section of the developer guide for information
+        about plugin implementation.|n
+        |n
+        List of builtin dataset formats: {}|n
+        |n
+        The command can only be applied to a project build target, a stage
+        or the combined 'project' target, in which case all the targets will
+        be affected.
+        |n
+        Examples:|n
+        - Export project as a VOC-like dataset, include images:|n
+        |s|s%(prog)s -f voc -- --save-images|n
+        |n
+        - Export project as a COCO-like dataset in other directory:|n
+        |s|s%(prog)s -f coco -o path/I/like/
+        """.format(
+            ", ".join(builtins)
+        ),
+        formatter_class=MultilineFormatter,
+    )
+
+    parser.add_argument(
+        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
+    )  # workaround for -- eaten by positionals
+    parser.add_argument(
+        "target",
+        nargs="?",
+        default="project",
+        help="A project target to be exported (default: %(default)s)",
+    )
+    parser.add_argument("-e", "--filter", help="XML XPath filter expression for dataset items")
+    parser.add_argument(
+        "--filter-mode",
+        default=FilterModes.i.name,
+        type=FilterModes.parse,
+        help="Filter mode (options: %s; default: %s)"
+        % (", ".join(FilterModes.list_options()), "%(default)s"),
+    )
+    parser.add_argument(
+        "-o",
+        "--output-dir",
+        dest="dst_dir",
+        help="Directory to save output (default: a subdir in the current one)",
+    )
+    parser.add_argument(
+        "--overwrite", action="store_true", help="Overwrite existing files in the save directory"
+    )
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.add_argument("-f", "--format", required=True, help="Output format")
+    parser.add_argument(
+        "extra_args",
+        nargs=argparse.REMAINDER,
+        help="Additional arguments for exporter (pass '-- -h' for help). "
+        "Must be specified after the main command arguments and after "
+        "the '--' separator",
+    )
+    parser.set_defaults(command=export_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        export_command: ["dst_dir", "project_dir", "name", "extra_args", "target", "filter"],
+    }
+
+
+@scoped
+def export_command(args):
+    has_sep = "--" in args._positionals
+    if has_sep:
+        pos = args._positionals.index("--")
+        if 1 < pos:
+            raise argparse.ArgumentError(None, message="Expected no more than 1 target argument")
+    else:
+        pos = 1
+    args.target = (args._positionals[:pos] or [ProjectBuildTargets.MAIN_TARGET])[0]
+    args.extra_args = args._positionals[pos + has_sep :]
+
+    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
+
+    project = None
+    try:
+        project = scope_add(load_project(args.project_dir))
+    except ProjectNotFoundError:
+        if not show_plugin_help:
+            raise
+
+    if project is not None:
+        env = project.env
+    else:
+        env = Environment()
+
+    try:
+        exporter = env.exporters[args.format]
+    except KeyError:
+        raise CliException("Exporter for format '%s' is not found" % args.format)
+
+    extra_args = exporter.parse_cmdline(args.extra_args)
+
+    dst_dir = args.dst_dir
+    if dst_dir:
+        if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir):
+            raise CliException(
+                "Directory '%s' already exists " "(pass --overwrite to overwrite)" % dst_dir
+            )
+    else:
+        dst_dir = generate_next_file_name("export-%s" % make_file_name(args.format))
+    dst_dir = osp.abspath(dst_dir)
+
+    if args.filter:
+        filter_args = FilterModes.make_filter_args(args.filter_mode)
+        filter_expr = args.filter
+
+    log.info("Loading the project...")
+
+    dataset = project.working_tree.make_dataset(args.target)
+    if args.filter:
+        dataset.filter(filter_expr, **filter_args)
+
+    log.info("Exporting...")
+
+    dataset.export(save_dir=dst_dir, format=exporter, **extra_args)
+
+    log.info("Results have been saved to '%s'" % dst_dir)
+
+    return 0
diff --git a/datumaro/cli/commands/info.py b/datumaro/cli/commands/require_project/dataset_operations/info.py
similarity index 97%
rename from datumaro/cli/commands/info.py
rename to datumaro/cli/commands/require_project/dataset_operations/info.py
index 37f4674e2..29ea245a1 100644
--- a/datumaro/cli/commands/info.py
+++ b/datumaro/cli/commands/require_project/dataset_operations/info.py
@@ -8,8 +8,8 @@ from datumaro.components.annotation import AnnotationType
 from datumaro.components.errors import DatasetMergeError, MissingObjectError, ProjectNotFoundError
 from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter
-from ..util.project import load_project, parse_full_revpath
+from ....util import MultilineFormatter
+from ....util.project import load_project, parse_full_revpath
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/require_project/dataset_operations/stats.py b/datumaro/cli/commands/require_project/dataset_operations/stats.py
new file mode 100644
index 000000000..cde4e694a
--- /dev/null
+++ b/datumaro/cli/commands/require_project/dataset_operations/stats.py
@@ -0,0 +1,105 @@
+# Copyright (C) 2019-2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import logging as log
+
+from datumaro.components.errors import ProjectNotFoundError
+from datumaro.components.operations import compute_ann_statistics, compute_image_statistics
+from datumaro.util import dump_json_file, str_to_bool
+from datumaro.util.scope import scope_add, scoped
+
+from ....util import MultilineFormatter
+from ....util.project import generate_next_file_name, load_project, parse_full_revpath
+
+__all__ = [
+    "build_parser",
+    "get_sensitive_args",
+]
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    parser = parser_ctor(
+        help="Get project statistics",
+        description="""
+        Outputs various project statistics like image mean and std,
+        annotations count etc.|n
+        |n
+        Target dataset is specified by a revpath. The full syntax is:|n
+        - Dataset paths:|n
+        |s|s- <dataset path>[ :<format> ]|n
+        - Revision paths:|n
+        |s|s- <project path> [ @<rev> ] [ :<target> ]|n
+        |s|s- <rev> [ :<target> ]|n
+        |s|s- <target>|n
+        |n
+        Both forms use the -p/--project as a context for plugins. It can be
+        useful for dataset paths in targets. When not specified, the current
+        project's working tree is used.|n
+        |n
+        Examples:|n
+        - Compute project statistics:|n
+        |s|s%(prog)s
+        """,
+        formatter_class=MultilineFormatter,
+    )
+
+    parser.add_argument(
+        "target", default="project", nargs="?", help="Target dataset revpath (default: project)"
+    )
+    parser.add_argument("-s", "--subset", help="Compute stats only for a specific subset")
+    parser.add_argument(
+        "--image-stats",
+        type=str_to_bool,
+        default=True,
+        help="Compute image mean and std (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--ann-stats",
+        type=str_to_bool,
+        default=True,
+        help="Compute annotation statistics (default: %(default)s)",
+    )
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.set_defaults(command=stats_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        stats_command: ["project_dir", "target"],
+    }
+
+
+@scoped
+def stats_command(args):
+    project = None
+    try:
+        project = scope_add(load_project(args.project_dir))
+    except ProjectNotFoundError:
+        if args.project_dir:
+            raise
+
+    dataset, target_project = parse_full_revpath(args.target, project)
+    if target_project:
+        scope_add(target_project)
+
+    if args.subset:
+        dataset = dataset.get_subset(args.subset)
+
+    stats = {}
+    if args.image_stats:
+        stats.update(compute_image_statistics(dataset))
+    if args.ann_stats:
+        stats.update(compute_ann_statistics(dataset))
+
+    dst_file = generate_next_file_name("statistics", ext=".json")
+    log.info("Writing project statistics to '%s'" % dst_file)
+    dump_json_file(dst_file, stats, indent=True)
diff --git a/datumaro/cli/commands/transform.py b/datumaro/cli/commands/require_project/dataset_operations/transform.py
similarity index 98%
rename from datumaro/cli/commands/transform.py
rename to datumaro/cli/commands/require_project/dataset_operations/transform.py
index 45fda030b..c864f9980 100644
--- a/datumaro/cli/commands/transform.py
+++ b/datumaro/cli/commands/require_project/dataset_operations/transform.py
@@ -13,9 +13,9 @@ from datumaro.components.project import ProjectBuildTargets
 from datumaro.util import str_to_bool
 from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter
-from ..util.errors import CliException
-from ..util.project import load_project, parse_full_revpath
+from ....util import MultilineFormatter
+from ....util.errors import CliException
+from ....util.project import load_project, parse_full_revpath
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/validate.py b/datumaro/cli/commands/require_project/dataset_operations/validate.py
similarity index 96%
rename from datumaro/cli/commands/validate.py
rename to datumaro/cli/commands/require_project/dataset_operations/validate.py
index 3fefc6fe5..c6d4b0542 100644
--- a/datumaro/cli/commands/validate.py
+++ b/datumaro/cli/commands/require_project/dataset_operations/validate.py
@@ -11,9 +11,9 @@ from datumaro.components.validator import TaskType
 from datumaro.util import dump_json_file
 from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter
-from ..util.errors import CliException
-from ..util.project import generate_next_file_name, load_project, parse_full_revpath
+from ....util import MultilineFormatter
+from ....util.errors import CliException
+from ....util.project import generate_next_file_name, load_project, parse_full_revpath
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/no_project/__init__.py b/datumaro/cli/commands/require_project/modification/__init__.py
similarity index 62%
rename from datumaro/cli/commands/no_project/__init__.py
rename to datumaro/cli/commands/require_project/modification/__init__.py
index ff847f012..eb26c83c6 100644
--- a/datumaro/cli/commands/no_project/__init__.py
+++ b/datumaro/cli/commands/require_project/modification/__init__.py
@@ -1,3 +1,5 @@
 # Copyright (C) 2023 Intel Corporation
 #
 # SPDX-License-Identifier: MIT
+
+from . import add, create, import_, remove
diff --git a/datumaro/cli/commands/require_project/modification/add.py b/datumaro/cli/commands/require_project/modification/add.py
new file mode 100644
index 000000000..db048e714
--- /dev/null
+++ b/datumaro/cli/commands/require_project/modification/add.py
@@ -0,0 +1,161 @@
+# Copyright (C) 2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+
+import argparse
+import logging as log
+
+from datumaro.components.environment import Environment
+from datumaro.components.errors import ProjectNotFoundError
+from datumaro.util.scope import on_error_do, scope_add, scoped
+
+from ....util import MultilineFormatter, join_cli_args, show_video_import_warning
+from ....util.errors import CliException
+from ....util.project import load_project
+
+__all__ = [
+    "build_parser",
+    "get_sensitive_args",
+]
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    env = Environment()
+    builtins = sorted(set(env.extractors) | set(env.importers))
+
+    parser = parser_ctor(
+        help="Add data source to project",
+        description="""
+        Adds a data source to a project. A data source is a dataset
+        in a supported format (check 'formats' section below).|n
+        |n
+        The command adds a project-local directory as a data source in
+        the project. Unlike the "import" command, it does not copy datasets
+        and only works with local directories. The source name is defined by
+        the directory name.|n
+        |n
+        Formats:|n
+        Datasets come in a wide variety of formats. Each dataset
+        format defines its own data structure and rules on how to
+        interpret the data. Check the user manual for the list of
+        supported formats, examples and documentation.
+        |n
+        The list of supported formats can be extended by plugins.
+        Check the "plugins" section of the developer guide for information
+        about plugin implementation.|n
+        |n
+        Each dataset format has its own import options, which are passed
+        after the '--' separator (see examples), pass '-- -h' for more info.|n
+        |n
+        Builtin formats: {}|n
+        |n
+        Examples:|n
+        - Add a local directory with a VOC-like dataset:|n
+        |s|s%(prog)s -f voc my_dataset/|n
+        |n
+        - Add a directory with a COCO dataset, use only a specific file:|n
+        |s|s%(prog)s -f coco_instances -r anns/train.json my_source/
+        """.format(
+            ", ".join(builtins)
+        ),
+        formatter_class=MultilineFormatter,
+    )
+
+    parser.add_argument(
+        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
+    )  # workaround for -- eaten by positionals
+    parser.add_argument("path", help="A path to a dataset directory")
+    parser.add_argument("-f", "--format", required=True, help="Source dataset format")
+    parser.add_argument(
+        "-r",
+        "--path",
+        dest="rpath",
+        help="A path relative to source root to the source data. Useful to "
+        "specify a path to subset, subtask, or a specific file in dataset.",
+    )
+    parser.add_argument("--no-check", action="store_true", help="Don't try to read the source")
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        default=".",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.add_argument(
+        "extra_args",
+        nargs=argparse.REMAINDER,
+        help="Additional arguments for extractor (pass '-- -h' for help). "
+        "Must be specified after the main command arguments and after "
+        "the '--' separator",
+    )
+    parser.set_defaults(command=add_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        add_command: ["url", "project_dir", "rpath", "extra_args"],
+    }
+
+
+@scoped
+def add_command(args):
+    # Workaround. Required positionals consume positionals from the end
+    args._positionals += join_cli_args(args, "path", "extra_args")
+
+    has_sep = "--" in args._positionals
+    if has_sep:
+        pos = args._positionals.index("--")
+    else:
+        pos = 1
+    args.path = (args._positionals[:pos] or [""])[0]
+    args.extra_args = args._positionals[pos + has_sep :]
+
+    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
+
+    project = None
+    try:
+        project = scope_add(load_project(args.project_dir))
+    except ProjectNotFoundError:
+        if not show_plugin_help:
+            raise
+
+    if project is not None:
+        env = project.env
+    else:
+        env = Environment()
+
+    fmt = args.format
+    if fmt in env.importers:
+        arg_parser = env.importers[fmt]
+    elif fmt in env.extractors:
+        arg_parser = env.extractors[fmt]
+    else:
+        raise CliException(
+            "Unknown format '%s'. A format can be added"
+            " by providing an Extractor and Importer plugins" % fmt
+        )
+
+    extra_args = arg_parser.parse_cmdline(args.extra_args)
+
+    if fmt == "video_frames":
+        show_video_import_warning()
+
+    name, _ = project.add_source(
+        args.path, format=args.format, options=extra_args, rpath=args.rpath
+    )
+    on_error_do(
+        project.remove_source, name, ignore_errors=True, kwargs={"force": True, "keep_data": True}
+    )
+
+    if not args.no_check:
+        log.info("Checking the source...")
+        project.working_tree.make_dataset(name)
+
+    project.working_tree.save()
+
+    log.info("Source '%s' with format '%s' has been added to the project", name, args.format)
+
+    return 0
diff --git a/datumaro/cli/commands/create.py b/datumaro/cli/commands/require_project/modification/create.py
similarity index 95%
rename from datumaro/cli/commands/create.py
rename to datumaro/cli/commands/require_project/modification/create.py
index afd5bbbe2..4d17130f4 100644
--- a/datumaro/cli/commands/create.py
+++ b/datumaro/cli/commands/require_project/modification/create.py
@@ -10,8 +10,8 @@ import os.path as osp
 from datumaro.components.project import Project
 from datumaro.util.os_util import rmtree
 
-from ..util import MultilineFormatter
-from ..util.errors import CliException
+from ....util import MultilineFormatter
+from ....util.errors import CliException
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/require_project/modification/import_.py b/datumaro/cli/commands/require_project/modification/import_.py
new file mode 100644
index 000000000..2cfaeff95
--- /dev/null
+++ b/datumaro/cli/commands/require_project/modification/import_.py
@@ -0,0 +1,182 @@
+# Copyright (C) 2020-2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import logging as log
+import os
+
+from datumaro.components.environment import Environment
+from datumaro.components.errors import ProjectNotFoundError
+from datumaro.util.scope import on_error_do, scope_add, scoped
+
+from ....util import MultilineFormatter, join_cli_args, show_video_import_warning
+from ....util.errors import CliException
+from ....util.project import generate_next_name, load_project
+
+__all__ = [
+    "build_parser",
+    "get_sensitive_args",
+]
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    env = Environment()
+    builtins = sorted(set(env.extractors) | set(env.importers))
+
+    parser = parser_ctor(
+        help="Import dataset to project",
+        description="""
+        Imports a data source to a project. A data source is a dataset
+        in a supported format (check 'formats' section below).|n
+        |n
+        Currently, only local paths to sources are supported.|n
+        During importing, a source is copied into the project.|n
+        |n
+        Formats:|n
+        Datasets come in a wide variety of formats. Each dataset
+        format defines its own data structure and rules on how to
+        interpret the data. Check the user manual for the list of
+        supported formats, examples and documentation.
+        |n
+        The list of supported formats can be extended by plugins.
+        Check the "plugins" section of the developer guide for information
+        about plugin implementation.|n
+        |n
+        Each dataset format has its own import options, which are passed
+        after the '--' separator (see examples), pass '-- -h' for more info.|n
+        |n
+        Builtin formats: {}|n
+        |n
+        Examples:|n
+        - Add a local directory with a VOC-like dataset:|n
+        |s|s%(prog)s -f voc path/to/voc|n
+        |n
+        - Add a directory with a COCO dataset, use only a specific file:|n
+        |s|s%(prog)s -f coco_instances -r anns/train.json path/to/coco|n
+        |n
+        - Add a local file with CVAT annotations, call it 'mysource'|n
+        |s|s|s|sto the project in a specific place:|n
+        |s|s%(prog)s -f cvat -n mysource -p project/path/ path/to/cvat.xml
+        """.format(
+            ", ".join(builtins)
+        ),
+        formatter_class=MultilineFormatter,
+    )
+
+    parser.add_argument(
+        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
+    )  # workaround for -- eaten by positionals
+    parser.add_argument("url", help="URL to the source dataset. A path to a file or directory")
+    parser.add_argument(
+        "-n", "--name", help="Name of the new source (default: generate automatically)"
+    )
+    parser.add_argument("-f", "--format", required=True, help="Source dataset format")
+    parser.add_argument(
+        "-r",
+        "--path",
+        dest="rpath",
+        help="A path relative to URL to the source data. Useful to specify "
+        "a path to subset, subtask, or a specific file in URL.",
+    )
+    parser.add_argument(
+        "--no-check", action="store_true", help="Don't try to read the source after importing"
+    )
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.add_argument(
+        "extra_args",
+        nargs=argparse.REMAINDER,
+        help="Additional arguments for extractor (pass '-- -h' for help). "
+        "Must be specified after the main command arguments and after "
+        "the '--' separator",
+    )
+    parser.set_defaults(command=import_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        import_command: ["url", "project_dir", "rpath", "name", "extra_args"],
+    }
+
+
+@scoped
+def import_command(args):
+    # Workaround. Required positionals consume positionals from the end
+    args._positionals += join_cli_args(args, "url", "extra_args")
+
+    has_sep = "--" in args._positionals
+    if has_sep:
+        pos = args._positionals.index("--")
+    else:
+        pos = 1
+    args.url = (args._positionals[:pos] or [""])[0]
+    args.extra_args = args._positionals[pos + has_sep :]
+
+    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
+
+    project = None
+    try:
+        project = scope_add(load_project(args.project_dir))
+    except ProjectNotFoundError:
+        if not show_plugin_help:
+            raise
+
+    if project is not None:
+        env = project.env
+    else:
+        env = Environment()
+
+    fmt = args.format
+    if fmt in env.importers:
+        arg_parser = env.importers[fmt]
+    elif fmt in env.extractors:
+        arg_parser = env.extractors[fmt]
+    else:
+        raise CliException(
+            "Unknown format '%s'. A format can be added"
+            " by providing an Extractor and Importer plugins" % fmt
+        )
+
+    extra_args = arg_parser.parse_cmdline(args.extra_args)
+
+    name = args.name
+    if name:
+        if name in project.working_tree.sources:
+            raise CliException("Source '%s' already exists" % name)
+    else:
+        name = generate_next_name(
+            list(project.working_tree.sources) + os.listdir(), "source", sep="-", default="1"
+        )
+
+    if fmt == "video_frames":
+        show_video_import_warning()
+
+    project.import_source(
+        name,
+        url=args.url,
+        format=args.format,
+        options=extra_args,
+        no_cache=True,
+        no_hash=True,
+        rpath=args.rpath,
+    )
+    on_error_do(
+        project.remove_source, name, ignore_errors=True, kwargs={"force": True, "keep_data": False}
+    )
+
+    if not args.no_check:
+        log.info("Checking the source...")
+        project.working_tree.make_dataset(name)
+
+    project.working_tree.save()
+
+    log.info("Source '%s' with format '%s' has been added to the project", name, args.format)
+
+    return 0
diff --git a/datumaro/cli/commands/require_project/modification/remove.py b/datumaro/cli/commands/require_project/modification/remove.py
new file mode 100644
index 000000000..7d036d724
--- /dev/null
+++ b/datumaro/cli/commands/require_project/modification/remove.py
@@ -0,0 +1,67 @@
+# Copyright (C) 2019-2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import logging as log
+
+from datumaro.util.scope import scope_add, scoped
+
+from ....util.errors import CliException
+from ....util.project import load_project
+
+__all__ = [
+    "build_parser",
+    "get_sensitive_args",
+]
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    parser = parser_ctor(
+        help="Remove source from project", description="Remove a source from a project"
+    )
+
+    parser.add_argument("names", nargs="+", help="Names of the sources to be removed")
+    parser.add_argument(
+        "--force", action="store_true", help="Do not fail and stop on errors during removal"
+    )
+    parser.add_argument(
+        "--keep-data",
+        action="store_true",
+        help="Do not remove source data from the working directory, remove "
+        "only project metainfo.",
+    )
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.set_defaults(command=remove_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        remove_command: [
+            "project_dir",
+            "names",
+        ],
+    }
+
+
+@scoped
+def remove_command(args):
+    project = scope_add(load_project(args.project_dir))
+
+    if not args.names:
+        raise CliException("Expected source name")
+
+    for name in args.names:
+        project.remove_source(name, force=args.force, keep_data=args.keep_data)
+    project.working_tree.save()
+
+    log.info("Sources '%s' have been removed from the project" % ", ".join(args.names))
+
+    return 0
diff --git a/datumaro/cli/commands/require_project/versioning/__init__.py b/datumaro/cli/commands/require_project/versioning/__init__.py
new file mode 100644
index 000000000..46e0e4aa3
--- /dev/null
+++ b/datumaro/cli/commands/require_project/versioning/__init__.py
@@ -0,0 +1,5 @@
+# Copyright (C) 2023 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from . import checkout, commit, info, log, status
diff --git a/datumaro/cli/commands/checkout.py b/datumaro/cli/commands/require_project/versioning/checkout.py
similarity index 97%
rename from datumaro/cli/commands/checkout.py
rename to datumaro/cli/commands/require_project/versioning/checkout.py
index 78ba7f1ab..4e4e25371 100644
--- a/datumaro/cli/commands/checkout.py
+++ b/datumaro/cli/commands/require_project/versioning/checkout.py
@@ -6,8 +6,8 @@ import argparse
 
 from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter
-from ..util.project import load_project
+from ....util import MultilineFormatter
+from ....util.project import load_project
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/commit.py b/datumaro/cli/commands/require_project/versioning/commit.py
similarity index 96%
rename from datumaro/cli/commands/commit.py
rename to datumaro/cli/commands/require_project/versioning/commit.py
index 4b1fe43c8..109aaa8ba 100644
--- a/datumaro/cli/commands/commit.py
+++ b/datumaro/cli/commands/require_project/versioning/commit.py
@@ -6,8 +6,8 @@ import argparse
 
 from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter
-from ..util.project import load_project
+from ....util import MultilineFormatter
+from ....util.project import load_project
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/require_project/versioning/info.py b/datumaro/cli/commands/require_project/versioning/info.py
new file mode 100644
index 000000000..b826da617
--- /dev/null
+++ b/datumaro/cli/commands/require_project/versioning/info.py
@@ -0,0 +1,91 @@
+# Copyright (C) 2023 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import argparse
+import os.path as osp
+
+from datumaro.util.scope import scope_add, scoped
+
+from ....util import MultilineFormatter
+from ....util.project import load_project
+
+
+def build_parser(parser_ctor=argparse.ArgumentParser):
+    parser = parser_ctor(
+        help="Get project info",
+        description="""
+        Outputs project info - information about plugins,
+        sources, build tree, models and revisions.|n
+        |n
+        Examples:|n
+        - Print project info for the current working tree:|n |n
+        |s|s%(prog)s|n
+        |n
+        - Print project info for the previous revision:|n |n
+        |s|s%(prog)s HEAD~1
+        """,
+        formatter_class=MultilineFormatter,
+    )
+
+    parser.add_argument(
+        "revision", default="", nargs="?", help="Target revision (default: current working tree)"
+    )
+    parser.add_argument(
+        "-p",
+        "--project",
+        dest="project_dir",
+        help="Directory of the project to operate on (default: current dir)",
+    )
+    parser.set_defaults(command=info_command)
+
+    return parser
+
+
+def get_sensitive_args():
+    return {
+        info_command: ["project_dir", "revision"],
+    }
+
+
+@scoped
+def info_command(args):
+    project = scope_add(load_project(args.project_dir))
+    rev = project.get_rev(args.revision)
+    env = rev.env
+
+    print("Project:")
+    print("  location:", project._root_dir)
+    print("Plugins:")
+    print("  extractors:", ", ".join(sorted(set(env.extractors) | set(env.importers))))
+    print("  exporters:", ", ".join(env.exporters))
+    print("  launchers:", ", ".join(env.launchers))
+
+    print("Models:")
+    for model_name, model in project.models.items():
+        print("  model '%s':" % model_name)
+        print("    type:", model.launcher)
+
+    print("Sources:")
+    for source_name, source in rev.sources.items():
+        print("  '%s':" % source_name)
+        print("    format:", source.format)
+        print("    url:", osp.abspath(source.url) if source.url else "")
+        print(
+            "    location:",
+            osp.abspath(osp.join(project.source_data_dir(source_name), source.path)),
+        )
+        print("    options:", source.options)
+
+        print("    stages:")
+        for stage in rev.build_targets[source_name].stages:
+            print("      '%s':" % stage.name)
+            print("        type:", stage.type)
+            print("        hash:", stage.hash)
+            print("        cached:", project.is_obj_cached(stage.hash) if stage.hash else "n/a")
+            if stage.kind:
+                print("        kind:", stage.kind)
+            if stage.params:
+                print("        parameters:", stage.params)
+
+    return 0
diff --git a/datumaro/cli/commands/log.py b/datumaro/cli/commands/require_project/versioning/log.py
similarity index 96%
rename from datumaro/cli/commands/log.py
rename to datumaro/cli/commands/require_project/versioning/log.py
index 19915ddd1..7088745f7 100644
--- a/datumaro/cli/commands/log.py
+++ b/datumaro/cli/commands/require_project/versioning/log.py
@@ -6,7 +6,7 @@ import argparse
 
 from datumaro.util.scope import scope_add, scoped
 
-from ..util.project import load_project
+from ....util.project import load_project
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/status.py b/datumaro/cli/commands/require_project/versioning/status.py
similarity index 97%
rename from datumaro/cli/commands/status.py
rename to datumaro/cli/commands/require_project/versioning/status.py
index cb06d2419..ed4771fcb 100644
--- a/datumaro/cli/commands/status.py
+++ b/datumaro/cli/commands/require_project/versioning/status.py
@@ -7,7 +7,7 @@ import argparse
 from datumaro.cli.util import MultilineFormatter
 from datumaro.util.scope import scope_add, scoped
 
-from ..util.project import load_project
+from ....util.project import load_project
 
 
 def build_parser(parser_ctor=argparse.ArgumentParser):
diff --git a/datumaro/cli/commands/stats.py b/datumaro/cli/commands/stats.py
deleted file mode 100644
index 756a71296..000000000
--- a/datumaro/cli/commands/stats.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (C) 2019-2021 Intel Corporation
-#
-# SPDX-License-Identifier: MIT
-
-from ..contexts.project import build_stats_parser as build_parser
-from ..contexts.project import get_stats_sensitive_args as get_sensitive_args
-
-__all__ = [
-    "build_parser",
-    "get_sensitive_args",
-]
diff --git a/datumaro/cli/contexts/project/__init__.py b/datumaro/cli/contexts/project/__init__.py
index 5abbb6e5d..f58dd54e4 100644
--- a/datumaro/cli/contexts/project/__init__.py
+++ b/datumaro/cli/contexts/project/__init__.py
@@ -18,373 +18,12 @@ from datumaro.util import dump_json_file, str_to_bool
 from datumaro.util.os_util import make_file_name
 from datumaro.util.scope import scope_add, scoped
 
-from ...util import MultilineFormatter, add_subparser
+from ...commands import get_project_commands
+from ...util import MultilineFormatter, add_subparser, make_subcommands_help
 from ...util.errors import CliException
 from ...util.project import generate_next_file_name, load_project, parse_full_revpath
 
 
-class FilterModes(Enum):
-    # primary
-    items = 1
-    annotations = 2
-    items_annotations = 3
-
-    # shortcuts
-    i = 1
-    a = 2
-    i_a = 3
-    a_i = 3
-    annotations_items = 3
-
-    @staticmethod
-    def parse(s):
-        s = s.lower()
-        s = s.replace("+", "_")
-        return FilterModes[s]
-
-    @classmethod
-    def make_filter_args(cls, mode):
-        if mode == cls.items:
-            return {}
-        elif mode == cls.annotations:
-            return {"filter_annotations": True}
-        elif mode == cls.items_annotations:
-            return {
-                "filter_annotations": True,
-                "remove_empty": True,
-            }
-        else:
-            raise NotImplementedError()
-
-    @classmethod
-    def list_options(cls):
-        return [m.name.replace("_", "+") for m in cls]
-
-
-def build_export_parser(parser_ctor=argparse.ArgumentParser):
-    builtins = sorted(Environment().exporters)
-
-    parser = parser_ctor(
-        help="Export project",
-        description="""
-        Exports a project in some format.|n
-        |n
-        Each dataset format has its own export
-        options, which are passed after the '--' separator (see examples),
-        pass '-- -h' for more info. If not stated otherwise, by default
-        only annotations are exported, to include images pass
-        '--save-images' parameter.|n
-        |n
-        A filter can be passed, check the 'filter' command description for
-        more info.|n
-        |n
-        Formats:|n
-        Datasets come in a wide variety of formats. Each dataset
-        format defines its own data structure and rules on how to
-        interpret the data. Check the user manual for the list of
-        supported formats, examples and documentation.
-        |n
-        The list of supported formats can be extended by plugins.
-        Check the "plugins" section of the developer guide for information
-        about plugin implementation.|n
-        |n
-        List of builtin dataset formats: {}|n
-        |n
-        The command can only be applied to a project build target, a stage
-        or the combined 'project' target, in which case all the targets will
-        be affected.
-        |n
-        Examples:|n
-        - Export project as a VOC-like dataset, include images:|n
-        |s|s%(prog)s -f voc -- --save-images|n
-        |n
-        - Export project as a COCO-like dataset in other directory:|n
-        |s|s%(prog)s -f coco -o path/I/like/
-        """.format(
-            ", ".join(builtins)
-        ),
-        formatter_class=MultilineFormatter,
-    )
-
-    parser.add_argument(
-        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
-    )  # workaround for -- eaten by positionals
-    parser.add_argument(
-        "target",
-        nargs="?",
-        default="project",
-        help="A project target to be exported (default: %(default)s)",
-    )
-    parser.add_argument("-e", "--filter", help="XML XPath filter expression for dataset items")
-    parser.add_argument(
-        "--filter-mode",
-        default=FilterModes.i.name,
-        type=FilterModes.parse,
-        help="Filter mode (options: %s; default: %s)"
-        % (", ".join(FilterModes.list_options()), "%(default)s"),
-    )
-    parser.add_argument(
-        "-o",
-        "--output-dir",
-        dest="dst_dir",
-        help="Directory to save output (default: a subdir in the current one)",
-    )
-    parser.add_argument(
-        "--overwrite", action="store_true", help="Overwrite existing files in the save directory"
-    )
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.add_argument("-f", "--format", required=True, help="Output format")
-    parser.add_argument(
-        "extra_args",
-        nargs=argparse.REMAINDER,
-        help="Additional arguments for exporter (pass '-- -h' for help). "
-        "Must be specified after the main command arguments and after "
-        "the '--' separator",
-    )
-    parser.set_defaults(command=export_command)
-
-    return parser
-
-
-def get_export_sensitive_args():
-    return {
-        export_command: ["dst_dir", "project_dir", "name", "extra_args", "target", "filter"],
-    }
-
-
-@scoped
-def export_command(args):
-    has_sep = "--" in args._positionals
-    if has_sep:
-        pos = args._positionals.index("--")
-        if 1 < pos:
-            raise argparse.ArgumentError(None, message="Expected no more than 1 target argument")
-    else:
-        pos = 1
-    args.target = (args._positionals[:pos] or [ProjectBuildTargets.MAIN_TARGET])[0]
-    args.extra_args = args._positionals[pos + has_sep :]
-
-    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
-
-    project = None
-    try:
-        project = scope_add(load_project(args.project_dir))
-    except ProjectNotFoundError:
-        if not show_plugin_help:
-            raise
-
-    if project is not None:
-        env = project.env
-    else:
-        env = Environment()
-
-    try:
-        exporter = env.exporters[args.format]
-    except KeyError:
-        raise CliException("Exporter for format '%s' is not found" % args.format)
-
-    extra_args = exporter.parse_cmdline(args.extra_args)
-
-    dst_dir = args.dst_dir
-    if dst_dir:
-        if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir):
-            raise CliException(
-                "Directory '%s' already exists " "(pass --overwrite to overwrite)" % dst_dir
-            )
-    else:
-        dst_dir = generate_next_file_name("export-%s" % make_file_name(args.format))
-    dst_dir = osp.abspath(dst_dir)
-
-    if args.filter:
-        filter_args = FilterModes.make_filter_args(args.filter_mode)
-        filter_expr = args.filter
-
-    log.info("Loading the project...")
-
-    dataset = project.working_tree.make_dataset(args.target)
-    if args.filter:
-        dataset.filter(filter_expr, **filter_args)
-
-    log.info("Exporting...")
-
-    dataset.export(save_dir=dst_dir, format=exporter, **extra_args)
-
-    log.info("Results have been saved to '%s'" % dst_dir)
-
-    return 0
-
-
-def build_stats_parser(parser_ctor=argparse.ArgumentParser):
-    parser = parser_ctor(
-        help="Get project statistics",
-        description="""
-        Outputs various project statistics like image mean and std,
-        annotations count etc.|n
-        |n
-        Target dataset is specified by a revpath. The full syntax is:|n
-        - Dataset paths:|n
-        |s|s- <dataset path>[ :<format> ]|n
-        - Revision paths:|n
-        |s|s- <project path> [ @<rev> ] [ :<target> ]|n
-        |s|s- <rev> [ :<target> ]|n
-        |s|s- <target>|n
-        |n
-        Both forms use the -p/--project as a context for plugins. It can be
-        useful for dataset paths in targets. When not specified, the current
-        project's working tree is used.|n
-        |n
-        Examples:|n
-        - Compute project statistics:|n
-        |s|s%(prog)s
-        """,
-        formatter_class=MultilineFormatter,
-    )
-
-    parser.add_argument(
-        "target", default="project", nargs="?", help="Target dataset revpath (default: project)"
-    )
-    parser.add_argument("-s", "--subset", help="Compute stats only for a specific subset")
-    parser.add_argument(
-        "--image-stats",
-        type=str_to_bool,
-        default=True,
-        help="Compute image mean and std (default: %(default)s)",
-    )
-    parser.add_argument(
-        "--ann-stats",
-        type=str_to_bool,
-        default=True,
-        help="Compute annotation statistics (default: %(default)s)",
-    )
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.set_defaults(command=stats_command)
-
-    return parser
-
-
-def get_stats_sensitive_args():
-    return {
-        stats_command: ["project_dir", "target"],
-    }
-
-
-@scoped
-def stats_command(args):
-    project = None
-    try:
-        project = scope_add(load_project(args.project_dir))
-    except ProjectNotFoundError:
-        if args.project_dir:
-            raise
-
-    dataset, target_project = parse_full_revpath(args.target, project)
-    if target_project:
-        scope_add(target_project)
-
-    if args.subset:
-        dataset = dataset.get_subset(args.subset)
-
-    stats = {}
-    if args.image_stats:
-        stats.update(compute_image_statistics(dataset))
-    if args.ann_stats:
-        stats.update(compute_ann_statistics(dataset))
-
-    dst_file = generate_next_file_name("statistics", ext=".json")
-    log.info("Writing project statistics to '%s'" % dst_file)
-    dump_json_file(dst_file, stats, indent=True)
-
-
-def build_info_parser(parser_ctor=argparse.ArgumentParser):
-    parser = parser_ctor(
-        help="Get project info",
-        description="""
-        Outputs project info - information about plugins,
-        sources, build tree, models and revisions.|n
-        |n
-        Examples:|n
-        - Print project info for the current working tree:|n |n
-        |s|s%(prog)s|n
-        |n
-        - Print project info for the previous revision:|n |n
-        |s|s%(prog)s HEAD~1
-        """,
-        formatter_class=MultilineFormatter,
-    )
-
-    parser.add_argument(
-        "revision", default="", nargs="?", help="Target revision (default: current working tree)"
-    )
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.set_defaults(command=info_command)
-
-    return parser
-
-
-def get_info_sensitive_args():
-    return {
-        info_command: ["project_dir", "revision"],
-    }
-
-
-@scoped
-def info_command(args):
-    project = scope_add(load_project(args.project_dir))
-    rev = project.get_rev(args.revision)
-    env = rev.env
-
-    print("Project:")
-    print("  location:", project._root_dir)
-    print("Plugins:")
-    print("  extractors:", ", ".join(sorted(set(env.extractors) | set(env.importers))))
-    print("  exporters:", ", ".join(env.exporters))
-    print("  launchers:", ", ".join(env.launchers))
-
-    print("Models:")
-    for model_name, model in project.models.items():
-        print("  model '%s':" % model_name)
-        print("    type:", model.launcher)
-
-    print("Sources:")
-    for source_name, source in rev.sources.items():
-        print("  '%s':" % source_name)
-        print("    format:", source.format)
-        print("    url:", osp.abspath(source.url) if source.url else "")
-        print(
-            "    location:",
-            osp.abspath(osp.join(project.source_data_dir(source_name), source.path)),
-        )
-        print("    options:", source.options)
-
-        print("    stages:")
-        for stage in rev.build_targets[source_name].stages:
-            print("      '%s':" % stage.name)
-            print("        type:", stage.type)
-            print("        hash:", stage.hash)
-            print("        cached:", project.is_obj_cached(stage.hash) if stage.hash else "n/a")
-            if stage.kind:
-                print("        kind:", stage.kind)
-            if stage.params:
-                print("        parameters:", stage.params)
-
-    return 0
-
-
 def build_migrate_parser(parser_ctor=argparse.ArgumentParser):
     parser = parser_ctor(
         help="Migrate project",
@@ -469,10 +108,29 @@ def build_parser(parser_ctor=argparse.ArgumentParser):
         formatter_class=MultilineFormatter,
     )
 
-    subparsers = parser.add_subparsers()
-    add_subparser(subparsers, "info", build_info_parser)
-    add_subparser(subparsers, "stats", build_stats_parser)
-    add_subparser(subparsers, "export", build_export_parser)
+    subcommands_desc = ""
+    known_commands = get_project_commands()
+    help_line_start = max((len(e[0]) for e in known_commands), default=0)
+    help_line_start = max((2 + help_line_start) // 4 + 1, 6) * 4  # align to tabs
+    if known_commands:
+        if subcommands_desc:
+            subcommands_desc += "\n"
+        subcommands_desc += make_subcommands_help(known_commands, help_line_start)
+    if subcommands_desc:
+        subcommands_desc += (
+            "\nRun '%s COMMAND --help' for more information on a command." % parser.prog
+        )
+
+    # TODO: revisit project related commands for better code structure
+    subparsers = parser.add_subparsers(
+        title=subcommands_desc, description="", help=argparse.SUPPRESS
+    )
+
+    commands = get_project_commands()
+    for command_name, command, _ in commands:
+        if command is not None:
+            add_subparser(subparsers, command_name, command.build_parser)
+
     add_subparser(subparsers, "migrate", build_migrate_parser)
 
     return parser
@@ -480,8 +138,5 @@ def build_parser(parser_ctor=argparse.ArgumentParser):
 
 def get_sensitive_args():
     return {
-        **get_info_sensitive_args(),
-        **get_stats_sensitive_args(),
-        **get_export_sensitive_args(),
         **get_migrate_sensitive_args(),
     }
diff --git a/datumaro/cli/contexts/source.py b/datumaro/cli/contexts/source.py
index 62f485aef..5eb8f2a88 100644
--- a/datumaro/cli/contexts/source.py
+++ b/datumaro/cli/contexts/source.py
@@ -3,370 +3,12 @@
 # SPDX-License-Identifier: MIT
 
 import argparse
-import logging as log
-import os
 
-from datumaro.components.environment import Environment
-from datumaro.components.errors import ProjectNotFoundError
-from datumaro.util.scope import on_error_do, scope_add, scoped
+from datumaro.util.scope import scope_add, scoped
 
-from ..util import MultilineFormatter, add_subparser, join_cli_args, show_video_import_warning
-from ..util.errors import CliException
-from ..util.project import generate_next_name, load_project
-
-
-def build_import_parser(parser_ctor=argparse.ArgumentParser):
-    env = Environment()
-    builtins = sorted(set(env.extractors) | set(env.importers))
-
-    parser = parser_ctor(
-        help="Import dataset to project",
-        description="""
-        Imports a data source to a project. A data source is a dataset
-        in a supported format (check 'formats' section below).|n
-        |n
-        Currently, only local paths to sources are supported.|n
-        During importing, a source is copied into the project.|n
-        |n
-        Formats:|n
-        Datasets come in a wide variety of formats. Each dataset
-        format defines its own data structure and rules on how to
-        interpret the data. Check the user manual for the list of
-        supported formats, examples and documentation.
-        |n
-        The list of supported formats can be extended by plugins.
-        Check the "plugins" section of the developer guide for information
-        about plugin implementation.|n
-        |n
-        Each dataset format has its own import options, which are passed
-        after the '--' separator (see examples), pass '-- -h' for more info.|n
-        |n
-        Builtin formats: {}|n
-        |n
-        Examples:|n
-        - Add a local directory with a VOC-like dataset:|n
-        |s|s%(prog)s -f voc path/to/voc|n
-        |n
-        - Add a directory with a COCO dataset, use only a specific file:|n
-        |s|s%(prog)s -f coco_instances -r anns/train.json path/to/coco|n
-        |n
-        - Add a local file with CVAT annotations, call it 'mysource'|n
-        |s|s|s|sto the project in a specific place:|n
-        |s|s%(prog)s -f cvat -n mysource -p project/path/ path/to/cvat.xml
-        """.format(
-            ", ".join(builtins)
-        ),
-        formatter_class=MultilineFormatter,
-    )
-
-    parser.add_argument(
-        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
-    )  # workaround for -- eaten by positionals
-    parser.add_argument("url", help="URL to the source dataset. A path to a file or directory")
-    parser.add_argument(
-        "-n", "--name", help="Name of the new source (default: generate automatically)"
-    )
-    parser.add_argument("-f", "--format", required=True, help="Source dataset format")
-    parser.add_argument(
-        "-r",
-        "--path",
-        dest="rpath",
-        help="A path relative to URL to the source data. Useful to specify "
-        "a path to subset, subtask, or a specific file in URL.",
-    )
-    parser.add_argument(
-        "--no-check", action="store_true", help="Don't try to read the source after importing"
-    )
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.add_argument(
-        "extra_args",
-        nargs=argparse.REMAINDER,
-        help="Additional arguments for extractor (pass '-- -h' for help). "
-        "Must be specified after the main command arguments and after "
-        "the '--' separator",
-    )
-    parser.set_defaults(command=import_command)
-
-    return parser
-
-
-def get_import_sensitive_args():
-    return {
-        import_command: ["url", "project_dir", "rpath", "name", "extra_args"],
-    }
-
-
-@scoped
-def import_command(args):
-    # Workaround. Required positionals consume positionals from the end
-    args._positionals += join_cli_args(args, "url", "extra_args")
-
-    has_sep = "--" in args._positionals
-    if has_sep:
-        pos = args._positionals.index("--")
-    else:
-        pos = 1
-    args.url = (args._positionals[:pos] or [""])[0]
-    args.extra_args = args._positionals[pos + has_sep :]
-
-    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
-
-    project = None
-    try:
-        project = scope_add(load_project(args.project_dir))
-    except ProjectNotFoundError:
-        if not show_plugin_help:
-            raise
-
-    if project is not None:
-        env = project.env
-    else:
-        env = Environment()
-
-    fmt = args.format
-    if fmt in env.importers:
-        arg_parser = env.importers[fmt]
-    elif fmt in env.extractors:
-        arg_parser = env.extractors[fmt]
-    else:
-        raise CliException(
-            "Unknown format '%s'. A format can be added"
-            " by providing an Extractor and Importer plugins" % fmt
-        )
-
-    extra_args = arg_parser.parse_cmdline(args.extra_args)
-
-    name = args.name
-    if name:
-        if name in project.working_tree.sources:
-            raise CliException("Source '%s' already exists" % name)
-    else:
-        name = generate_next_name(
-            list(project.working_tree.sources) + os.listdir(), "source", sep="-", default="1"
-        )
-
-    if fmt == "video_frames":
-        show_video_import_warning()
-
-    project.import_source(
-        name,
-        url=args.url,
-        format=args.format,
-        options=extra_args,
-        no_cache=True,
-        no_hash=True,
-        rpath=args.rpath,
-    )
-    on_error_do(
-        project.remove_source, name, ignore_errors=True, kwargs={"force": True, "keep_data": False}
-    )
-
-    if not args.no_check:
-        log.info("Checking the source...")
-        project.working_tree.make_dataset(name)
-
-    project.working_tree.save()
-
-    log.info("Source '%s' with format '%s' has been added to the project", name, args.format)
-
-    return 0
-
-
-def build_add_parser(parser_ctor=argparse.ArgumentParser):
-    env = Environment()
-    builtins = sorted(set(env.extractors) | set(env.importers))
-
-    parser = parser_ctor(
-        help="Add data source to project",
-        description="""
-        Adds a data source to a project. A data source is a dataset
-        in a supported format (check 'formats' section below).|n
-        |n
-        The command adds a project-local directory as a data source in
-        the project. Unlike the "import" command, it does not copy datasets
-        and only works with local directories. The source name is defined by
-        the directory name.|n
-        |n
-        Formats:|n
-        Datasets come in a wide variety of formats. Each dataset
-        format defines its own data structure and rules on how to
-        interpret the data. Check the user manual for the list of
-        supported formats, examples and documentation.
-        |n
-        The list of supported formats can be extended by plugins.
-        Check the "plugins" section of the developer guide for information
-        about plugin implementation.|n
-        |n
-        Each dataset format has its own import options, which are passed
-        after the '--' separator (see examples), pass '-- -h' for more info.|n
-        |n
-        Builtin formats: {}|n
-        |n
-        Examples:|n
-        - Add a local directory with a VOC-like dataset:|n
-        |s|s%(prog)s -f voc my_dataset/|n
-        |n
-        - Add a directory with a COCO dataset, use only a specific file:|n
-        |s|s%(prog)s -f coco_instances -r anns/train.json my_source/
-        """.format(
-            ", ".join(builtins)
-        ),
-        formatter_class=MultilineFormatter,
-    )
-
-    parser.add_argument(
-        "_positionals", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
-    )  # workaround for -- eaten by positionals
-    parser.add_argument("path", help="A path to a dataset directory")
-    parser.add_argument("-f", "--format", required=True, help="Source dataset format")
-    parser.add_argument(
-        "-r",
-        "--path",
-        dest="rpath",
-        help="A path relative to source root to the source data. Useful to "
-        "specify a path to subset, subtask, or a specific file in dataset.",
-    )
-    parser.add_argument("--no-check", action="store_true", help="Don't try to read the source")
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        default=".",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.add_argument(
-        "extra_args",
-        nargs=argparse.REMAINDER,
-        help="Additional arguments for extractor (pass '-- -h' for help). "
-        "Must be specified after the main command arguments and after "
-        "the '--' separator",
-    )
-    parser.set_defaults(command=add_command)
-
-    return parser
-
-
-def get_add_sensitive_args():
-    return {
-        add_command: ["url", "project_dir", "rpath", "extra_args"],
-    }
-
-
-@scoped
-def add_command(args):
-    # Workaround. Required positionals consume positionals from the end
-    args._positionals += join_cli_args(args, "path", "extra_args")
-
-    has_sep = "--" in args._positionals
-    if has_sep:
-        pos = args._positionals.index("--")
-    else:
-        pos = 1
-    args.path = (args._positionals[:pos] or [""])[0]
-    args.extra_args = args._positionals[pos + has_sep :]
-
-    show_plugin_help = "-h" in args.extra_args or "--help" in args.extra_args
-
-    project = None
-    try:
-        project = scope_add(load_project(args.project_dir))
-    except ProjectNotFoundError:
-        if not show_plugin_help:
-            raise
-
-    if project is not None:
-        env = project.env
-    else:
-        env = Environment()
-
-    fmt = args.format
-    if fmt in env.importers:
-        arg_parser = env.importers[fmt]
-    elif fmt in env.extractors:
-        arg_parser = env.extractors[fmt]
-    else:
-        raise CliException(
-            "Unknown format '%s'. A format can be added"
-            " by providing an Extractor and Importer plugins" % fmt
-        )
-
-    extra_args = arg_parser.parse_cmdline(args.extra_args)
-
-    if fmt == "video_frames":
-        show_video_import_warning()
-
-    name, _ = project.add_source(
-        args.path, format=args.format, options=extra_args, rpath=args.rpath
-    )
-    on_error_do(
-        project.remove_source, name, ignore_errors=True, kwargs={"force": True, "keep_data": True}
-    )
-
-    if not args.no_check:
-        log.info("Checking the source...")
-        project.working_tree.make_dataset(name)
-
-    project.working_tree.save()
-
-    log.info("Source '%s' with format '%s' has been added to the project", name, args.format)
-
-    return 0
-
-
-def build_remove_parser(parser_ctor=argparse.ArgumentParser):
-    parser = parser_ctor(
-        help="Remove source from project", description="Remove a source from a project"
-    )
-
-    parser.add_argument("names", nargs="+", help="Names of the sources to be removed")
-    parser.add_argument(
-        "--force", action="store_true", help="Do not fail and stop on errors during removal"
-    )
-    parser.add_argument(
-        "--keep-data",
-        action="store_true",
-        help="Do not remove source data from the working directory, remove "
-        "only project metainfo.",
-    )
-    parser.add_argument(
-        "-p",
-        "--project",
-        dest="project_dir",
-        help="Directory of the project to operate on (default: current dir)",
-    )
-    parser.set_defaults(command=remove_command)
-
-    return parser
-
-
-def get_remove_sensitive_args():
-    return {
-        remove_command: [
-            "project_dir",
-            "names",
-        ],
-    }
-
-
-@scoped
-def remove_command(args):
-    project = scope_add(load_project(args.project_dir))
-
-    if not args.names:
-        raise CliException("Expected source name")
-
-    for name in args.names:
-        project.remove_source(name, force=args.force, keep_data=args.keep_data)
-    project.working_tree.save()
-
-    log.info("Sources '%s' have been removed from the project" % ", ".join(args.names))
-
-    return 0
+from ..commands.require_project.modification import add, import_, remove
+from ..util import MultilineFormatter, add_subparser
+from ..util.project import load_project
 
 
 def build_info_parser(parser_ctor=argparse.ArgumentParser):
@@ -426,9 +68,10 @@ def build_parser(parser_ctor=argparse.ArgumentParser):
     )
 
     subparsers = parser.add_subparsers()
-    add_subparser(subparsers, "import", build_import_parser)
-    add_subparser(subparsers, "add", build_add_parser)
-    add_subparser(subparsers, "remove", build_remove_parser)
+    add_subparser(subparsers, "import", import_.build_parser)
+    add_subparser(subparsers, "add", add.build_parser)
+    add_subparser(subparsers, "remove", remove.build_parser)
+    # TODO: we should distinguish this with datumaro/cli/project_commands/dataset_operations/info.py
     add_subparser(subparsers, "info", build_info_parser)
 
     return parser
@@ -436,8 +79,8 @@ def build_parser(parser_ctor=argparse.ArgumentParser):
 
 def get_sensitive_args():
     return {
-        **get_add_sensitive_args(),
-        **get_import_sensitive_args(),
-        **get_remove_sensitive_args(),
+        **add.get_sensitive_args(),
+        **import_.get_sensitive_args(),
+        **remove.get_sensitive_args(),
         **get_info_sensitive_args(),
     }
diff --git a/datumaro/cli/util/__init__.py b/datumaro/cli/util/__init__.py
index 0c60c1bac..578ab5505 100644
--- a/datumaro/cli/util/__init__.py
+++ b/datumaro/cli/util/__init__.py
@@ -109,3 +109,17 @@ def show_video_import_warning():
         "manually using instructions at: "
         "https://openvinotoolkit.github.io/datumaro/docs/user-manual/media_formats/"
     )
+
+
+def make_subcommands_help(commands, help_line_start=0):
+    desc = ""
+    for command_name, command_type, command_help in commands:
+        msg = ("  %-" + str(max(0, help_line_start - 2 - 1)) + "s%s\n") % (
+            command_name,
+            command_help,
+        )
+        if command_type is None:
+            msg = msg.lstrip()  # un-indent for not subparser
+        desc += msg
+
+    return desc
diff --git a/datumaro/cli/contexts/project/diff.py b/datumaro/cli/util/diff.py
similarity index 100%
rename from datumaro/cli/contexts/project/diff.py
rename to datumaro/cli/util/diff.py
diff --git a/datumaro/cli/util/project.py b/datumaro/cli/util/project.py
index 05aa634ed..593451080 100644
--- a/datumaro/cli/util/project.py
+++ b/datumaro/cli/util/project.py
@@ -4,6 +4,7 @@
 
 import os
 import re
+from enum import Enum
 from typing import Optional, Tuple
 
 from datumaro.cli.util.errors import WrongRevpathError
@@ -166,3 +167,41 @@ def split_local_revpath(revpath: str) -> Tuple[Revision, str]:
         target = revpath
 
     return rev, target
+
+
+class FilterModes(Enum):
+    # primary
+    items = 1
+    annotations = 2
+    items_annotations = 3
+
+    # shortcuts
+    i = 1
+    a = 2
+    i_a = 3
+    a_i = 3
+    annotations_items = 3
+
+    @staticmethod
+    def parse(s):
+        s = s.lower()
+        s = s.replace("+", "_")
+        return FilterModes[s]
+
+    @classmethod
+    def make_filter_args(cls, mode):
+        if mode == cls.items:
+            return {}
+        elif mode == cls.annotations:
+            return {"filter_annotations": True}
+        elif mode == cls.items_annotations:
+            return {
+                "filter_annotations": True,
+                "remove_empty": True,
+            }
+        else:
+            raise NotImplementedError()
+
+    @classmethod
+    def list_options(cls):
+        return [m.name.replace("_", "+") for m in cls]
diff --git a/datumaro/components/extractor_tfds.py b/datumaro/components/extractor_tfds.py
index a7fa163ce..deeb2103d 100644
--- a/datumaro/components/extractor_tfds.py
+++ b/datumaro/components/extractor_tfds.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2021-2022 Intel Corporation
+# Copyright (C) 2021-2023 Intel Corporation
 #
 # SPDX-License-Identifier: MIT
 
@@ -313,6 +313,31 @@ _IMAGENET_ADAPTER = _TfdsAdapter(
     ),
 )
 
+_EUROSAT_ADAPTER = _TfdsAdapter(
+    category_transformers=[_SetLabelCategoriesFromClassLabelFeature("label")],
+    data_transformers=[
+        _SetImageFromImageFeature("image"),
+        _AddLabelFromClassLabelFeature("label"),
+    ],
+    id_generator=_GenerateIdFromFilenameFeature("filename"),
+    metadata=TfdsDatasetMetadata(
+        human_name="EuroSAT", default_output_format="imagenet_txt", media_type=Image
+    ),
+)
+
+
+_UC_MERCED_ADAPTER = _TfdsAdapter(
+    category_transformers=[_SetLabelCategoriesFromClassLabelFeature("label")],
+    data_transformers=[
+        _SetImageFromImageFeature("image"),
+        _AddLabelFromClassLabelFeature("label"),
+    ],
+    id_generator=_GenerateIdFromFilenameFeature("filename"),
+    metadata=TfdsDatasetMetadata(
+        human_name="UCMerced", default_output_format="imagenet_txt", media_type=Image
+    ),
+)
+
 
 def _voc_save_pose_names(
     tfds_builder: tfds.core.DatasetBuilder,
@@ -369,6 +394,8 @@ _TFDS_ADAPTERS = {
     "imagenet_v2": _evolve_adapter_meta(_IMAGENET_ADAPTER, human_name="ImageNetV2"),
     "mnist": _MNIST_ADAPTER,
     "voc/2012": _evolve_adapter_meta(_VOC_ADAPTER, human_name="PASCAL VOC 2012"),
+    "eurosat": _evolve_adapter_meta(_EUROSAT_ADAPTER, human_name="EuroSAT"),
+    "uc_merced": _evolve_adapter_meta(_UC_MERCED_ADAPTER, human_name="UCMerced"),
 }
 
 # Assign the TFDS catalog page as the documentation URL for all datasets.
diff --git a/datumaro/plugins/data_formats/celeba/celeba.py b/datumaro/plugins/data_formats/celeba/celeba.py
index 15b1ce55a..957002283 100644
--- a/datumaro/plugins/data_formats/celeba/celeba.py
+++ b/datumaro/plugins/data_formats/celeba/celeba.py
@@ -2,7 +2,6 @@
 #
 # SPDX-License-Identifier: MIT
 
-import logging as log
 import os.path as osp
 
 from datumaro.components.annotation import (
@@ -236,7 +235,10 @@ class CelebaImporter(Importer):
 
     @classmethod
     def detect(cls, context: FormatDetectionContext) -> FormatDetectionConfidence:
-        super().detect(context)
+        try:
+            super().detect(context)
+        except DatasetImportError as e:
+            context.fail(str(e))
         return FormatDetectionConfidence.MEDIUM
 
     @classmethod
@@ -248,19 +250,17 @@ class CelebaImporter(Importer):
         )
 
         if len(sources) > 1:
-            log.error(
+            raise DatasetImportError(
                 f"{cls.NAME} label file ({cls.PATH_CLS.LABELS_FILE}) must be unique "
                 f"but the found sources have multiple duplicates. sources = {sources}"
             )
-            return []
 
         for source in sources:
             anno_dir = osp.dirname(source["url"])
             root_dir = osp.dirname(anno_dir)
             img_dir = osp.join(root_dir, cls.PATH_CLS.IMAGES_DIR)
             if not osp.exists(img_dir):
-                log.error(f"Cannot find {cls.NAME}'s images directory at {img_dir}")
-                return []
+                raise DatasetImportError(f"Cannot find {cls.NAME}'s images directory at {img_dir}")
             source["url"] = root_dir
 
         return sources
diff --git a/datumaro/util/telemetry_utils.py b/datumaro/util/telemetry_utils.py
index 750a5c7da..c24f3fcc4 100644
--- a/datumaro/util/telemetry_utils.py
+++ b/datumaro/util/telemetry_utils.py
@@ -22,23 +22,24 @@ NO_TELEMETRY_KEY = "DATUMARO_NO_OV_TELEMETRY"
 
 
 def _get_action_name(command):
-    if command is contexts.project.info_command:
+    # TODO: We should clean these nonsense if-else branches.
+    if command is commands.require_project.versioning.info.info_command:
         return "project_info_result"
-    elif command is contexts.project.stats_command:
+    elif command is commands.require_project.dataset_operations.stats.stats_command:
         return "project_stats_result"
     elif command is contexts.project.migrate_command:
         return "project_migrate_result"
-    elif command is contexts.project.export_command:
+    elif command is commands.require_project.dataset_operations.export.export_command:
         return "project_export_result"
-    elif command is commands.validate.validate_command:
+    elif command is commands.require_project.dataset_operations.validate.validate_command:
         return "project_validate_result"
     elif command is commands.filter.filter_command:
         return "project_filter_result"
-    elif command is commands.transform.transform_command:
+    elif command is commands.require_project.dataset_operations.transform.transform_command:
         return "project_transform_result"
-    elif command is contexts.source.import_command:
+    elif command is commands.require_project.modification.import_.import_command:
         return "source_add_result"
-    elif command is contexts.source.remove_command:
+    elif command is commands.require_project.modification.remove.remove_command:
         return "source_remove_result"
     elif command is contexts.source.info_command:
         return "source_info_result"
@@ -50,29 +51,29 @@ def _get_action_name(command):
         return "model_run_result"
     elif command is contexts.model.info_command:
         return "model_info_result"
-    elif command is commands.checkout.checkout_command:
+    elif command is commands.require_project.versioning.checkout.checkout_command:
         return "checkout_result"
-    elif command is commands.commit.commit_command:
+    elif command is commands.require_project.versioning.commit.commit_command:
         return "commit_result"
     elif command is commands.convert.convert_command:
         return "convert_result"
-    elif command is commands.create.create_command:
+    elif command is commands.require_project.modification.create.create_command:
         return "create_result"
-    elif command is commands.diff.diff_command:
+    elif command is commands.require_project.dataset_operations.diff.diff_command:
         return "diff_result"
     elif command is commands.explain.explain_command:
         return "explain_result"
     elif command is commands.generate.generate_command:
         return "generate_result"
-    elif command is commands.info.info_command:
+    elif command is commands.require_project.dataset_operations.info.info_command:
         return "info_result"
-    elif command is commands.log.log_command:
+    elif command is commands.require_project.versioning.log.log_command:
         return "log_result"
     elif command is commands.merge.merge_command:
         return "merge_result"
     elif command is commands.patch.patch_command:
         return "patch_result"
-    elif command is commands.status.status_command:
+    elif command is commands.require_project.versioning.status.status_command:
         return "status_result"
 
     return f"{command.__name__}_result"


Test patch:
diff --git a/tests/integration/cli/test_diff.py b/tests/integration/cli/test_diff.py
index 95c86eca0..52a1ea46f 100644
--- a/tests/integration/cli/test_diff.py
+++ b/tests/integration/cli/test_diff.py
@@ -4,7 +4,7 @@ from unittest import TestCase
 
 import numpy as np
 
-from datumaro.cli.contexts.project.diff import DiffVisualizer
+from datumaro.cli.util.diff import DiffVisualizer
 from datumaro.components.annotation import (
     AnnotationType,
     Bbox,
@@ -220,6 +220,7 @@ class DiffTest(TestCase):
             result_dir = osp.join(test_dir, "cmp_result")
             run(
                 self,
+                "project",
                 "diff",
                 dataset1_url + ":coco",
                 dataset2_url + ":voc",
@@ -272,6 +273,7 @@ class DiffTest(TestCase):
             result_dir = osp.join(test_dir, "cmp_result")
             run(
                 self,
+                "project",
                 "diff",
                 dataset1_url + ":coco",
                 dataset2_url + ":voc",
diff --git a/tests/integration/cli/test_dm_binary_encryption.py b/tests/integration/cli/test_dm_binary_encryption.py
index 359e1ced1..60465f0e3 100644
--- a/tests/integration/cli/test_dm_binary_encryption.py
+++ b/tests/integration/cli/test_dm_binary_encryption.py
@@ -60,13 +60,14 @@ def test_yolo_to_dm_binary_encryption(
     yolo_dir = get_test_asset_path("yolo_dataset")
 
     # 1. Create project
-    run(helper_tc, "create", "-o", test_dir)
+    run(helper_tc, "project", "create", "-o", test_dir)
 
     # 2. Import yolo format dataset as "src_yolo" name
-    run(helper_tc, "import", "-n", "src_yolo", "-p", test_dir, "-f", "yolo", yolo_dir)
+    run(helper_tc, "project", "import", "-n", "src_yolo", "-p", test_dir, "-f", "yolo", yolo_dir)
 
     # 3. Export it to DatumaroBinary format with encryption
     cmd = [
+        "project",
         "export",
         "-p",
         test_dir,
@@ -86,7 +87,7 @@ def test_yolo_to_dm_binary_encryption(
     run(helper_tc, *cmd)
 
     # Remove src_yolo dataset from the project
-    run(helper_tc, "remove", "-p", test_dir, "src_yolo")
+    run(helper_tc, "project", "remove", "-p", test_dir, "src_yolo")
 
     # Check whether the key exists
     key_path = osp.join(export_dir, "dm_binary", DatumaroBinaryPath.SECRET_KEY_FILE)
@@ -116,6 +117,7 @@ def test_yolo_to_dm_binary_encryption(
     # 5. Succeed to import the encrypted dataset with the true key
     run(
         helper_tc,
+        "project",
         "import",
         "-p",
         test_dir,
@@ -132,6 +134,7 @@ def test_yolo_to_dm_binary_encryption(
     # 6. Re-export it to the yolo format
     run(
         helper_tc,
+        "project",
         "export",
         "-p",
         test_dir,
diff --git a/tests/integration/cli/test_explain.py b/tests/integration/cli/test_explain.py
index 28e8d4239..82e1a6645 100644
--- a/tests/integration/cli/test_explain.py
+++ b/tests/integration/cli/test_explain.py
@@ -30,7 +30,7 @@ class ExplainIntegrationScenarios(TestCase):
 
         with TestDir() as project_dir:
             # Create project
-            run(self, "create", "-o", project_dir)
+            run(self, "project", "create", "-o", project_dir)
 
             model_name = "my-model"
 
diff --git a/tests/integration/cli/test_image_zip_format.py b/tests/integration/cli/test_image_zip_format.py
index d9a58b95d..4423736a2 100644
--- a/tests/integration/cli/test_image_zip_format.py
+++ b/tests/integration/cli/test_image_zip_format.py
@@ -39,13 +39,14 @@ class ImageZipIntegrationScenarios(TestCase):
             make_zip_archive(test_dir, zip_path)
 
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
-            run(self, "import", "-p", proj_dir, "-f", "image_zip", zip_path)
+            run(self, "project", "create", "-o", proj_dir)
+            run(self, "project", "import", "-p", proj_dir, "-f", "image_zip", zip_path)
 
             result_dir = osp.join(test_dir, "result")
             export_path = osp.join(result_dir, "export.zip")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 proj_dir,
@@ -69,12 +70,13 @@ class ImageZipIntegrationScenarios(TestCase):
                 "coco",
             )
 
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "coco", coco_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "coco", coco_dir)
 
             export_path = osp.join(test_dir, "export.zip")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 test_dir,
@@ -108,12 +110,13 @@ class ImageZipIntegrationScenarios(TestCase):
             make_zip_archive(test_dir, zip_path)
 
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
-            run(self, "import", "-p", proj_dir, "-f", "image_zip", zip_path)
+            run(self, "project", "create", "-o", proj_dir)
+            run(self, "project", "import", "-p", proj_dir, "-f", "image_zip", zip_path)
 
             export_path = osp.join(test_dir, "export.zip")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 proj_dir,
diff --git a/tests/integration/cli/test_model.py b/tests/integration/cli/test_model.py
index 9d42cf312..5974de616 100644
--- a/tests/integration/cli/test_model.py
+++ b/tests/integration/cli/test_model.py
@@ -21,7 +21,7 @@ class ModelIntegrationScenarios(TestCase):
 
         with TestDir() as project_dir:
             # Create project
-            run(self, "create", "-o", project_dir)
+            run(self, "project", "create", "-o", project_dir)
 
             model_name = "my-model"
 
diff --git a/tests/integration/cli/test_project.py b/tests/integration/cli/test_project.py
index d46277477..11e94b941 100644
--- a/tests/integration/cli/test_project.py
+++ b/tests/integration/cli/test_project.py
@@ -57,12 +57,22 @@ class ProjectIntegrationScenarios(TestCase):
         )
 
         with TestDir() as test_dir:
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-f", "coco", "-p", test_dir, coco_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-f", "coco", "-p", test_dir, coco_dir)
 
             result_dir = osp.join(test_dir, "voc_export")
             run(
-                self, "export", "-f", "voc", "-p", test_dir, "-o", result_dir, "--", "--save-images"
+                self,
+                "project",
+                "export",
+                "-f",
+                "voc",
+                "-p",
+                test_dir,
+                "-o",
+                result_dir,
+                "--",
+                "--save-images",
             )
 
             self.assertTrue(osp.isdir(result_dir))
@@ -75,14 +85,14 @@ class ProjectIntegrationScenarios(TestCase):
         )
 
         with TestDir() as test_dir:
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-f", "coco", "-p", test_dir, coco_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-f", "coco", "-p", test_dir, coco_dir)
 
             with self.subTest("on project"):
-                run(self, "project", "info", "-p", test_dir)
+                run(self, "project", "pinfo", "-p", test_dir)
 
             with self.subTest("on project revision"):
-                run(self, "project", "info", "-p", test_dir, "HEAD")
+                run(self, "project", "pinfo", "-p", test_dir, "HEAD")
 
     @mark_requirement(Requirements.DATUM_GENERAL_REQ)
     def test_can_list_dataset_info(self):
@@ -92,26 +102,25 @@ class ProjectIntegrationScenarios(TestCase):
         )
 
         with TestDir() as test_dir:
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-f", "coco", "-p", test_dir, coco_dir)
-            run(self, "commit", "-m", "first", "-p", test_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-f", "coco", "-p", test_dir, coco_dir)
+            run(self, "project", "commit", "-m", "first", "-p", test_dir)
 
             with self.subTest("on current project"):
-                run(self, "info", "-p", test_dir)
+                run(self, "project", "dinfo", "-p", test_dir)
 
             with self.subTest("on current project revision"):
-                run(self, "info", "-p", test_dir, "HEAD")
+                run(self, "project", "dinfo", "-p", test_dir, "HEAD")
 
             with self.subTest("on other project"):
-                run(self, "info", test_dir)
+                run(self, "project", "dinfo", test_dir)
 
             with self.subTest("on other project revision"):
-                run(self, "info", test_dir + "@HEAD")
+                run(self, "project", "dinfo", test_dir + "@HEAD")
 
             with self.subTest("on dataset"):
-                run(self, "info", coco_dir + ":coco")
+                run(self, "project", "dinfo", coco_dir + ":coco")
 
-    @mark_requirement(Requirements.DATUM_GENERAL_REQ)
     def test_can_use_vcs(self):
         with TestDir() as test_dir:
             dataset_dir = osp.join(test_dir, "dataset")
@@ -132,12 +141,13 @@ class ProjectIntegrationScenarios(TestCase):
                 categories=["a", "b"],
             ).save(dataset_dir, save_media=True)
 
-            run(self, "create", "-o", project_dir)
-            run(self, "import", "-p", project_dir, "-f", "datumaro", dataset_dir)
-            run(self, "commit", "-p", project_dir, "-m", "Add data")
+            run(self, "project", "create", "-o", project_dir)
+            run(self, "project", "import", "-p", project_dir, "-f", "datumaro", dataset_dir)
+            run(self, "project", "commit", "-p", project_dir, "-m", "Add data")
 
             run(
                 self,
+                "project",
                 "transform",
                 "-p",
                 project_dir,
@@ -148,7 +158,7 @@ class ProjectIntegrationScenarios(TestCase):
                 "-l",
                 "b:cat",
             )
-            run(self, "commit", "-p", project_dir, "-m", "Add transform")
+            run(self, "project", "commit", "-p", project_dir, "-m", "Add transform")
 
             run(
                 self,
@@ -160,10 +170,11 @@ class ProjectIntegrationScenarios(TestCase):
                 "-m",
                 "i+a",
             )
-            run(self, "commit", "-p", project_dir, "-m", "Add filter")
+            run(self, "project", "commit", "-p", project_dir, "-m", "Add filter")
 
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 project_dir,
@@ -205,9 +216,10 @@ class ProjectIntegrationScenarios(TestCase):
             )
 
             shutil.rmtree(result_dir, ignore_errors=True)
-            run(self, "checkout", "-p", project_dir, "HEAD~1")
+            run(self, "project", "checkout", "-p", project_dir, "HEAD~1")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 project_dir,
@@ -272,9 +284,10 @@ class ProjectIntegrationScenarios(TestCase):
         dataset.save(source_url)
 
         project_dir = osp.join(test_dir, "proj")
-        run(self, "create", "-o", project_dir)
+        run(self, "project", "create", "-o", project_dir)
         run(
             self,
+            "project",
             "import",
             "-p",
             project_dir,
@@ -285,9 +298,10 @@ class ProjectIntegrationScenarios(TestCase):
             source_url,
         )
         run(self, "filter", "-p", project_dir, "-e", '/item/annotation[label="b"]')
-        run(self, "transform", "-p", project_dir, "-t", "rename", "--", "-e", "|2|qq|")
+        run(self, "project", "transform", "-p", project_dir, "-t", "rename", "--", "-e", "|2|qq|")
         run(
             self,
+            "project",
             "transform",
             "-p",
             project_dir,
diff --git a/tests/integration/cli/test_transform.py b/tests/integration/cli/test_transform.py
index 1803a2195..cd4147225 100644
--- a/tests/integration/cli/test_transform.py
+++ b/tests/integration/cli/test_transform.py
@@ -36,6 +36,7 @@ class TransformTest(TestCase):
 
         run(
             self,
+            "project",
             "transform",
             "-t",
             "remap_labels",
@@ -69,7 +70,15 @@ class TransformTest(TestCase):
                 categories=["a", "b"],
             ).export(test_dir, "coco")
 
-            run(self, "transform", "-t", "random_split", test_dir + ":coco", expected_code=1)
+            run(
+                self,
+                "project",
+                "transform",
+                "-t",
+                "random_split",
+                test_dir + ":coco",
+                expected_code=1,
+            )
 
     @mark_requirement(Requirements.DATUM_GENERAL_REQ)
     def test_transform_fails_on_inplace_update_of_stage(self):
@@ -103,6 +112,7 @@ class TransformTest(TestCase):
             with self.subTest("without overwrite"):
                 run(
                     self,
+                    "project",
                     "transform",
                     "-p",
                     project_dir,
@@ -116,6 +126,7 @@ class TransformTest(TestCase):
                 with self.assertRaises(ReadonlyDatasetError):
                     run(
                         self,
+                        "project",
                         "transform",
                         "-p",
                         project_dir,
diff --git a/tests/integration/cli/test_video.py b/tests/integration/cli/test_video.py
index 760bc45ab..20a6a9de7 100644
--- a/tests/integration/cli/test_video.py
+++ b/tests/integration/cli/test_video.py
@@ -23,11 +23,12 @@ class VideoTest(TestCase):
             make_sample_video(osp.join(video_dir, "video.avi"), frames=4)
 
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
+            run(self, "project", "create", "-o", proj_dir)
 
             with self.assertLogs() as capture:
                 run(
                     self,
+                    "project",
                     "import",
                     "-f",
                     "video_frames",
@@ -44,14 +45,25 @@ class VideoTest(TestCase):
     def test_can_display_video_import_warning_in_add(self):
         with TestDir() as test_dir:
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
+            run(self, "project", "create", "-o", proj_dir)
 
             video_dir = osp.join(proj_dir, "src")
             os.makedirs(video_dir)
             make_sample_video(osp.join(video_dir, "video.avi"), frames=4)
 
             with self.assertLogs() as capture:
-                run(self, "add", "-f", "video_frames", "-p", proj_dir, "-r", "video.avi", video_dir)
+                run(
+                    self,
+                    "project",
+                    "add",
+                    "-f",
+                    "video_frames",
+                    "-p",
+                    proj_dir,
+                    "-r",
+                    "video.avi",
+                    video_dir,
+                )
 
             self.assertTrue("results across multiple runs" in "\n".join(capture.output))
 
@@ -61,10 +73,11 @@ class VideoTest(TestCase):
 
         with TestDir() as test_dir:
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
+            run(self, "project", "create", "-o", proj_dir)
 
             run(
                 self,
+                "project",
                 "import",
                 "-f",
                 "video_frames",
@@ -82,7 +95,7 @@ class VideoTest(TestCase):
             )
 
             result_dir = osp.join(proj_dir, "result")
-            run(self, "export", "-f", "image_dir", "-p", proj_dir, "-o", result_dir)
+            run(self, "project", "export", "-f", "image_dir", "-p", proj_dir, "-o", result_dir)
             parsed_dataset = Dataset.import_from(result_dir, "image_dir")
 
             compare_datasets(self, expected, parsed_dataset)
@@ -100,10 +113,11 @@ class VideoTest(TestCase):
 
         with TestDir() as test_dir:
             proj_dir = osp.join(test_dir, "proj")
-            run(self, "create", "-o", proj_dir)
+            run(self, "project", "create", "-o", proj_dir)
 
             run(
                 self,
+                "project",
                 "import",
                 "-f",
                 "video_keyframes",
@@ -117,7 +131,7 @@ class VideoTest(TestCase):
             )
 
             result_dir = osp.join(proj_dir, "result")
-            run(self, "export", "-f", "image_dir", "-p", proj_dir, "-o", result_dir)
+            run(self, "project", "export", "-f", "image_dir", "-p", proj_dir, "-o", result_dir)
             parsed_dataset = Dataset.import_from(result_dir, "image_dir")
 
             compare_datasets(self, expected, parsed_dataset)
diff --git a/tests/integration/cli/test_voc_format.py b/tests/integration/cli/test_voc_format.py
index add6716f4..09cf0eac7 100644
--- a/tests/integration/cli/test_voc_format.py
+++ b/tests/integration/cli/test_voc_format.py
@@ -28,18 +28,39 @@ class VocIntegrationScenarios(TestCase):
         result_path="",
         label_map=None,
     ):
-        run(self, "create", "-o", project_path)
+        run(self, "project", "create", "-o", project_path)
 
         extra_args = []
         if result_path:
             extra_args += ["-r", result_path]
-        run(self, "import", "-p", project_path, "-f", dataset_format, *extra_args, source_path)
+        run(
+            self,
+            "project",
+            "import",
+            "-p",
+            project_path,
+            "-f",
+            dataset_format,
+            *extra_args,
+            source_path,
+        )
 
         result_dir = osp.join(project_path, "result")
         extra_args = ["--", "--save-images"]
         if label_map:
             extra_args += ["--label-map", label_map]
-        run(self, "export", "-f", dataset_format, "-p", project_path, "-o", result_dir, *extra_args)
+        run(
+            self,
+            "project",
+            "export",
+            "-f",
+            dataset_format,
+            "-p",
+            project_path,
+            "-o",
+            result_dir,
+            *extra_args,
+        )
 
         result_path = osp.join(result_dir, result_path)
         parsed_dataset = Dataset.import_from(result_path, dataset_format)
@@ -104,8 +125,8 @@ class VocIntegrationScenarios(TestCase):
         dataset_path = osp.join(DUMMY_DATASETS_DIR, "voc_dataset2")
 
         with TestDir() as test_dir:
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "voc", dataset_path)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "voc", dataset_path)
 
             run(
                 self,
@@ -120,6 +141,7 @@ class VocIntegrationScenarios(TestCase):
 
             run(
                 self,
+                "project",
                 "transform",
                 "-p",
                 test_dir,
@@ -137,6 +159,7 @@ class VocIntegrationScenarios(TestCase):
             export_path = osp.join(test_dir, "dataset")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 test_dir,
@@ -194,12 +217,22 @@ class VocIntegrationScenarios(TestCase):
         with TestDir() as test_dir:
             yolo_dir = get_test_asset_path("yolo_dataset")
 
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "yolo", yolo_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "yolo", yolo_dir)
 
             voc_export = osp.join(test_dir, "voc_export")
             run(
-                self, "export", "-p", test_dir, "-f", "voc", "-o", voc_export, "--", "--save-images"
+                self,
+                "project",
+                "export",
+                "-p",
+                test_dir,
+                "-f",
+                "voc",
+                "-o",
+                voc_export,
+                "--",
+                "--save-images",
             )
 
             parsed_dataset = Dataset.import_from(voc_export, format="voc")
@@ -672,11 +705,12 @@ class VocIntegrationScenarios(TestCase):
         dataset_path = osp.join(DUMMY_DATASETS_DIR, "voc_dataset1")
 
         with TestDir() as test_dir:
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "voc", dataset_path)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "voc", dataset_path)
 
             run(
                 self,
+                "project",
                 "transform",
                 "-p",
                 test_dir,
diff --git a/tests/integration/cli/test_yolo_format.py b/tests/integration/cli/test_yolo_format.py
index ac774adc1..f1911535e 100644
--- a/tests/integration/cli/test_yolo_format.py
+++ b/tests/integration/cli/test_yolo_format.py
@@ -37,12 +37,13 @@ class YoloIntegrationScenarios(TestCase):
         with TestDir() as test_dir:
             yolo_dir = get_test_asset_path("yolo_dataset")
 
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "yolo", yolo_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "yolo", yolo_dir)
 
             export_dir = osp.join(test_dir, "export_dir")
             run(
                 self,
+                "project",
                 "export",
                 "-p",
                 test_dir,
@@ -67,11 +68,23 @@ class YoloIntegrationScenarios(TestCase):
         with TestDir() as test_dir:
             mot_dir = get_test_asset_path("mot_dataset")
 
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "mot_seq", mot_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "mot_seq", mot_dir)
 
             yolo_dir = osp.join(test_dir, "yolo_dir")
-            run(self, "export", "-p", test_dir, "-o", yolo_dir, "-f", "yolo", "--", "--save-media")
+            run(
+                self,
+                "project",
+                "export",
+                "-p",
+                test_dir,
+                "-o",
+                yolo_dir,
+                "-f",
+                "yolo",
+                "--",
+                "--save-media",
+            )
 
             parsed_dataset = Dataset.import_from(yolo_dir, format="yolo")
             compare_datasets(self, target_dataset, parsed_dataset)
@@ -139,8 +152,8 @@ class YoloIntegrationScenarios(TestCase):
         with TestDir() as test_dir:
             yolo_dir = get_test_asset_path("yolo_dataset")
 
-            run(self, "create", "-o", test_dir)
-            run(self, "import", "-p", test_dir, "-f", "yolo", yolo_dir)
+            run(self, "project", "create", "-o", test_dir)
+            run(self, "project", "import", "-p", test_dir, "-f", "yolo", yolo_dir)
 
             run(
                 self,
@@ -155,6 +168,7 @@ class YoloIntegrationScenarios(TestCase):
 
             run(
                 self,
+                "project",
                 "transform",
                 "-p",
                 test_dir,
@@ -169,7 +183,17 @@ class YoloIntegrationScenarios(TestCase):
 
             export_dir = osp.join(test_dir, "export")
             run(
-                self, "export", "-p", test_dir, "-o", export_dir, "-f", "yolo", "--", "--save-image"
+                self,
+                "project",
+                "export",
+                "-p",
+                test_dir,
+                "-o",
+                export_dir,
+                "-f",
+                "yolo",
+                "--",
+                "--save-image",
             )
 
             parsed_dataset = Dataset.import_from(export_dir, format="yolo")
diff --git a/tests/unit/test_telemetry_utils.py b/tests/unit/test_telemetry_utils.py
index a1b8387d3..87cedc284 100644
--- a/tests/unit/test_telemetry_utils.py
+++ b/tests/unit/test_telemetry_utils.py
@@ -2,7 +2,7 @@ import json
 import types
 from unittest import TestCase, mock
 
-from datumaro.cli.commands.info import info_command
+from datumaro.cli.commands.require_project.dataset_operations.info import info_command
 from datumaro.util.telemetry_utils import (
     send_command_exception_info,
     send_command_failure_info,