diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ae5ec9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +build/ +dist/ +*.egg-info +*.swp +*.pyc diff --git a/README.md b/README.md index 75e923a..51e2987 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,20 @@ NocoDB is a great Airtable alternative. This client allows python developers to use NocoDB API in a simple way. +- [Contributors guidelines](contributors.md) + +## Installation + +```bash +pip install nocodb +``` + ## Usage ### Client configuration ```python from nocodb.nocodb import NocoDBProject, APIToken, JWTAuthToken -from nocodb.filters import InFilter, EqFilter +from nocodb.filters import LikeFilter, EqFilter, And from nocodb.infra.requests_client import NocoDBRequestsClient @@ -83,18 +91,39 @@ table_name = "tablename" # Retrieve a page of rows from a table table_rows = client.table_row_list(project, table_name) -# Retrieve the first 10000 rows -table_rows = client.table_row_list(project, table_name, params={'limit': 10000}) +# Retrieve the first 1000 rows +table_rows = client.table_row_list(project, table_name, params={'limit': 1000}) # Skip 100 rows table_rows = client.table_row_list(project, table_name, params={'offset': 100}) +``` + +⚠️ Seems that we can't retrieve more than 1000 rows at the same time but we can paginate + to retrieve all the rows from a table + +Pagination example +```python + +first_100_rows = client.table_row_list(project, table_name, params={'limit': 100}) +next_100_rows = client.table_row_list(project, table_name, params={'limit': 100, 'offset': 100}) +next_100_rows = client.table_row_list(project, table_name, params={'limit': 100, 'offset': 200}) +``` + +More row operations + +```python # Filter the query -# Currently only one filter at a time is allowed. I don't know how to join -# multiple conditions in nocodb dsl. If you know how please let me know :). -table_rows = client.table_row_list(project, table_name, InFilter("name", "sam")) +table_rows = client.table_row_list(project, table_name, LikeFilter("name", "%sam%")) +table_rows = client.table_row_list(project, table_name, And(LikeFilter("name", "%sam%"), EqFilter("age", 26))) table_rows = client.table_row_list(project, table_name, filter_obj=EqFilter("Id", 100)) +# Filter and count rows +count = client.table_count(project, table_name, filter_obj=EqFilter("Id", 100)) + +# Find one row +table_row = client.table_find_one(project, table_name, filter_obj=EqFilter("Id", 100), params={"sort": "-created_at"}) + # Retrieve a single row row_id = 10 row = client.table_row_detail(project, table_name, row_id) @@ -102,7 +131,7 @@ row = client.table_row_detail(project, table_name, row_id) # Create a new row row_info = { "name": "my thoughts", - "content": "i'm going to buy samuel a beer because i love this module", + "content": "i'm going to buy samuel a beer 🍻 because I 💚 this module", "mood": ":)" } client.table_row_create(project, table_name, row_info) @@ -110,7 +139,7 @@ client.table_row_create(project, table_name, row_info) # Update a row row_id = 2 row_info = { - "content": "i'm going to buy samuel a new car because i love this module", + "content": "i'm going to buy samuel a new car 🚙 because I 💚 this module", } client.table_row_update(project, table_name, row_id, row_info) @@ -118,6 +147,91 @@ client.table_row_update(project, table_name, row_id, row_info) client.table_row_delete(project, table_name, row_id) ``` +### Available filters + +- EqFilter +- EqualFilter (Alias of EqFilter) +- NotEqualFilter +- GreaterThanFilter +- GreaterOrEqualFilter +- LessThanFilter +- LessOrEqualFilter +- LikeFilter +- Or +- Not +- And + +#### Combining filters using Logical operations + +```python +from nocodb import filters + +# Basic filters... +nick_filter = filters.EqFilter("nickname", "elchicodepython") +country_filter = filters.EqFilter("country", "es") +girlfriend_code = filters.EqFilter("gfcode", "404") +current_mood_code = filters.EqFilter("moodcode", "418") + +# Combining filters using logical filters +or_filter = filters.Or(nick_filter, country_filter) +and_filter = filters.And(girlfriend_code, current_mood_code) + +# Negating filters with a Not filter +not_me = filters.Not(filters.EqFilter("nickname", "elchicodepython")) + +# You can also combine combinations +or_combined_filter = filters.Or(or_filter, and_filter) +and_combined_filter = filters.And(or_filter, and_filter) + +``` + +### Using custom filters + +Nocodb is evolving and new operators are coming with each release. + +Most of the basic operations are inside this package but you could need some new +feature that could not be added yet. +For those filters you can build your own. + +Example for basic filters: + +```python +from nocodb.filters.factory import basic_filter_class_factory + +BasicFilter = basic_filter_class_factory('=') +table_rows = client.table_row_list(project, table_name, BasicFilter('age', '16')) + +``` + +You can find the updated list of all the available nocodb operators [here](https://docs.nocodb.com/developer-resources/rest-apis/#comparison-operators). + +In some cases you might want to write your own filter string as described in the previous link. +For that cases you can use the less-semmantic RawFilter. + +```python +from nocodb.filters.raw_filter import RawFilter + +table_rows = client.table_row_list(project, table_name, RawFilter('(birthday,eq,exactDate,2023-06-01)')) +``` + +In some cases we might want to have a file with some custom raw filters already defined by us. +We can easily create custom raw filter classes using `raw_template_filter_class_factory`. + +```python +from nocodb.filters.factory import raw_template_filter_class_factory + +BirthdayDateFilter = raw_template_filter_class_factory('(birthday,eq,exactDate,{})') +ExactDateEqFilter = raw_template_filter_class_factory('({},eq,exactDate,{})') +ExactDateOpFilter = raw_template_filter_class_factory('({},{op},exactDate,{})') + +table_rows = client.table_row_list(project, table_name, BirthdayDateFilter('2023-06-01')) +table_rows = client.table_row_list(project, table_name, ExactDateEqFilter('column', '2023-06-01')) +table_rows = client.table_row_list(project, table_name, ExactDateOpFilter('column', '2023-06-01', op='eq')) +``` + + +Credits to @MitPitt for asking this feature. + ## Author notes I created this package to bootstrap some personal projects and I hope it @@ -128,6 +242,11 @@ Feel free to add new capabilities by creating a new MR. ## Contributors +![Contributors image](https://contrib.rocks/image?repo=elchicodepython/python-nocodb) + + - Samuel López Saura @elchicodepython - Ilya Sapunov @davert0 +- Delena Malan @delenamalan +- Jan Scheiper @jangxx diff --git a/contributors.md b/contributors.md new file mode 100644 index 0000000..514fa6b --- /dev/null +++ b/contributors.md @@ -0,0 +1,81 @@ +# Contributor Guidelines for python-nocodb + +Welcome to python-nocodb! We're excited that you want to contribute to our open-source project. Please take a moment to read and follow these guidelines to ensure a smooth and collaborative development process. + +## Table of Contents + +- [Contribution Process](#contribution-process) + - [1. Fork the Repository](#1-fork-the-repository) + - [2. Create a Branch](#2-create-a-branch) + - [3. Work on Your Contribution](#3-work-on-your-contribution) + - [4. Test Your Code](#4-test-your-code) + - [5. Commit Your Changes](#5-commit-your-changes) + - [6. Create a Pull Request](#6-create-a-pull-request) +- [Coding Guidelines](#coding-guidelines) +- [Documentation](#documentation) +- [Authors](#authors) + +## Contribution Process + +To contribute to this project, follow these steps: + +### 1. Fork the Repository + +Click the "Fork" button on the top right corner of the repository's page on GitHub. This will create a copy of the repository in your GitHub account. + +### 2. Create a Branch + +Clone your forked repository to your local machine, then create a new branch for your contribution. Name your branch in a descriptive manner that reflects the purpose of your contribution. + +```bash +git clone https://github.com/your-username/python-nocodb.git +cd python-nocodb +git checkout -b feature/your-feature +``` + +### 3. Work on Your Contribution + +Now you can start working on your contribution. Be sure to follow the coding guidelines (mentioned below) and implement your changes or new features. + +### 4. Test Your Code + +Thoroughly test your code to ensure it works as expected. Make sure to write unit tests, if applicable. Your contribution should not introduce new bugs or regressions. + +### 5. Commit Your Changes + +Once you are satisfied with your work, commit your changes. Be sure to write clear and descriptive commit messages. + +```bash +git add . +git commit -m "Add a clear and concise commit message" +``` + +### 6. Create a Pull Request + +When your code is ready for review, push your changes to your forked repository, and then open a Pull Request (PR) to the main repository's `main` branch. In your PR description, provide a clear and detailed explanation of your contribution, including what the change does and why it is needed. + +Our team will review your PR, provide feedback, and merge it when it meets our quality and functionality standards. + +## Coding Guidelines + +Please adhere to the following coding guidelines: + +- Follow the style and conventions used in the existing codebase. +- Write clean, readable, and well-documented code. +- Keep code modular and DRY (Don't Repeat Yourself). +- Ensure your code is consistent with the project's coding standards. +- Include unit tests when adding new functionality. + +## Documentation + +Documentation is crucial for maintaining and improving our project. When you make a contribution, you should: + +- Update or add documentation to explain the new capabilities or changes you've made. +- Include comments within your code to explain complex logic or important decisions. +- If you add or change a feature, update the project's README to reflect these changes. + +## Authors + +We appreciate all contributors to our project. To give credit where it's due, please add your name and GitHub username to the "Authors" section of the README if it's not already there. + +Thank you for your interest in contributing to python-nocodb. We look forward to your contributions and collaborations. Happy coding! diff --git a/nocodb/__init__.py b/nocodb/__init__.py index f102a9c..159d48b 100644 --- a/nocodb/__init__.py +++ b/nocodb/__init__.py @@ -1 +1 @@ -__version__ = "0.0.1" +__version__ = "2.0.1" diff --git a/nocodb/api.py b/nocodb/api.py index aed549a..296fa21 100644 --- a/nocodb/api.py +++ b/nocodb/api.py @@ -1,43 +1,54 @@ from enum import Enum +from urllib.parse import urljoin from .nocodb import NocoDBProject class NocoDBAPIUris(Enum): - V1_DB_DATA_PREFIX = "api/v1/db/data" - V1_DB_META_PREFIX = "api/v1/db/meta" + V1_DB_DATA_PREFIX = "api/v1/db/data/" + V1_DB_META_PREFIX = "api/v1/db/meta/" class NocoDBAPI: def __init__(self, base_uri: str): - self.__base_data_uri = ( - f"{base_uri}/{NocoDBAPIUris.V1_DB_DATA_PREFIX.value}" - ) - self.__base_meta_uri = ( - f"{base_uri}/{NocoDBAPIUris.V1_DB_META_PREFIX.value}" - ) + self.__base_data_uri = urljoin(base_uri + "/", NocoDBAPIUris.V1_DB_DATA_PREFIX.value) + self.__base_meta_uri = urljoin(base_uri + "/", NocoDBAPIUris.V1_DB_META_PREFIX.value) def get_table_uri(self, project: NocoDBProject, table: str) -> str: - return "/".join( + return urljoin(self.__base_data_uri, "/".join( ( - self.__base_data_uri, project.org_name, project.project_name, table, ) + )) + + def get_table_count_uri(self, project: NocoDBProject, table: str) -> str: + return "/".join( + ( + self.get_table_uri(project, table), + 'count' + ) + ) + + def get_table_find_one_uri(self, project: NocoDBProject, table: str) -> str: + return "/".join( + ( + self.get_table_uri(project, table), + 'find-one' + ) ) def get_row_detail_uri( self, project: NocoDBProject, table: str, row_id: int ): - return "/".join( + return urljoin(self.__base_data_uri, "/".join( ( - self.__base_data_uri, project.org_name, project.project_name, table, str(row_id), ) - ) + )) def get_nested_relations_rows_list_uri( self, @@ -47,9 +58,8 @@ def get_nested_relations_rows_list_uri( row_id: int, column_name: str, ) -> str: - return "/".join( + return urljoin(self.__base_data_uri, "/".join( ( - self.__base_data_uri, project.org_name, project.project_name, table, @@ -57,14 +67,48 @@ def get_nested_relations_rows_list_uri( relation_type, column_name, ) - ) + )) def get_project_uri( self, ) -> str: - return "/".join( + return urljoin(self.__base_meta_uri, "projects") + + def get_project_tables_uri( + self, project: NocoDBProject, + ) -> str: + return urljoin(self.__base_meta_uri, "/".join( ( - self.__base_meta_uri, - "projects" + "projects", + project.project_name, + "tables" ) - ) \ No newline at end of file + )) + + def get_table_meta_uri( + self, tableId: str, operation: str = None, + ) -> str: + additional_path = [] + if operation is not None: + additional_path.append(operation) + + return urljoin(self.__base_meta_uri, "/".join( + [ + "tables", + tableId, + ] + additional_path + )) + + def get_column_uri( + self, columnId: str, operation: str = None, + ) -> str: + additional_path = [] + if operation is not None: + additional_path.append(operation) + + return urljoin(self.__base_meta_uri, "/".join( + [ + "columns", + columnId, + ] + additional_path + )) diff --git a/nocodb/exceptions.py b/nocodb/exceptions.py new file mode 100644 index 0000000..48bdd3a --- /dev/null +++ b/nocodb/exceptions.py @@ -0,0 +1,15 @@ +from typing import Optional + + +class NocoDBAPIError(Exception): + def __init__( + self, + message: str, + status_code: int, + response_json: Optional[dict] = None, + response_text: Optional[str] = None, + ): + super().__init__(message) + self.status_code = status_code + self.response_json = response_json + self.response_text = response_text diff --git a/nocodb/filters.py b/nocodb/filters.py deleted file mode 100644 index 6fa7b4b..0000000 --- a/nocodb/filters.py +++ /dev/null @@ -1,19 +0,0 @@ -from .nocodb import WhereFilter - - -class InFilter(WhereFilter): - def __init__(self, column_name: str, value: str): - self.__column_name = column_name - self.__value = value - - def get_where(self) -> str: - return f"({self.__column_name},like,%{self.__value}%)" - - -class EqFilter(WhereFilter): - def __init__(self, column_name: str, value: str): - self.__column_name = column_name - self.__value = value - - def get_where(self) -> str: - return f"({self.__column_name},eq,{self.__value})" diff --git a/nocodb/filters/__init__.py b/nocodb/filters/__init__.py new file mode 100644 index 0000000..40a458b --- /dev/null +++ b/nocodb/filters/__init__.py @@ -0,0 +1,25 @@ +from .factory import basic_filter_class_factory +from .logical import And, Not, Or + +EqFilter = basic_filter_class_factory("eq") +EqualFilter = EqFilter +NotEqualFilter = basic_filter_class_factory("neq") +GreaterThanFilter = basic_filter_class_factory("gt") +GreaterOrEqualFilter = basic_filter_class_factory("ge") +LessThanFilter = basic_filter_class_factory("lt") +LessOrEqualFilter = basic_filter_class_factory("le") +LikeFilter = basic_filter_class_factory("like") + +__all__ = [ + "And", + "Not", + "Or", + "EqFilter", + "EqualFilter", + "NotEqualFilter", + "GreaterThanFilter", + "GreaterOrEqualFilter", + "LessThanFilter", + "LessOrEqualFilter", + "LikeFilter", +] diff --git a/nocodb/filters/factory.py b/nocodb/filters/factory.py new file mode 100644 index 0000000..e1bb8aa --- /dev/null +++ b/nocodb/filters/factory.py @@ -0,0 +1,15 @@ +from ..nocodb import WhereFilter + +from .raw_filter import RawTemplateFilter + + +def basic_filter_class_factory(filter_name: str): + return raw_template_filter_class_factory('({},' + filter_name + ',{})') + +def raw_template_filter_class_factory(template: str): + class WrappedFilter(WhereFilter): + def __init__(self, *args, **kwargs): + self.__filter = RawTemplateFilter(template, *args, **kwargs) + def get_where(self) -> str: + return self.__filter.get_where() + return WrappedFilter diff --git a/nocodb/filters/factory_test.py b/nocodb/filters/factory_test.py new file mode 100644 index 0000000..836aa11 --- /dev/null +++ b/nocodb/filters/factory_test.py @@ -0,0 +1,15 @@ +from .factory import basic_filter_class_factory, raw_template_filter_class_factory + + +def test_basic_filter_class_factory(): + FilterClass = basic_filter_class_factory('eq') + assert FilterClass('column', 'value').get_where() == '(column,eq,value)' + + +def test_raw_template_filter_class_factory(): + FilterClassWithoutParams = raw_template_filter_class_factory('()') + FilterClassWithParams = raw_template_filter_class_factory('({},{},{})') + FilterClassWithKwargs = raw_template_filter_class_factory('({},{op},{})') + assert FilterClassWithoutParams().get_where() == '()' + assert FilterClassWithParams('1', '2','3').get_where() == '(1,2,3)' + assert FilterClassWithKwargs('1', '2', op='eq').get_where() == '(1,eq,2)' diff --git a/nocodb/filters/filters_test.py b/nocodb/filters/filters_test.py new file mode 100644 index 0000000..eb17954 --- /dev/null +++ b/nocodb/filters/filters_test.py @@ -0,0 +1,71 @@ +import pytest + +from .. import filters + +from ..nocodb import WhereFilter + + +@pytest.mark.parametrize( + "filter_class, expected_operator", + [ + (filters.EqFilter, "eq"), + (filters.EqualFilter, "eq"), + (filters.NotEqualFilter, "neq"), + (filters.GreaterOrEqualFilter, "ge"), + (filters.GreaterThanFilter, "gt"), + (filters.LessThanFilter, "lt"), + (filters.LessOrEqualFilter, "le"), + (filters.LikeFilter, "like"), + ], +) +def test_basic_filters_are_correctly_created( + filter_class: WhereFilter, expected_operator: str +): + test_filter = filter_class("column", "value") + assert test_filter.get_where() == f"(column,{expected_operator},value)" + + +def test_or_filter(): + nick_filter = filters.EqFilter("nickname", "elchicodepython") + country_filter = filters.EqFilter("country", "es") + nick_or_country_filter = filters.Or(nick_filter, country_filter) + assert ( + nick_or_country_filter.get_where() + == "((nickname,eq,elchicodepython)~or(country,eq,es))" + ) + + +def test_and_filter(): + nick_filter = filters.EqFilter("nickname", "elchicodepython") + country_filter = filters.EqFilter("country", "es") + nick_or_country_filter = filters.And(nick_filter, country_filter) + assert ( + nick_or_country_filter.get_where() + == "((nickname,eq,elchicodepython)~and(country,eq,es))" + ) + + +def test_combined_filter(): + nick_filter = filters.EqFilter("nickname", "elchicodepython") + country_filter = filters.EqFilter("country", "es") + girlfriend_code = filters.EqFilter("gfcode", "404") + current_mood_code = filters.EqFilter("moodcode", "418") + or_filter = filters.Or(nick_filter, country_filter) + and_filter = filters.And(girlfriend_code, current_mood_code) + or_combined_filter = filters.Or(or_filter, and_filter) + and_combined_filter = filters.And(or_filter, and_filter) + + assert ( + or_combined_filter.get_where() + == "(((nickname,eq,elchicodepython)~or(country,eq,es))~or((gfcode,eq,404)~and(moodcode,eq,418)))" + ) + assert ( + and_combined_filter.get_where() + == "(((nickname,eq,elchicodepython)~or(country,eq,es))~and((gfcode,eq,404)~and(moodcode,eq,418)))" + ) + + +def test_not_filter(): + me = filters.EqFilter("nickname", "elchicodepython") + not_me = filters.Not(me) + assert not_me.get_where() == "~not(nickname,eq,elchicodepython)" diff --git a/nocodb/filters/logical.py b/nocodb/filters/logical.py new file mode 100644 index 0000000..94379a2 --- /dev/null +++ b/nocodb/filters/logical.py @@ -0,0 +1,26 @@ +from typing import List +from ..nocodb import WhereFilter + + +class Or(WhereFilter): + def __init__(self, *filters: List[WhereFilter]): + self.__filters = filters + + def get_where(self) -> str: + return f"({'~or'.join([filter.get_where() for filter in self.__filters])})" + + +class And(WhereFilter): + def __init__(self, *filters: List[WhereFilter]): + self.__filters = filters + + def get_where(self) -> str: + return f"({'~and'.join([filter.get_where() for filter in self.__filters])})" + + +class Not(WhereFilter): + def __init__(self, filter: WhereFilter): + self.__filter = filter + + def get_where(self) -> str: + return f"~not{self.__filter.get_where()}" diff --git a/nocodb/filters/logical_test.py b/nocodb/filters/logical_test.py new file mode 100644 index 0000000..d6865b6 --- /dev/null +++ b/nocodb/filters/logical_test.py @@ -0,0 +1,21 @@ +from nocodb import filters + + +def test_or_with_two_filters(): + filter1 = filters.EqFilter("column1", "value1") + filter2 = filters.EqFilter("column2", "value2") + or_filter = filters.Or(filter1, filter2) + assert or_filter.get_where() == "((column1,eq,value1)~or(column2,eq,value2))" + + +def test_and_with_two_filters(): + filter1 = filters.And(filters.EqFilter("column1", "value1")) + filter2 = filters.And(filters.EqFilter("column2", "value2")) + and_filter = filters.And(filter1, filter2) + assert and_filter.get_where() == "(((column1,eq,value1))~and((column2,eq,value2)))" + + +def test_not_filter(): + filter = filters.EqFilter("column", "value") + not_filter = filters.Not(filter) + assert not_filter.get_where() == "~not(column,eq,value)" diff --git a/nocodb/filters/raw_filter.py b/nocodb/filters/raw_filter.py new file mode 100644 index 0000000..451d659 --- /dev/null +++ b/nocodb/filters/raw_filter.py @@ -0,0 +1,19 @@ +from ..nocodb import WhereFilter + + +class RawFilter(WhereFilter): + def __init__(self, raw: str): + self.__raw = raw + + def get_where(self) -> str: + return self.__raw + + +class RawTemplateFilter(WhereFilter): + def __init__(self, template: str, *args, **kwargs): + self.__template = template + self.__template_values = args + self.__template_kvalues = kwargs + + def get_where(self) -> str: + return self.__template.format(*self.__template_values, **self.__template_kvalues) diff --git a/nocodb/infra/requests_client.py b/nocodb/infra/requests_client.py index fd223ed..98e63b5 100644 --- a/nocodb/infra/requests_client.py +++ b/nocodb/infra/requests_client.py @@ -7,6 +7,7 @@ ) from ..api import NocoDBAPI from ..utils import get_query_params +from ..exceptions import NocoDBAPIError import requests @@ -20,6 +21,24 @@ def __init__(self, auth_token: AuthToken, base_uri: str): self.__session.headers.update({"Content-Type": "application/json"}) self.__api_info = NocoDBAPI(base_uri) + def _request(self, method: str, url: str, *args, **kwargs): + response = self.__session.request(method, url, *args, **kwargs) + response_json = None + try: + response.raise_for_status() + response_json = response.json() + except requests.exceptions.JSONDecodeError: + ... + except requests.exceptions.HTTPError as http_error: + raise NocoDBAPIError( + message=str(http_error), + status_code=http_error.response.status_code, + response_json=response_json, + response_text=response.text + ) + + return response + def table_row_list( self, project: NocoDBProject, @@ -27,42 +46,63 @@ def table_row_list( filter_obj: Optional[WhereFilter] = None, params: Optional[dict] = None, ) -> dict: - - response = self.__session.get( + return self._request( + "GET", self.__api_info.get_table_uri(project, table), params=get_query_params(filter_obj, params), - ) - return response.json() + ).json() - def table_row_create( - self, project: NocoDBProject, table: str, body: dict - ) -> dict: - return self.__session.post( - self.__api_info.get_table_uri(project, table), json=body + def table_row_create(self, project: NocoDBProject, table: str, body: dict) -> dict: + return self._request( + "POST", self.__api_info.get_table_uri(project, table), json=body ).json() - def table_row_detail( - self, project: NocoDBProject, table: str, row_id: int - ) -> dict: - return self.__session.get( + def table_row_detail(self, project: NocoDBProject, table: str, row_id: int) -> dict: + return self._request( + "GET", self.__api_info.get_row_detail_uri(project, table, row_id), ).json() def table_row_update( self, project: NocoDBProject, table: str, row_id: int, body: dict ) -> dict: - return self.__session.patch( + return self._request( + "PATCH", self.__api_info.get_row_detail_uri(project, table, row_id), json=body, ).json() - def table_row_delete( - self, project: NocoDBProject, table: str, row_id: int - ) -> int: - return self.__session.delete( + def table_row_delete(self, project: NocoDBProject, table: str, row_id: int) -> int: + return self._request( + "DELETE", self.__api_info.get_row_detail_uri(project, table, row_id), ).json() + def table_count( + self, + project: NocoDBProject, + table: str, + filter_obj: Optional[WhereFilter] = None, + ) -> dict: + return self._request( + "GET", + self.__api_info.get_table_count_uri(project, table), + params=get_query_params(filter_obj), + ).json() + + def table_find_one( + self, + project: NocoDBProject, + table: str, + filter_obj: Optional[WhereFilter] = None, + params: Optional[dict] = None, + ) -> dict: + return self._request( + "GET", + self.__api_info.get_table_find_one_uri(project, table), + params=get_query_params(filter_obj, params), + ).json() + def table_row_nested_relations_list( self, project: NocoDBProject, @@ -71,16 +111,102 @@ def table_row_nested_relations_list( row_id: int, column_name: str, ) -> dict: - return self.__session.get( + return self._request( + "GET", self.__api_info.get_nested_relations_rows_list_uri( project, table, relation_type, row_id, column_name - ) + ), + ).json() + + def project_create(self, body): + return self._request( + "POST", self.__api_info.get_project_uri(), json=body + ).json() + + def table_create( + self, project: NocoDBProject, body: dict + ) -> dict: + return self._request( + "POST", + url=self.__api_info.get_project_tables_uri(project), + json=body, ).json() - def project_create( + def table_list( self, - body + project: NocoDBProject, + params: Optional[dict] = None, + ) -> dict: + return self._request( + "GET", + url=self.__api_info.get_project_tables_uri(project), + params=params, + ).json() + + def table_read( + self, tableId: str, + ) -> dict: + return self._request( + "GET", + url=self.__api_info.get_table_meta_uri(tableId) + ).json() + + def table_update( + self, tableId: str, body: dict ): - return self.__session.post( - self.__api_info.get_project_uri(), json=body + return self._request( + "PATCH", + url=self.__api_info.get_table_meta_uri(tableId), + json=body, + ).json() + + def table_delete( + self, tableId: str, + ) -> dict: + return self._request( + "DELETE", + url=self.__api_info.get_table_meta_uri(tableId) + ).json() + + def table_reorder( + self, tableId: str, order: int + ) -> dict: + return self._request( + "POST", + url=self.__api_info.get_table_meta_uri(tableId, "reorder"), + json={ "order": order } + ).json() + + def table_column_create( + self, tableId: str, body: dict, + ) -> dict: + return self._request( + "POST", + url=self.__api_info.get_table_meta_uri(tableId, "columns"), + json=body, + ).json() + + def table_column_update( + self, columnId: str, body: dict, + ) -> dict: + return self._request( + "PATCH", + url=self.__api_info.get_column_uri(columnId), + json=body, + ).json() + + def table_column_delete( + self, columnId: str, + ) -> dict: + return self._request( + "DELETE", + url=self.__api_info.get_column_uri(columnId) + ).json() + + def table_column_set_primary( + self, columnId: str, + ) -> bool: + return self._request( + "POST", + url=self.__api_info.get_column_uri(columnId, "primary"), ).json() diff --git a/nocodb/infra/requests_client_test.py b/nocodb/infra/requests_client_test.py new file mode 100644 index 0000000..c394d0c --- /dev/null +++ b/nocodb/infra/requests_client_test.py @@ -0,0 +1,22 @@ +from unittest import mock + +import pytest +import requests + +from .requests_client import NocoDBRequestsClient, requests as requests_lib +from ..exceptions import NocoDBAPIError + + +@mock.patch.object(requests_lib, "Session") +def test_NocoDBAPIError_raised_on_bad_response(mock_requests_session): + mock_session = mock.Mock() + mock_resp = requests.models.Response() + mock_resp.status_code = 401 + mock_requests_session.return_value = mock_session + mock_session.request.return_value = mock_resp + + client = NocoDBRequestsClient(mock.Mock(), "") + with pytest.raises(NocoDBAPIError) as exc_info: + client._request("GET", "/") + + assert exc_info.value.status_code == 401 diff --git a/nocodb/nocodb.py b/nocodb/nocodb.py index 8575a8c..1fe801e 100644 --- a/nocodb/nocodb.py +++ b/nocodb/nocodb.py @@ -32,7 +32,7 @@ def get_header(self) -> dict: pass -class APIToken: +class APIToken(AuthToken): def __init__(self, token: str): self.__token = token @@ -40,7 +40,7 @@ def get_header(self) -> dict: return {"xc-token": self.__token} -class JWTAuthToken: +class JWTAuthToken(AuthToken): def __init__(self, token: str): self.__token = token @@ -54,29 +54,6 @@ def get_where(self) -> str: pass -"""This could be great but actually I don't know how to join filters in the -NocoDB DSL. I event don't know if this is possible through the current API. -I hope they add docs about it soon. - -class NocoDBWhere: - - def __init__(self): - self.__filter_array: List[WhereFilter] = [] - - def add_filter(self, where_filter: WhereFilter) -> NocoDBWhere: - self.__filter_array.append( - where_filter - ) - return self - - def get_where(self) -> str: - return '&'.join([filter_.get_where() for filter_ in self.__filter_array]) - - def __str__(self): - return f'Where: "{self.get_where()}"' -""" - - class NocoDBProject: def __init__(self, org_name: str, project_name: str): self.project_name = project_name @@ -134,3 +111,65 @@ def table_row_nested_relations_list( column_name: str, ) -> dict: pass + + @abstractmethod + def table_create( + self, project: NocoDBProject, body: dict + ) -> dict: + pass + + @abstractmethod + def table_list( + self, + project: NocoDBProject, + params: Optional[dict] = None, + ) -> dict: + pass + + @abstractmethod + def table_read( + self, tableId: str, + ) -> dict: + pass + + @abstractmethod + def table_update( + self, tableId: str, body: dict, + ) -> bool: + pass + + @abstractmethod + def table_delete( + self, tableId: str, + ) -> dict: + pass + + @abstractmethod + def table_reorder( + self, tableId: str, order: int, + ) -> dict: + pass + + @abstractmethod + def table_column_create( + self, tableId: str, body: dict, + ) -> dict: + pass + + @abstractmethod + def table_column_update( + self, columnId: str, body: dict, + ) -> dict: + pass + + @abstractmethod + def table_column_delete( + self, columnId: str, + ) -> dict: + pass + + @abstractmethod + def table_column_set_primary( + self, columnId: str, + ) -> dict: + pass diff --git a/nocodb/utils.py b/nocodb/utils.py index 4b37cdf..2b98823 100644 --- a/nocodb/utils.py +++ b/nocodb/utils.py @@ -1,4 +1,4 @@ -def get_query_params(filter_obj, params) -> dict: +def get_query_params(filter_obj, params=None) -> dict: query_params = params or {} if filter_obj: query_params["where"] = filter_obj.get_where() diff --git a/setup.py b/setup.py index 750c490..fd1d200 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='nocodb', - version='0.1.0', + version='2.0.1', author='Samuel López Saura', author_email='samuellopezsaura@gmail.com', packages=find_packages(),