From ce95be1a67c8e619ea414c54f2d26ece97e2d145 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Fri, 11 Mar 2022 18:53:11 +0300 Subject: [PATCH 1/5] Draft interface for mlem integration --- gto/api.py | 12 +++++++++++- gto/cli.py | 8 ++++++++ gto/config.py | 15 ++++++++++++--- gto/ext.py | 40 ++++++++++++++++++++++++++++++++++++++++ gto/ext_mlem.py | 36 ++++++++++++++++++++++++++++++++++++ gto/registry.py | 4 ++-- setup.py | 1 + tests/conftest.py | 6 +++--- 8 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 gto/ext.py create mode 100644 gto/ext_mlem.py diff --git a/gto/api.py b/gto/api.py index 52ae5527..28ee6316 100644 --- a/gto/api.py +++ b/gto/api.py @@ -1,8 +1,10 @@ -from typing import Union +from typing import List, Union import pandas as pd from git import Repo +from gto.config import CONFIG +from gto.ext import EnrichmentInfo from gto.index import FileIndexManager, RepoIndexManager from gto.registry import GitRegistry from gto.tag import parse_name @@ -173,3 +175,11 @@ def audit_promotion(repo: Union[str, Repo], dataframe: bool = False): df.sort_values("creation_date", ascending=False, inplace=True) df.set_index(["creation_date", "name"], inplace=True) return df + + +def describe(name: str) -> List[EnrichmentInfo]: + res = [] + for enrichment in CONFIG.enrichments: + if enrichment.is_enriched(name): + res.append(enrichment.describe(name)) + return res \ No newline at end of file diff --git a/gto/cli.py b/gto/cli.py index 3817b15b..a6dcb802 100644 --- a/gto/cli.py +++ b/gto/cli.py @@ -250,5 +250,13 @@ def print_index(repo: str, format: str): raise NotImplementedError("Unknown format") +@gto_command() +@arg_name +def describe(name: str): + infos = gto.api.describe(name) + for info in infos: + click.echo(info.get_human_readable()) + + if __name__ == "__main__": cli() diff --git a/gto/config.py b/gto/config.py index 95fe1d49..84e83650 100644 --- a/gto/config.py +++ b/gto/config.py @@ -10,11 +10,12 @@ from .constants import BRANCH, COMMIT, TAG from .exceptions import UnknownEnvironment +from .ext import Enrichment, find_enrichments yaml = YAML(typ="safe", pure=True) yaml.default_flow_style = False -CONFIG_FILE = "gto.yaml" +CONFIG_FILE_NAME = "gto.yaml" def _set_location_init_source(init_source: InitSettingsSource): @@ -32,7 +33,7 @@ def config_settings_source(settings: "RegistryConfig") -> Dict[str, Any]: """ encoding = settings.__config__.env_file_encoding - config_file = getattr(settings, "CONFIG_FILE", CONFIG_FILE) + config_file = getattr(settings, "CONFIG_FILE", CONFIG_FILE_NAME) if not isinstance(config_file, Path): config_file = Path(config_file) if not config_file.exists(): @@ -52,7 +53,9 @@ class RegistryConfig(BaseSettings): ENV_BRANCH_MAPPING: Dict[str, str] = {} LOG_LEVEL: str = "INFO" DEBUG: bool = False - CONFIG_FILE: Optional[str] = CONFIG_FILE + ENRICHMENTS: List[Enrichment] = [] + AUTOLOAD_ENRICHMENTS: bool = True + CONFIG_FILE: Optional[str] = CONFIG_FILE_NAME @property def VERSION_SYSTEM_MAPPING(self): @@ -74,6 +77,12 @@ def ENV_MANAGERS_MAPPING(self): return {TAG: TagEnvManager, BRANCH: BranchEnvManager} + @property + def enrichments(self) -> List[Enrichment]: + if self.AUTOLOAD_ENRICHMENTS: + return find_enrichments() + self.ENRICHMENTS + return self.ENRICHMENTS + def assert_env(self, name): if not self.check_env(name): raise UnknownEnvironment(name) diff --git a/gto/ext.py b/gto/ext.py new file mode 100644 index 00000000..7d1674f3 --- /dev/null +++ b/gto/ext.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import List +import entrypoints +from pydantic import BaseModel + +ENRICHMENT_ENRTYPOINT = "gto.enrichment" + + +class EnrichmentInfo(BaseModel, ABC): + source: str + + @abstractmethod + def get_object(self) -> BaseModel: + raise NotImplementedError + + def get_dict(self): + return self.get_object().dict() + + @abstractmethod + def get_human_readable(self) -> str: + raise NotImplementedError + + +class Enrichment(BaseModel, ABC): + @abstractmethod + def is_enriched(self, obj: str) -> bool: + raise NotImplementedError + + @abstractmethod + def describe(self, obj: str) -> EnrichmentInfo: + raise NotImplementedError + + +@lru_cache() +def find_enrichments() -> List[Enrichment]: + eps = entrypoints.get_group_named(ENRICHMENT_ENRTYPOINT) + enrichments = [ep.load() for _, ep in eps.items()] + enrichments = [e() if isinstance(e, type) and issubclass(e, Enrichment) else e for e in enrichments] + return [e for e in enrichments if isinstance(e, Enrichment)] diff --git a/gto/ext_mlem.py b/gto/ext_mlem.py new file mode 100644 index 00000000..b8a60be8 --- /dev/null +++ b/gto/ext_mlem.py @@ -0,0 +1,36 @@ +"""This is temporary file that should be moved to mlem.gto module""" +from mlem.core.errors import MlemObjectNotFound +from mlem.core.metadata import load_meta +from mlem.core.objects import MlemMeta, ModelMeta, DatasetMeta +from pydantic import BaseModel + +from gto.ext import Enrichment, EnrichmentInfo +import mlem + + +class MlemInfo(EnrichmentInfo): + meta: MlemMeta + + def get_object(self) -> BaseModel: + return self.meta + + def get_human_readable(self) -> str: + # TODO: create `.describe` method in MlemMeta https://github.com/iterative/mlem/issues/98 + description = f"""Mlem {self.meta.object_type}""" + if isinstance(self.meta, ModelMeta): + description += f": {self.meta.model_type.type}" + if isinstance(self.meta, DatasetMeta): + description += f": {self.meta.dataset.dataset_type.type}" + return description + + +class MlemEnrichment(Enrichment): + def is_enriched(self, obj: str) -> bool: + try: + mlem.api.load_meta(obj) + return True + except MlemObjectNotFound: + return False + + def describe(self, obj: str) -> MlemInfo: + return MlemInfo(source="mlem", meta=load_meta(obj)) diff --git a/gto/registry.py b/gto/registry.py index 33320fd9..b93a3e74 100644 --- a/gto/registry.py +++ b/gto/registry.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from gto.base import BaseManager, BaseObject, BaseRegistryState -from gto.config import CONFIG_FILE, RegistryConfig +from gto.config import CONFIG_FILE_NAME, RegistryConfig from gto.exceptions import ( NoActiveLabel, VersionAlreadyRegistered, @@ -32,7 +32,7 @@ def from_repo(cls, repo=Union[str, Repo], config=None): repo = git.Repo(repo) if config is None: config = RegistryConfig( - CONFIG_FILE=os.path.join(repo.working_dir, CONFIG_FILE) + CONFIG_FILE=os.path.join(repo.working_dir, CONFIG_FILE_NAME) ) return cls( diff --git a/setup.py b/setup.py index 1d0273b6..6868b1e5 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ include_package_data=True, entry_points={ "console_scripts": ["gto = gto.cli:cli"], + "gto.enrichment": ["mlem = gto.ext_mlem:MlemEnrichment"] }, cmdclass={"build_py": build_py}, zip_safe=False, diff --git a/tests/conftest.py b/tests/conftest.py index c2f6f625..4cc6fec4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest import gto -from gto.config import CONFIG_FILE +from gto.config import CONFIG_FILE_NAME @pytest.fixture @@ -24,7 +24,7 @@ def write_file(name, content): file.write(content) write_file( - CONFIG_FILE, + CONFIG_FILE_NAME, """ version_base: tag env_base: tag @@ -46,7 +46,7 @@ def write_file(name, content): file.write(content) write_file( - CONFIG_FILE, + CONFIG_FILE_NAME, """ version_base: tag env_base: tag From 0e59d46c1e340d91512e17dab21a39b61e0df451 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Fri, 11 Mar 2022 18:56:47 +0300 Subject: [PATCH 2/5] Draft interface for mlem integration --- gto/api.py | 2 +- gto/ext.py | 6 +++++- gto/ext_mlem.py | 4 ++-- setup.py | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/gto/api.py b/gto/api.py index 28ee6316..e7d4d330 100644 --- a/gto/api.py +++ b/gto/api.py @@ -182,4 +182,4 @@ def describe(name: str) -> List[EnrichmentInfo]: for enrichment in CONFIG.enrichments: if enrichment.is_enriched(name): res.append(enrichment.describe(name)) - return res \ No newline at end of file + return res diff --git a/gto/ext.py b/gto/ext.py index 7d1674f3..6e164c35 100644 --- a/gto/ext.py +++ b/gto/ext.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from functools import lru_cache from typing import List + import entrypoints from pydantic import BaseModel @@ -36,5 +37,8 @@ def describe(self, obj: str) -> EnrichmentInfo: def find_enrichments() -> List[Enrichment]: eps = entrypoints.get_group_named(ENRICHMENT_ENRTYPOINT) enrichments = [ep.load() for _, ep in eps.items()] - enrichments = [e() if isinstance(e, type) and issubclass(e, Enrichment) else e for e in enrichments] + enrichments = [ + e() if isinstance(e, type) and issubclass(e, Enrichment) else e + for e in enrichments + ] return [e for e in enrichments if isinstance(e, Enrichment)] diff --git a/gto/ext_mlem.py b/gto/ext_mlem.py index b8a60be8..93e30720 100644 --- a/gto/ext_mlem.py +++ b/gto/ext_mlem.py @@ -1,11 +1,11 @@ """This is temporary file that should be moved to mlem.gto module""" +import mlem from mlem.core.errors import MlemObjectNotFound from mlem.core.metadata import load_meta -from mlem.core.objects import MlemMeta, ModelMeta, DatasetMeta +from mlem.core.objects import DatasetMeta, MlemMeta, ModelMeta from pydantic import BaseModel from gto.ext import Enrichment, EnrichmentInfo -import mlem class MlemInfo(EnrichmentInfo): diff --git a/setup.py b/setup.py index 6868b1e5..cdcb1d22 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ "pydantic", "ruamel.yaml", "semver==3.0.0-dev.3", + "entrypoints", ] @@ -54,7 +55,7 @@ include_package_data=True, entry_points={ "console_scripts": ["gto = gto.cli:cli"], - "gto.enrichment": ["mlem = gto.ext_mlem:MlemEnrichment"] + "gto.enrichment": ["mlem = gto.ext_mlem:MlemEnrichment"], }, cmdclass={"build_py": build_py}, zip_safe=False, From f8d455b24c0552a19a37b1f0574a0cb63dd7e749 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Mon, 14 Mar 2022 20:32:29 +0300 Subject: [PATCH 3/5] no double reading --- gto/api.py | 8 +++++--- gto/base.py | 25 +++++++++++++++++++++++-- gto/ext.py | 8 ++------ gto/ext_mlem.py | 13 +++++-------- gto/tag.py | 2 +- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/gto/api.py b/gto/api.py index e7d4d330..02f21840 100644 --- a/gto/api.py +++ b/gto/api.py @@ -84,7 +84,8 @@ def find_active_label(repo: Union[str, Repo], name: str, label: str): def check_ref(repo: Union[str, Repo], ref: str): """Find out what have been registered/promoted in the provided ref""" reg = GitRegistry.from_repo(repo) - ref = ref.removeprefix("refs/tags/") + if ref.startswith("refs/tags/"): + ref = ref[len("refs/tags/") :] if ref.startswith("refs/heads/"): ref = reg.repo.commit(ref).hexsha result = reg.check_ref(ref) @@ -180,6 +181,7 @@ def audit_promotion(repo: Union[str, Repo], dataframe: bool = False): def describe(name: str) -> List[EnrichmentInfo]: res = [] for enrichment in CONFIG.enrichments: - if enrichment.is_enriched(name): - res.append(enrichment.describe(name)) + enrichment_data = enrichment.describe(name) + if enrichment_data is not None: + res.append(enrichment_data) return res diff --git a/gto/base.py b/gto/base.py index 425c4ec4..73141790 100644 --- a/gto/base.py +++ b/gto/base.py @@ -1,8 +1,9 @@ from datetime import datetime -from typing import Dict, FrozenSet, List, Optional +from typing import Dict, FrozenSet, List, Optional, overload import git from pydantic import BaseModel +from typing_extensions import Literal from gto.constants import Action from gto.index import ObjectCommits @@ -84,11 +85,31 @@ def latest_labels(self) -> Dict[str, BaseLabel]: labels[label.name] = label return labels + @overload def find_version( self, name: str = None, commit_hexsha: str = None, - raise_if_not_found=False, + raise_if_not_found: Literal[True] = ..., + skip_unregistered=True, + ) -> BaseVersion: + ... + + @overload + def find_version( + self, + name: str = None, + commit_hexsha: str = None, + raise_if_not_found: Literal[False] = ..., + skip_unregistered=True, + ) -> Optional[BaseVersion]: + ... + + def find_version( + self, + name: str = None, + commit_hexsha: str = None, + raise_if_not_found: bool = False, skip_unregistered=True, ) -> Optional[BaseVersion]: versions = [ diff --git a/gto/ext.py b/gto/ext.py index 6e164c35..0b366c7f 100644 --- a/gto/ext.py +++ b/gto/ext.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from functools import lru_cache -from typing import List +from typing import List, Optional import entrypoints from pydantic import BaseModel @@ -25,11 +25,7 @@ def get_human_readable(self) -> str: class Enrichment(BaseModel, ABC): @abstractmethod - def is_enriched(self, obj: str) -> bool: - raise NotImplementedError - - @abstractmethod - def describe(self, obj: str) -> EnrichmentInfo: + def describe(self, obj: str) -> Optional[EnrichmentInfo]: raise NotImplementedError diff --git a/gto/ext_mlem.py b/gto/ext_mlem.py index 93e30720..17403825 100644 --- a/gto/ext_mlem.py +++ b/gto/ext_mlem.py @@ -1,5 +1,6 @@ """This is temporary file that should be moved to mlem.gto module""" -import mlem +from typing import Optional + from mlem.core.errors import MlemObjectNotFound from mlem.core.metadata import load_meta from mlem.core.objects import DatasetMeta, MlemMeta, ModelMeta @@ -25,12 +26,8 @@ def get_human_readable(self) -> str: class MlemEnrichment(Enrichment): - def is_enriched(self, obj: str) -> bool: + def describe(self, obj: str) -> Optional[MlemInfo]: try: - mlem.api.load_meta(obj) - return True + return MlemInfo(source="mlem", meta=load_meta(obj)) except MlemObjectNotFound: - return False - - def describe(self, obj: str) -> MlemInfo: - return MlemInfo(source="mlem", meta=load_meta(obj)) + return None diff --git a/gto/tag.py b/gto/tag.py index cd9c8140..766ad874 100644 --- a/gto/tag.py +++ b/gto/tag.py @@ -147,7 +147,7 @@ def label_from_tag(tag: git.Tag, obj: BaseObject) -> BaseLabel: object=mtag.name, version=obj.find_version( commit_hexsha=tag.commit.hexsha, raise_if_not_found=True - ).name, # type: ignore + ).name, name=mtag.label, creation_date=mtag.creation_date, author=tag.tag.tagger.name, From 5061ad68f893ad5fecbae858eac208ffd0c60bc8 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Tue, 15 Mar 2022 19:55:10 +0300 Subject: [PATCH 4/5] dvc ext (kinda) --- gto/ext_dvc.py | 29 +++++++++++++++++++++++++++++ gto/ext_mlem.py | 3 ++- setup.py | 5 ++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 gto/ext_dvc.py diff --git a/gto/ext_dvc.py b/gto/ext_dvc.py new file mode 100644 index 00000000..89cf0fef --- /dev/null +++ b/gto/ext_dvc.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel +from ruamel.yaml import safe_load + +from gto.ext import Enrichment, EnrichmentInfo + + +class DVCEnrichmentInfo(EnrichmentInfo): + source = "dvc" + size: int + hash: str + + def get_object(self) -> BaseModel: + return self + + def get_human_readable(self) -> str: + return f"""DVC-tracked [{self.size} bytes]""" + + +class DVCEnrichment(Enrichment): + def describe(self, obj: str) -> Optional[DVCEnrichmentInfo]: + try: + with open(obj + ".dvc", encoding="utf8") as f: + dvc_data = safe_load(f) + data = dvc_data["outs"][0] + return DVCEnrichmentInfo(size=data["size"], hash=data["md5"]) + except FileNotFoundError: + return None diff --git a/gto/ext_mlem.py b/gto/ext_mlem.py index 17403825..b0f1e7fb 100644 --- a/gto/ext_mlem.py +++ b/gto/ext_mlem.py @@ -10,6 +10,7 @@ class MlemInfo(EnrichmentInfo): + source = "mlem" meta: MlemMeta def get_object(self) -> BaseModel: @@ -28,6 +29,6 @@ def get_human_readable(self) -> str: class MlemEnrichment(Enrichment): def describe(self, obj: str) -> Optional[MlemInfo]: try: - return MlemInfo(source="mlem", meta=load_meta(obj)) + return MlemInfo(meta=load_meta(obj)) except MlemObjectNotFound: return None diff --git a/setup.py b/setup.py index cdcb1d22..93a6f711 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,10 @@ include_package_data=True, entry_points={ "console_scripts": ["gto = gto.cli:cli"], - "gto.enrichment": ["mlem = gto.ext_mlem:MlemEnrichment"], + "gto.enrichment": [ + "mlem = gto.ext_mlem:MlemEnrichment", + "dvc = gto.ext_dvc:DVCEnrichment", + ], }, cmdclass={"build_py": build_py}, zip_safe=False, From 6c6f227e88be3bca41b3f3f84dc0eb2541651db3 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Wed, 16 Mar 2022 21:45:23 +0300 Subject: [PATCH 5/5] cli enrichment prototype --- gto/config.py | 19 ++++++++---- gto/ext.py | 80 +++++++++++++++++++++++++++++++++++++++++++++------ setup.py | 1 + 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/gto/config.py b/gto/config.py index 84e83650..31825811 100644 --- a/gto/config.py +++ b/gto/config.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from pydantic import BaseSettings, validator +from pydantic import BaseModel, BaseSettings, validator from pydantic.env_settings import InitSettingsSource from ruamel.yaml import YAML @@ -10,7 +10,7 @@ from .constants import BRANCH, COMMIT, TAG from .exceptions import UnknownEnvironment -from .ext import Enrichment, find_enrichments +from .ext import Enrichment, find_enrichment_types, find_enrichments yaml = YAML(typ="safe", pure=True) yaml.default_flow_style = False @@ -43,6 +43,14 @@ def config_settings_source(settings: "RegistryConfig") -> Dict[str, Any]: return {k.upper(): v for k, v in conf.items()} if conf else {} +class EnrichmentConfig(BaseModel): + type: str + config: Dict = {} + + def load(self) -> Enrichment: + return find_enrichment_types()[self.type](**self.config) + + class RegistryConfig(BaseSettings): INDEX: str = "artifacts.yaml" VERSION_BASE: str = TAG @@ -53,7 +61,7 @@ class RegistryConfig(BaseSettings): ENV_BRANCH_MAPPING: Dict[str, str] = {} LOG_LEVEL: str = "INFO" DEBUG: bool = False - ENRICHMENTS: List[Enrichment] = [] + ENRICHMENTS: List[EnrichmentConfig] = [] AUTOLOAD_ENRICHMENTS: bool = True CONFIG_FILE: Optional[str] = CONFIG_FILE_NAME @@ -79,9 +87,10 @@ def ENV_MANAGERS_MAPPING(self): @property def enrichments(self) -> List[Enrichment]: + res = [e.load() for e in self.ENRICHMENTS] if self.AUTOLOAD_ENRICHMENTS: - return find_enrichments() + self.ENRICHMENTS - return self.ENRICHMENTS + return find_enrichments() + res + return res def assert_env(self, name): if not self.check_env(name): diff --git a/gto/ext.py b/gto/ext.py index 0b366c7f..b75be393 100644 --- a/gto/ext.py +++ b/gto/ext.py @@ -1,9 +1,12 @@ +import subprocess from abc import ABC, abstractmethod from functools import lru_cache -from typing import List, Optional +from json import loads +from typing import Dict, List, Optional, Type, Union import entrypoints -from pydantic import BaseModel +from mlem.utils.importing import import_string +from pydantic import BaseModel, parse_obj_as, validator ENRICHMENT_ENRTYPOINT = "gto.enrichment" @@ -29,12 +32,71 @@ def describe(self, obj: str) -> Optional[EnrichmentInfo]: raise NotImplementedError +class CLIEnrichmentInfo(EnrichmentInfo): + data: Dict + repr: str + + def get_object(self) -> BaseModel: + return self + + def get_human_readable(self) -> str: + return self.repr + + +class CLIEnrichment(Enrichment): + cmd: str + info_type: Union[str, Type[EnrichmentInfo]] = CLIEnrichmentInfo + + @validator("info_type") + def info_class_validator( + cls, value + ): # pylint: disable=no-self-argument,no-self-use # noqa: B902 + if isinstance(value, type): + return value + info_class = import_string(value) + if not isinstance(info_class, type) or not issubclass( + info_class, EnrichmentInfo + ): + raise ValueError( + "Wrong value for info_type: should be class or string path to class (e.g. `package.module.ClassName`)" + ) + return info_class + + @property + def info_class(self) -> Type[EnrichmentInfo]: + return self.info_class_validator(self.info_type) + + def describe(self, obj: str) -> Optional[EnrichmentInfo]: + try: + data = loads(subprocess.check_output(self.cmd.split() + [obj])) + return parse_obj_as(self.info_class, data) + except subprocess.SubprocessError: + return None + + @lru_cache() -def find_enrichments() -> List[Enrichment]: +def _find_enrichments(): eps = entrypoints.get_group_named(ENRICHMENT_ENRTYPOINT) - enrichments = [ep.load() for _, ep in eps.items()] - enrichments = [ - e() if isinstance(e, type) and issubclass(e, Enrichment) else e - for e in enrichments - ] - return [e for e in enrichments if isinstance(e, Enrichment)] + return {k: ep.load() for k, ep in eps.items()} + + +@lru_cache() +def find_enrichments() -> List[Enrichment]: + enrichments = _find_enrichments() + res = [] + for e in enrichments: + if isinstance(e, type) and issubclass(e, Enrichment) and not e.__fields_set__: + res.append(e()) + if isinstance(e, Enrichment): + res.append(e) + return res + + +@lru_cache() +def find_enrichment_types() -> Dict[str, Type[Enrichment]]: + enrichments = _find_enrichments() + return { + k: e + for k, e in enrichments.items() + if isinstance(e, type) and issubclass(e, Enrichment) + } diff --git a/setup.py b/setup.py index 93a6f711..9243f6ca 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ "gto.enrichment": [ "mlem = gto.ext_mlem:MlemEnrichment", "dvc = gto.ext_dvc:DVCEnrichment", + "cli = gto.ext:CLIEnrichment", ], }, cmdclass={"build_py": build_py},