├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── contributors.md ├── nocodb ├── __init__.py ├── api.py ├── exceptions.py ├── filters │ ├── __init__.py │ ├── factory.py │ ├── factory_test.py │ ├── filters_test.py │ ├── logical.py │ ├── logical_test.py │ └── raw_filter.py ├── infra │ ├── __init__.py │ ├── requests_client.py │ └── requests_client_test.py ├── nocodb.py └── utils.py └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | ko_fi: elchicodepython 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | build/ 3 | dist/ 4 | *.egg-info 5 | *.swp 6 | *.pyc 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Samuel López Saura 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NocoDB Python Client 2 | 3 | NocoDB is a great Airtable alternative. This client allows python developers 4 | to use NocoDB API in a simple way. 5 | 6 | - [Contributors guidelines](contributors.md) 7 | 8 | ## Installation 9 | 10 | ```bash 11 | pip install nocodb 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### Client configuration 17 | ```python 18 | from nocodb.nocodb import NocoDBProject, APIToken, JWTAuthToken 19 | from nocodb.filters import LikeFilter, EqFilter, And 20 | from nocodb.infra.requests_client import NocoDBRequestsClient 21 | 22 | 23 | # Usage with API Token 24 | client = NocoDBRequestsClient( 25 | # Your API Token retrieved from NocoDB conf 26 | APIToken("YOUR-API-TOKEN"), 27 | # Your nocodb root path 28 | "http://localhost:8080" 29 | ) 30 | 31 | # Usage with JWT Token 32 | client = NocoDBRequestsClient( 33 | # Your API Token retrieved from NocoDB conf 34 | JWTAuthToken("your.jwt.token"), 35 | # Your nocodb root path 36 | "http://localhost:8080" 37 | ) 38 | ``` 39 | 40 | ### Project creation 41 | ```python 42 | # Example with default database 43 | project_body = {"title": "My new project"} 44 | 45 | # Example with Postgresql 46 | project_body = { 47 | "title": "MyProject", 48 | "bases": [ 49 | { 50 | "type": "pg", 51 | "config": { 52 | "client": "pg", 53 | "connection": { 54 | "host": "localhost", 55 | "port": "5432", 56 | "user": "postgres", 57 | "password": "postgres", 58 | "database": "postgres" 59 | }, 60 | "searchPath": [ 61 | "public" 62 | ] 63 | }, 64 | "inflection_column": "camelize", 65 | "inflection_table": "camelize" 66 | } 67 | ], 68 | "external": True 69 | } 70 | 71 | project = client.project_create(body=project_body) 72 | ``` 73 | 74 | ### Project selection 75 | ```python 76 | # Be very carefull with org, project_name and table names 77 | # weird errors from nocodb can arrive if they are wrong 78 | # example: id is not defined... 79 | # probably they will fix that in a future release. 80 | project = NocoDBProject( 81 | "noco", # org name. noco by default 82 | "myproject" # project name. Case sensitive!! 83 | ) 84 | 85 | ``` 86 | 87 | ### Table rows operations 88 | ```python 89 | table_name = "tablename" 90 | 91 | # Retrieve a page of rows from a table 92 | table_rows = client.table_row_list(project, table_name) 93 | 94 | # Retrieve the first 1000 rows 95 | table_rows = client.table_row_list(project, table_name, params={'limit': 1000}) 96 | 97 | # Skip 100 rows 98 | table_rows = client.table_row_list(project, table_name, params={'offset': 100}) 99 | ``` 100 | 101 | ⚠️ Seems that we can't retrieve more than 1000 rows at the same time but we can paginate 102 | to retrieve all the rows from a table 103 | 104 | Pagination example 105 | 106 | ```python 107 | 108 | first_100_rows = client.table_row_list(project, table_name, params={'limit': 100}) 109 | next_100_rows = client.table_row_list(project, table_name, params={'limit': 100, 'offset': 100}) 110 | next_100_rows = client.table_row_list(project, table_name, params={'limit': 100, 'offset': 200}) 111 | ``` 112 | 113 | More row operations 114 | 115 | ```python 116 | # Filter the query 117 | table_rows = client.table_row_list(project, table_name, LikeFilter("name", "%sam%")) 118 | table_rows = client.table_row_list(project, table_name, And(LikeFilter("name", "%sam%"), EqFilter("age", 26))) 119 | table_rows = client.table_row_list(project, table_name, filter_obj=EqFilter("Id", 100)) 120 | 121 | # Filter and count rows 122 | count = client.table_count(project, table_name, filter_obj=EqFilter("Id", 100)) 123 | 124 | # Find one row 125 | table_row = client.table_find_one(project, table_name, filter_obj=EqFilter("Id", 100), params={"sort": "-created_at"}) 126 | 127 | # Retrieve a single row 128 | row_id = 10 129 | row = client.table_row_detail(project, table_name, row_id) 130 | 131 | # Create a new row 132 | row_info = { 133 | "name": "my thoughts", 134 | "content": "i'm going to buy samuel a beer 🍻 because I 💚 this module", 135 | "mood": ":)" 136 | } 137 | client.table_row_create(project, table_name, row_info) 138 | 139 | # Update a row 140 | row_id = 2 141 | row_info = { 142 | "content": "i'm going to buy samuel a new car 🚙 because I 💚 this module", 143 | } 144 | client.table_row_update(project, table_name, row_id, row_info) 145 | 146 | # Delete a row (only if you've already bought me a beer) 147 | client.table_row_delete(project, table_name, row_id) 148 | ``` 149 | 150 | ### Available filters 151 | 152 | - EqFilter 153 | - EqualFilter (Alias of EqFilter) 154 | - NotEqualFilter 155 | - GreaterThanFilter 156 | - GreaterOrEqualFilter 157 | - LessThanFilter 158 | - LessOrEqualFilter 159 | - LikeFilter 160 | - Or 161 | - Not 162 | - And 163 | 164 | #### Combining filters using Logical operations 165 | 166 | ```python 167 | from nocodb import filters 168 | 169 | # Basic filters... 170 | nick_filter = filters.EqFilter("nickname", "elchicodepython") 171 | country_filter = filters.EqFilter("country", "es") 172 | girlfriend_code = filters.EqFilter("gfcode", "404") 173 | current_mood_code = filters.EqFilter("moodcode", "418") 174 | 175 | # Combining filters using logical filters 176 | or_filter = filters.Or(nick_filter, country_filter) 177 | and_filter = filters.And(girlfriend_code, current_mood_code) 178 | 179 | # Negating filters with a Not filter 180 | not_me = filters.Not(filters.EqFilter("nickname", "elchicodepython")) 181 | 182 | # You can also combine combinations 183 | or_combined_filter = filters.Or(or_filter, and_filter) 184 | and_combined_filter = filters.And(or_filter, and_filter) 185 | 186 | ``` 187 | 188 | ### Using custom filters 189 | 190 | Nocodb is evolving and new operators are coming with each release. 191 | 192 | Most of the basic operations are inside this package but you could need some new 193 | feature that could not be added yet. 194 | For those filters you can build your own. 195 | 196 | Example for basic filters: 197 | 198 | ```python 199 | from nocodb.filters.factory import basic_filter_class_factory 200 | 201 | BasicFilter = basic_filter_class_factory('=') 202 | table_rows = client.table_row_list(project, table_name, BasicFilter('age', '16')) 203 | 204 | ``` 205 | 206 | You can find the updated list of all the available nocodb operators [here](https://docs.nocodb.com/developer-resources/rest-apis/#comparison-operators). 207 | 208 | In some cases you might want to write your own filter string as described in the previous link. 209 | For that cases you can use the less-semmantic RawFilter. 210 | 211 | ```python 212 | from nocodb.filters.raw_filter import RawFilter 213 | 214 | table_rows = client.table_row_list(project, table_name, RawFilter('(birthday,eq,exactDate,2023-06-01)')) 215 | ``` 216 | 217 | In some cases we might want to have a file with some custom raw filters already defined by us. 218 | We can easily create custom raw filter classes using `raw_template_filter_class_factory`. 219 | 220 | ```python 221 | from nocodb.filters.factory import raw_template_filter_class_factory 222 | 223 | BirthdayDateFilter = raw_template_filter_class_factory('(birthday,eq,exactDate,{})') 224 | ExactDateEqFilter = raw_template_filter_class_factory('({},eq,exactDate,{})') 225 | ExactDateOpFilter = raw_template_filter_class_factory('({},{op},exactDate,{})') 226 | 227 | table_rows = client.table_row_list(project, table_name, BirthdayDateFilter('2023-06-01')) 228 | table_rows = client.table_row_list(project, table_name, ExactDateEqFilter('column', '2023-06-01')) 229 | table_rows = client.table_row_list(project, table_name, ExactDateOpFilter('column', '2023-06-01', op='eq')) 230 | ``` 231 | 232 | 233 | Credits to @MitPitt for asking this feature. 234 | 235 | ## Author notes 236 | 237 | I created this package to bootstrap some personal projects and I hope it 238 | will help other developers from the python community. It's not completed but 239 | it has what I needed: A full CRUD with some filters. 240 | 241 | Feel free to add new capabilities by creating a new MR. 242 | 243 | ## Contributors 244 | 245 | ![Contributors image](https://contrib.rocks/image?repo=elchicodepython/python-nocodb) 246 | 247 | 248 | - Samuel López Saura @elchicodepython 249 | - Ilya Sapunov @davert0 250 | - Delena Malan @delenamalan 251 | - Jan Scheiper @jangxx 252 | 253 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | # Contributor Guidelines for python-nocodb 2 | 3 | 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. 4 | 5 | ## Table of Contents 6 | 7 | - [Contribution Process](#contribution-process) 8 | - [1. Fork the Repository](#1-fork-the-repository) 9 | - [2. Create a Branch](#2-create-a-branch) 10 | - [3. Work on Your Contribution](#3-work-on-your-contribution) 11 | - [4. Test Your Code](#4-test-your-code) 12 | - [5. Commit Your Changes](#5-commit-your-changes) 13 | - [6. Create a Pull Request](#6-create-a-pull-request) 14 | - [Coding Guidelines](#coding-guidelines) 15 | - [Documentation](#documentation) 16 | - [Authors](#authors) 17 | 18 | ## Contribution Process 19 | 20 | To contribute to this project, follow these steps: 21 | 22 | ### 1. Fork the Repository 23 | 24 | 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. 25 | 26 | ### 2. Create a Branch 27 | 28 | 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. 29 | 30 | ```bash 31 | git clone https://github.com/your-username/python-nocodb.git 32 | cd python-nocodb 33 | git checkout -b feature/your-feature 34 | ``` 35 | 36 | ### 3. Work on Your Contribution 37 | 38 | Now you can start working on your contribution. Be sure to follow the coding guidelines (mentioned below) and implement your changes or new features. 39 | 40 | ### 4. Test Your Code 41 | 42 | 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. 43 | 44 | ### 5. Commit Your Changes 45 | 46 | Once you are satisfied with your work, commit your changes. Be sure to write clear and descriptive commit messages. 47 | 48 | ```bash 49 | git add . 50 | git commit -m "Add a clear and concise commit message" 51 | ``` 52 | 53 | ### 6. Create a Pull Request 54 | 55 | 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. 56 | 57 | Our team will review your PR, provide feedback, and merge it when it meets our quality and functionality standards. 58 | 59 | ## Coding Guidelines 60 | 61 | Please adhere to the following coding guidelines: 62 | 63 | - Follow the style and conventions used in the existing codebase. 64 | - Write clean, readable, and well-documented code. 65 | - Keep code modular and DRY (Don't Repeat Yourself). 66 | - Ensure your code is consistent with the project's coding standards. 67 | - Include unit tests when adding new functionality. 68 | 69 | ## Documentation 70 | 71 | Documentation is crucial for maintaining and improving our project. When you make a contribution, you should: 72 | 73 | - Update or add documentation to explain the new capabilities or changes you've made. 74 | - Include comments within your code to explain complex logic or important decisions. 75 | - If you add or change a feature, update the project's README to reflect these changes. 76 | 77 | ## Authors 78 | 79 | 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. 80 | 81 | Thank you for your interest in contributing to python-nocodb. We look forward to your contributions and collaborations. Happy coding! 82 | -------------------------------------------------------------------------------- /nocodb/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.1" 2 | -------------------------------------------------------------------------------- /nocodb/api.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from urllib.parse import urljoin 3 | from .nocodb import NocoDBProject 4 | 5 | 6 | class NocoDBAPIUris(Enum): 7 | V1_DB_DATA_PREFIX = "api/v1/db/data/" 8 | V1_DB_META_PREFIX = "api/v1/db/meta/" 9 | 10 | 11 | class NocoDBAPI: 12 | def __init__(self, base_uri: str): 13 | self.__base_data_uri = urljoin(base_uri + "/", NocoDBAPIUris.V1_DB_DATA_PREFIX.value) 14 | self.__base_meta_uri = urljoin(base_uri + "/", NocoDBAPIUris.V1_DB_META_PREFIX.value) 15 | 16 | def get_table_uri(self, project: NocoDBProject, table: str) -> str: 17 | return urljoin(self.__base_data_uri, "/".join( 18 | ( 19 | project.org_name, 20 | project.project_name, 21 | table, 22 | ) 23 | )) 24 | 25 | def get_table_count_uri(self, project: NocoDBProject, table: str) -> str: 26 | return "/".join( 27 | ( 28 | self.get_table_uri(project, table), 29 | 'count' 30 | ) 31 | ) 32 | 33 | def get_table_find_one_uri(self, project: NocoDBProject, table: str) -> str: 34 | return "/".join( 35 | ( 36 | self.get_table_uri(project, table), 37 | 'find-one' 38 | ) 39 | ) 40 | 41 | def get_row_detail_uri( 42 | self, project: NocoDBProject, table: str, row_id: int 43 | ): 44 | return urljoin(self.__base_data_uri, "/".join( 45 | ( 46 | project.org_name, 47 | project.project_name, 48 | table, 49 | str(row_id), 50 | ) 51 | )) 52 | 53 | def get_nested_relations_rows_list_uri( 54 | self, 55 | project: NocoDBProject, 56 | table: str, 57 | relation_type: str, 58 | row_id: int, 59 | column_name: str, 60 | ) -> str: 61 | return urljoin(self.__base_data_uri, "/".join( 62 | ( 63 | project.org_name, 64 | project.project_name, 65 | table, 66 | str(row_id), 67 | relation_type, 68 | column_name, 69 | ) 70 | )) 71 | 72 | def get_project_uri( 73 | self, 74 | ) -> str: 75 | return urljoin(self.__base_meta_uri, "projects") 76 | 77 | def get_project_tables_uri( 78 | self, project: NocoDBProject, 79 | ) -> str: 80 | return urljoin(self.__base_meta_uri, "/".join( 81 | ( 82 | "projects", 83 | project.project_name, 84 | "tables" 85 | ) 86 | )) 87 | 88 | def get_table_meta_uri( 89 | self, tableId: str, operation: str = None, 90 | ) -> str: 91 | additional_path = [] 92 | if operation is not None: 93 | additional_path.append(operation) 94 | 95 | return urljoin(self.__base_meta_uri, "/".join( 96 | [ 97 | "tables", 98 | tableId, 99 | ] + additional_path 100 | )) 101 | 102 | def get_column_uri( 103 | self, columnId: str, operation: str = None, 104 | ) -> str: 105 | additional_path = [] 106 | if operation is not None: 107 | additional_path.append(operation) 108 | 109 | return urljoin(self.__base_meta_uri, "/".join( 110 | [ 111 | "columns", 112 | columnId, 113 | ] + additional_path 114 | )) 115 | -------------------------------------------------------------------------------- /nocodb/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class NocoDBAPIError(Exception): 5 | def __init__( 6 | self, 7 | message: str, 8 | status_code: int, 9 | response_json: Optional[dict] = None, 10 | response_text: Optional[str] = None, 11 | ): 12 | super().__init__(message) 13 | self.status_code = status_code 14 | self.response_json = response_json 15 | self.response_text = response_text 16 | -------------------------------------------------------------------------------- /nocodb/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .factory import basic_filter_class_factory 2 | from .logical import And, Not, Or 3 | 4 | EqFilter = basic_filter_class_factory("eq") 5 | EqualFilter = EqFilter 6 | NotEqualFilter = basic_filter_class_factory("neq") 7 | GreaterThanFilter = basic_filter_class_factory("gt") 8 | GreaterOrEqualFilter = basic_filter_class_factory("ge") 9 | LessThanFilter = basic_filter_class_factory("lt") 10 | LessOrEqualFilter = basic_filter_class_factory("le") 11 | LikeFilter = basic_filter_class_factory("like") 12 | 13 | __all__ = [ 14 | "And", 15 | "Not", 16 | "Or", 17 | "EqFilter", 18 | "EqualFilter", 19 | "NotEqualFilter", 20 | "GreaterThanFilter", 21 | "GreaterOrEqualFilter", 22 | "LessThanFilter", 23 | "LessOrEqualFilter", 24 | "LikeFilter", 25 | ] 26 | -------------------------------------------------------------------------------- /nocodb/filters/factory.py: -------------------------------------------------------------------------------- 1 | from ..nocodb import WhereFilter 2 | 3 | from .raw_filter import RawTemplateFilter 4 | 5 | 6 | def basic_filter_class_factory(filter_name: str): 7 | return raw_template_filter_class_factory('({},' + filter_name + ',{})') 8 | 9 | def raw_template_filter_class_factory(template: str): 10 | class WrappedFilter(WhereFilter): 11 | def __init__(self, *args, **kwargs): 12 | self.__filter = RawTemplateFilter(template, *args, **kwargs) 13 | def get_where(self) -> str: 14 | return self.__filter.get_where() 15 | return WrappedFilter 16 | -------------------------------------------------------------------------------- /nocodb/filters/factory_test.py: -------------------------------------------------------------------------------- 1 | from .factory import basic_filter_class_factory, raw_template_filter_class_factory 2 | 3 | 4 | def test_basic_filter_class_factory(): 5 | FilterClass = basic_filter_class_factory('eq') 6 | assert FilterClass('column', 'value').get_where() == '(column,eq,value)' 7 | 8 | 9 | def test_raw_template_filter_class_factory(): 10 | FilterClassWithoutParams = raw_template_filter_class_factory('()') 11 | FilterClassWithParams = raw_template_filter_class_factory('({},{},{})') 12 | FilterClassWithKwargs = raw_template_filter_class_factory('({},{op},{})') 13 | assert FilterClassWithoutParams().get_where() == '()' 14 | assert FilterClassWithParams('1', '2','3').get_where() == '(1,2,3)' 15 | assert FilterClassWithKwargs('1', '2', op='eq').get_where() == '(1,eq,2)' 16 | -------------------------------------------------------------------------------- /nocodb/filters/filters_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .. import filters 4 | 5 | from ..nocodb import WhereFilter 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "filter_class, expected_operator", 10 | [ 11 | (filters.EqFilter, "eq"), 12 | (filters.EqualFilter, "eq"), 13 | (filters.NotEqualFilter, "neq"), 14 | (filters.GreaterOrEqualFilter, "ge"), 15 | (filters.GreaterThanFilter, "gt"), 16 | (filters.LessThanFilter, "lt"), 17 | (filters.LessOrEqualFilter, "le"), 18 | (filters.LikeFilter, "like"), 19 | ], 20 | ) 21 | def test_basic_filters_are_correctly_created( 22 | filter_class: WhereFilter, expected_operator: str 23 | ): 24 | test_filter = filter_class("column", "value") 25 | assert test_filter.get_where() == f"(column,{expected_operator},value)" 26 | 27 | 28 | def test_or_filter(): 29 | nick_filter = filters.EqFilter("nickname", "elchicodepython") 30 | country_filter = filters.EqFilter("country", "es") 31 | nick_or_country_filter = filters.Or(nick_filter, country_filter) 32 | assert ( 33 | nick_or_country_filter.get_where() 34 | == "((nickname,eq,elchicodepython)~or(country,eq,es))" 35 | ) 36 | 37 | 38 | def test_and_filter(): 39 | nick_filter = filters.EqFilter("nickname", "elchicodepython") 40 | country_filter = filters.EqFilter("country", "es") 41 | nick_or_country_filter = filters.And(nick_filter, country_filter) 42 | assert ( 43 | nick_or_country_filter.get_where() 44 | == "((nickname,eq,elchicodepython)~and(country,eq,es))" 45 | ) 46 | 47 | 48 | def test_combined_filter(): 49 | nick_filter = filters.EqFilter("nickname", "elchicodepython") 50 | country_filter = filters.EqFilter("country", "es") 51 | girlfriend_code = filters.EqFilter("gfcode", "404") 52 | current_mood_code = filters.EqFilter("moodcode", "418") 53 | or_filter = filters.Or(nick_filter, country_filter) 54 | and_filter = filters.And(girlfriend_code, current_mood_code) 55 | or_combined_filter = filters.Or(or_filter, and_filter) 56 | and_combined_filter = filters.And(or_filter, and_filter) 57 | 58 | assert ( 59 | or_combined_filter.get_where() 60 | == "(((nickname,eq,elchicodepython)~or(country,eq,es))~or((gfcode,eq,404)~and(moodcode,eq,418)))" 61 | ) 62 | assert ( 63 | and_combined_filter.get_where() 64 | == "(((nickname,eq,elchicodepython)~or(country,eq,es))~and((gfcode,eq,404)~and(moodcode,eq,418)))" 65 | ) 66 | 67 | 68 | def test_not_filter(): 69 | me = filters.EqFilter("nickname", "elchicodepython") 70 | not_me = filters.Not(me) 71 | assert not_me.get_where() == "~not(nickname,eq,elchicodepython)" 72 | -------------------------------------------------------------------------------- /nocodb/filters/logical.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from ..nocodb import WhereFilter 3 | 4 | 5 | class Or(WhereFilter): 6 | def __init__(self, *filters: List[WhereFilter]): 7 | self.__filters = filters 8 | 9 | def get_where(self) -> str: 10 | return f"({'~or'.join([filter.get_where() for filter in self.__filters])})" 11 | 12 | 13 | class And(WhereFilter): 14 | def __init__(self, *filters: List[WhereFilter]): 15 | self.__filters = filters 16 | 17 | def get_where(self) -> str: 18 | return f"({'~and'.join([filter.get_where() for filter in self.__filters])})" 19 | 20 | 21 | class Not(WhereFilter): 22 | def __init__(self, filter: WhereFilter): 23 | self.__filter = filter 24 | 25 | def get_where(self) -> str: 26 | return f"~not{self.__filter.get_where()}" 27 | -------------------------------------------------------------------------------- /nocodb/filters/logical_test.py: -------------------------------------------------------------------------------- 1 | from nocodb import filters 2 | 3 | 4 | def test_or_with_two_filters(): 5 | filter1 = filters.EqFilter("column1", "value1") 6 | filter2 = filters.EqFilter("column2", "value2") 7 | or_filter = filters.Or(filter1, filter2) 8 | assert or_filter.get_where() == "((column1,eq,value1)~or(column2,eq,value2))" 9 | 10 | 11 | def test_and_with_two_filters(): 12 | filter1 = filters.And(filters.EqFilter("column1", "value1")) 13 | filter2 = filters.And(filters.EqFilter("column2", "value2")) 14 | and_filter = filters.And(filter1, filter2) 15 | assert and_filter.get_where() == "(((column1,eq,value1))~and((column2,eq,value2)))" 16 | 17 | 18 | def test_not_filter(): 19 | filter = filters.EqFilter("column", "value") 20 | not_filter = filters.Not(filter) 21 | assert not_filter.get_where() == "~not(column,eq,value)" 22 | -------------------------------------------------------------------------------- /nocodb/filters/raw_filter.py: -------------------------------------------------------------------------------- 1 | from ..nocodb import WhereFilter 2 | 3 | 4 | class RawFilter(WhereFilter): 5 | def __init__(self, raw: str): 6 | self.__raw = raw 7 | 8 | def get_where(self) -> str: 9 | return self.__raw 10 | 11 | 12 | class RawTemplateFilter(WhereFilter): 13 | def __init__(self, template: str, *args, **kwargs): 14 | self.__template = template 15 | self.__template_values = args 16 | self.__template_kvalues = kwargs 17 | 18 | def get_where(self) -> str: 19 | return self.__template.format(*self.__template_values, **self.__template_kvalues) 20 | -------------------------------------------------------------------------------- /nocodb/infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elchicodepython/python-nocodb/56e68f3b1dd5fe3ed83d20e041d088c25a736b0d/nocodb/infra/__init__.py -------------------------------------------------------------------------------- /nocodb/infra/requests_client.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from ..nocodb import ( 3 | NocoDBClient, 4 | NocoDBProject, 5 | AuthToken, 6 | WhereFilter, 7 | ) 8 | from ..api import NocoDBAPI 9 | from ..utils import get_query_params 10 | from ..exceptions import NocoDBAPIError 11 | 12 | import requests 13 | 14 | 15 | class NocoDBRequestsClient(NocoDBClient): 16 | def __init__(self, auth_token: AuthToken, base_uri: str): 17 | self.__session = requests.Session() 18 | self.__session.headers.update( 19 | auth_token.get_header(), 20 | ) 21 | self.__session.headers.update({"Content-Type": "application/json"}) 22 | self.__api_info = NocoDBAPI(base_uri) 23 | 24 | def _request(self, method: str, url: str, *args, **kwargs): 25 | response = self.__session.request(method, url, *args, **kwargs) 26 | response_json = None 27 | try: 28 | response.raise_for_status() 29 | response_json = response.json() 30 | except requests.exceptions.JSONDecodeError: 31 | ... 32 | except requests.exceptions.HTTPError as http_error: 33 | raise NocoDBAPIError( 34 | message=str(http_error), 35 | status_code=http_error.response.status_code, 36 | response_json=response_json, 37 | response_text=response.text 38 | ) 39 | 40 | return response 41 | 42 | def table_row_list( 43 | self, 44 | project: NocoDBProject, 45 | table: str, 46 | filter_obj: Optional[WhereFilter] = None, 47 | params: Optional[dict] = None, 48 | ) -> dict: 49 | return self._request( 50 | "GET", 51 | self.__api_info.get_table_uri(project, table), 52 | params=get_query_params(filter_obj, params), 53 | ).json() 54 | 55 | def table_row_create(self, project: NocoDBProject, table: str, body: dict) -> dict: 56 | return self._request( 57 | "POST", self.__api_info.get_table_uri(project, table), json=body 58 | ).json() 59 | 60 | def table_row_detail(self, project: NocoDBProject, table: str, row_id: int) -> dict: 61 | return self._request( 62 | "GET", 63 | self.__api_info.get_row_detail_uri(project, table, row_id), 64 | ).json() 65 | 66 | def table_row_update( 67 | self, project: NocoDBProject, table: str, row_id: int, body: dict 68 | ) -> dict: 69 | return self._request( 70 | "PATCH", 71 | self.__api_info.get_row_detail_uri(project, table, row_id), 72 | json=body, 73 | ).json() 74 | 75 | def table_row_delete(self, project: NocoDBProject, table: str, row_id: int) -> int: 76 | return self._request( 77 | "DELETE", 78 | self.__api_info.get_row_detail_uri(project, table, row_id), 79 | ).json() 80 | 81 | def table_count( 82 | self, 83 | project: NocoDBProject, 84 | table: str, 85 | filter_obj: Optional[WhereFilter] = None, 86 | ) -> dict: 87 | return self._request( 88 | "GET", 89 | self.__api_info.get_table_count_uri(project, table), 90 | params=get_query_params(filter_obj), 91 | ).json() 92 | 93 | def table_find_one( 94 | self, 95 | project: NocoDBProject, 96 | table: str, 97 | filter_obj: Optional[WhereFilter] = None, 98 | params: Optional[dict] = None, 99 | ) -> dict: 100 | return self._request( 101 | "GET", 102 | self.__api_info.get_table_find_one_uri(project, table), 103 | params=get_query_params(filter_obj, params), 104 | ).json() 105 | 106 | def table_row_nested_relations_list( 107 | self, 108 | project: NocoDBProject, 109 | table: str, 110 | relation_type: str, 111 | row_id: int, 112 | column_name: str, 113 | ) -> dict: 114 | return self._request( 115 | "GET", 116 | self.__api_info.get_nested_relations_rows_list_uri( 117 | project, table, relation_type, row_id, column_name 118 | ), 119 | ).json() 120 | 121 | def project_create(self, body): 122 | return self._request( 123 | "POST", self.__api_info.get_project_uri(), json=body 124 | ).json() 125 | 126 | def table_create( 127 | self, project: NocoDBProject, body: dict 128 | ) -> dict: 129 | return self._request( 130 | "POST", 131 | url=self.__api_info.get_project_tables_uri(project), 132 | json=body, 133 | ).json() 134 | 135 | def table_list( 136 | self, 137 | project: NocoDBProject, 138 | params: Optional[dict] = None, 139 | ) -> dict: 140 | return self._request( 141 | "GET", 142 | url=self.__api_info.get_project_tables_uri(project), 143 | params=params, 144 | ).json() 145 | 146 | def table_read( 147 | self, tableId: str, 148 | ) -> dict: 149 | return self._request( 150 | "GET", 151 | url=self.__api_info.get_table_meta_uri(tableId) 152 | ).json() 153 | 154 | def table_update( 155 | self, tableId: str, body: dict 156 | ): 157 | return self._request( 158 | "PATCH", 159 | url=self.__api_info.get_table_meta_uri(tableId), 160 | json=body, 161 | ).json() 162 | 163 | def table_delete( 164 | self, tableId: str, 165 | ) -> dict: 166 | return self._request( 167 | "DELETE", 168 | url=self.__api_info.get_table_meta_uri(tableId) 169 | ).json() 170 | 171 | def table_reorder( 172 | self, tableId: str, order: int 173 | ) -> dict: 174 | return self._request( 175 | "POST", 176 | url=self.__api_info.get_table_meta_uri(tableId, "reorder"), 177 | json={ "order": order } 178 | ).json() 179 | 180 | def table_column_create( 181 | self, tableId: str, body: dict, 182 | ) -> dict: 183 | return self._request( 184 | "POST", 185 | url=self.__api_info.get_table_meta_uri(tableId, "columns"), 186 | json=body, 187 | ).json() 188 | 189 | def table_column_update( 190 | self, columnId: str, body: dict, 191 | ) -> dict: 192 | return self._request( 193 | "PATCH", 194 | url=self.__api_info.get_column_uri(columnId), 195 | json=body, 196 | ).json() 197 | 198 | def table_column_delete( 199 | self, columnId: str, 200 | ) -> dict: 201 | return self._request( 202 | "DELETE", 203 | url=self.__api_info.get_column_uri(columnId) 204 | ).json() 205 | 206 | def table_column_set_primary( 207 | self, columnId: str, 208 | ) -> bool: 209 | return self._request( 210 | "POST", 211 | url=self.__api_info.get_column_uri(columnId, "primary"), 212 | ).json() 213 | -------------------------------------------------------------------------------- /nocodb/infra/requests_client_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | import requests 5 | 6 | from .requests_client import NocoDBRequestsClient, requests as requests_lib 7 | from ..exceptions import NocoDBAPIError 8 | 9 | 10 | @mock.patch.object(requests_lib, "Session") 11 | def test_NocoDBAPIError_raised_on_bad_response(mock_requests_session): 12 | mock_session = mock.Mock() 13 | mock_resp = requests.models.Response() 14 | mock_resp.status_code = 401 15 | mock_requests_session.return_value = mock_session 16 | mock_session.request.return_value = mock_resp 17 | 18 | client = NocoDBRequestsClient(mock.Mock(), "") 19 | with pytest.raises(NocoDBAPIError) as exc_info: 20 | client._request("GET", "/") 21 | 22 | assert exc_info.value.status_code == 401 23 | -------------------------------------------------------------------------------- /nocodb/nocodb.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | """ 5 | License MIT 6 | 7 | Copyright 2022 Samuel López Saura 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy of 10 | this software and associated documentation files (the "Software"), to deal in 11 | the Software without restriction, including without limitation the rights to 12 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 13 | of the Software, and to permit persons to whom the Software is furnished to do 14 | so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | """ 27 | 28 | 29 | class AuthToken(ABC): 30 | @abstractmethod 31 | def get_header(self) -> dict: 32 | pass 33 | 34 | 35 | class APIToken(AuthToken): 36 | def __init__(self, token: str): 37 | self.__token = token 38 | 39 | def get_header(self) -> dict: 40 | return {"xc-token": self.__token} 41 | 42 | 43 | class JWTAuthToken(AuthToken): 44 | def __init__(self, token: str): 45 | self.__token = token 46 | 47 | def get_header(self) -> dict: 48 | return {"xc-auth": self.__token} 49 | 50 | 51 | class WhereFilter(ABC): 52 | @abstractmethod 53 | def get_where(self) -> str: 54 | pass 55 | 56 | 57 | class NocoDBProject: 58 | def __init__(self, org_name: str, project_name: str): 59 | self.project_name = project_name 60 | self.org_name = org_name 61 | 62 | 63 | class NocoDBClient: 64 | @abstractmethod 65 | def table_row_list( 66 | self, project: NocoDBProject, table: str, filter_obj=None, params=None 67 | ) -> dict: 68 | pass 69 | 70 | @abstractmethod 71 | def table_row_list( 72 | self, 73 | project: NocoDBProject, 74 | table: str, 75 | filter_obj: Optional[WhereFilter] = None, 76 | params: Optional[dict] = None, 77 | ) -> dict: 78 | pass 79 | 80 | @abstractmethod 81 | def table_row_create( 82 | self, project: NocoDBProject, table: str, body: dict 83 | ) -> dict: 84 | pass 85 | 86 | @abstractmethod 87 | def table_row_detail( 88 | self, project: NocoDBProject, table: str, row_id: int 89 | ) -> dict: 90 | pass 91 | 92 | @abstractmethod 93 | def table_row_update( 94 | self, project: NocoDBProject, table: str, row_id: int, body: dict 95 | ) -> dict: 96 | pass 97 | 98 | @abstractmethod 99 | def table_row_delete( 100 | self, project: NocoDBProject, table: str, row_id: int 101 | ) -> int: 102 | pass 103 | 104 | @abstractmethod 105 | def table_row_nested_relations_list( 106 | self, 107 | project: NocoDBProject, 108 | table: str, 109 | relation_type: str, 110 | row_id: int, 111 | column_name: str, 112 | ) -> dict: 113 | pass 114 | 115 | @abstractmethod 116 | def table_create( 117 | self, project: NocoDBProject, body: dict 118 | ) -> dict: 119 | pass 120 | 121 | @abstractmethod 122 | def table_list( 123 | self, 124 | project: NocoDBProject, 125 | params: Optional[dict] = None, 126 | ) -> dict: 127 | pass 128 | 129 | @abstractmethod 130 | def table_read( 131 | self, tableId: str, 132 | ) -> dict: 133 | pass 134 | 135 | @abstractmethod 136 | def table_update( 137 | self, tableId: str, body: dict, 138 | ) -> bool: 139 | pass 140 | 141 | @abstractmethod 142 | def table_delete( 143 | self, tableId: str, 144 | ) -> dict: 145 | pass 146 | 147 | @abstractmethod 148 | def table_reorder( 149 | self, tableId: str, order: int, 150 | ) -> dict: 151 | pass 152 | 153 | @abstractmethod 154 | def table_column_create( 155 | self, tableId: str, body: dict, 156 | ) -> dict: 157 | pass 158 | 159 | @abstractmethod 160 | def table_column_update( 161 | self, columnId: str, body: dict, 162 | ) -> dict: 163 | pass 164 | 165 | @abstractmethod 166 | def table_column_delete( 167 | self, columnId: str, 168 | ) -> dict: 169 | pass 170 | 171 | @abstractmethod 172 | def table_column_set_primary( 173 | self, columnId: str, 174 | ) -> dict: 175 | pass 176 | -------------------------------------------------------------------------------- /nocodb/utils.py: -------------------------------------------------------------------------------- 1 | def get_query_params(filter_obj, params=None) -> dict: 2 | query_params = params or {} 3 | if filter_obj: 4 | query_params["where"] = filter_obj.get_where() 5 | return query_params 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='nocodb', 6 | version='2.0.1', 7 | author='Samuel López Saura', 8 | author_email='samuellopezsaura@gmail.com', 9 | packages=find_packages(), 10 | license='MIT', 11 | url='https://github.com/ElChicoDePython/python-nocodb', 12 | classifiers=[ 13 | "Programming Language :: Python :: 3", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | ], 17 | description='A package to use NocoDB API in a simple way', 18 | long_description=open('README.md').read(), 19 | long_description_content_type="text/markdown", 20 | install_requires=[ 21 | "requests>=2.0", 22 | ], 23 | ) 24 | --------------------------------------------------------------------------------