Add GridFS integration to large_file

This commit is contained in:
Andras Schmelczer 2022-06-03 14:15:01 +02:00
parent a4e16d5739
commit a0d3a9cb2a
8 changed files with 187 additions and 42 deletions

View file

@ -1,3 +1,3 @@
from .great_ai import * from .great_ai import *
from .open_s3 import * from .large_file import *
from .utilities import * from .utilities import *

View file

@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from argparse import Namespace
from pathlib import Path from pathlib import Path
from typing import Mapping, Type
from great_ai.utilities.logger import get_logger from great_ai.utilities.logger import get_logger
from .large_file import LargeFileLocal, LargeFileS3 from .large_file import LargeFile, LargeFileLocal, LargeFileMongo, LargeFileS3
from .parse_arguments import parse_arguments from .parse_arguments import parse_arguments
logger = get_logger("large_file") logger = get_logger("large_file")
@ -13,21 +15,7 @@ logger = get_logger("large_file")
def main() -> None: def main() -> None:
parser, args = parse_arguments() parser, args = parse_arguments()
factory = {"s3": LargeFileS3, "local": LargeFileLocal} large_file = get_class(args)
if args.driver not in factory:
raise ValueError(
f"Driver {args.driver} does not exits, available options: {' ,'.join(factory.keys())}"
)
large_file = factory[args.driver]
if args.driver != "local":
if args.secrets is None:
raise ValueError(
"Providing a credentials file is required when the driver mode is not `local`."
)
large_file.configure_credentials_from_file(args.secrets) # type: ignore
if not args.cache and not args.push and not args.delete: if not args.cache and not args.push and not args.delete:
logger.warning("No action required.") logger.warning("No action required.")
@ -50,6 +38,30 @@ def main() -> None:
large_file(f).delete() large_file(f).delete()
def get_class(args: Namespace) -> Type[LargeFile]:
factory: Mapping[str, Type[LargeFile]] = {
"s3": LargeFileS3,
"local": LargeFileLocal,
"mongodb": LargeFileMongo,
}
if args.backend not in factory:
raise ValueError(
f"Backend {args.backend} does not exits, available options: {' ,'.join(factory.keys())}"
)
large_file = factory[args.backend]
if args.backend != "local":
if args.secrets is None:
raise ValueError(
"Providing a credentials file is required when the backend mode is not `local`."
)
large_file.configure_credentials_from_file(args.secrets)
return large_file
if __name__ == "__main__": if __name__ == "__main__":
try: try:
main() main()
@ -57,4 +69,4 @@ if __name__ == "__main__":
logger.warning("Exiting") logger.warning("Exiting")
exit() exit()
except Exception as e: except Exception as e:
logger.error(e) logger.exception(e)

View file

@ -1,3 +1,4 @@
from .large_file import LargeFile from .large_file import LargeFile
from .large_file_local import LargeFileLocal from .large_file_local import LargeFileLocal
from .large_file_mongo import LargeFileMongo
from .large_file_s3 import LargeFileS3 from .large_file_s3 import LargeFileS3

View file

