mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
* add support for tracking remote dataset This adds support for virtually tracking a dataset such as dvcx dataset, dataset from remote dvc/git registries, and cloud-versioned remotes. This PR introduces two different commands under `ds` namespace: `add` and `update`. `dvc ds add` command adds the dataset to `dvc.yaml` and tracks the sources in `dvc.lock` file. Similarly, `dvc ds update` updates the sources in `dvc.lock` file. Example Usage: ```console dvc ds add --name dogs --url dvcx://dogs # tracking dvcx dataset # tracking dvc/git registries dvc ds add --name example --url dvc://[email protected]/iterative/example.git # cloud-versioned-remotes dvc ds add --name versioning --url s3://cloud-versioning-demo ``` To update, specify the name. ```console dvc ds update <name> ``` `dvc ds add` freezes (in the traditional sense of `dvc import/import-url`). It keeps a "specification" of the dataset in `dvc.yaml` and also freezes information about the dataset in the `dvc.lock` file. They are kept inside `datasets` section in both `dvc.yaml` and `dvc.lock` files. This metadata is used in the pipelines. You can add a dependency to your stage using `ds://` scheme, followed by the name of the dataset in `dvc.yaml` file. As it is used in pipelines, the `name` of the dataset has to be unique in the repository. Different dataset of same names are not allowed. On `dvc repro`, `dvc` copies the frozen information about the particular dataset into the `deps` field for the stage in `dvc.lock`. On successive invocation, `dvc` will compare the information from the deps field of the lock with the frozen information in the `datasets` section and decides whether to rerun or not. As the dataset is frozen, `dvc repro` won't rerun until the dataset is updated via `dvc update`. Here are some examples for how `dvc.yaml` looks like: ```yaml datasets: - name: dogs url: dvcx://dogs type: dvcx - name: example-get-started url: [email protected]:iterative/example-get-started.git type: dvc path: path - name: dogs2 url: dvcx://dogs@v2 type: dvcx - name: cloud-versioning-demo url: s3://cloud-versioning-demo type: url ``` ```yaml schema: '2.0' datasets: - name: dogs url: dvcx://dogs type: dvcx version: 3 created_at: '2023-12-11T10:32:05.942708+00:00' - name: example-get-started url: [email protected]:iterative/example-get-started.git type: dvc path: path rev_lock: df75c16ef61f0772d6e4bb27ba4617b06b4b5398 - name: cloud-versioning-demo url: s3://cloud-versioning-demo type: url meta: isdir: true size: 323919 nfiles: 33 files: - relpath: myproject/model.pt meta: size: 106433 version_id: 5qrtnhnQ4fBzV73kqqK6pMGhTOzd_IPr etag: 3bc0028677ce6fb65bec8090c248b002 # truncated ``` The pipeline stages keep them in `dataset` section of individual deps. ```console dvc stage add -n train -d ds://dogs python train.py cat dvc.yaml ``` ```yaml # truncated stages: train: cmd: python train.py deps: - ds://dogs ``` When it is reproduced, the `dvc.lock` will look something like follows: ```yaml # truncated stages: train: cmd: python train.py deps: - path: ds://dogs dataset: name: dogs url: dvcx://dogs type: dvcx version: 3 created_at: '2023-12-11T10:32:05.942708+00:00' ``` * remodeling * remove non-cloud-versioned remotes * improve deserializing; handle invalidation
135 lines
4.0 KiB
Python
135 lines
4.0 KiB
Python
import os
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from tempfile import NamedTemporaryFile
|
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
|
|
|
|
from sqlalchemy import create_engine # type: ignore[import]
|
|
from sqlalchemy.engine import make_url as _make_url # type: ignore[import]
|
|
from sqlalchemy.exc import NoSuchModuleError # type: ignore[import]
|
|
|
|
from dvc import env
|
|
from dvc.exceptions import DvcException
|
|
from dvc.log import logger
|
|
from dvc.types import StrOrBytesPath
|
|
from dvc.utils import env2bool
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.engine import URL, Connectable, Engine
|
|
from sqlalchemy.sql.expression import Selectable # type: ignore[import]
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
def noop(_):
|
|
pass
|
|
|
|
|
|
def make_url(url: Union["URL", str], **kwargs: Any) -> "URL":
|
|
return _make_url(url).set(**kwargs)
|
|
|
|
|
|
def url_from_config(config: Union[str, "URL", dict[str, str]]) -> "URL":
|
|
if isinstance(config, dict):
|
|
return make_url(**config)
|
|
return make_url(config)
|
|
|
|
|
|
@contextmanager
|
|
def atomic_file(file: StrOrBytesPath, mode: str = "w+b"):
|
|
head, tail = os.path.split(os.fsdecode(file))
|
|
with NamedTemporaryFile(mode, prefix=tail + "-", dir=head, delete=False) as f:
|
|
yield f
|
|
os.replace(f.name, file)
|
|
|
|
|
|
@dataclass
|
|
class Serializer:
|
|
sql: "Union[str, Selectable]"
|
|
con: "Union[str, Connectable]"
|
|
chunksize: int = 10_000
|
|
|
|
def to_csv(self, file: StrOrBytesPath, progress=noop):
|
|
import pandas as pd
|
|
|
|
idfs = pd.read_sql(self.sql, self.con, chunksize=self.chunksize)
|
|
with atomic_file(file) as f:
|
|
for i, df in enumerate(idfs):
|
|
df.to_csv(f, header=i == 0, index=False)
|
|
progress(len(df))
|
|
|
|
def to_json(self, file: StrOrBytesPath, progress=noop): # noqa: ARG002
|
|
import pandas as pd
|
|
|
|
df = pd.read_sql(self.sql, self.con)
|
|
with atomic_file(file) as f:
|
|
df.to_json(f, orient="records")
|
|
|
|
def export(self, file: StrOrBytesPath, format: str = "csv", progress=noop): # noqa: A002
|
|
if format == "json":
|
|
return self.to_json(file, progress=progress)
|
|
return self.to_csv(file, progress=progress)
|
|
|
|
|
|
@dataclass
|
|
class Client:
|
|
engine: "Engine"
|
|
|
|
def test_connection(self, onerror: Optional[Callable[[], Any]] = None) -> None:
|
|
try:
|
|
with self.engine.connect() as conn:
|
|
conn.exec_driver_sql("select 1")
|
|
except Exception as exc:
|
|
if callable(onerror):
|
|
onerror()
|
|
logger.exception(
|
|
"Could not connect to the database. "
|
|
"Check your database credentials and try again.",
|
|
exc_info=False,
|
|
)
|
|
raise DvcException("The database returned the following error") from exc
|
|
|
|
def export(
|
|
self,
|
|
sql: "Union[str, Selectable]",
|
|
file: StrOrBytesPath,
|
|
format: str = "csv", # noqa: A002
|
|
progress=noop,
|
|
) -> None:
|
|
con = self.engine.connect()
|
|
if format == "csv":
|
|
con = con.execution_options(stream_results=True) # use server-side cursors
|
|
|
|
with con:
|
|
serializer = Serializer(sql, con)
|
|
return serializer.export(file, format=format, progress=progress)
|
|
|
|
|
|
@contextmanager
|
|
def handle_error(url: "URL"):
|
|
try:
|
|
yield
|
|
except (ModuleNotFoundError, NoSuchModuleError) as e:
|
|
# TODO: write installation instructions
|
|
driver = url.drivername
|
|
raise DvcException(f"Could not load database driver for {driver!r}") from e
|
|
|
|
|
|
@contextmanager
|
|
def client(
|
|
url_or_config: Union[str, "URL", dict[str, str]], **engine_kwargs: Any
|
|
) -> Iterator[Client]:
|
|
url = url_from_config(url_or_config)
|
|
echo = env2bool(env.DVC_SQLALCHEMY_ECHO, False)
|
|
engine_kwargs.setdefault("echo", echo)
|
|
|
|
with handle_error(url):
|
|
engine = create_engine(url, **engine_kwargs)
|
|
|
|
try:
|
|
yield Client(engine)
|
|
finally:
|
|
engine.dispose()
|