diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55b6cf1044e546916263274b79b0785a86b9abcb..713e7a30ec2f530d7a3fb96cc2617e37a8449947 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,6 +60,8 @@ repos: - psycopg2==2.9.1 - sqlalchemy2-stubs==0.0.2a4 - types-requests==2.25.0 + - types-tabulate==0.8.0 + - types-termcolor==1.1.0 exclude: ^migrations/ #- repo: https://github.com/pre-commit/mirrors-pylint diff --git a/esap_client/clients/db_client.py b/esap_client/clients/db_client.py index 85219c311ddaa01e45b20fcb888ead52c1fbc25d..efda4a9dbff47686b0dbb2a511a726d54b92f076 100644 --- a/esap_client/clients/db_client.py +++ b/esap_client/clients/db_client.py @@ -1,6 +1,6 @@ """This module provides a client to ESAP-DB.""" from ..config import settings -from ..helpers import raise_for_status +from ..helpers import print_table, raise_for_status from ..models import Project, ResourceCollection from ..sessions import BasedSession @@ -34,3 +34,8 @@ def create_project( response = self.session.post('/projects', project) raise_for_status(response) return Project.deserialize(response.json()) + + def describe(self) -> None: + """Prints the projects available to the user through the client.""" + projects = ', '.join(_.name.split('.')[-1] for _ in self.projects) or '[]' + print_table(projects=projects) diff --git a/esap_client/helpers.py b/esap_client/helpers.py index 18f5d385ae6e452dcb74ffcf21e72f8a8ab50113..57d3aeba715b7ed5abdd8922b6c2d77b70d4d3ae 100644 --- a/esap_client/helpers.py +++ b/esap_client/helpers.py @@ -2,6 +2,8 @@ from typing import Type from requests import Response +from tabulate import tabulate +from termcolor import colored from .exceptions import ESAPClientError, ESAPError, ESAPServerError @@ -20,3 +22,9 @@ def raise_for_status(response: Response) -> None: if isinstance(error, dict) and 'detail' in error: error = error['detail'] raise cls(f'HTTP Error {response.status_code}: {error}') + + +def print_table(**keywords: str) -> None: + """Prints a colorful table.""" + table = [(colored(k.capitalize(), 'red'), v) for k, v in keywords.items()] + print(tabulate(table, tablefmt='plain')) diff --git a/esap_client/models/datasets.py b/esap_client/models/datasets.py index bb50e45da604bf58846ac959263dad1bcd97108a..8637bfcab6c8a4eea4bdb04dd9fda7389620bf59 100644 --- a/esap_client/models/datasets.py +++ b/esap_client/models/datasets.py @@ -7,7 +7,7 @@ import pandas as pd -from ..helpers import raise_for_status +from ..helpers import print_table, raise_for_status from ..sessions import BasedSession from .collections import ResourceCollection from .tables import Table @@ -52,7 +52,11 @@ def tables(self) -> ResourceCollection[Table]: ) def create_table_from( - self, source: Union[str, pd.DataFrame], name: Optional[str] = None, description: str = '', **keywords + self, + source: Union[str, pd.DataFrame], + name: Optional[str] = None, + description: str = '', + **keywords: Any, ) -> Table: """Creates a table from a Pandas DataFrame.""" if not isinstance(source, (str, pd.DataFrame)): @@ -119,3 +123,13 @@ def create_table_from_esap_gateway_query( response = session.post('/esap-gateway-operations', payload) raise_for_status(response) return Table.deserialize(response.json()) + + def describe(self) -> None: + """Prints information associated with the dataset.""" + infos = { + 'name': self.name, + 'description': self.description, + } + tables = ', '.join(_.name.split('.')[-1] for _ in self.tables) + infos['tables'] = tables or '[]' + print_table(**infos) diff --git a/esap_client/models/projects.py b/esap_client/models/projects.py index 219ad989705acd5ca1b0ce6bf3310371cdbfe75f..280910bc47450f37c930476cdc7b988b93e23b1e 100644 --- a/esap_client/models/projects.py +++ b/esap_client/models/projects.py @@ -4,7 +4,7 @@ from typing import Any from ..config import settings -from ..helpers import raise_for_status +from ..helpers import print_table, raise_for_status from ..sessions import BasedSession from .collections import ResourceCollection from .datasets import Dataset @@ -13,12 +13,13 @@ class Project: """The project defines a scope, in which are stored collections of database tables.""" # noqa - def __init__(self, name: str, description: str, max_size: int) -> None: + def __init__(self, name: str, description: str, type: str, max_size: int) -> None: """The `Project` constructor.""" if '.' in name: raise ValueError("Project names cannot contain the character '.'") self.name = name self.description = description + self.type = type self.max_size = max_size self.session = BasedSession(f'/projects/{name}') @@ -46,8 +47,9 @@ def deserialize(cls, project: dict) -> Project: """Deserializes a dict-like project.""" name = project['name'] description = project['description'] + type = project['type'] max_size = project.get('max_size', settings.DEFAULT_PROJECT_MAX_SIZE) - return Project(name, description, max_size) + return Project(name, description, type, max_size) def create_dataset(self, name: str, description: str = '') -> Dataset: """Creates a dataset inside this project.""" @@ -63,3 +65,16 @@ def create_dataset(self, name: str, description: str = '') -> Dataset: def datasets(self) -> ResourceCollection[Dataset]: """The member datasets of this project.""" return ResourceCollection[Dataset](f'/projects/{self.name}/datasets', Dataset) + + def describe(self) -> None: + """Prints information associated with the project.""" + infos = { + 'name': self.name, + 'description': self.description, + 'type': self.type, + } + if self.type == 'user': + infos['max size'] = f'{self.max_size / 2 ** 30} GiB' + datasets = ', '.join(_.name.split('.')[-1] for _ in self.datasets) + infos['datasets'] = datasets or '[]' + print_table(**infos) diff --git a/esap_client/models/tables.py b/esap_client/models/tables.py index d3a82e0d263eebd0a150349f92b5a28357cd023e..6a8b6c9048475ea4ee4a7e3ad7abf5096d85fbea 100644 --- a/esap_client/models/tables.py +++ b/esap_client/models/tables.py @@ -5,6 +5,7 @@ import pandas as pd +from ..helpers import print_table, raise_for_status from ..sessions import BasedSession @@ -26,6 +27,12 @@ def __eq__(self, other: Any) -> bool: return NotImplemented return self.name == other.name and self.description == other.description + def __len__(self) -> int: + """Returns the number of entries in the table.""" + response = self.session.post(':getLength') + raise_for_status(response) + return response.json() + def __repr__(self) -> str: """The string representation of a `Table`.""" return f'<Table {self.name}>' @@ -33,13 +40,14 @@ def __repr__(self) -> str: def delete(self) -> None: """Deletes this table.""" response = self.session.delete('') - response.raise_for_status() + raise_for_status(response) @classmethod def deserialize(cls, table: dict) -> Table: """Deserializes a dict-like table.""" return Table(table['name'], table['description']) + @property def column_names(self) -> List[str]: """Returns the names of the table columns.""" return self.session.get('/column-names').json() @@ -51,3 +59,16 @@ def _content(self) -> list[dict]: def aspandas(self) -> pd.DataFrame: """Returns the content of the table as a Pandas `DataFrame`.""" return pd.read_json(f'{self.session.base_url}/content') + + def describe(self) -> None: + """Prints information associated with the table.""" + nrow = len(self) + row_str = 'row' if nrow < 2 else 'rows' + ncol = len(self.column_names) + col_str = 'column' if ncol < 2 else 'columns' + infos = { + 'name': self.name, + 'description': self.description, + 'shape': f'{nrow} {row_str} x {ncol} {col_str}', + } + print_table(**infos) diff --git a/poetry.lock b/poetry.lock index 1c90e6072a66afab7d657cb803f38b8b57e75236..8b0e23a3f4a29860faaffba659d907d15d2ee850 100644 --- a/poetry.lock +++ b/poetry.lock @@ -578,6 +578,25 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "tabulate" +version = "0.8.9" +description = "Pretty-print tabular data" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "toml" version = "0.10.2" @@ -692,7 +711,7 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "54b6ee91552dc096f0511b64ba5c9c4571a9905b82dcb6af7c93ea5e3078bc79" +content-hash = "c5a6013ec5017853114ad3f12bdcfd8b5c732ce206d08431a7886ac39372b42e" [metadata.files] anyio = [ @@ -1039,6 +1058,13 @@ sortedcontainers = [ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] +tabulate = [ + {file = "tabulate-0.8.9-py3-none-any.whl", hash = "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4"}, + {file = "tabulate-0.8.9.tar.gz", hash = "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7"}, +] +termcolor = [ + {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, diff --git a/pyproject.toml b/pyproject.toml index 031ff25e43d9d55a72dc0c298bca1f5ad24da796..09179311d39385439433d3ba8ad0890cad8c3937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ asks = "^2.4.12" requests = "^2.25.1" pandas = "^1.2.5" pydantic = "^1.8.2" +tabulate = "^0.8.9" +termcolor = "^1.1.0" [tool.poetry.dev-dependencies] pytest = "^6.2.4" @@ -28,7 +30,7 @@ skip-string-normalization = true [tool.isort] profile = "black" -known_third_party = ["pandas", "pydantic", "pytest", "requests"] +known_third_party = ["pandas", "pydantic", "pytest", "requests", "tabulate", "termcolor"] [tool.mypy] python_version = "3.9"