@ -1,7 +1,8 @@
import configparser
import os import os
import shutil import shutil
import tempfile import tempfile
from abc import ABC, abstractmethod from abc import ABC, abstractclassmethod, abstractmethod
from pathlib import Path from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import IO, Any, List, Optional, Type, Union, cast from typing import IO, Any, List, Optional, Type, Union, cast
@ -79,6 +80,28 @@ class LargeFile(ABC):
self._find_instances() self._find_instances()
self._check_mode_and_set_version() self._check_mode_and_set_version()
@classmethod
def configure_credentials_from_file(
cls,
secrets_path: Union[Path, str],
) -> None:
if isinstance(secrets_path, str):
secrets_path = Path(secrets_path)
if not secrets_path.exists():
raise FileNotFoundError(secrets_path.resolve())
credentials = configparser.ConfigParser()
credentials.read(secrets_path)
credentials.default_section
cls.configure_credentials(**credentials[credentials.default_section])
@abstractclassmethod
def configure_credentials(
cls,
) -> None:
pass
def __enter__(self) -> IO: def __enter__(self) -> IO:
self._file: IO[Any] = ( self._file: IO[Any] = (
tempfile.NamedTemporaryFile( tempfile.NamedTemporaryFile(
@ -168,6 +191,7 @@ class LargeFile(ABC):
try: try:
# Make local copy in the cache # Make local copy in the cache
shutil.rmtree(self.cache_path / self._local_name, ignore_errors=True)
copy(str(path), str(self.cache_path / self._local_name)) copy(str(path), str(self.cache_path / self._local_name))
except shutil.SameFileError: except shutil.SameFileError:
pass # No worries pass # No worries

View file

@ -0,0 +1,121 @@
import re
from functools import cached_property
from pathlib import Path
from typing import Any, List, Mapping
from gridfs import DEFAULT_CHUNK_SIZE, Database, GridFSBucket
from pymongo import MongoClient
from great_ai.utilities.logger import get_logger
from ..helper import DownloadProgressBar, UploadProgressBar
from ..models import DataInstance
from .large_file import LargeFile
logger = get_logger("large_file")
MONGO_NAME_VERSION_SEPARATOR = "_"
class LargeFileMongo(LargeFile):
connection_string = None
database = None
@classmethod
def configure_credentials( # type: ignore
cls,
*,
connection_string: str,
database: str,
**_: Mapping[str, Any],
) -> None:
cls.connection_string = connection_string
cls.database = database
@cached_property
def _client(self) -> GridFSBucket:
if self.connection_string is None or self.database is None:
raise ValueError(
"Please configure the MongoDB access options by calling LargeFileMongo.configure_credentials or set offline_mode=True in the constructor."
)
db: Database = MongoClient(self.connection_string)[self.database]
return GridFSBucket(db)
def _find_remote_instances(self) -> List[DataInstance]:
logger.debug(f"Fetching Mongo (GridFS) versions of {self._name}")
return [
DataInstance(
name=MONGO_NAME_VERSION_SEPARATOR.join(
f.name.split(MONGO_NAME_VERSION_SEPARATOR)[:-1]
),
version=int(f.name.split(MONGO_NAME_VERSION_SEPARATOR)[-1]),
remote_path=(f._id, f.length),
origin="mongodb",
)
for f in self._client.find(
{
"filename": re.compile(
re.escape(self._name + MONGO_NAME_VERSION_SEPARATOR) + ".*"
)
}
)
]
def _download(
self, remote_path: Any, local_path: Path, hide_progress: bool
) -> None:
logger.info(f"Downloading {remote_path[0]} from Mongo (GridFS)")
progress = (
DownloadProgressBar(
name=str(remote_path[0]), size=remote_path[1], logger=logger
)
if not hide_progress
else None
)
with self._client.open_download_stream(remote_path[0]) as stream:
with open(local_path, "wb") as f:
while True:
content = stream.read(DEFAULT_CHUNK_SIZE)
f.write(content)
if progress:
progress(len(content))
if len(content) < DEFAULT_CHUNK_SIZE:
break
def _upload(self, local_path: Path, hide_progress: bool) -> None:
logger.info(f"Uploading {local_path} to Mongo (GridFS)")
progress = (
UploadProgressBar(path=local_path, logger=logger)
if not hide_progress
else None
)
with self._client.open_upload_stream(
f"{self._name}{MONGO_NAME_VERSION_SEPARATOR}{self.version}"
) as stream:
with open(local_path, "rb") as f:
while True:
content = f.read(DEFAULT_CHUNK_SIZE)
stream.write(content)
if progress:
progress(len(content))
if len(content) < DEFAULT_CHUNK_SIZE:
break
def _delete_old_remote_versions(self) -> None:
if self._keep_last_n is not None:
for i in (
self._instances[: -self._keep_last_n]
if self._keep_last_n > 0
else self._instances
):
logger.info(
f"Removing old version from MongoDB (GridFS) (keep_last_n={self._keep_last_n}): {i.name}{MONGO_NAME_VERSION_SEPARATOR}{i.version}"
)
self._client.delete(i.remote_path[0])

View file

@ -1,7 +1,6 @@
import configparser
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import Any, List, Mapping, Optional, Union from typing import Any, List, Mapping, Optional
import boto3 import boto3
@ -52,23 +51,7 @@ class LargeFileS3(LargeFile):
endpoint_url = None endpoint_url = None
@classmethod @classmethod
def configure_credentials_from_file( def configure_credentials( # type: ignore
cls,
secrets_path: Union[Path, str],
) -> None:
if isinstance(secrets_path, str):
secrets_path = Path(secrets_path)
if not secrets_path.exists():
raise FileNotFoundError(secrets_path.resolve())
credentials = configparser.ConfigParser()
credentials.read(secrets_path)
credentials.default_section
cls.configure_credentials(**credentials[credentials.default_section])
@classmethod
def configure_credentials(
cls, cls,
*, *,
aws_region_name: str, aws_region_name: str,
@ -93,7 +76,7 @@ class LargeFileS3(LargeFile):
or self.bucket_name is None or self.bucket_name is None
): ):
raise ValueError( raise ValueError(
"Please configure the S3 access options by calling LargeFile.configure_credentials or set offline_mode=True in the constructor." "Please configure the S3 access options by calling LargeFileS3.configure_credentials or set offline_mode=True in the constructor."
) )
return boto3.client( return boto3.client(
@ -136,7 +119,7 @@ class LargeFileS3(LargeFile):
self._client.download_file( self._client.download_file(
Bucket=self.bucket_name, Bucket=self.bucket_name,
Key=hide_progress, Key=remote_path,
Filename=str(local_path), Filename=str(local_path),
Callback=None Callback=None
if hide_progress if hide_progress

View file

@ -8,7 +8,8 @@ def parse_arguments() -> Tuple[ArgumentParser, Namespace]:
) )
parser.add_argument( parser.add_argument(
"--driver", "-b",
"--backend",
type=str, type=str,
help="choose which backend to use, available options: `local`, `s3`, `mongodb`", help="choose which backend to use, available options: `local`, `s3`, `mongodb`",
required=True, required=True,
@ -27,7 +28,7 @@ def parse_arguments() -> Tuple[ArgumentParser, Namespace]:
"--cache", "--cache",
nargs="+", nargs="+",
type=str, type=str,
help="download file into local cache, example: file_name:version", help="download file into local cache, example: file_name.txt:version",
required=False, required=False,
) )

3
mongo.ini Normal file
View file

@ -0,0 +1,3 @@
[DEFAULT]
connection_string=mongodb://localhost:27017/
database=large_file_tests