From 7e4ca468ade042aac15144ebec6ab24796fea460 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 30 Sep 2024 03:51:13 +0200 Subject: [PATCH] Merge branch 'dev' --- .gitignore | 1 + README.md | 45 ++++++++++++++++++++++- nocodb/Base.py | 15 ++++---- nocodb/Column.py | 92 ++++++++++++++++++++++++++++++++++++++++++++-- nocodb/Record.py | 39 +++++++++++++++++++- nocodb/Table.py | 33 ++++++++++------- nocodb/__init__.py | 27 ++++++++------ pyproject.toml | 4 +- 8 files changed, 217 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index ec0c21f..eb878dd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ testrun.py test_config.json* +build diff --git a/README.md b/README.md index bf527e2..78a4d66 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,20 @@ Python client for NocoDB API v2 +## Install + +Install from [pypi](https://pypi.org/project/nocodb-api/): + +```shell +pip install nocodb-api +``` + +Install from Github: + +```shell +pip install "nocodb-api@git+https://github.com/infeeeee/py-nocodb" +``` + ## Quickstart ```python @@ -17,6 +31,20 @@ table = base.get_table_by_title("Sample Views") [print(i, r.metadata) for i,r in enumerate(table.get_records())] ``` +Get debug log: + +```python +import logging +from nocodb import NocoDB + +logging.basicConfig() +logging.getLogger('nocodb').setLevel(logging.DEBUG) +# Now every log is visible. + +# Limit to submodules: +logging.getLogger('nocodb.Base').setLevel(logging.DEBUG) +``` + ## Development @@ -31,4 +59,19 @@ Create a file `test_config.json` with the parameters, or change the Environment ```shell docker run --rm -it $(docker build -q -f tests/Dockerfile .) -``` \ No newline at end of file +``` + +### Official docs + +- https://meta-apis-v2.nocodb.com +- https://data-apis-v2.nocodb.com +- https://docs.nocodb.com + +### Documentation with [pdoc](https://pdoc.dev) + +*TODO* + +```shell +pip install -e ".[doc]" +pdoc -d google nocodb +``` diff --git a/nocodb/Base.py b/nocodb/Base.py index 5ebe94d..0106855 100644 --- a/nocodb/Base.py +++ b/nocodb/Base.py @@ -8,8 +8,8 @@ from nocodb.Table import Table import logging -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) +_logger = logging.getLogger(__name__) +_logger.addHandler(logging.NullHandler()) class Base: @@ -33,14 +33,14 @@ def duplicate(self, "excludeData": exclude_data, "excludeViews": exclude_views, "excludeHooks": exclude_hooks}) - logger.debug(f"Base {self.title} duplicated") + _logger.info(f"Base {self.title} duplicated") return self.noco_db.get_base(base_id=r.json()["base_id"]) def delete(self) -> bool: r = self.noco_db.call_noco(path=f"meta/bases/{self.base_id}", method="DELETE") - logger.debug(f"Base {self.title} deleted") + _logger.info(f"Base {self.title} deleted") return r.json() def update(self, **kwargs) -> None: @@ -54,14 +54,15 @@ def get_base_info(self) -> dict: def get_tables(self) -> list[Table]: r = self.noco_db.call_noco(path=f"meta/bases/{self.base_id}/tables") - tables = [Table(base=self, **t) for t in r.json()["list"]] - logger.debug(f"Tables in base {self.title}: {[t.title for t in tables]}") + tables = [Table(noco_db=self.noco_db, **t) for t in r.json()["list"]] + _logger.debug(f"Tables in base {self.title}: " + + str([t.title for t in tables])) return tables def get_table(self, table_id: str) -> Table: r = self.noco_db.call_noco( path=f"meta/tables/{table_id}") - return Table(base=self, **r.json()) + return Table(noco_db=self.noco_db, **r.json()) def get_table_by_title(self, title: str) -> Table: try: diff --git a/nocodb/Column.py b/nocodb/Column.py index 549bb97..247cdc6 100644 --- a/nocodb/Column.py +++ b/nocodb/Column.py @@ -1,21 +1,107 @@ from __future__ import annotations + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from nocodb.Table import Table + from nocodb import NocoDB + + +class DataType: + + def __init__(self, uidt: str) -> None: + self.name = uidt + + def __str__(self) -> str: + return self.name + + class Column: - def __init__(self, **kwargs) -> None: + def __init__(self, noco_db: "NocoDB",**kwargs) -> None: + self.noco_db = noco_db self.title = kwargs["title"] self.column_id = kwargs["id"] + self.table_id = kwargs["fk_model_id"] + self.system = bool(kwargs["system"]) self.primary_key = bool(kwargs["pk"]) + + self.data_type = Column.DataType.get_data_type(kwargs["uidt"]) self.metadata = kwargs + if "colOptions" in kwargs and "fk_related_model_id" in kwargs["colOptions"]: + self.linked_table_id = kwargs["colOptions"]["fk_related_model_id"] + + def get_linked_table(self) -> Table: + if hasattr(self, "linked_table_id"): + return self.noco_db.get_table(self.linked_table_id) + else: + raise Exception("Not linked column!") + @staticmethod def get_id_metadata() -> list[dict]: return [ - {'title': 'Id', 'column_name': 'id', 'uidt': 'ID', + {'title': 'Id', 'column_name': 'id', 'uidt': str(Column.DataType.ID), 'dt': 'int4', 'np': '11', 'ns': '0', 'clen': None, 'pk': True, 'pv': None, 'rqd': True, 'ct': 'int(11)', 'ai': True, 'dtx': 'integer', 'dtxp': '11', }, - {'title': 'Title', 'column_name': 'title', 'uidt': 'SingleLineText', + {'title': 'Title', 'column_name': 'title', 'uidt': str(Column.DataType.SingleLineText), 'dt': 'character varying', 'np': None, 'ns': None, 'clen': '45', 'pk': False, 'pv': True, 'rqd': False, 'ct': 'varchar(45)', 'ai': False, 'dtx': 'specificType', 'dtxp': '45', } ] + + class DataType: + Formula = DataType("Formula") + + LinkToAnotherRecord = DataType("LinkToAnotherRecord") + Links = DataType("Links") + + Lookup = DataType("Lookup") + Rollup = DataType("Rollup") + + Attachment = DataType("Attachment") + AutoNumber = DataType("AutoNumber") + Barcode = DataType("Barcode") + Button = DataType("Button") + Checkbox = DataType("Checkbox") + Collaborator = DataType("Collaborator") + Count = DataType("Count") + CreatedBy = DataType("CreatedBy") + CreatedTime = DataType("CreatedTime") + Currency = DataType("Currency") + Date = DataType("Date") + DateTime = DataType("DateTime") + Decimal = DataType("Decimal") + Duration = DataType("Duration") + Email = DataType("Email") + ForeignKey = DataType("ForeignKey") + GeoData = DataType("GeoData") + Geometry = DataType("Geometry") + ID = DataType("ID") + JSON = DataType("JSON") + LastModifiedBy = DataType("LastModifiedBy") + LastModifiedTime = DataType("LastModifiedTime") + LongText = DataType("LongText") + MultiSelect = DataType("MultiSelect") + Number = DataType("Number") + Percent = DataType("Percent") + PhoneNumber = DataType("PhoneNumber") + QrCode = DataType("QrCode") + Rating = DataType("Rating") + SingleLineText = DataType("SingleLineText") + SingleSelect = DataType("SingleSelect") + SpecificDBType = DataType("SpecificDBType") + Time = DataType("Time") + URL = DataType("URL") + User = DataType("User") + Year = DataType("Year") + + @classmethod + def get_data_type(cls, uidt: str) -> DataType: + if hasattr(cls, uidt): + return getattr(cls, uidt) + else: + raise Exception(f"Invalid datatype {uidt}") + + diff --git a/nocodb/Record.py b/nocodb/Record.py index a148add..31881c8 100644 --- a/nocodb/Record.py +++ b/nocodb/Record.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from nocodb.Column import Column if TYPE_CHECKING: @@ -29,3 +29,40 @@ def link_records(self, column: Column, link_records: list["Record"]) -> bool: method="POST", json=[{"Id": l.record_id} for l in link_records]) return r.json() + + def get_linked_records(self, column: Column) -> list[Record]: + path = (f"tables/{self.table.table_id}/links/" + + f"{column.column_id}/records/{self.record_id}") + r = self.noco_db.call_noco(path=path) + + if "list" in r.json(): + if not r.json()["list"]: + return [] + elif isinstance(r.json()["list"], list): + record_ids = [l["Id"] for l in r.json()["list"]] + elif "Id" in r.json()["list"]: + record_ids = [r.json()["list"]["Id"]] + else: + raise Exception("Invalid response") + else: + record_ids = [r.json()["Id"]] + + linked_table = self.noco_db.get_table(column.linked_table_id) + return [linked_table.get_record(i) for i in record_ids] + + def get_value(self, field: str) -> Any: + try: + return self.metadata[field] + except KeyError: + raise Exception(f"Value for {field} not found!") + + def get_column_value(self, column: Column) -> Any: + return self.get_value(column.title) + + def get_attachments(self, field: str, encoding: str = "utf-8") -> list[str]: + value_list = self.get_value(field) + if not isinstance(value_list, list): + raise Exception("Invalid field value") + + return [self.noco_db.get_file(p["signedPath"], encoding=encoding) + for p in value_list] diff --git a/nocodb/Table.py b/nocodb/Table.py index edc0c89..f178e91 100644 --- a/nocodb/Table.py +++ b/nocodb/Table.py @@ -1,22 +1,24 @@ from __future__ import annotations import re from nocodb.Record import Record -from nocodb.Column import Column +from nocodb.Column import Column, DataType from typing import TYPE_CHECKING if TYPE_CHECKING: from nocodb.Base import Base + from nocodb import NocoDB + import logging -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) +_logger = logging.getLogger(__name__) +_logger.addHandler(logging.NullHandler()) class Table: - def __init__(self, base: "Base", **kwargs) -> None: + def __init__(self, noco_db: "NocoDB", **kwargs) -> None: - self.base = base - self.noco_db = base.noco_db + self.noco_db = noco_db + self.base_id = kwargs["base_id"] self.table_id = kwargs["id"] self.title = kwargs["title"] @@ -32,10 +34,10 @@ def get_number_of_records(self) -> int: path=f"tables/{self.table_id}/records/count") return r.json()["count"] - def get_columns(self, include_system: bool = True) -> list[Column]: + def get_columns(self, include_system: bool = False) -> list[Column]: r = self.noco_db.call_noco( path=f"meta/tables/{self.table_id}") - cols = [Column(**f) for f in r.json()["columns"]] + cols = [Column(noco_db=self.noco_db, **f) for f in r.json()["columns"]] if include_system: return cols else: @@ -53,11 +55,11 @@ def get_column_by_title(self, title: str) -> Column: raise Exception(f"Column with title {title} not found!") def create_column(self, column_name: str, - title: str, uidt: str = "SingleLineText", + title: str, data_type: DataType = Column.DataType.SingleLineText, **kwargs) -> Column: kwargs["column_name"] = column_name kwargs["title"] = title - kwargs["uidt"] = uidt + kwargs["uidt"] = str(data_type) r = self.noco_db.call_noco(path=f"meta/tables/{self.table_id}/columns", method="POST", @@ -67,18 +69,18 @@ def create_column(self, column_name: str, def duplicate(self, exclude_data: bool = True, exclude_views: bool = True) -> None: - r = self.noco_db.call_noco(path=f"meta/duplicate/{self.base.base_id}/table/{self.table_id}", + r = self.noco_db.call_noco(path=f"meta/duplicate/{self.base_id}/table/{self.table_id}", method="POST", json={"excludeData": exclude_data, "excludeViews": exclude_views}) - logger.debug(f"Table {self.title} duplicated") + _logger.info(f"Table {self.title} duplicated") return # Bug in noco API, wrong Id response def get_duplicates(self) -> list["Table"]: duplicates = {} - for t in self.base.get_tables(): + for t in self.get_base().get_tables(): if re.match(f"^{self.title} copy(_\\d+)?$", t.title): nr = re.findall("_(\\d+)", t.title) if nr: @@ -91,7 +93,7 @@ def get_duplicates(self) -> list["Table"]: def delete(self) -> bool: r = self.noco_db.call_noco(path=f"meta/tables/{self.table_id}", method="DELETE") - logger.debug(f"Table {self.title} deleted") + _logger.info(f"Table {self.title} deleted") return r.json() def get_records(self, params: dict | None = None) -> list[Record]: @@ -144,3 +146,6 @@ def create_records(self, records: list[dict]) -> list[Record]: json=records) ids_string = ','.join([str(d["Id"]) for d in r.json()]) return self.get_records(params={"where": f"(Id,in,{ids_string})"}) + + def get_base(self) -> Base: + return self.noco_db.get_base(self.base_id) diff --git a/nocodb/__init__.py b/nocodb/__init__.py index c200902..078e8e7 100644 --- a/nocodb/__init__.py +++ b/nocodb/__init__.py @@ -1,13 +1,15 @@ from __future__ import annotations import requests from urllib.parse import urlsplit, urljoin -import logging from nocodb.Base import Base from nocodb.Column import Column +from nocodb.Table import Table + -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) +import logging +_logger = logging.getLogger(__name__) +_logger.addHandler(logging.NullHandler()) API_PATH_BASE = "api/v2" @@ -55,20 +57,19 @@ def call_noco(self, path: str, method: str = "GET", **kwargs) -> requests.Respon headers = {"xc-token": self.api_key} url = urljoin(self.api_url, path) - logger.debug(f"Calling {method} {url} {kwargs}") + _logger.debug(f"Calling {method} {url} {kwargs}") r = requests.request(method, url, headers=headers, **kwargs) - logger.debug(r.status_code) + _logger.debug(r.status_code) if r.status_code >= 400: raise Exception(f"Server response: {r.status_code} - {r.text}") if r.status_code != 200: - logger.warning(r.text) + _logger.warning(r.text) return r - def get_bases(self) -> list[Base]: r = self.call_noco(path="meta/bases") return [Base(noco_db=self, **f) for f in r.json()["list"]] @@ -83,7 +84,7 @@ def get_base_by_title(self, title: str) -> Base: except StopIteration: raise Exception(f"Base with name {title} not found!") - def create_base(self, title:str, **kwargs) -> Base: + def create_base(self, title: str, **kwargs) -> Base: kwargs["title"] = title r = self.call_noco(path="meta/bases", @@ -91,11 +92,13 @@ def create_base(self, title:str, **kwargs) -> Base: json=kwargs) return self.get_base(base_id=r.json()["id"]) + def get_table(self, table_id: str) -> Table: + r = self.call_noco(path=f"meta/tables/{table_id}") + return Table(noco_db=self, **r.json()) - def get_column(self, column_id:str) -> Column: + def get_column(self, column_id: str) -> Column: r = self.call_noco(path=f"meta/columns/{column_id}") - return Column(**r.json()) - + return Column(noco_db=self, **r.json()) def get_app_info(self) -> dict: r = self.call_noco(path="meta/nocodb/info") @@ -103,7 +106,7 @@ def get_app_info(self) -> dict: return r.json() def is_cloud(self) -> bool: - if hasattr(self,"__app_info"): + if hasattr(self, "__app_info"): return self.__app_info["isCloud"] else: return self.get_app_info()["isCloud"] diff --git a/pyproject.toml b/pyproject.toml index 2e1c51e..f690ae2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,14 @@ classifiers = [ dependencies = ["requests"] +[project.optional-dependencies] +doc = ["pdoc"] [project.urls] homepage = "https://github.com/infeeeee/py-nocodb" documentation = "https://github.com/infeeeee/py-nocodb" repository = "https://github.com/infeeeee/py-nocodb" -changelog = "https://github.com/infeeeee/py-nocodb" +changelog = "https://github.com/infeeeee/py-nocodb/releases" [build-system]