├── tests ├── __init__.py ├── data │ ├── empty.json │ ├── put_row.json │ ├── not_found.json │ ├── unauthorized.json │ ├── doc_deleted.json │ ├── row_not_found.json │ ├── create_doc.json │ ├── get_doc.json │ ├── get_column.json │ ├── get_tables.json │ ├── get_docs.json │ ├── get_updated_rows.json │ ├── get_row_by_query.json │ ├── get_rows.json │ ├── get_updated_row_by_query.json │ ├── get_updated_row.json │ ├── get_row.json │ ├── get_table.json │ ├── get_columns.json │ └── get_added_rows.json ├── test_Cell.py ├── test_Row.py ├── conftest.py ├── test_Coda.py └── test_Table.py ├── codaio ├── __init__.py ├── err.py └── coda.py ├── .gitignore ├── setup.cfg ├── noxfile.py ├── Makefile ├── source ├── index.rst ├── requirements.txt └── conf.py ├── pyproject.toml ├── .github └── workflows │ ├── test_and_deploy.yml │ └── codeql-analysis.yml ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/empty.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /codaio/__init__.py: -------------------------------------------------------------------------------- 1 | from .coda import Cell, Coda, Column, Document, Row, Table # noqa 2 | -------------------------------------------------------------------------------- /tests/data/put_row.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "row_id", 3 | "requestId": "request_id" 4 | } 5 | -------------------------------------------------------------------------------- /tests/data/not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "statusCode": 404, 3 | "statusMessage": "Not Found", 4 | "message": "Not Found" 5 | } 6 | -------------------------------------------------------------------------------- /tests/data/unauthorized.json: -------------------------------------------------------------------------------- 1 | { 2 | "statusCode": 401, 3 | "statusMessage": "Unauthorized", 4 | "message": "Unauthorized" 5 | } 6 | -------------------------------------------------------------------------------- /tests/data/doc_deleted.json: -------------------------------------------------------------------------------- 1 | { 2 | "statusCode": 404, 3 | "statusMessage": "Not Found", 4 | "message": "Doc has been deleted." 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .coverage 3 | .idea/ 4 | .nox 5 | *.bat 6 | *.xml 7 | build/ 8 | codaio.egg-info/ 9 | dist/ 10 | .editorconfig 11 | .env 12 | *.env 13 | -------------------------------------------------------------------------------- /tests/data/row_not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "statusCode": 404, 3 | "statusMessage": "Not Found", 4 | "message": "Could not find a row with the specified ID or name." 5 | } 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | max-line-length = 99 3 | 4 | [flake8] 5 | max-line-length = 99 6 | 7 | [isort] 8 | known_first_party = codaio 9 | default_section = THIRDPARTY 10 | line_length = 99 11 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 12 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | @nox.session 5 | def tests(session): 6 | args = session.posargs or ["--cov"] 7 | session.run("poetry", "install", external=True) 8 | session.run("pytest", *args) 9 | 10 | 11 | @nox.session 12 | def lint(session): 13 | session.install("flake8") 14 | session.run("flake8", "codaio", "tests") 15 | -------------------------------------------------------------------------------- /tests/data/create_doc.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "doc_id", 3 | "type": "doc", 4 | "href": "https://coda.io/apis/v1/docs/doc_id", 5 | "browserLink": "https://coda.io/d/_ddoc_id", 6 | "name": "New Document", 7 | "owner": "foo@bar.com", 8 | "ownerName": "Foo Bar", 9 | "createdAt": "2020-01-01T00:00:00.000Z", 10 | "updatedAt": "2020-01-01T00:00:00.000Z" 11 | } 12 | -------------------------------------------------------------------------------- /tests/data/get_doc.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "doc_id", 3 | "type": "doc", 4 | "name": "Test_Document", 5 | "owner": "foobar@example.com", 6 | "ownerName": "Foo Bar", 7 | "createdAt": "2020-01-01T00:00:00.000Z", 8 | "updatedAt": "2020-01-01T00:00:00.000Z", 9 | "href": "https://coda.io/apis/v1/docs/doc_id", 10 | "browserLink": "https://coda.io/d/_ddoc_id", 11 | "docSize": { 12 | "totalRowCount": 1, 13 | "tableAndViewCount": 1, 14 | "pageCount": 1, 15 | "overApiSizeLimit": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/data/get_column.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "column_id", 3 | "type": "column", 4 | "name": "Beta", 5 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id", 6 | "format": { 7 | "type": "text", 8 | "isArray": false 9 | }, 10 | "parent": { 11 | "id": "table_id", 12 | "type": "table", 13 | "tableType": "table", 14 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id", 15 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id", 16 | "name": "Main" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /codaio/err.py: -------------------------------------------------------------------------------- 1 | class CodaError(Exception): 2 | pass 3 | 4 | 5 | class NoApiKey(CodaError): 6 | pass 7 | 8 | 9 | class DocumentNotFound(CodaError): 10 | pass 11 | 12 | 13 | class InvalidFilter(CodaError): 14 | pass 15 | 16 | 17 | class NotFound(CodaError): 18 | pass 19 | 20 | 21 | class TableNotFound(NotFound): 22 | pass 23 | 24 | 25 | class RowNotFound(NotFound): 26 | pass 27 | 28 | 29 | class ColumnNotFound(NotFound): 30 | pass 31 | 32 | 33 | class AmbiguousName(CodaError): 34 | pass 35 | 36 | 37 | class InvalidCell(CodaError): 38 | pass 39 | -------------------------------------------------------------------------------- /tests/data/get_tables.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "table_id", 5 | "type": "table", 6 | "tableType": "table", 7 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id", 8 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id", 9 | "name": "Main", 10 | "parent": { 11 | "id": "canvas_id", 12 | "type": "page", 13 | "href": "https://coda.io/apis/v1/docs/doc_id/pages/canvas_id", 14 | "browserLink": "https://coda.io/d/_ddoc_id/_link", 15 | "name": "Test Document" 16 | } 17 | } 18 | ], 19 | "href": "https://coda.io/apis/v1/docs/doc_id/tables?pageToken=token" 20 | } 21 | -------------------------------------------------------------------------------- /tests/data/get_docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "doc_id", 5 | "type": "doc", 6 | "href": "https://coda.io/apis/v1/docs/doc_id", 7 | "browserLink": "https://coda.io/d/_ddoc_id", 8 | "name": "Test Document", 9 | "owner": "foobar@example.com", 10 | "ownerName": "Foo Bar", 11 | "createdAt": "2020-01-01T00:00:00.000Z", 12 | "updatedAt": "2020-01-01T00:00:00.000Z", 13 | "docSize": { 14 | "totalRowCount": 1, 15 | "tableAndViewCount": 1, 16 | "pageCount": 1, 17 | "overApiSizeLimit": false 18 | } 19 | } 20 | ], 21 | "href": "https://coda.io/apis/v1/docs?pageToken=token" 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/data/get_updated_rows.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "index_id", 5 | "type": "row", 6 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 7 | "name": "completely_new_value", 8 | "index": 0, 9 | "createdAt": "2020-01-01T00:00:00.000Z", 10 | "updatedAt": "2020-01-01T01:00:00.000Z", 11 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 12 | "values": { 13 | "column_id": "completely_new_value", 14 | "column_id-1": "", 15 | "column_id-2": "", 16 | "column_id-3": "" 17 | } 18 | } 19 | ], 20 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows?pageToken=token" 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/get_row_by_query.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "row_id", 5 | "type": "row", 6 | "name": "value-Alpha", 7 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/row_id", 8 | "index": 0, 9 | "createdAt": "2020-01-21T10:36:12.557Z", 10 | "updatedAt": "2020-01-21T10:36:12.557Z", 11 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_rurow_id", 12 | "values": { 13 | "column_id": "value-Alpha", 14 | "column_id-1": "value-Beta", 15 | "column_id-2": "value-Omega", 16 | "column_id-3": "value-Gamma" 17 | } 18 | } 19 | ], 20 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows?pageToken=token" 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/get_rows.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "index_id", 5 | "type": "row", 6 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 7 | "name": "value-1-Alpha", 8 | "index": 0, 9 | "createdAt": "2020-01-01T00:00:00.000Z", 10 | "updatedAt": "2020-01-01T00:00:00.000Z", 11 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 12 | "values": { 13 | "column_id": "value-1-Alpha", 14 | "column_id-1": "value-1-Beta", 15 | "column_id-2": "value-1-Omega", 16 | "column_id-3": "value-1-Gamma" 17 | } 18 | } 19 | ], 20 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows?pageToken=token" 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/get_updated_row_by_query.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "row_id", 5 | "type": "row", 6 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/row_id", 7 | "name": "value-1-Alpha", 8 | "index": 5, 9 | "createdAt": "2020-01-01T00:00:00.000Z", 10 | "updatedAt": "2020-01-01T01:00:00.000Z", 11 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_rurow_id", 12 | "values": { 13 | "column_id": "value-5-Alpha", 14 | "column_id-1": "updated_value", 15 | "column_id-2": "value-5-Omega", 16 | "column_id-3": "value-5-Gamma" 17 | } 18 | } 19 | ], 20 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows?pageToken=token" 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/get_updated_row.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "index_id", 3 | "type": "row", 4 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 5 | "name": "completely_new_value", 6 | "index": 0, 7 | "createdAt": "2020-01-01T00:00:00.000Z", 8 | "updatedAt": "2020-01-01T01:00:00.000Z", 9 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 10 | "values": { 11 | "column_id": "completely_new_value", 12 | "column_id-1": "", 13 | "column_id-2": "", 14 | "column_id-3": "" 15 | }, 16 | "parent": { 17 | "id": "table_id", 18 | "type": "table", 19 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id", 20 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id", 21 | "name": "Main" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/data/get_row.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "index_id", 3 | "type": "row", 4 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 5 | "name": "value-1-Alpha", 6 | "index": 0, 7 | "createdAt": "2020-01-01T00:00:00.000Z", 8 | "updatedAt": "2020-01-01T00:00:00.000Z", 9 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 10 | "values": { 11 | "column_id": "value-1-Alpha", 12 | "column_id-1": "value-1-Beta", 13 | "column_id-2": "value-1-Omega", 14 | "column_id-3": "value-1-Gamma" 15 | }, 16 | "parent": { 17 | "id": "table_id", 18 | "type": "table", 19 | "tableType": "table", 20 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id", 21 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id", 22 | "name": "Main" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | .. codaio documentation master file, created by 2 | sphinx-quickstart on Thu Aug 29 12:12:19 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | `codaio` documentation 7 | ================================== 8 | Python wrapper for `coda.io `_ API 9 | 10 | Project home: https://github.com/blasterai/codaio 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Contents: 15 | 16 | .. autoclass:: codaio.Coda 17 | :members: 18 | 19 | .. autoclass:: codaio.Document 20 | :members: 21 | 22 | .. autoclass:: codaio.Table 23 | :members: 24 | 25 | .. autoclass:: codaio.Column 26 | :members: 27 | 28 | .. autoclass:: codaio.Row 29 | :members: 30 | 31 | .. autoclass:: codaio.Cell 32 | :members: 33 | 34 | 35 | Indices and tables 36 | ================== 37 | 38 | * :ref:`genindex` 39 | * :ref:`search` 40 | -------------------------------------------------------------------------------- /source/requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | atomicwrites==1.3.0 3 | attrs==19.1.0 4 | Babel==2.9.1 5 | certifi==2023.7.22 6 | chardet==3.0.4 7 | Click==7.0 8 | colorama==0.4.1 9 | coverage==4.5.4 10 | docutils==0.15.2 11 | envparse==0.2.0 12 | idna==2.8 13 | imagesize==1.1.0 14 | importlib-metadata==0.19 15 | inflection==0.3.1 16 | Jinja2==2.11.3 17 | livereload==2.6.1 18 | Markdown==3.1.1 19 | MarkupSafe==1.1.1 20 | mkdocs==1.2.3 21 | more-itertools==7.2.0 22 | packaging==19.1 23 | pluggy==0.12.0 24 | py==1.10.0 25 | pydoc-markdown==2.0.5 26 | Pygments==2.15.0 27 | pyparsing==2.4.2 28 | pytest==5.1.1 29 | pytest-cov==2.7.1 30 | python-dateutil==2.8.0 31 | pytz==2019.2 32 | PyYAML==5.4 33 | requests==2.31.0 34 | six==1.12.0 35 | snowballstemmer==1.9.0 36 | Sphinx==2.2.0 37 | sphinxcontrib-applehelp==1.0.1 38 | sphinxcontrib-devhelp==1.0.1 39 | sphinxcontrib-htmlhelp==1.0.2 40 | sphinxcontrib-jsmath==1.0.1 41 | sphinxcontrib-qthelp==1.0.2 42 | sphinxcontrib-serializinghtml==1.1.3 43 | tornado==6.3.3 44 | urllib3==1.26.18 45 | wcwidth==0.1.7 46 | zipp==0.6.0 47 | sphinx_rtd_theme 48 | decorator 49 | -------------------------------------------------------------------------------- /tests/test_Cell.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codaio import Cell 4 | from tests.conftest import BASE_URL 5 | 6 | BASE_TABLE_URL = BASE_URL + "/docs/doc_id/tables/table_id/" 7 | 8 | 9 | class TestCell: 10 | @pytest.mark.parametrize("new_value", ["completely_new_value"]) 11 | def test_set_value(self, mock_json_responses, main_table, new_value): 12 | 13 | responses = [ 14 | ("rows?useColumnNames=False", "get_rows.json", {}), 15 | ("rows?useColumnNames=False", "get_updated_rows.json", {}), 16 | ("columns", "get_columns.json", {}), 17 | ("rows/index_id", "put_row.json", {"method": "PUT", "status": 202}), 18 | ("rows/index_id", "get_updated_row.json", {}), 19 | ] 20 | mock_json_responses(responses, BASE_TABLE_URL) 21 | 22 | cell_a = main_table.rows()[0].cells()[0] 23 | assert isinstance(cell_a, Cell) 24 | cell_a.value = new_value 25 | assert cell_a.value == new_value 26 | fetched_again_cell = main_table.rows()[0].cells()[0] 27 | assert fetched_again_cell.value == new_value 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "codaio" 3 | version = "0.6.12" 4 | description = "Python wrapper for Coda.io API" 5 | authors = ["MB "] 6 | license = "MIT" 7 | readme='README.md' 8 | homepage='https://github.com/Blasterai/codaio' 9 | documentation='https://codaio.readthedocs.io/en/latest/index.html' 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | requests = "^2.31.0" 14 | attrs = "^23.1.0" 15 | python-dateutil = "^2.8.2" 16 | inflection = "^0.5.1" 17 | envparse = "^0.2.0" 18 | decorator = "^5.1.1" 19 | 20 | [tool.poetry.dev-dependencies] 21 | pytest = "^7.4.3" 22 | pydoc-markdown = "^2.0" 23 | sphinx = "^2.2" 24 | sphinx_rtd_theme = "^0.4.3" 25 | pylint = "^2.4.4" 26 | pytest-coverage = "^0.0" 27 | coverage = {version = "^5.0.3", extras = ["toml"]} 28 | black = {version = "^19.10b0", allow-prereleases = true} 29 | responses = "^0.10.11" 30 | nox = "^2023.4.22" 31 | 32 | [tool.coverage.paths] 33 | source = ["src", "*/site-packages"] 34 | 35 | [tool.coverage.run] 36 | branch = true 37 | source = ["codaio"] 38 | 39 | [tool.coverage.report] 40 | show_missing = true 41 | 42 | [build-system] 43 | requires = ["poetry>=0.12"] 44 | build-backend = "poetry.masonry.api" 45 | 46 | -------------------------------------------------------------------------------- /tests/test_Row.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codaio import Cell, Column, Row 4 | from tests.conftest import BASE_URL 5 | 6 | 7 | @pytest.fixture 8 | def mock_row_responses(mock_json_responses): 9 | base_table_url = BASE_URL + "/docs/doc_id/tables/table_id/" 10 | responses = [ 11 | ("rows?useColumnNames=False", "get_rows.json", {}), 12 | ("columns", "get_columns.json", {}), 13 | ("rows/index_id", "get_row.json", {}), 14 | ] 15 | mock_json_responses(responses, base_url=base_table_url) 16 | 17 | 18 | @pytest.mark.usefixtures("mock_row_responses") 19 | class TestRow: 20 | def test_get_cell_by_column_id(self, main_table, mock_json_responses): 21 | row_a: Row = main_table.rows()[0] 22 | cell_a: Cell = row_a.cells()[0] 23 | assert isinstance(cell_a, Cell) 24 | 25 | fetched_cell = row_a.get_cell_by_column_id(cell_a.column.id) 26 | assert isinstance(fetched_cell, Cell) 27 | 28 | def test_row_getitem(self, main_table, mock_json_responses): 29 | 30 | row_a: Row = main_table.rows()[0] 31 | column_a: Column = main_table.columns()[0] 32 | assert isinstance(row_a, Row) 33 | assert isinstance(column_a, Column) 34 | 35 | res_cell = row_a[column_a] 36 | assert isinstance(res_cell, Cell) 37 | assert res_cell.column == column_a 38 | assert res_cell.row == row_a 39 | 40 | def test_refresh(self, main_table, mock_json_responses): 41 | row_a: Row = main_table.rows()[0] 42 | assert row_a.refresh() 43 | -------------------------------------------------------------------------------- /tests/data/get_table.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "table_id", 3 | "type": "table", 4 | "tableType": "table", 5 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id", 6 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id", 7 | "parent": { 8 | "id": "canvas_id", 9 | "type": "page", 10 | "href": "https://coda.io/apis/v1/docs/doc_id/pages/canvas_id", 11 | "browserLink": "https://coda.io/d/_ddoc_id/_link", 12 | "name": "Test Document" 13 | }, 14 | "name": "Main", 15 | "displayColumn": { 16 | "id": "column_id", 17 | "type": "column", 18 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id" 19 | }, 20 | "rowCount": 1, 21 | "createdAt": "2020-01-01T00:00:00.000Z", 22 | "updatedAt": "2020-01-01T00:00:00.000Z", 23 | "sorts": [], 24 | "layout": "default", 25 | "filter": { 26 | "valid": true, 27 | "isVolatile": false, 28 | "hasUserFormula": false, 29 | "hasTodayFormula": false, 30 | "hasNowFormula": false 31 | }, 32 | "parentTable": { 33 | "id": "grid-pqRst-U", 34 | "type": "table", 35 | "tableType": "table", 36 | "href": "https://coda.io/apis/v1/docs/AbCDeFGH/tables/grid-pqRst-U", 37 | "browserLink": "https://coda.io/d/_dAbCDeFGH/#Teams-and-Tasks_tpqRst-U", 38 | "name": "Tasks", 39 | "parent": { 40 | "id": "canvas-IjkLmnO", 41 | "type": "page", 42 | "href": "https://coda.io/apis/v1/docs/AbCDeFGH/pages/canvas-IjkLmnO", 43 | "browserLink": "https://coda.io/d/_dAbCDeFGH/Launch-Status_sumnO", 44 | "name": "Launch Status" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/data/get_columns.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "column_id", 5 | "type": "column", 6 | "name": "Alpha", 7 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id", 8 | "display": true, 9 | "format": { 10 | "type": "text", 11 | "isArray": false 12 | } 13 | }, 14 | { 15 | "id": "column_id-1", 16 | "type": "column", 17 | "name": "Beta", 18 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id-1", 19 | "format": { 20 | "type": "text", 21 | "isArray": false 22 | } 23 | }, 24 | { 25 | "id": "column_id-2", 26 | "type": "column", 27 | "name": "Omega", 28 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id-2", 29 | "format": { 30 | "type": "text", 31 | "isArray": false 32 | } 33 | }, 34 | { 35 | "id": "column_id-3", 36 | "type": "column", 37 | "name": "Gamma", 38 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id-3", 39 | "format": { 40 | "type": "text", 41 | "isArray": false 42 | } 43 | }, 44 | { 45 | "id": "column_id-4", 46 | "type": "column", 47 | "name": "Delta", 48 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns/column_id-4", 49 | "format": { 50 | "type": "text", 51 | "isArray": false 52 | }, 53 | "calculated": true, 54 | "formula": "1=1" 55 | } 56 | ], 57 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/columns?pageToken=token" 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: codaio CI 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - master 10 | push: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: [ "3.9", "3.10" ] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install nox and poetry 27 | run: | 28 | pip install nox poetry 29 | - name: Lint with flake8 30 | run: | 31 | pip install flake8 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | - name: Test with nox 37 | env: 38 | CODA_API_KEY: ${{ secrets.CODA_API_KEY }} 39 | TEST_DOC_ID: ${{ secrets.TEST_DOC_ID }} 40 | run: | 41 | nox 42 | 43 | deploy: 44 | runs-on: ubuntu-latest 45 | 46 | if: github.event_name == 'push' 47 | needs: test 48 | 49 | steps: 50 | - uses: actions/checkout@v2 51 | 52 | - name: Build and publish to pypi 53 | uses: JRubics/poetry-publish@v2.0 54 | with: 55 | pypi_token: ${{ secrets.PYPI_TOKEN }} 56 | -------------------------------------------------------------------------------- /source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import sys 14 | from pathlib import Path 15 | 16 | ROOT_DIR = Path(__file__).parent.parent 17 | CODAIO_DIR = ROOT_DIR / "codaio" 18 | 19 | sys.path.insert(0, ROOT_DIR.as_posix()) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "codaio" 24 | copyright = "2019, Mikhail Beliansky" 25 | author = "Mikhail Beliansky" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["sphinx.ext.autodoc"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = [] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "sphinx_rtd_theme" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ["_static"] 55 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | import responses 4 | import json 5 | from codaio import Coda, Document 6 | 7 | BASE_URL = "https://coda.io/apis/v1" 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def coda(): 12 | API_KEY = "ANY_KEY" 13 | return Coda(API_KEY) 14 | 15 | 16 | @pytest.fixture 17 | def mocked_responses(): 18 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 19 | yield rsps 20 | 21 | 22 | @pytest.fixture 23 | def mock_unauthorized_response(mock_json_response): 24 | def _mock_unauthorized_response(method): 25 | url = BASE_URL + "/" 26 | json_file = "unauthorized.json" 27 | mock_json_response(url, json_file, status=401, method=method) 28 | 29 | return _mock_unauthorized_response 30 | 31 | 32 | @pytest.fixture 33 | def mock_json_response(mocked_responses): 34 | """ 35 | register mocked json responses. 36 | 37 | For a url, return the content of a json file found in the /test/data/ folder. 38 | """ 39 | 40 | def _mock_json_response_from_file( 41 | url, filename, method="GET", status=200, **kwargs 42 | ): 43 | test_directory = Path(__file__).parent.resolve() 44 | relative_data_directory = "data" 45 | json_path = Path(test_directory / relative_data_directory / filename) 46 | with open(json_path) as json_file: 47 | json_content = json.load(json_file) 48 | 49 | method_map = { 50 | "ANY": responses.UNSET, 51 | "GET": responses.GET, 52 | "POST": responses.POST, 53 | "PUT": responses.PUT, 54 | "DELETE": responses.DELETE, 55 | "PATCH": responses.PATCH, 56 | "HEAD": responses.HEAD, 57 | } 58 | 59 | mocked_responses.add( 60 | method_map.get(method), url, json=json_content, status=status, **kwargs 61 | ) 62 | 63 | return _mock_json_response_from_file 64 | 65 | 66 | @pytest.fixture 67 | def mock_json_responses(mock_json_response): 68 | """ 69 | register multiple json responses. 70 | 71 | Responses should be passed as a list of (url, filename, kwargs) tupples. 72 | """ 73 | 74 | def _mock_json_responses(json_responses, base_url=None): 75 | for url, filename, kwargs in json_responses: 76 | mock_json_response(base_url + url, filename, **kwargs) 77 | 78 | return _mock_json_responses 79 | 80 | 81 | @pytest.fixture 82 | def main_document(coda, mock_json_response): 83 | mock_json_response(BASE_URL + "/docs/doc_id/", "get_doc.json") 84 | return Document("doc_id", coda=coda) 85 | 86 | 87 | @pytest.fixture 88 | def main_table(main_document, mock_json_response): 89 | mock_json_response(BASE_URL + "/docs/doc_id/tables/table_id", "get_table.json") 90 | return main_document.get_table("table_id") 91 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 13 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /tests/test_Coda.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codaio import Coda, err 4 | from tests.conftest import BASE_URL 5 | 6 | BASE_DOC_URL = BASE_URL + "/docs" 7 | 8 | 9 | class TestCoda: 10 | def test_init(self, coda): 11 | assert isinstance(coda, Coda) 12 | 13 | def test_raise_GET(self, coda, mock_unauthorized_response): 14 | mock_unauthorized_response("GET") 15 | with pytest.raises(err.CodaError): 16 | coda.get("/") 17 | 18 | def test_raise_POST(self, coda, mock_unauthorized_response): 19 | mock_unauthorized_response("POST") 20 | with pytest.raises(err.CodaError): 21 | coda.post("/", {}) 22 | 23 | def test_raise_PUT(self, coda, mock_unauthorized_response): 24 | mock_unauthorized_response("PUT") 25 | with pytest.raises(err.CodaError): 26 | coda.put("/", {}) 27 | 28 | def test_raise_DELETE(self, coda, mock_unauthorized_response): 29 | mock_unauthorized_response("DELETE") 30 | with pytest.raises(err.CodaError): 31 | coda.delete("/") 32 | 33 | def test_list_documents(self, coda, mock_json_response): 34 | url = BASE_DOC_URL 35 | json_file = "get_docs.json" 36 | 37 | mock_json_response(url, json_file) 38 | 39 | docs = coda.list_docs() 40 | assert docs 41 | 42 | def test_create_doc(self, coda, mock_json_response): 43 | url = BASE_DOC_URL 44 | json_file = "get_doc.json" 45 | mock_json_response(url, json_file, method="POST") 46 | 47 | response = coda.create_doc("Test_Document") 48 | doc_id = response["id"] 49 | assert doc_id 50 | 51 | def test_get_doc(self, coda, mock_json_response): 52 | doc_id = "doc_id" 53 | url = BASE_DOC_URL + "/doc_id" 54 | json_file = "get_doc.json" 55 | mock_json_response(url, json_file) 56 | 57 | data = coda.get_doc(doc_id) 58 | assert data["id"] == doc_id 59 | 60 | def test_delete_doc(self, coda, mock_json_response): 61 | doc_id = "doc_id" 62 | delete_url = BASE_DOC_URL + "/doc_id" 63 | json_file = "empty.json" 64 | mock_json_response(delete_url, json_file, method="DELETE", status=202) 65 | 66 | coda.delete_doc(doc_id) 67 | 68 | get_url = BASE_DOC_URL + "/doc_id" 69 | file_not_found_json = "doc_deleted.json" 70 | mock_json_response(get_url, file_not_found_json, status=404) 71 | 72 | with pytest.raises(err.CodaError): 73 | coda.get_doc(doc_id) 74 | 75 | def test_get_rows(self, coda, mock_json_response): 76 | doc_id = 'doc_id' 77 | table_id_or_name = 'table_id' 78 | sync_token = 'test_token' 79 | 80 | url = BASE_DOC_URL + ( 81 | f'/doc_id/tables/table_id/rows' 82 | f'?useColumnNames=False&syncToken={sync_token}' 83 | ) 84 | json_file = 'get_row_by_query.json' 85 | 86 | mock_json_response(url, json_file) 87 | coda.list_rows( 88 | doc_id=doc_id, 89 | table_id_or_name=table_id_or_name, 90 | sync_token=sync_token, 91 | ) 92 | -------------------------------------------------------------------------------- /tests/data/get_added_rows.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "index_id", 5 | "type": "row", 6 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 7 | "name": "value-1-Alpha", 8 | "index": 0, 9 | "createdAt": "2020-01-01T00:00:00.000Z", 10 | "updatedAt": "2020-01-01T00:00:00.000Z", 11 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 12 | "values": { 13 | "column_id": "value-1-Alpha", 14 | "column_id-1": "value-1-Beta", 15 | "column_id-2": "value-1-Omega", 16 | "column_id-3": "value-1-Gamma" 17 | } 18 | }, 19 | { 20 | "id": "index_id-1", 21 | "type": "row", 22 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 23 | "name": "value-2-Alpha", 24 | "index": 1, 25 | "createdAt": "2020-01-01T00:00:00.000Z", 26 | "updatedAt": "2020-01-01T01:00:00.000Z", 27 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 28 | "values": { 29 | "column_id": "value-2-Alpha", 30 | "column_id-1": "value-2-Beta", 31 | "column_id-2": "value-2-Omega", 32 | "column_id-3": "value-2-Gamma" 33 | } 34 | }, 35 | { 36 | "id": "index_id-2", 37 | "type": "row", 38 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 39 | "name": "value-3-Alpha", 40 | "index": 2, 41 | "createdAt": "2020-01-01T00:00:00.000Z", 42 | "updatedAt": "2020-01-01T01:00:00.000Z", 43 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 44 | "values": { 45 | "column_id": "value-3-Alpha", 46 | "column_id-1": "value-3-Beta", 47 | "column_id-2": "value-3-Omega", 48 | "column_id-3": "value-3-Gamma" 49 | } 50 | }, 51 | { 52 | "id": "index_id-3", 53 | "type": "row", 54 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 55 | "name": "value-4-Alpha", 56 | "index": 3, 57 | "createdAt": "2020-01-01T00:00:00.000Z", 58 | "updatedAt": "2020-01-01T01:00:00.000Z", 59 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 60 | "values": { 61 | "column_id": "value-4-Alpha", 62 | "column_id-1": "value-4-Beta", 63 | "column_id-2": "value-4-Omega", 64 | "column_id-3": "value-4-Gamma" 65 | } 66 | }, 67 | { 68 | "id": "index_id-4", 69 | "type": "row", 70 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows/index_id", 71 | "name": "value-5-Alpha", 72 | "index": 4, 73 | "createdAt": "2020-01-01T00:00:00.000Z", 74 | "updatedAt": "2020-01-01T01:00:00.000Z", 75 | "browserLink": "https://coda.io/d/_ddoc_id#_tutable_id/_ruindex_id", 76 | "values": { 77 | "column_id": "value-5-Alpha", 78 | "column_id-1": "value-5-Beta", 79 | "column_id-2": "value-5-Omega", 80 | "column_id-3": "value-5-Gamma" 81 | } 82 | } 83 | ], 84 | "href": "https://coda.io/apis/v1/docs/doc_id/tables/table_id/rows?pageToken=token" 85 | } 86 | -------------------------------------------------------------------------------- /tests/test_Table.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codaio import Cell, Column, Row, err 4 | from tests.conftest import BASE_URL 5 | 6 | 7 | @pytest.fixture 8 | def mock_table_responses(mock_json_responses): 9 | base_table_url = BASE_URL + "/docs/doc_id/tables/table_id/" 10 | responses = [ 11 | ("rows?useColumnNames=False", "get_rows.json", {}), 12 | ("columns", "get_columns.json", {}), 13 | ("column/column_id", "get_column.json", {}), 14 | ("rows/index_id", "get_row.json", {}), 15 | ("rows", "empty.json", {"method": "POST"}), 16 | ("rows?useColumnNames=False", "get_added_rows.json", {}), 17 | ("rows/index_id", "empty.json", {"method": "DELETE"}), 18 | ( 19 | "rows?useColumnNames=False&query=column_id%3A%22value-Alpha%22", 20 | "get_row_by_query.json", 21 | {}, 22 | ), 23 | ( 24 | "rows?useColumnNames=False&query=column_id%3A%22value-5-Alpha%22", 25 | "get_updated_row_by_query.json", 26 | {}, 27 | ), 28 | ("rows/no_such_id", "row_not_found.json", {"status": 404}), 29 | ] 30 | mock_json_responses(responses, base_url=base_table_url) 31 | 32 | 33 | @pytest.mark.usefixtures("mock_table_responses") 34 | class TestTable: 35 | def test_columns(self, main_table): 36 | assert main_table.columns() 37 | assert isinstance(main_table.columns()[0], Column) 38 | 39 | def test_get_column_by_id(self, main_table): 40 | columns = main_table.columns() 41 | for col in columns: 42 | assert main_table.get_column_by_id(col.id) == col 43 | with pytest.raises(err.CodaError): 44 | main_table.get_column_by_id("no_such_id") 45 | 46 | def test_get_row_by_id(self, main_table): 47 | rows = main_table.rows() 48 | for row in rows: 49 | fetched_row = main_table.get_row_by_id(row.id) 50 | assert fetched_row == row 51 | with pytest.raises(err.NotFound): 52 | main_table.get_row_by_id("no_such_id") 53 | 54 | def test_table_getitem(self, main_table): 55 | assert main_table[main_table.rows()[0].id] == main_table.rows()[0] 56 | assert main_table[main_table.rows()[0]] == main_table.rows()[0] 57 | 58 | def test_upsert_row(self, main_table): 59 | columns = main_table.columns() 60 | cell_1 = Cell(columns[0], f"value-{columns[0].name}") 61 | cell_2 = Cell(columns[1], f"value-{columns[1].name}") 62 | result = main_table.upsert_row([cell_1, cell_2]) 63 | assert result["status"] == 200 64 | rows = main_table.find_row_by_column_id_and_value( 65 | cell_1.column.id, cell_1.value 66 | ) 67 | row = rows[0] 68 | assert isinstance(row, Row) 69 | assert row[cell_1.column.id].value == cell_1.value 70 | assert row[cell_2.column.id].value == cell_2.value 71 | 72 | def test_upsert_rows_by_column_id(self, main_table): 73 | existing_rows = main_table.rows() 74 | 75 | for row in existing_rows: 76 | main_table.delete_row(row) 77 | 78 | result = main_table.upsert_rows( 79 | [ 80 | [ 81 | Cell(column.id, f"value-{str(row)}-{column.name}") 82 | for column in main_table.columns() 83 | ] 84 | for row in range(1, 6) 85 | ] 86 | ) 87 | assert result["status"] == 200 88 | 89 | saved_rows = main_table.rows() 90 | 91 | assert len(saved_rows) == 5 92 | assert all([isinstance(row, Row) for row in saved_rows]) 93 | 94 | def test_upsert_existing_rows(self, main_table): 95 | columns = main_table.columns() 96 | key_column = columns[0] 97 | 98 | result = main_table.upsert_rows( 99 | [ 100 | [Cell(column, f"value-{str(row)}-{column.name}") for column in columns] 101 | for row in range(1, 11) 102 | ] 103 | ) 104 | 105 | assert result["status"] == 200 106 | 107 | cell_to_update_1 = Cell(key_column, f"value-5-{columns[0].name}") 108 | cell_to_update_2 = Cell(columns[1], "updated_value") 109 | 110 | row_to_update = [cell_to_update_1, cell_to_update_2] 111 | 112 | result = main_table.upsert_rows([row_to_update], key_columns=[key_column]) 113 | 114 | assert result["status"] == 200 115 | updated_rows = main_table.find_row_by_column_id_and_value( 116 | cell_to_update_1.column.id, cell_to_update_1.value 117 | ) 118 | 119 | assert len(updated_rows) == 1 120 | 121 | updated_row = updated_rows[0] 122 | assert ( 123 | updated_row.get_cell_by_column_id(columns[1].id).value 124 | == cell_to_update_2.value 125 | ) 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine) 2 | 3 | ## Python wrapper for [Coda.io](https://coda.io) API 4 | 5 | [![CodaAPI](https://img.shields.io/badge/Coda_API_-V1-green)](https://coda.io/developers/apis/v1beta1) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/codaio) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | [![Documentation Status](https://readthedocs.org/projects/codaio/badge/?version=latest)](https://codaio.readthedocs.io/en/latest/?badge=latest) 9 | [![PyPI](https://img.shields.io/pypi/v/codaio)](https://pypi.org/project/codaio/) 10 | ![PyPI - Downloads](https://img.shields.io/pypi/dw/codaio) 11 | [![](https://img.shields.io/badge/Support-Buy_coffee!-Orange)](https://www.buymeacoffee.com/licht1stein) 12 | 13 | Don't hesitate to contribute, issues and PRs very welcome! 14 | 15 | 16 | ### Installation 17 | Install with [poetry](https://python-poetry.org/) (always recommended): 18 | 19 | ```shell script 20 | poetry add codaio 21 | ``` 22 | 23 | or with `pip` 24 | 25 | ```shell script 26 | pip install codaio 27 | ``` 28 | 29 | ### Config via environment variables 30 | The following variables will be called from environment where applicable: 31 | 32 | * `CODA_API_ENDPOINT` (default value `https://coda.io/apis/v1`) 33 | * `CODA_API_KEY` - your API key to use when initializing client from environment 34 | 35 | ### Quickstart using raw API 36 | Coda class provides a wrapper for all API methods. If API response included a JSON it will be returned as a dictionary from all methods. If it didn't a dictionary `{"status": response.status_code}` will be returned. 37 | If request wasn't successful a `CodaError` will be raised with details of the API error. 38 | 39 | ```python 40 | from codaio import Coda 41 | 42 | coda = Coda('YOUR_API_KEY') 43 | 44 | >>> coda.create_doc('My Document') 45 | {'id': 'NEW_DOC_ID', 'type': 'doc', 'href': 'https://coda.io/apis/v1/docs/NEW_DOC_ID', 'browserLink': 'https://coda.io/d/_dNEW_DOC_ID', 'name': 'My Document', 'owner': 'your@email', 'ownerName': 'Your Name', 'createdAt': '2020-09-28T19:32:20.866Z', 'updatedAt': '2020-09-28T19:32:20.924Z'} 46 | ``` 47 | For full API reference for Coda class see [documentation](https://codaio.readthedocs.io/en/latest/index.html#codaio.Coda) 48 | 49 | ### Quickstart using codaio objects 50 | 51 | `codaio` implements convenient classes to work with Coda documents: `Document`, `Table`, `Row`, `Column` and `Cell`. 52 | 53 | ```python 54 | from codaio import Coda, Document 55 | 56 | # Initialize by providing a coda object directly 57 | coda = Coda('YOUR_API_KEY') 58 | 59 | doc = Document('YOUR_DOC_ID', coda=coda) 60 | 61 | # Or initialiaze from environment by storing your API key in environment variable `CODA_API_KEY` 62 | doc = Document.from_environment('YOUR_DOC_ID') 63 | 64 | doc.list_tables() 65 | 66 | table = doc.get_table('TABLE_ID') 67 | ``` 68 | #### Fetching a Row 69 | ```python 70 | # You can fetch a row by ID 71 | row = table['ROW_ID'] 72 | ``` 73 | 74 | #### Using with Pandas 75 | If you want to load a codaio Table or Row into pandas, you can use the `Table.to_dict()` or `Row.to_dict()` methods: 76 | ```python 77 | import pandas as pd 78 | 79 | df = pd.DataFrame(table.to_dict()) 80 | ``` 81 | 82 | #### Fetching a Cell 83 | ```python 84 | # Or fetch a cell by ROW_ID and COLUMN_ID 85 | cell = table['ROW_ID']['COLUMN_ID'] 86 | 87 | # This is equivalent to getting item from a row 88 | cell = row['COLUMN_ID'] 89 | # or 90 | cell = row['COLUMN_NAME'] # This should work fine if COLUMN_NAME is unique, otherwise it will raise AmbiguousColumn error 91 | # or use a Column instance 92 | cell = row[column] 93 | ``` 94 | 95 | #### Changing Cell value 96 | 97 | ```python 98 | row['COLUMN_ID'] = 'foo' 99 | # or 100 | row['Column Name'] = 'foo' 101 | ``` 102 | 103 | #### Iterating over rows 104 | ```python 105 | # Iterate over rows using IDs -> delete rows that match a condition 106 | for row in table.rows(): 107 | if row['COLUMN_ID'] == 'foo': 108 | row.delete() 109 | 110 | # Iterate over rows using names -> edit cells in rows that match a condition 111 | for row in table.rows(): 112 | if row['Name'] == 'bar': 113 | row['Value'] = 'spam' 114 | ``` 115 | 116 | #### Upserting new row 117 | To upsert a new row you can pass a list of cells to `table.upsert_row()` 118 | ```python 119 | name_cell = Cell(column='COLUMN_ID', value_storage='new_name') 120 | value_cell = Cell(column='COLUMN_ID', value_storage='new_value') 121 | 122 | table.upsert_row([name_cell, value_cell]) 123 | ``` 124 | 125 | #### Upserting multiple new rows 126 | Works like upserting one row, except you pass a list of lists to `table.upsert_rows()` (rows, not row) 127 | ```python 128 | name_cell_a = Cell(column='COLUMN_ID', value_storage='new_name') 129 | value_cell_a = Cell(column='COLUMN_ID', value_storage='new_value') 130 | 131 | name_cell_b = Cell(column='COLUMN_ID', value_storage='new_name') 132 | value_cell_b = Cell(column='COLUMN_ID', value_storage='new_value') 133 | 134 | table.upsert_rows([[name_cell_a, value_cell_a], [name_cell_b, value_cell_b]]) 135 | ``` 136 | 137 | #### Updating a row 138 | To update a row use `table.update_row(row, cells)` 139 | ```python 140 | row = table['ROW_ID'] 141 | 142 | name_cell_a = Cell(column='COLUMN_ID', value_storage='new_name') 143 | value_cell_a = Cell(column='COLUMN_ID', value_storage='new_value') 144 | 145 | table.update_row(row, [name_cell_a, value_cell_a]) 146 | ``` 147 | 148 | ### Documentation 149 | 150 | `codaio` documentation lives at [readthedocs.io](https://codaio.readthedocs.io/en/latest/index.html) 151 | 152 | ### Running the tests 153 | 154 | The recommended way of running the test suite is to use [nox](https://nox.thea.codes/en/stable/tutorial.html). 155 | 156 | Once `nox`: is installed, just run the following command: 157 | ```shell script 158 | nox 159 | ``` 160 | 161 | The nox session will run the test suite against python 3.8 and 3.7. It will also look for linting errors with `flake8`. 162 | 163 | You can still invoke `pytest` directly with: 164 | ```shell script 165 | poetry run pytest --cov 166 | ``` 167 | 168 | Check out the fixtures if you want to improve the testing process. 169 | 170 | 171 | #### Contributing 172 | 173 | If you are willing to contribute please go ahead, we can use some help! 174 | 175 | ##### Using CI to deploy to PyPi 176 | 177 | When a PR is merged to master the CI will try to deploy to pypi.org using poetry. It will succeed only if the 178 | version number changed in pyproject.toml. 179 | 180 | To do so use poetry's [version command](https://python-poetry.org/docs/cli/#version). For example: 181 | 182 | Bump 0.4.11 to 0.4.12: 183 | ```bash 184 | poetry version patch 185 | ``` 186 | 187 | Bump 0.4.11 to 0.5.0: 188 | ```bash 189 | poetry version minor 190 | ``` 191 | 192 | Bump 0.4.11 to 1.0.0: 193 | ```bash 194 | poetry version major 195 | ``` 196 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /codaio/coda.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime as dt 4 | import json 5 | import time 6 | from typing import Any, Dict, List, Tuple, Union 7 | 8 | import attr 9 | import inflection 10 | from dateutil.parser import parse 11 | from decorator import decorator 12 | import warnings 13 | from envparse import env 14 | 15 | from codaio import err 16 | 17 | with warnings.catch_warnings(record=False): 18 | warnings.simplefilter("ignore") 19 | env.read_envfile() 20 | 21 | # Trying to make it compatible with eventlet 22 | USE_HTTPX = env("USE_HTTPX", cast=bool, default=False) 23 | if not USE_HTTPX: 24 | import requests 25 | else: 26 | try: 27 | import httpx as requests 28 | except ImportError: 29 | import requests 30 | 31 | 32 | MAX_GET_LIMIT = 200 33 | 34 | 35 | @decorator 36 | def handle_response(func, *args, **kwargs) -> Dict: 37 | response = func(*args, **kwargs) 38 | 39 | if isinstance(response, List): 40 | res = {} 41 | items = [] 42 | for r in response: 43 | if r.json().get("items"): 44 | items.extend(r.json().pop("items")) 45 | 46 | res.update(r.json()) 47 | if items: 48 | res["items"] = items 49 | return res 50 | 51 | if 200 <= response.status_code <= 299: 52 | if not response.json(): 53 | return {"status": response.status_code} 54 | return response.json() 55 | 56 | error_dict = {404: err.NotFound} 57 | 58 | if response.status_code in error_dict: 59 | raise error_dict[response.status_code]( 60 | f'Status code: {response.status_code}. Message: {response.json()["message"]}' 61 | ) 62 | 63 | raise err.CodaError( 64 | f'Status code: {response.status_code}. Message: {response.json()["message"]}' 65 | ) 66 | 67 | 68 | @attr.s(hash=True) 69 | class Coda: 70 | """ 71 | Raw API client. 72 | 73 | It is used in `codaio` objects like Document to access the raw API endpoints. 74 | Can also be used by itself to access Raw API. 75 | """ 76 | 77 | api_key: str = attr.ib(repr=False) 78 | authorization: Dict = attr.ib(init=False, repr=False) 79 | href: str = attr.ib( 80 | repr=False, 81 | default=env("CODA_API_ENDPOINT", cast=str, default="https://coda.io/apis/v1"), 82 | ) 83 | 84 | @classmethod 85 | def from_environment(cls) -> Coda: 86 | """ 87 | Instantiates Coda using the API key stored in the `CODA_API_KEY` environment variable. 88 | 89 | :return: 90 | """ 91 | api_key = env("CODA_API_KEY", cast=str) 92 | return cls(api_key=api_key) 93 | 94 | def __attrs_post_init__(self): 95 | self.authorization = {"Authorization": f"Bearer {self.api_key}"} 96 | 97 | @handle_response 98 | def get(self, endpoint: str, data: Dict = None, limit=None, offset=None) -> Dict: 99 | """ 100 | Makes a GET request to API endpoint. 101 | 102 | :param endpoint: API endpoint to request 103 | 104 | :param data: dictionary of optional query params 105 | 106 | :param limit: Maximum number of results to return in this query. 107 | 108 | :param offset: An opaque token used to fetch the next page of results. 109 | 110 | :return: 111 | """ 112 | if not data: 113 | data = {} 114 | if limit: 115 | if limit > MAX_GET_LIMIT: 116 | limit = MAX_GET_LIMIT 117 | data["limit"] = limit 118 | 119 | if offset: 120 | data["pageToken"] = offset 121 | r = requests.get(self.href + endpoint, params=data, headers=self.authorization) 122 | if limit or not r.json().get("nextPageLink"): 123 | return r 124 | 125 | res = [r] 126 | while r.json().get("nextPageLink"): 127 | next_page = r.json()["nextPageLink"] 128 | r = requests.get(next_page, headers=self.authorization) 129 | res.append(r) 130 | return res 131 | 132 | # noinspection PyTypeChecker 133 | @handle_response 134 | def post(self, endpoint: str, data: Dict) -> Dict: 135 | """ 136 | Makes a POST request to the API endpoint. 137 | 138 | :param endpoint: API endpoint to request 139 | 140 | :param data: data dict to be sent as body json 141 | 142 | :return: 143 | """ 144 | return requests.post( 145 | self.href + endpoint, 146 | json=data, 147 | headers={**self.authorization, "Content-Type": "application/json"}, 148 | ) 149 | 150 | # noinspection PyTypeChecker 151 | @handle_response 152 | def put(self, endpoint: str, data: Dict) -> Dict: 153 | """ 154 | Makes a PUT request to the API endpoint. 155 | 156 | :param endpoint: API endpoint to request 157 | 158 | :param data: data dict to be sent as body json 159 | 160 | :return: 161 | """ 162 | return requests.put(self.href + endpoint, json=data, headers=self.authorization) 163 | 164 | # noinspection PyTypeChecker 165 | @handle_response 166 | def delete(self, endpoint: str, data: Dict = None) -> Dict: 167 | """ 168 | Makes a DELETE request to the API endpoint. 169 | 170 | :param endpoint: API endpoint to request 171 | 172 | :param data: data dict to be sent as body json 173 | 174 | :return: 175 | """ 176 | if data is not None: 177 | return requests.delete( 178 | self.href + endpoint, json=data, headers=self.authorization 179 | ) 180 | 181 | return requests.delete(self.href + endpoint, headers=self.authorization) 182 | 183 | def list_docs( 184 | self, 185 | is_owner: bool = False, 186 | query: str = None, 187 | source_doc_id: str = None, 188 | limit: int = None, 189 | offset: int = None, 190 | ) -> Dict: 191 | """ 192 | Returns a list of Coda documents accessible by the user. 193 | 194 | These are returned in the same order as on the docs page: reverse 195 | chronological by the latest event relevant to the user (last viewed, edited, or shared). 196 | 197 | Docs: https://coda.io/developers/apis/v1/#operation/listDocs 198 | 199 | :param is_owner: Show only docs owned by the user. 200 | 201 | :param query: Search term used to filter down results. 202 | 203 | :param source_doc_id: Show only docs copied from the specified doc ID. 204 | 205 | :param limit: Maximum number of results to return in this query. 206 | 207 | :param offset: An opaque token used to fetch the next page of results. 208 | 209 | :return: 210 | """ 211 | return self.get( 212 | "/docs", 213 | data={"isOwner": is_owner, "query": query, "sourceDoc": source_doc_id}, 214 | limit=limit, 215 | offset=offset, 216 | ) 217 | 218 | def create_doc(self, title: str, source_doc: str = None, tz: str = None) -> Dict: 219 | """ 220 | Creates a new Coda doc, optionally copying an existing doc. 221 | 222 | Docs: https://coda.io/developers/apis/v1/#operation/createDoc 223 | 224 | :param title: Title of the new doc. 225 | 226 | :param source_doc: An optional doc ID from which to create a copy. 227 | 228 | :param tz: The timezone to use for the newly created doc. 229 | 230 | :return: 231 | """ 232 | data = {"title": title} 233 | if source_doc: 234 | data["sourceDoc"] = source_doc 235 | if tz: 236 | data["timezone"] = tz 237 | 238 | return self.post("/docs", data) 239 | 240 | def get_doc(self, doc_id: str) -> Dict: 241 | """ 242 | Returns metadata for the specified doc. 243 | 244 | Docs: https://coda.io/developers/apis/v1/#operation/getDoc 245 | 246 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 247 | 248 | :return: 249 | """ 250 | return self.get("/docs/" + doc_id) 251 | 252 | def delete_doc(self, doc_id: str) -> Dict: 253 | """ 254 | Deletes a doc. 255 | 256 | Docs: https://coda.io/developers/apis/v1/#operation/deleteDoc 257 | 258 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 259 | 260 | :return: 261 | """ 262 | return self.delete("/docs/" + doc_id) 263 | 264 | def list_sections(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 265 | """ 266 | Returns a list of sections in a Coda doc. 267 | 268 | Docs: https://coda.io/developers/apis/v1/#operation/listSections 269 | 270 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 271 | 272 | :param limit: Maximum number of results to return in this query. 273 | 274 | :param offset: An opaque token used to fetch the next page of results. 275 | 276 | :return: 277 | """ 278 | return self.get(f"/docs/{doc_id}/pages", offset=offset, limit=limit) 279 | 280 | def get_section(self, doc_id: str, section_id_or_name: str) -> Dict: 281 | """ 282 | Returns details about a section. 283 | 284 | Docs: https://coda.io/developers/apis/v1/#operation/getSection 285 | 286 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 287 | 288 | :param section_id_or_name: ID or name of the section. 289 | Names are discouraged because they're easily prone to being changed by users. 290 | If you're using a name, be sure to URI-encode it. Example: "canvas-IjkLmnO" 291 | 292 | :return: 293 | """ 294 | return self.get(f"/docs/{doc_id}/pages/{section_id_or_name}") 295 | 296 | def list_folders(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 297 | """ 298 | Returns a list of folders in a Coda doc. 299 | 300 | Docs: https://coda.io/developers/apis/v1/#operation/listFolders 301 | 302 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 303 | 304 | :param limit: Maximum number of results to return in this query. 305 | 306 | :param offset: An opaque token used to fetch the next page of results. 307 | 308 | :return: 309 | """ 310 | return self.get(f"/docs/{doc_id}/folders", offset=offset, limit=limit) 311 | 312 | def get_folder(self, doc_id: str, folder_id_or_name: str) -> Dict: 313 | """ 314 | Returns details about a folder. 315 | 316 | Docs: https://coda.io/developers/apis/v1/#operation/getFolder 317 | 318 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 319 | 320 | :param folder_id_or_name: ID or name of the folder. 321 | Names are discouraged because they're easily prone to being 322 | changed by users. If you're using a name, be sure to URI-encode it. 323 | Example: "section-IjkLmnO" 324 | 325 | :return: 326 | """ 327 | return self.get(f"/docs/{doc_id}/folders/{folder_id_or_name}") 328 | 329 | def list_tables(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 330 | """ 331 | Returns a list of tables in a Coda doc. 332 | 333 | Docs: https://coda.io/developers/apis/v1/#operation/listTables 334 | 335 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 336 | 337 | :param limit: Maximum number of results to return in this query. 338 | 339 | :param offset: An opaque token used to fetch the next page of results. 340 | 341 | :return: 342 | """ 343 | return self.get(f"/docs/{doc_id}/tables", offset=offset, limit=limit) 344 | 345 | def get_table(self, doc_id: str, table_id_or_name: str) -> Dict: 346 | """ 347 | Returns details about a specific table. 348 | 349 | Docs: https://coda.io/developers/apis/v1/#operation/getTable 350 | 351 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 352 | 353 | :param table_id_or_name: ID or name of the table. 354 | Names are discouraged because they're easily prone to being changed by users. 355 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 356 | 357 | :return: 358 | """ 359 | return self.get(f"/docs/{doc_id}/tables/{table_id_or_name}") 360 | 361 | def list_views(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 362 | """ 363 | Returns a list of views in a Coda doc. 364 | 365 | Docs: https://coda.io/developers/apis/v1/#operation/listViews 366 | 367 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 368 | 369 | :param limit: Maximum number of results to return in this query. 370 | 371 | :param offset: An opaque token used to fetch the next page of results. 372 | 373 | :return: 374 | """ 375 | return self.get( 376 | f"/docs/{doc_id}/tables?tableTypes=view", offset=offset, limit=limit 377 | ) 378 | 379 | def get_view(self, doc_id: str, view_id_or_name: str) -> Dict: 380 | """ 381 | Returns details about a specific view. 382 | 383 | Docs: https://coda.io/developers/apis/v1/#operation/getView 384 | 385 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 386 | 387 | :param view_id_or_name: ID or name of the view. 388 | Names are discouraged because they're easily prone to being changed by users. 389 | If you're using a name, be sure to URI-encode it. Example: "table-pqRst-U" 390 | 391 | :return: 392 | """ 393 | return self.get(f"/docs/{doc_id}/tables/{view_id_or_name}") 394 | 395 | def list_columns( 396 | self, doc_id: str, table_id_or_name: str, offset: int = None, limit: int = None 397 | ) -> Dict: 398 | """ 399 | Returns a list of columns in a table. 400 | 401 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 402 | 403 | :param table_id_or_name: ID or name of the table. 404 | Names are discouraged because they're easily prone to being changed by users. 405 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 406 | 407 | :param limit: Maximum number of results to return in this query. 408 | 409 | :param offset: An opaque token used to fetch the next page of results. 410 | 411 | :return: 412 | """ 413 | return self.get( 414 | f"/docs/{doc_id}/tables/{table_id_or_name}/columns", 415 | offset=offset, 416 | limit=limit, 417 | ) 418 | 419 | def get_column( 420 | self, doc_id: str, table_id_or_name: str, column_id_or_name: str 421 | ) -> Dict: 422 | """ 423 | Returns details about a column in a table. 424 | 425 | Docs: https://coda.io/developers/apis/v1/#operation/getColumn 426 | 427 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 428 | 429 | :param table_id_or_name: ID or name of the table. 430 | Names are discouraged because they're easily prone to being changed by users. 431 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 432 | 433 | :param column_id_or_name: ID or name of the column. 434 | Names are discouraged because they're easily prone to being changed by users. 435 | If you're using a name, be sure to URI-encode it. Example: "c-tuVwxYz" 436 | 437 | :return: 438 | """ 439 | return self.get( 440 | f"/docs/{doc_id}/tables/{table_id_or_name}/columns/{column_id_or_name}" 441 | ) 442 | 443 | def list_rows( 444 | self, 445 | doc_id: str, 446 | table_id_or_name: str, 447 | query: str = None, 448 | use_column_names: bool = False, 449 | limit: int = None, 450 | offset: int = None, 451 | sync_token: str = None, 452 | ) -> Dict: 453 | """ 454 | Returns a list of rows in a table. 455 | 456 | Docs: https://coda.io/developers/apis/v1/#tag/Rows 457 | 458 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 459 | 460 | :param table_id_or_name: ID or name of the table. 461 | Names are discouraged because they're easily prone to being changed by users. 462 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 463 | 464 | :param query: filter returned rows, specified as `:`. 465 | If you'd like to use a column name instead of an ID, 466 | you must quote it (e.g., `"My Column":123`). 467 | Also note that `value` is a JSON value; if you'd like to use a string, 468 | you must surround it in quotes (e.g., `"groceries"`). 469 | 470 | :param use_column_names: Use column names instead of column IDs in the returned output. 471 | This is generally discouraged as it is fragile. 472 | If columns are renamed, code using original names may throw errors. 473 | 474 | :param limit: Maximum number of results to return in this query. 475 | 476 | :param offset: An opaque token used to fetch the next page of results. 477 | 478 | :param sync_token: An opaque token returned from a previous call that 479 | can be used to return results that are relevant to the query since 480 | the call where the syncToken was generated.. 481 | """ 482 | data = {"useColumnNames": use_column_names} 483 | if query: 484 | data["query"] = query 485 | 486 | if sync_token: 487 | data['syncToken'] = sync_token 488 | 489 | return self.get( 490 | f"/docs/{doc_id}/tables/{table_id_or_name}/rows", 491 | data=data, 492 | limit=limit, 493 | offset=offset, 494 | ) 495 | 496 | def upsert_row(self, doc_id: str, table_id_or_name: str, data: Dict) -> Dict: 497 | """ 498 | Inserts rows into a table, optionally updating existing rows if key columns are provided. 499 | 500 | This endpoint will always return a 202, so long as the doc and table exist and 501 | are accessible (and the update is structurally valid). Row inserts/upserts are generally 502 | processed within several seconds. 503 | When upserting, if multiple rows match the specified key column(s), 504 | they will all be updated with the specified value. 505 | 506 | Docs: https://coda.io/developers/apis/v1/#operation/upsertRows 507 | 508 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 509 | 510 | :param table_id_or_name: ID or name of the table. 511 | Names are discouraged because they're easily prone to being changed by users. 512 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 513 | 514 | :param data: 515 | { 516 | "rows": [{"cells": [{"column": "c-tuVwxYz", "value": "$12.34"}]}], 517 | "keyColumns": ["c-bCdeFgh"] 518 | } 519 | """ 520 | return self.post(f"/docs/{doc_id}/tables/{table_id_or_name}/rows", data) 521 | 522 | def get_row(self, doc_id: str, table_id_or_name: str, row_id_or_name: str) -> Dict: 523 | """ 524 | Returns details about a row in a table. 525 | 526 | Docs: https://coda.io/developers/apis/v1/#operation/getRow 527 | 528 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 529 | 530 | :param table_id_or_name: ID or name of the table. 531 | Names are discouraged because they're easily prone to being changed by users. 532 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 533 | 534 | :param row_id_or_name: ID or name of the row. 535 | Names are discouraged because they're easily prone to being changed by users. 536 | If you're using a name, be sure to URI-encode it. 537 | If there are multiple rows with the same value in the identifying column, 538 | an arbitrary one will be selected. 539 | """ 540 | return self.get( 541 | f"/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}" 542 | ) 543 | 544 | def update_row( 545 | self, doc_id: str, table_id_or_name: str, row_id_or_name: str, data: Dict 546 | ) -> Dict: 547 | """ 548 | Updates the specified row in the table. 549 | 550 | This endpoint will always return a 202, so long as the doc and table exist and 551 | are accessible (and the update is structurally valid). Row updates are generally 552 | processed within several seconds. 553 | When updating using a name as opposed to an ID, an arbitrary row will be affected. 554 | 555 | Docs: https://coda.io/developers/apis/v1/#operation/updateRow 556 | 557 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 558 | 559 | :param table_id_or_name: ID or name of the table. 560 | Names are discouraged because they're easily prone to being changed by users. 561 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 562 | 563 | :param row_id_or_name: ID or name of the row. 564 | Names are discouraged because they're easily prone to being changed by users. 565 | If you're using a name, be sure to URI-encode it. 566 | If there are multiple rows with the same value in the identifying column, 567 | an arbitrary one will be selected. 568 | 569 | :param data: Example: {"row": {"cells": [{"column": "c-tuVwxYz", "value": "$12.34"}]}} 570 | """ 571 | return self.put( 572 | f"/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}", data 573 | ) 574 | 575 | def delete_row(self, doc_id, table_id_or_name: str, row_id_or_name: str) -> Dict: 576 | """ 577 | Deletes the specified row from the table. 578 | 579 | This endpoint will always return a 202, so long as the row exists and 580 | is accessible (and the update is structurally valid). 581 | Row deletions are generally processed within several seconds. 582 | When deleting using a name as opposed to an ID, an arbitrary row will be removed. 583 | 584 | Docs: https://coda.io/developers/apis/v1/#operation/deleteRow 585 | 586 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 587 | 588 | :param table_id_or_name: ID or name of the table. 589 | Names are discouraged because they're easily prone to being changed by users. 590 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 591 | 592 | :param row_id_or_name: ID or name of the row. 593 | Names are discouraged because they're easily prone to being changed by users. 594 | If you're using a name, be sure to URI-encode it. 595 | If there are multiple rows with the same value in the identifying column, 596 | an arbitrary one will be selected. 597 | """ 598 | return self.delete( 599 | f"/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}" 600 | ) 601 | 602 | def list_formulas(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 603 | """ 604 | Returns a list of named formulas in a Coda doc. 605 | 606 | Docs: https://coda.io/developers/apis/v1/#operation/listFormulas 607 | 608 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 609 | 610 | :param limit: Maximum number of results to return in this query. 611 | 612 | :param offset: An opaque token used to fetch the next page of results. 613 | """ 614 | return self.get(f"/docs/{doc_id}/formulas", offset=offset, limit=limit) 615 | 616 | def get_formula(self, doc_id: str, formula_id_or_name: str) -> Dict: 617 | """ 618 | Returns info on a formula. 619 | 620 | Docs: https://coda.io/developers/apis/v1/#operation/getFormula 621 | 622 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 623 | 624 | :param formula_id_or_name: ID or name of the formula. 625 | Names are discouraged because they're easily prone to being changed by users. 626 | If you're using a name, be sure to URI-encode it. Example: "f-fgHijkLm". 627 | """ 628 | return self.get(f"/docs/{doc_id}/formulas/{formula_id_or_name}") 629 | 630 | def list_controls(self, doc_id: str, offset: int = None, limit: int = None) -> Dict: 631 | """ 632 | Lists controls and get their current values. 633 | 634 | Controls provide a user-friendly way to input a value 635 | that can affect other parts of the doc. 636 | 637 | Docs: https://coda.io/developers/apis/v1/#tag/Controls 638 | 639 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 640 | 641 | :param limit: Maximum number of results to return in this query. 642 | 643 | :param offset: An opaque token used to fetch the next page of results. 644 | 645 | :return: 646 | """ 647 | return self.get(f"/docs/{doc_id}/controls", offset=offset, limit=limit) 648 | 649 | def get_control(self, doc_id: str, control_id_or_name: str) -> Dict: 650 | """ 651 | Returns info on a control. 652 | 653 | Docs: https://coda.io/developers/apis/v1/#operation/getControl 654 | 655 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 656 | :param control_id_or_name: ID or name of the control. 657 | Names are discouraged because they're easily prone to being changed by users. 658 | If you're using a name, be sure to URI-encode it. Example: "ctrl-cDefGhij". 659 | """ 660 | return self.get(f"/docs/{doc_id}/controls/{control_id_or_name}") 661 | 662 | def account(self) -> Dict: 663 | """ 664 | Retrieves logged-in account information. 665 | 666 | At this time, the API exposes some limited information about your account. 667 | However, /whoami is a good endpoint to hit to verify that 668 | you're hitting the API correctly and that your token is working as expected. 669 | 670 | Docs: https://coda.io/developers/apis/v1/#tag/Account 671 | """ 672 | return self.get("/whoami") 673 | 674 | def resolve_browser_link(self, url: str, degrade_gracefully: bool = False) -> Dict: 675 | """ 676 | Retrieves the metadata of a Coda object for an URL. 677 | 678 | Given a browser link to a Coda object, attempts to find it and 679 | return metadata that can be used to get more info on it. 680 | Returns a 400 if the URL does not appear to be a Coda URL or a 681 | 404 if the resource cannot be located with the current credentials. 682 | 683 | Docs: https://coda.io/developers/apis/v1/#operation/resolveBrowserLink 684 | 685 | :param url: The browser link to try to resolve. 686 | Example: "https://coda.io/d/_dAbCDeFGH/Launch-Status_sumnO" 687 | 688 | :param degrade_gracefully: By default, attempting to resolve the Coda URL 689 | of a deleted object will result in an error. If this flag is set, 690 | the next-available object, all the way up to the doc itself, will be resolved. 691 | """ 692 | return self.get( 693 | "/resolveBrowserLink", 694 | data={"url": url, "degradeGracefully": degrade_gracefully}, 695 | ) 696 | 697 | 698 | @attr.s(hash=True) 699 | class CodaObject: 700 | id: str = attr.ib(repr=False) 701 | type: str = attr.ib(repr=False) 702 | href: str = attr.ib(repr=False) 703 | 704 | document: Document = attr.ib(repr=False) 705 | 706 | @classmethod 707 | def from_json(cls, js: Dict, *, document: Document): 708 | js = {inflection.underscore(k): v for k, v in js.items()} 709 | for key in ["parent", "format"]: 710 | if key in js: 711 | js.pop(key) 712 | return cls(**js, document=document) 713 | 714 | 715 | @attr.s(hash=True) 716 | class Document: 717 | """Main class for interacting with coda.io API using `codaio` objects.""" 718 | 719 | id: str = attr.ib(repr=False) 720 | type: str = attr.ib(init=False, repr=False) 721 | href: str = attr.ib(init=False, repr=False) 722 | name: str = attr.ib(init=False) 723 | owner: str = attr.ib(init=False) 724 | created_at: dt.datetime = attr.ib(init=False, repr=False) 725 | updated_at: dt.datetime = attr.ib(init=False, repr=False) 726 | browser_link: str = attr.ib(init=False) 727 | coda: Coda = attr.ib(repr=False) 728 | 729 | @classmethod 730 | def from_environment(cls, doc_id: str): 731 | """ 732 | Instantiates a `Document` with the API key in the `CODA_API_KEY` environment variable. 733 | 734 | :param doc_id: ID of the doc. Example: "AbCDeFGH" 735 | 736 | :return: 737 | """ 738 | return cls(id=doc_id, coda=Coda.from_environment()) 739 | 740 | def __attrs_post_init__(self): 741 | self.href = f"/docs/{self.id}" 742 | data = self.coda.get(self.href + "/") 743 | if not data: 744 | raise err.DocumentNotFound(f"No document with id {self.id}") 745 | self.name = data["name"] 746 | self.owner = data["owner"] 747 | self.created_at = parse(data["createdAt"]) 748 | self.updated_at = parse(data["updatedAt"]) 749 | self.type = data["type"] 750 | self.browser_link = data["browserLink"] 751 | 752 | def list_sections(self, offset: int = None, limit: int = None) -> List[Section]: 753 | """ 754 | Returns a list of `Section` objects for each section in the document. 755 | 756 | :param limit: Maximum number of results to return in this query. 757 | 758 | :param offset: An opaque token used to fetch the next page of results. 759 | 760 | :return: 761 | """ 762 | return [ 763 | Section.from_json(i, document=self) 764 | for i in self.coda.list_sections(self.id, offset=offset, limit=limit)[ 765 | "items" 766 | ] 767 | ] 768 | 769 | def list_tables(self, offset: int = None, limit: int = None) -> List[Table]: 770 | """ 771 | Returns a list of `Table` objects for each table in the document. 772 | 773 | :param limit: Maximum number of results to return in this query. 774 | 775 | :param offset: An opaque token used to fetch the next page of results. 776 | 777 | :return: 778 | """ 779 | 780 | return [ 781 | Table.from_json(i, document=self) 782 | for i in self.coda.list_tables(self.id, offset=offset, limit=limit)["items"] 783 | ] 784 | 785 | def get_table(self, table_id_or_name: str) -> Table: 786 | """ 787 | Gets a Table object from table name or ID. 788 | 789 | :param table_id_or_name: ID or name of the table. 790 | Names are discouraged because they're easily prone to being changed by users. 791 | If you're using a name, be sure to URI-encode it. Example: "grid-pqRst-U" 792 | 793 | :return: 794 | """ 795 | table_data = self.coda.get_table(self.id, table_id_or_name) 796 | if table_data: 797 | return Table.from_json(table_data, document=self) 798 | raise err.TableNotFound(f"{table_id_or_name}") 799 | 800 | 801 | @attr.s(auto_attribs=True, hash=True) 802 | class Folder(CodaObject): 803 | pass 804 | 805 | 806 | @attr.s(auto_attribs=True, hash=True) 807 | class Section(CodaObject): 808 | name: str 809 | browser_link: str = attr.ib(repr=False) 810 | document: Document = attr.ib(repr=False) 811 | 812 | 813 | @attr.s(auto_attribs=True, hash=True) 814 | class Table(CodaObject): 815 | name: str 816 | document: Document = attr.ib(repr=False) 817 | display_column: Dict = attr.ib(default=None, repr=False) 818 | browser_link: str = attr.ib(default=None, repr=False) 819 | row_count: int = attr.ib(default=None, repr=False) 820 | sorts: List = attr.ib(default=[], repr=False) 821 | layout: str = attr.ib(repr=False, default=None) 822 | table_type: str = attr.ib(default=None, repr=False) 823 | created_at: dt.datetime = attr.ib( 824 | repr=False, converter=lambda x: parse(x) if x else None, default=None 825 | ) 826 | updated_at: dt.datetime = attr.ib( 827 | repr=False, converter=lambda x: parse(x) if x else None, default=None 828 | ) 829 | columns_storage: List[Column] = attr.ib(default=[], repr=False) 830 | filter: Dict = attr.ib(default=None, repr=False) 831 | parent_table: Table = attr.ib(default=None, repr=False) 832 | view_id: str = attr.ib(default=None, repr=False) 833 | 834 | def __getitem__(self, item): 835 | """ 836 | table[row_id] -> Row with this id 837 | table[Row] -> Row with id == Row.id 838 | 839 | table[row_id][column_id] -> Cell from this intersection 840 | table[row_id][Column] -> Cell from this intersection 841 | 842 | :param item: 843 | 844 | :return: 845 | """ 846 | if isinstance(item, str): 847 | return self.get_row_by_id(item) 848 | elif isinstance(item, Row): 849 | return self.get_row_by_id(item.id) 850 | raise ValueError("item type must be in [str, Row]") 851 | 852 | def columns(self, offset: int = None, limit: int = None) -> List[Column]: 853 | """ 854 | Lists Table columns. 855 | 856 | Columns are stored in self.columns_storage for faster access 857 | as they tend to change less frequently than rows. 858 | 859 | :param limit: Maximum number of results to return in this query. 860 | 861 | :param offset: An opaque token used to fetch the next page of results. 862 | 863 | :return: 864 | """ 865 | if not self.columns_storage: 866 | self.columns_storage = [ 867 | Column.from_json({**i, "table": self}, document=self.document) 868 | for i in self.document.coda.list_columns( 869 | self.document.id, self.id, offset=offset, limit=limit 870 | )["items"] 871 | ] 872 | return self.columns_storage 873 | 874 | def rows(self, offset: int = None, limit: int = None) -> List[Row]: 875 | """ 876 | Returns list of Table rows. 877 | 878 | :param limit: Maximum number of results to return in this query. 879 | 880 | :param offset: An opaque token used to fetch the next page of results. 881 | 882 | :return: 883 | """ 884 | return [ 885 | Row.from_json({"table": self, **i}, document=self.document) 886 | for i in self.document.coda.list_rows( 887 | self.document.id, self.id, offset=offset, limit=limit 888 | )["items"] 889 | ] 890 | 891 | def get_row_by_id(self, row_id: str) -> Row: 892 | row_js = self.document.coda.get_row(self.document.id, self.id, row_id) 893 | row = Row.from_json({**row_js, "table": self}, document=self.document) 894 | return row 895 | 896 | def get_column_by_id(self, column_id) -> Column: 897 | """ 898 | Gets a Column by id. 899 | 900 | :param column_id: ID of the column. Example: "c-tuVwxYz" 901 | 902 | :return: 903 | """ 904 | try: 905 | return next(filter(lambda x: x.id == column_id, self.columns())) 906 | except StopIteration: 907 | raise err.ColumnNotFound(f"No column with id {column_id}") 908 | 909 | def get_column_by_name(self, column_name) -> Column: 910 | """ 911 | Gets a Column by id. 912 | 913 | :param column_name: Name of the column. Discouraged in case using column_id is possible. 914 | Example: "Column 1" 915 | 916 | :return: 917 | """ 918 | res = list(filter(lambda x: x.name == column_name, self.columns())) 919 | if not res: 920 | raise err.ColumnNotFound(f"No column with name: {column_name}") 921 | if len(res) > 1: 922 | raise err.AmbiguousName( 923 | "More than 1 column found. Try using ID instead of Name" 924 | ) 925 | return res[0] 926 | 927 | def find_row_by_column_name_and_value( 928 | self, column_name: str, value: Any 929 | ) -> List[Row]: 930 | """ 931 | Finds rows by a value in column specified by name (discouraged). 932 | 933 | :param column_name: Name of the column. 934 | 935 | :param value: Search value. 936 | 937 | :return: 938 | """ 939 | r = self.document.coda.list_rows( 940 | self.document.id, self.id, query=f'"{column_name}":{json.dumps(value)}' 941 | ) 942 | if not r.get("items"): 943 | return [] 944 | return [ 945 | Row.from_json({**i, "table": self}, document=self.document) 946 | for i in r["items"] 947 | ] 948 | 949 | def find_row_by_column_id_and_value(self, column_id, value) -> List[Row]: 950 | """ 951 | Finds rows by a value in column specified by id. 952 | 953 | :param column_id: ID of the column. 954 | 955 | :param value: Search value. 956 | 957 | :return: 958 | """ 959 | r = self.document.coda.list_rows( 960 | self.document.id, self.id, query=f"{column_id}:{json.dumps(value)}" 961 | ) 962 | if not r.get("items"): 963 | return [] 964 | return [ 965 | Row.from_json({**i, "table": self}, document=self.document) 966 | for i in r["items"] 967 | ] 968 | 969 | def upsert_row( 970 | self, cells: List[Cell], key_columns: List[Union[str, Column]] = None 971 | ) -> Dict: 972 | """ 973 | Upsert a Table row using a list of `Cell` objects optionally updating existing rows. 974 | 975 | :param cells: list of `Cell` objects. 976 | :param key_columns: list of `Column` objects, column IDs, URLs, or names 977 | specifying columns to be used as upsert keys. 978 | """ 979 | 980 | return self.upsert_rows([cells], key_columns) 981 | 982 | def upsert_rows( 983 | self, 984 | rows: List[List[Cell]], 985 | key_columns: List[Union[str, Column]] = None, 986 | ) -> Dict: 987 | """ 988 | Upsert multiple Table rows optionally updating existing rows. 989 | 990 | Works similar to Table.upsert_row() but uses 1 POST request for multiple rows. 991 | Input is a list of lists of Cells. 992 | 993 | :param rows: list of lists of `Cell` objects, one list for each row. 994 | :param key_columns: list of `Column` objects, column IDs, URLs, or names 995 | specifying columns to be used as upsert keys. 996 | """ 997 | data = { 998 | "rows": [ 999 | { 1000 | "cells": [ 1001 | {"column": cell.column_id_or_name, "value": cell.value} 1002 | for cell in row 1003 | ] 1004 | } 1005 | for row in rows 1006 | ] 1007 | } 1008 | 1009 | if key_columns: 1010 | if not isinstance(key_columns, list): 1011 | raise err.ColumnNotFound( 1012 | f"key_columns parameter '{key_columns}' is not a list." 1013 | ) 1014 | 1015 | data["keyColumns"] = [] 1016 | 1017 | for key_column in key_columns: 1018 | if isinstance(key_column, Column): 1019 | data["keyColumns"].append(key_column.id) 1020 | elif isinstance(key_column, str): 1021 | data["keyColumns"].append(key_column) 1022 | else: 1023 | raise err.ColumnNotFound( 1024 | f"Invalid parameter: '{key_column}' in key_columns." 1025 | ) 1026 | 1027 | return self.document.coda.upsert_row(self.document.id, self.id, data) 1028 | 1029 | def update_row(self, row: Union[str, Row], cells: List[Cell]) -> Dict: 1030 | """ 1031 | Updates row with values according to list in cells. 1032 | 1033 | :param row: a str ROW_ID or an instance of class Row 1034 | :param cells: list of `Cell` objects. 1035 | """ 1036 | if isinstance(row, Row): 1037 | row_id = row.id 1038 | elif isinstance(row, str): 1039 | row_id = row 1040 | else: 1041 | raise TypeError("row must be str ROW_ID or an instance of Row") 1042 | 1043 | data = { 1044 | "row": { 1045 | "cells": [ 1046 | {"column": cell.column_id_or_name, "value": cell.value} 1047 | for cell in cells 1048 | ] 1049 | } 1050 | } 1051 | 1052 | return self.document.coda.update_row(self.document.id, self.id, row_id, data) 1053 | 1054 | def delete_row_by_id(self, row_id: str): 1055 | """ 1056 | Deletes row by id. 1057 | 1058 | :param row_id: ID of the row to delete. 1059 | """ 1060 | return self.document.coda.delete_row(self.document.id, self.id, row_id) 1061 | 1062 | def delete_row(self, row: Row) -> Dict: 1063 | """ 1064 | Delete row. 1065 | 1066 | :param row: a `Row` object to delete. 1067 | """ 1068 | 1069 | return self.delete_row_by_id(row.id) 1070 | 1071 | def to_dict(self) -> List[Dict]: 1072 | """ 1073 | Returns entire table as list of dicts. Intended for use with pandas: 1074 | 1075 | pd.DataFrame(table.to_dict()) 1076 | """ 1077 | return [row.to_dict() for row in self.rows()] 1078 | 1079 | 1080 | @attr.s(auto_attribs=True, hash=True) 1081 | class Column(CodaObject): 1082 | name: str 1083 | table: Table = attr.ib(repr=False) 1084 | display: bool = attr.ib(default=None, repr=False) 1085 | calculated: bool = attr.ib(default=False) 1086 | formula: str = attr.ib(default=None, repr=False) 1087 | default_value: str = attr.ib(default=None, repr=False) 1088 | 1089 | 1090 | @attr.s(auto_attribs=True, hash=True) 1091 | class Row(CodaObject): 1092 | name: str 1093 | created_at: dt.datetime = attr.ib(converter=lambda x: parse(x), repr=False) 1094 | index: int 1095 | updated_at: dt.datetime = attr.ib( 1096 | converter=lambda x: parse(x) if x else None, repr=False 1097 | ) 1098 | values: Tuple[Tuple] = attr.ib( 1099 | converter=lambda x: tuple([(k, v) for k, v in x.items()]), repr=False 1100 | ) 1101 | table: Table = attr.ib(repr=False) 1102 | browser_link: str = attr.ib(default=None, repr=False) 1103 | 1104 | def columns(self): 1105 | return self.table.columns() 1106 | 1107 | def refresh(self): 1108 | new_data = self.table.document.coda.get_row( 1109 | self.table.document.id, self.table.id, self.id 1110 | ) 1111 | self.values = tuple([(k, v) for k, v in new_data["values"].items()]) 1112 | return self 1113 | 1114 | def cells(self) -> List[Cell]: 1115 | return [ 1116 | Cell(column=self.table.get_column_by_id(i[0]), value_storage=i[1], row=self) 1117 | for i in self.values 1118 | ] 1119 | 1120 | def delete(self): 1121 | """ 1122 | Delete row. 1123 | 1124 | :return: 1125 | """ 1126 | return self.table.delete_row(self) 1127 | 1128 | def get_cell_by_column_id(self, column_id: str) -> Cell: 1129 | try: 1130 | return next(filter(lambda x: x.column.id == column_id, self.cells())) 1131 | except StopIteration: 1132 | raise KeyError("Column not found") 1133 | 1134 | def __getitem__(self, item) -> Cell: 1135 | if isinstance(item, Column): 1136 | return self.get_cell_by_column_id(item.id) 1137 | elif isinstance(item, str): 1138 | try: 1139 | return self.get_cell_by_column_id(item) 1140 | except KeyError: 1141 | pass 1142 | column = self.table.get_column_by_name(item) 1143 | found_by_name = self.get_cell_by_column_id(column.id) 1144 | if found_by_name: 1145 | return found_by_name 1146 | 1147 | raise KeyError(f"Invalid column_id: {item}") 1148 | 1149 | def __setitem__(self, item, value) -> Cell: 1150 | cell = self.__getitem__(item) 1151 | data = {"row": {"cells": [{"column": cell.column.id, "value": value}]}} 1152 | self.document.coda.update_row( 1153 | self.document.id, self.table.id, self.id, data=data 1154 | ) 1155 | cell.value_storage = value 1156 | return cell 1157 | 1158 | def to_dict(self) -> Dict: 1159 | """ 1160 | Returns a row as a dictionary. 1161 | 1162 | :return: 1163 | """ 1164 | return {column.name: self[column].value for column in self.columns()} 1165 | 1166 | 1167 | @attr.s(auto_attribs=True, hash=True, repr=False) 1168 | class Cell: 1169 | column: Union[str, Column] 1170 | value_storage: Any 1171 | row: Row = attr.ib(default=None) 1172 | 1173 | @property 1174 | def name(self): 1175 | return self.column.name 1176 | 1177 | @property 1178 | def table(self): 1179 | return self.row.table 1180 | 1181 | @property 1182 | def document(self): 1183 | return self.table.document 1184 | 1185 | def __repr__(self): 1186 | return ( 1187 | f"Cell(column={self.column.name}, row={self.row.name}, value={self.value})" 1188 | ) 1189 | 1190 | @property 1191 | def value(self): 1192 | return self.value_storage 1193 | 1194 | @property 1195 | def column_id_or_name(self): 1196 | if isinstance(self.column, Column): 1197 | return self.column.id 1198 | elif isinstance(self.column, str): 1199 | return self.column 1200 | 1201 | @value.setter 1202 | def value(self, value): 1203 | data = {"row": {"cells": [{"column": self.column.id, "value": value}]}} 1204 | self.document.coda.update_row( 1205 | self.document.id, self.table.id, self.row.id, data=data 1206 | ) 1207 | self.value_storage = value 1208 | 1209 | new_value = None 1210 | while new_value != value: 1211 | self.row.refresh() 1212 | new_value = self.row.get_cell_by_column_id(self.column.id).value 1213 | time.sleep(0.3) 1214 | --------------------------------------------------------------------------------