├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples └── example.py ├── py_appsheet ├── __init__.py ├── client.py └── utils.py ├── setup.py └── tests ├── __init__.py └── test_client.py /.gitignore: -------------------------------------------------------------------------------- 1 | # General Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual Environment 7 | .env/ 8 | venv/ 9 | 10 | # Environment Variables 11 | .env 12 | 13 | # IDEs and editors 14 | .idea/ 15 | .vscode/ 16 | *.swp 17 | *.swo 18 | 19 | 20 | # Build 21 | build/ 22 | dist/ 23 | *.egg-info/ 24 | 25 | # Unit test / coverage reports 26 | htmlcov/ 27 | .tox/ 28 | .nox/ 29 | 30 | # PyPI upload artifacts 31 | *.egg-info/ 32 | *.eggs 33 | MANIFEST 34 | 35 | 36 | # Logs and Debugging 37 | *.log 38 | 39 | # IDEs and Editors 40 | .idea/ 41 | .vscode/ 42 | 43 | # Testing Cache 44 | .tox/ 45 | htmlcov/ 46 | 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Great Scott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Exclude examples and tests from the PyPI package 2 | exclude examples/* 3 | exclude tests/* 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-appsheet 2 | A no-frills Python library for interacting with Google AppSheet 3 | 4 | ## Installation 5 | py-appsheet is available on [PyPI](https://pypi.org/project/py-appsheet/) so can be installed via `pip install py-appsheet` in the terminal. 6 | 7 | ## Background and Setup 8 | * To work with this you need to create an AppSheet App first (i.e. not just an AppSheet Database). 9 | * To enable working with the API, you must go into the app's settings (gear icon) and then the integrations sub-item on the left. There you will find the App ID (make sure it's switched to enabled) and can create an Application Access key. 10 | * Be sure to not write these explicitly into your code. Instead it's better to store these in a .env file (make sure .gitignore lists the .env if working with a repo) and use `os.environ.get()` to pull in the secret values to work with them. 11 | * Some basic troubleshooting tips: 12 | * Make sure that you have your key column set correctly and that your schema is up-to-date (can be regenerated in the data view for the application) 13 | * Leverage Appsheet's Log Analyzer to get in-depth error messages. Can be access under your Appsheet App -> icon that looks like a pulse -> "Monitor" submenu -> "Audit History" -> "Launch log analyzer" 14 | 15 | To initialize in your code, simply import the library and instantiate the class: 16 | 17 | ``` 18 | from py_appsheet.client import AppSheetClient 19 | 20 | APPSHEET_APP_ID = 21 | APPSHEET_API_KEY = 22 | 23 | client = AppSheetClient(app_id=APPSHEET_APP_ID, api_key=APPSHEET_API_KEY) 24 | ``` 25 | 26 | ## Available Methods 27 | 28 | ### Find Items 29 | 1. Search for a Specific Value in Any Column 30 | `result = client.find_item("Table Name", "ABC123") 31 | ` 32 | 2. Search for a Specific Vaue in a Specific Column 33 | `result = client.find_item("Table Name", "ABC123", target_column="column name")` 34 | 35 | ### Add Items (New Rows) 36 | 37 | ``` 38 | rows_to_add = [ 39 | { 40 | "Generation Date": "1700000000", 41 | "UserID": "someone@someone.com", 42 | "Serial Number Hex": "ABC123", 43 | "SKU": "SKU123", 44 | "Batch": "Batch01", 45 | }, 46 | { 47 | "Generation Date": "1700000001", 48 | "UserID": "john@doe.com", 49 | "Serial Number Hex": "DEF456", 50 | "SKU": "SKU456", 51 | "Batch": "Batch02", 52 | } 53 | ] 54 | 55 | 56 | # Add rows to the AppSheet table 57 | response = client.add_items("Inventory Table", rows_to_add) 58 | 59 | # Process the response 60 | print("Response from AppSheet API:", response) 61 | 62 | 63 | ``` 64 | 65 | ### Edit Item 66 | 67 | Note: when updating an entry, the dictionary's first entry in the row data should be the designated key column (as defined in the AppSheet app settings for that table) 68 | 69 | ``` 70 | # Example usage of edit_item 71 | serial_number = "ABC123" 72 | sku = "SKU456" 73 | 74 | row_data = { 75 | "Serial Number Hex": serial_number, # Key column for the table 76 | "Bar Code": f"Inventory_Images/{serial_number}_serial_barcode.svg", 77 | "QR Code": f"Inventory_Images/{serial_number}_serial_qr.svg", 78 | "SKU Bar Code": f"Inventory_Images/{sku}_sku_barcode.svg" 79 | } 80 | 81 | response = client.edit_item("Inventory Table", "Serial Number Hex", row_data) 82 | 83 | if response.get("status") == "OK": 84 | print("Row updated successfully with image paths.") 85 | else: 86 | print(f"Failed to update row. API response: {response}") 87 | 88 | ``` 89 | 90 | ### Delete Row by Key 91 | 92 | ``` 93 | # Example: Delete a row by its key 94 | # "Serial Number Hex" is key col name 95 | 96 | response = client.delete_row("Inventory Table", "Serial Number Hex", "ABC123") 97 | 98 | ``` 99 | 100 | 101 | ## Known Limitations and Important Notes 102 | *(Contributions Welcome!)* 103 | 104 | * Querying for specific rows that contain an item of interest currently pulls all rows and filters locally. 105 | * Finding items currently pulls all rows and returns it in whatever, but the API does appear to have support for filtering and ordering. [See here](https://support.google.com/appsheet/answer/10105770?hl=en&ref_topic=10105767&sjid=1506075158107162628-NC) 106 | * Appsheet table names, which are used in URL-encoding, are assumed to not contain any special characters other than spaces. I.e. you can supply a table name like `"my table"` and the library will convert this to `"my%20table"` as needed under the hood, but does not handle other special characters that may mess with URL-encoding. 107 | 108 | ## Additional Credits 109 | Credit where credit is due. ChatGPT was leveraged extensively to put this together quickly. 110 | 111 | ## Contributing 112 | Contributions are welcome. Please submit pull requests to the dev branch. 113 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from py_appsheet.client import AppSheetClient 4 | 5 | # Step 1: Load environment variables from .env file 6 | load_dotenv() 7 | 8 | APPSHEET_APP_ID = os.getenv("APPSHEET_APP_ID") 9 | APPSHEET_API_KEY = os.getenv("APPSHEET_API_KEY") 10 | 11 | if not APPSHEET_APP_ID or not APPSHEET_API_KEY: 12 | raise Exception("AppSheet App ID or API Key is missing. Check your .env file.") 13 | 14 | # Step 2: Initialize the AppSheetClient 15 | client = AppSheetClient(app_id=APPSHEET_APP_ID, api_key=APPSHEET_API_KEY) 16 | 17 | # Step 3: Test table name and key column 18 | TABLE_NAME = "Example Table Name Here" 19 | KEY_COLUMN = "Title Example" 20 | 21 | # Step 4: Add a new row to the table 22 | print("Adding a new row...") 23 | new_row = { 24 | "Title Example": "Test Row 1", 25 | "Another Column": "Value 1" 26 | } 27 | 28 | response = client.add_items(TABLE_NAME, [new_row]) 29 | print("Add response:", response) 30 | 31 | input("\nPress Enter to continue to the next step...") 32 | 33 | # Step 5: Find the newly added row 34 | print("\nFinding the new row...") 35 | find_response = client.find_items(TABLE_NAME, "Test Row 1", KEY_COLUMN) 36 | print("Find response:", find_response) 37 | 38 | input("\nPress Enter to continue to the next step...") 39 | 40 | # Step 6: Edit the newly added row 41 | print("\nEditing the new row...") 42 | updated_row = { 43 | "Title Example": "Test Row 1", # Must include the key column and its value 44 | "Another Column": "Updated Value" 45 | } 46 | 47 | edit_response = client.edit_item(TABLE_NAME, KEY_COLUMN, updated_row) 48 | print("Edit response:", edit_response) 49 | 50 | input("\nPress Enter to continue to the next step...") 51 | 52 | # Step 7: Delete the row 53 | print("\nDeleting the new row...") 54 | delete_response = client.delete_row(TABLE_NAME, KEY_COLUMN, "Test Row 1") 55 | print("Delete response:", delete_response) 56 | -------------------------------------------------------------------------------- /py_appsheet/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import AppSheetClient 2 | 3 | __all__ = ["AppSheetClient"] -------------------------------------------------------------------------------- /py_appsheet/client.py: -------------------------------------------------------------------------------- 1 | # 2 | import os 3 | import requests 4 | 5 | ''' 6 | Some notes: 7 | - API Reference: https://support.google.com/appsheet/answer/10105398?sjid=1506075158107162628-NC 8 | - Available actions: Add, Delete, Edit (requires lookup by table key), Find 9 | - Table-names are passed in through URL, so if there are spaces in the name, %20 (percent-encoding) 10 | needs to be used 11 | - Column-names are strings in the JSON payload and should not use %20 for representing spaces. 12 | ''' 13 | 14 | 15 | class AppSheetClient: 16 | def __init__(self, app_id, api_key): 17 | self.app_id = app_id 18 | self.api_key = api_key 19 | 20 | def _make_request(self, table_name, action, payload): 21 | url = f"https://api.appsheet.com/api/v2/apps/{self.app_id}/tables/{table_name}/Action" 22 | headers = { 23 | "ApplicationAccessKey": self.api_key, 24 | "Content-Type": "application/json", 25 | } 26 | response = requests.post(url, json=payload, headers=headers) 27 | if response.status_code != 200: 28 | print(response) 29 | raise Exception(f"Request failed with status code {response.status_code}") 30 | return response.json() 31 | 32 | """ 33 | Find rows containing a specific item in the specified table. 34 | 35 | This method queries a table and retrieves rows containing the specified item. 36 | If the item corresponds to a unique key in the table, the returned row will 37 | contain all related data. For example, in an inventory table with columns like 38 | "serial number" (unique) and "registered", you might: 39 | 40 | - Query a specific serial number to find whether it is registered. 41 | - Query `True` to get all rows where "registered" is `True`. 42 | 43 | Args: 44 | table_name (str): The name of the table to search. 45 | item (Any): The value to search for in the table. 46 | 47 | Returns: 48 | list: A list of rows (dicts) containing the matching items. Returns an 49 | empty list if no matching rows are found. 50 | """ 51 | 52 | def find_items(self, table_name, item, target_column=None): 53 | """ 54 | Find rows containing a specific item in the specified table. 55 | Optionally filter based on a specific column. 56 | Args: 57 | table_name (str): The name of the table to search. 58 | Assumes only spaces as special characters (i.e. not &, ?, #) 59 | item (Any): The value to search for in the table. 60 | target_column (str, optional): The specific column to search in. If None, 61 | all columns are searched. 62 | 63 | Returns: 64 | list: A list of rows (dicts) containing the matching items. Returns an 65 | empty list if no matching rows are found. 66 | """ 67 | payload = { 68 | "Action": "Find", 69 | "Properties": {"Locale": "en-US", "Timezone": "UTC"}, 70 | "Rows": [], 71 | } 72 | # Send the request 73 | response_data = self._make_request(table_name.replace(' ', '%20'), "Find", payload) 74 | 75 | # Error handling: Validate response format 76 | if not isinstance(response_data, list): 77 | raise ValueError("Unexpected response format: Expected a list of rows.") 78 | 79 | # Process response: Filter rows locally for matches 80 | if target_column: 81 | matching_rows = [row for row in response_data if row.get(target_column) == item] 82 | else: 83 | matching_rows = [row for row in response_data if item in row.values()] 84 | 85 | return matching_rows 86 | 87 | def add_items(self, table_name, rows): 88 | """ 89 | Add one or more new rows to the specified AppSheet table. 90 | 91 | Args: 92 | table_name (str): The name of the table to which rows will be added. 93 | Assumes only spaces as special characters (i.e. not &, ?, #) 94 | rows (list[dict]): A list of dictionaries where each dictionary represents a row to be added. 95 | 96 | Returns: 97 | dict: The response from the AppSheet API. 98 | 99 | Raises: 100 | ValueError: If the response from the API is not in JSON format or contains an error. 101 | """ 102 | payload = { 103 | "Action": "Add", 104 | "Properties": { 105 | "Locale": "en-US", 106 | "Timezone": "UTC" 107 | }, 108 | "Rows": rows 109 | } 110 | 111 | # Encode table name for URL and send the request 112 | response_data = self._make_request(table_name.replace(' ', '%20'), "Add", payload) 113 | 114 | # Validate the response 115 | if not isinstance(response_data, dict): 116 | raise ValueError("Unexpected response format: Expected a JSON dictionary.") 117 | 118 | # Return the response 119 | return response_data 120 | 121 | def edit_item(self, table_name, key_column, row_data): 122 | """ 123 | Edit a row in the specified AppSheet table. 124 | 125 | Args: 126 | table_name (str): The name of the table where the row exists. 127 | Assumes only spaces as special characters (i.e. not &, ?, #) 128 | key_column (str): The name of the key column in the table. 129 | row_data (dict): A dictionary containing the data to update. The key 130 | column and its value must be included. 131 | 132 | Returns: 133 | dict: The response from the AppSheet API. 134 | 135 | Raises: 136 | ValueError: If the key column is not present in `row_data`. 137 | """ 138 | if key_column not in row_data: 139 | raise ValueError(f"The key column '{key_column}' must be included in the row data.") 140 | 141 | # Ensure the key column is the first dictionary entry 142 | row_data = {key_column: row_data[key_column], **{k: v for k, v in row_data.items() if k != key_column}} 143 | 144 | payload = { 145 | "Action": "Edit", 146 | "Properties": { 147 | "Locale": "en-US", 148 | "Timezone": "UTC" 149 | }, 150 | "Rows": [row_data] 151 | } 152 | 153 | # Encode table name for URL and send the request 154 | response_data = self._make_request(table_name.replace(' ', '%20'), "Edit", payload) 155 | 156 | # Validate the response 157 | if not isinstance(response_data, dict): 158 | raise ValueError("Unexpected response format: Expected a JSON dictionary.") 159 | 160 | return response_data 161 | 162 | def delete_row(self, table_name, key_column, key_value): 163 | """ 164 | Delete a row in the specified AppSheet table. 165 | 166 | Args: 167 | table_name (str): The name of the table from which to delete the row. 168 | Assumes only spaces as special characters (i.e. not &, ?, #) 169 | key_column (str): The name of the key column in the table. 170 | key_value (Any): The value of the key column for the row to be deleted. 171 | 172 | Returns: 173 | dict: The response from the AppSheet API. 174 | 175 | Raises: 176 | ValueError: If the API response is not in JSON format or contains an error. 177 | """ 178 | payload = { 179 | "Action": "Delete", 180 | "Properties": { 181 | "Locale": "en-US", 182 | "Timezone": "UTC" 183 | }, 184 | "Rows": [ 185 | {key_column: key_value} 186 | ] 187 | } 188 | 189 | # Encode table name for URL and send the request 190 | response_data = self._make_request(table_name.replace(' ', '%20'), "Delete", payload) 191 | 192 | # Validate the response 193 | if not isinstance(response_data, dict): 194 | raise ValueError("Unexpected response format: Expected a JSON dictionary.") 195 | 196 | return response_data 197 | 198 | 199 | 200 | 201 | -------------------------------------------------------------------------------- /py_appsheet/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="py-appsheet", 5 | version="0.1.0", 6 | description="A no-frills Python library for interacting with the Google AppSheet API.", 7 | long_description=open("README.md").read(), 8 | long_description_content_type="text/markdown", 9 | author="Scott Novich", 10 | author_email="scott.novich@gmail.com", 11 | url="https://github.com/greatscott/py-appsheet", 12 | license="MIT", 13 | packages=find_packages(include=["py_appsheet", "py_appsheet.*"]), 14 | install_requires=[ 15 | "requests>=2.0.0", 16 | ], 17 | python_requires=">=3.7", 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | from py_appsheet.client import AppSheetClient 4 | 5 | class TestAppSheetClient(unittest.TestCase): 6 | def setUp(self): 7 | self.app_id = "test_app_id" 8 | self.api_key = "test_api_key" 9 | self.client = AppSheetClient(app_id=self.app_id, api_key=self.api_key) 10 | 11 | def test_find_items_found(self): 12 | table_name = "Patients" 13 | item = "test@example.com" 14 | key_column = "Email" 15 | 16 | mock_response = { 17 | "Rows": [ 18 | {"Email": "test@example.com", "Name": "John Doe", "ID": "123"} 19 | ] 20 | } 21 | 22 | with patch.object(self.client, '_make_request', return_value=mock_response): 23 | result = self.client.find_items(table_name, item, key_column) 24 | self.assertEqual(len(result), 1) 25 | self.assertEqual(result[0]["Email"], item) 26 | 27 | def test_find_items_not_found(self): 28 | table_name = "Patients" 29 | item = "nonexistent@example.com" 30 | key_column = "Email" 31 | 32 | mock_response = { 33 | "Rows": [ 34 | {"Email": "test@example.com", "Name": "John Doe", "ID": "123"} 35 | ] 36 | } 37 | 38 | with patch.object(self.client, '_make_request', return_value=mock_response): 39 | result = self.client.find_items(table_name, item, key_column) 40 | self.assertEqual(len(result), 0) 41 | 42 | def test_find_items_malformed_response(self): 43 | table_name = "Patients" 44 | item = "test@example.com" 45 | key_column = "Email" 46 | 47 | mock_response = {} 48 | 49 | with patch.object(self.client, '_make_request', return_value=mock_response): 50 | with self.assertRaises(ValueError) as context: 51 | self.client.find_items(table_name, item, key_column) 52 | self.assertIn("Unexpected response format or missing 'Rows' key", str(context.exception)) 53 | 54 | def test_find_items_api_error(self): 55 | table_name = "Patients" 56 | item = "test@example.com" 57 | key_column = "Email" 58 | 59 | with patch.object(self.client, '_make_request', side_effect=Exception("API Error")): 60 | with self.assertRaises(Exception) as context: 61 | self.client.find_items(table_name, item, key_column) 62 | self.assertIn("API Error", str(context.exception)) 63 | 64 | def test_find_items_in_any_column(self): 65 | table_name = "Patients" 66 | item = "John Doe" 67 | 68 | mock_response = { 69 | "Rows": [ 70 | {"Email": "test@example.com", "Name": "John Doe", "ID": "123"}, 71 | {"Email": "another@example.com", "Name": "Jane Smith", "ID": "456"} 72 | ] 73 | } 74 | 75 | with patch.object(self.client, '_make_request', return_value=mock_response): 76 | result = self.client.find_items(table_name, item) 77 | self.assertEqual(len(result), 1) 78 | self.assertEqual(result[0]["Name"], item) 79 | 80 | def test_add_items(self): 81 | table_name = "Inventory Table" 82 | rows = [ 83 | { 84 | "Generation Date": "1700000000", 85 | "UserID": "test@example.com", 86 | "Serial Number Hex": "ABC123", 87 | "SKU": "SKU123", 88 | "Batch": "Batch01", 89 | } 90 | ] 91 | 92 | mock_response = {"status": "OK"} 93 | 94 | with patch.object(self.client, '_make_request', return_value=mock_response): 95 | response = self.client.add_items(table_name, rows) 96 | self.assertEqual(response["status"], "OK") 97 | 98 | def test_edit_item(self): 99 | table_name = "Inventory Table" 100 | key_column = "Serial Number Hex" 101 | row_data = { 102 | "Serial Number Hex": "ABC123", 103 | "Bar Code": "Inventory_Images/ABC123_serial_barcode.svg", 104 | "QR Code": "Inventory_Images/ABC123_serial_qr.svg" 105 | } 106 | 107 | mock_response = {"status": "OK"} 108 | 109 | with patch.object(self.client, '_make_request', return_value=mock_response): 110 | response = self.client.edit_item(table_name, key_column, row_data) 111 | self.assertEqual(response["status"], "OK") 112 | 113 | def test_delete_row(self): 114 | table_name = "Inventory Table" 115 | key_column = "Serial Number Hex" 116 | key_value = "ABC123" 117 | 118 | mock_response = {"status": "OK"} 119 | 120 | with patch.object(self.client, '_make_request', return_value=mock_response): 121 | response = self.client.delete_row(table_name, key_column, key_value) 122 | self.assertEqual(response["status"], "OK") 123 | 124 | 125 | 126 | 127 | if __name__ == '__main__': 128 | unittest.main() 129 | --------------------------------------------------------------------------------