Module base
Classes
class BaserowApi (database_url: str,
token: str | None = None,
token_path: str | None = None,
jwt_token: bool = False)-
Expand source code
class BaserowApi: """BaserowAPI class: A wrapper around the Baserow API.""" table_path = "api/database/rows/table" fields_path = "api/database/fields/table" tables_path = "api/database/tables" applications_path = "api/applications" workspaces_path = "api/workspaces" token_auth_path = "api/user/token-auth" def __init__( self, database_url: str, token: Optional[str] = None, token_path: Optional[str] = None, jwt_token: bool = False, ): """Initialize the BaserowApi class. This class is a wrapper around the Baserow API. Args: database_url (str): URL of the Baserow instance (e.g. ``https://api.baserow.io``). token (Optional[str], optional): Token-String for Baserow access. Defaults to None. token_path (Optional[str], optional): Path to file containing the Token-String. Defaults to None. jwt_token (bool, optional): Whether JWT-Token is used instead of Token-String. Defaults to False. Schema operations (create/delete database or table) require JWT. """ self._database_url = database_url.rstrip("/") if token_path: with open(token_path) as tokenfile: self._token = tokenfile.readline().strip() elif token: self._token = token else: self._token = None if not self._token: raise ValueError( "A Baserow token is required: pass token=... or token_path=..." ) self._token_mode = "JWT" if jwt_token else "Token" # ------------------------------------------------------------------ # Auth / HTTP helpers # ------------------------------------------------------------------ def _auth_headers(self, content_type: Optional[str] = None) -> dict: """Build request headers including Authorization.""" headers = {"Authorization": f"{self._token_mode} {self._token}"} if content_type: headers["Content-Type"] = content_type return headers @staticmethod def login(database_url: str, email: str, password: str) -> dict: """Obtain a JWT access token via email/password. Args: database_url: Baserow API base URL. email: Account email. password: Account password. Returns: Full token-auth response (includes ``access_token`` and ``refresh_token``). Raises: requests.HTTPError: If authentication fails. """ url = f"{database_url.rstrip('/')}/{BaserowApi.token_auth_path}/" resp = requests.post(url, json={"email": email, "password": password}) resp.raise_for_status() return resp.json() @classmethod def from_credentials( cls, database_url: str, email: str, password: str ) -> "BaserowApi": """Create a JWT-authenticated client from email and password. Args: database_url: Baserow API base URL. email: Account email. password: Account password. Returns: Authenticated ``BaserowApi`` instance (JWT mode). """ token_data = cls.login(database_url, email, password) return cls( database_url=database_url, token=token_data["access_token"], jwt_token=True, ) # ------------------------------------------------------------------ # Schema helpers (JWT required) # ------------------------------------------------------------------ def list_workspaces(self) -> list[dict]: """List workspaces visible to the authenticated user. Returns: List of workspace dicts. """ url = f"{self._database_url}/{self.workspaces_path}/" resp = requests.get(url, headers=self._auth_headers()) resp.raise_for_status() return resp.json() def create_database( self, workspace_id: int, name: str, init_with_data: bool = False ) -> dict: """Create a database application in a workspace. Requires JWT authentication. Args: workspace_id: Target workspace ID. name: Database name. init_with_data: Whether Baserow should seed example data. Returns: Created application dict (includes ``id``). """ url = ( f"{self._database_url}/{self.applications_path}/" f"workspace/{workspace_id}/" ) resp = requests.post( url, headers=self._auth_headers("application/json"), json={"name": name, "type": "database", "init_with_data": init_with_data}, ) resp.raise_for_status() return resp.json() def delete_database(self, database_id: int) -> None: """Delete a database application. Requires JWT authentication. Args: database_id: Application ID of the database to delete. """ url = f"{self._database_url}/{self.applications_path}/{database_id}/" resp = requests.delete(url, headers=self._auth_headers()) resp.raise_for_status() def create_table( self, database_id: int, name: str, primary_field_name: str = "Name", fields: Optional[list[dict]] = None, ) -> dict: """Create a table and optionally additional fields. Requires JWT authentication. The table is created with a single primary text field named *primary_field_name*. Any *fields* are then created via :meth:`create_field`. Args: database_id: Parent database application ID. name: Table name. primary_field_name: Name of the primary text field. fields: Optional list of field-create payloads (each must include at least ``name`` and ``type``). Returns: Dict with ``id``, ``name``, and ``fields`` (full field list). """ url = ( f"{self._database_url}/{self.tables_path}/database/{database_id}/" ) resp = requests.post( url, headers=self._auth_headers("application/json"), json={ "name": name, "data": [[primary_field_name], [""]], "first_row_header": True, }, ) resp.raise_for_status() table = resp.json() table_id = table["id"] if fields: for field_spec in fields: self.create_field(table_id, field_spec) return { "id": table_id, "name": name, "fields": self.get_fields(table_id), } def delete_table(self, table_id: int) -> None: """Delete a table. Requires JWT authentication. Args: table_id: Table ID to delete. """ url = f"{self._database_url}/{self.tables_path}/{table_id}/" resp = requests.delete(url, headers=self._auth_headers()) resp.raise_for_status() def create_field(self, table_id: int, field_spec: dict) -> dict: """Create a field on a table. Requires JWT authentication. Args: table_id: Target table ID. field_spec: Field payload (must include ``name`` and ``type``; type-specific keys such as ``select_options`` or ``link_row_table_id`` as needed). Returns: Created field dict from the API. """ url = f"{self._database_url}/{self.fields_path}/{table_id}/" resp = requests.post( url, headers=self._auth_headers("application/json"), json=field_spec, ) resp.raise_for_status() return resp.json() # ------------------------------------------------------------------ # Field / row access # ------------------------------------------------------------------ def get_fields(self, table_id: int) -> list[dict]: """Get all fields / column specifications for a table. Args: table_id (int): ID of the table of interest. Returns: list[dict]: List of column specifications (dict of fields) """ get_fields_url = f"{self._database_url}/{self.fields_path}/{table_id}/" resp = requests.get(get_fields_url, headers=self._auth_headers()) resp.raise_for_status() return resp.json() def _payload_keys_as_fields( self, data: Union[dict, list[dict]], table_fields: list[dict] ) -> list[dict]: """Convert payload keys to field descriptors for compatibility checks. Example: {"field_42": "x", "id": 7} with table field id 42 named becomes "Medgen ID" → [{"name": "Medgen ID"}]. Keys named ``field_<id>`` are resolved to the corresponding field name. The special key ``id`` (row id) is ignored. """ fields_by_id = {field["id"]: field for field in table_fields} keys: set[str] = set() entries = [data] if isinstance(data, dict) else data for entry in entries: for key in entry: if key == "id": continue if key.startswith("field_"): try: field_id = int(key.split("_", 1)[1]) except ValueError: keys.add(key) continue if field_id in fields_by_id: keys.add(fields_by_id[field_id]["name"]) else: keys.add(key) else: keys.add(key) # Name-only descriptors: skip type checks against the table schema. return [{"name": name} for name in sorted(keys)] def _validate_field_compatibility( self, table_id: int, data: Union[dict, list[dict]], fields: Optional[list[dict]] = None, fail_on_error: bool = False, ) -> list[dict]: """Validate that payload field names exist on the table. Args: table_id: ID of the table of interest. data: Single row dict or list of row dicts. fields: Optional pre-fetched table fields (avoids a second request). fail_on_error: If True, raise on incompatibility; otherwise warn. Returns: The table field list used for validation (fetched if needed). Raises: RuntimeError: When fields are incompatible and *fail_on_error* is True. """ if fields is None: fields = self.get_fields(table_id) input_fields = self._payload_keys_as_fields(data, fields) try: _check_for_fields_mismatch(input_fields, fields) except RuntimeError as err: if fail_on_error: raise warnings.warn(str(err), stacklevel=2) return fields def _get_rows_data( self, url: Optional[str] = None, table_id: Optional[int] = None, row_id: Optional[int] = None, user_field_names: bool = False, paginated: bool = False, include: Optional[list[str]] = None, exclude: Optional[list[str]] = None, ) -> Union[dict, list]: """Get rows data from a table. Args: url (str, optional): URL to lookup data. Defaults to None. table_id (int, optional): ID of table of interest. Defaults to None. row_id (int, optional): ID of entry of interest. Defaults to None. If provided, only the entry is returned. Else, all entries are returned. user_field_names (bool, optional): Whether to use field names or field IDs. Defaults to False. paginated (bool, optional): Whether to load multiple pages of data. Defaults to False. include (list[str], optional): List of fields to include in the response. Defaults to None (all fields). exclude (list[str], optional): List of fields to exclude from the response. Defaults to None (no fields excluded). Returns: A single row dict when *row_id* is set, otherwise a list of row dicts. """ query_params = [] if (not table_id and not url) or (table_id and url): raise RuntimeError( "Either table_id or url must be provided, " "but not both." ) if row_id and not table_id: raise RuntimeError("row_id can only be provided with table_id.") if row_id and paginated: warnings.warn("row_id is not paginated.") paginated = False if url: get_rows_url = url elif table_id: get_rows_url = f"{self._database_url}/{self.table_path}/{table_id}/" if row_id: get_rows_url += f"{row_id}/" if user_field_names: query_params += ["user_field_names=true"] else: raise RuntimeError("Either table_id or url must be provided.") if include: query_params += [f"include={','.join(include)}"] if exclude: query_params += [f"exclude={','.join(exclude)}"] if query_params: separator = "&" if "?" in get_rows_url else "?" get_rows_url = get_rows_url + separator + "&".join(query_params) resp = requests.get(get_rows_url, headers=self._auth_headers()) resp.raise_for_status() data = resp.json() # A: specific entry if row_id: # Specific entry return data # B: all entries else: if "results" not in data: raise RuntimeError( f"Could not get query result data from {get_rows_url}" ) results = data["results"] # All results (first page) if paginated: # Get all remaining pages if data["next"]: return results + self._get_rows_data( url=data["next"], paginated=paginated ) # If no pagination, return all results return results def _resolve_field( self, table_id: int, column: str, fields: Optional[list[dict]] = None ) -> dict: """Resolve a field name or ``field_<id>`` key to a field dict. Raises: RuntimeError: If the column cannot be found on the table. """ if fields is None: fields = self.get_fields(table_id) fields_by_name = {f["name"]: f for f in fields} fields_by_id = {f["id"]: f for f in fields} if column in fields_by_name: return fields_by_name[column] if column.startswith("field_"): try: field_id = int(column.split("_", 1)[1]) except ValueError as err: raise RuntimeError(f"Unknown column '{column}'") from err if field_id in fields_by_id: return fields_by_id[field_id] raise RuntimeError(f"Unknown column '{column}' in table {table_id}") def _primary_field(self, table_id: int, fields: Optional[list[dict]] = None) -> dict: """Return the primary field definition for a table.""" if fields is None: fields = self.get_fields(table_id) for field in fields: if field.get("primary"): return field raise RuntimeError(f"No primary field found for table {table_id}") def find_entries( self, table_id: int, column: str, value: Any, *, user_field_names: bool = True, writable_only: bool = False, use_linked_row_ids: bool = True, paginated: bool = True, ) -> dict[int, dict[str, Any]]: """Find rows in a table where *column* equals *value*. Uses Baserow's ``filter__field_<id>__equal`` query filter. Args: table_id: Table to search. column: Field name or ``field_<id>`` key to match on. value: Value that must equal the column contents. user_field_names: Return/use human field names when True. writable_only: If True, omit read-only fields from results. use_linked_row_ids: Format link_row values as IDs (True) or primary display values (False). paginated: Load all result pages when True. Returns: Dict mapping row ID → formatted row data (same shape as :meth:`get_data`). """ fields = self.get_fields(table_id) field = self._resolve_field(table_id, column, fields=fields) params = {f"filter__field_{field['id']}__equal": value} if user_field_names: params["user_field_names"] = "true" get_rows_url = f"{self._database_url}/{self.table_path}/{table_id}/" resp = requests.get( get_rows_url, headers=self._auth_headers(), params=params ) resp.raise_for_status() data = resp.json() if "results" not in data: raise RuntimeError(f"Could not get query result data from {get_rows_url}") results = data["results"] if paginated and data.get("next"): results = results + self._get_rows_data( url=data["next"], paginated=True ) if writable_only: names = {f["name"]: f for f in fields if not f["read_only"]} else: names = {f["name"]: f for f in fields} if not user_field_names: names = { f'field_{f["id"]}': f for f in fields if (not writable_only) or (not f["read_only"]) } return { row["id"]: { k: _format_value(v, names[k], use_linked_row_ids) for k, v in row.items() if k in names } for row in results } def _create_row( self, table_id: int, data: dict, user_field_names: bool = False ) -> int: """Create a row in a table. Args: table_id (int): ID of the table of interest. data (dict): Data to add to the table. user_field_names (bool, optional): Whether to use field names of field IDs. Defaults to False. Returns: int: Row ID. """ create_row_url = f"{self._database_url}/{self.table_path}/{table_id}/" if user_field_names: create_row_url += "?user_field_names=true" resp = requests.post( create_row_url, headers=self._auth_headers("application/json"), json=data, ) resp.raise_for_status() resp_data = resp.json() if "id" in resp_data: return resp_data["id"] else: raise RuntimeError(f"Malformed response {resp_data}") def _create_rows( self, table_id: int, datas: list[dict], user_field_names: bool = False ): create_rows_url = f"{self._database_url}/{self.table_path}/{table_id}/batch/" if user_field_names: create_rows_url += "?user_field_names=true" resp = requests.post( create_rows_url, headers=self._auth_headers("application/json"), json={"items": datas}, ) resp.raise_for_status() data = resp.json() ids = [e["id"] for e in data["items"]] return ids def _update_row( self, table_id: int, data: dict, user_field_names: bool = False ) -> int: """Update a row in a table. Args: table_id (int): ID of the table of interest. data (dict): Data to update (must include the row ID as ``id``). The caller's dict is not mutated. user_field_names (bool, optional): Whether to use field names or field IDs for the data keys. Defaults to False. Returns: int: Updated row ID. Raises: RuntimeError: If the response is malformed or ``id`` is missing. """ payload = deepcopy(data) if "id" not in payload: raise RuntimeError("Update payload must include an 'id' key") row_id = payload.pop("id") update_row_url = f"{self._database_url}/{self.table_path}/{table_id}/{row_id}/" if user_field_names: update_row_url += "?user_field_names=true" resp = requests.patch( update_row_url, headers=self._auth_headers("application/json"), json=payload, ) resp.raise_for_status() resp_data = resp.json() if "id" in resp_data: return resp_data["id"] raise RuntimeError(f"Malformed response {resp_data}") def _update_rows( self, table_id: int, datas: list[dict], user_field_names: bool = False ): update_rows_url = f"{self._database_url}/{self.table_path}/{table_id}/batch/" if user_field_names: update_rows_url += "?user_field_names=true" resp = requests.patch( update_rows_url, headers=self._auth_headers("application/json"), json={"items": datas}, ) resp.raise_for_status() data = resp.json() ids = [e["id"] for e in data["items"]] return ids def _delete_row(self, table_id: int, row_id: int): delete_row_url = f"{self._database_url}/{self.table_path}/{table_id}/{row_id}/" resp = requests.delete(delete_row_url, headers=self._auth_headers()) resp.raise_for_status() def _convert_selects(self, data, fields): """ Convert the values in a dataset to their corresponding IDs based on field definitions. Example: data = {"status": "active", "tags": ["urgent", "important"]} fields = [ {"name": "status", "type": "single_select", "select_options": [{"value": "active", "id": 1}, {"value": "inactive", "id": 2}], "read_only": False}, {"name": "tags", "type": "multiple_select", "select_options": [{"value": "urgent", "id": 1}, {"value": "important", "id": 2}], "read_only": False} ] converted_data = self._convert_selects(data, fields) # converted_data would be {"status": 1, "tags": [1, 2]} """ data_conv = deepcopy(data) def convert_option(v, opts): """ Return the id of the option with value v. """ if isinstance(v, int): return v for opt in opts: if opt["value"] == v: return opt["id"] raise RuntimeError(f"Could not convert {v} to any of {opts}") for field in fields: # Support both human names and field_<id> keys in the payload. field_keys = [field["name"]] if "id" in field: field_keys.append(f"field_{field['id']}") active_key = next((k for k in field_keys if k in data_conv), None) if field.get("read_only") or active_key is None: continue cur_value = data_conv[active_key] if cur_value is None or cur_value == []: continue if field["type"] == "single_select": data_conv[active_key] = convert_option( cur_value, field["select_options"] ) elif field["type"] == "multiple_select": if not isinstance(cur_value, list): raise RuntimeError( f"multiple_select value for '{active_key}' must be a " f"list, got {type(cur_value).__name__}" ) data_conv[active_key] = [ convert_option(single_value, field["select_options"]) for single_value in cur_value ] return data_conv def get_writable_fields(self, table_id: int) -> list[dict]: """Get all writable fields in a table. Args: table_id (int): ID of the table of interest. Returns: list[dict]: List of writable fields. """ fields = self.get_fields(table_id) writable_fields = [field for field in fields if not field["read_only"]] return writable_fields def get_data( self, table_id: int, writable_only: bool = True, user_field_names: bool = True, paginated: bool = True, include: Optional[list[str]] = None, exclude: Optional[list[str]] = None, use_linked_row_ids: bool = True, ) -> dict[int, dict[str, Any]]: """Get all data from a table. Args: table_id (int): ID of the table of interest. writable_only (bool, optional): Only return fields which can be written to. This excludes all formula and computed fields. Defaults to True (only writable fields). user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True (use names). paginated (bool, optional): Whether to load multiple pages of data. Defaults to True. include (list[str], optional): List of fields to include in the response. Defaults to None (all fields). exclude (list[str], optional): List of fields to exclude from the response. Defaults to None (no fields excluded). use_linked_row_ids (bool, optional): Return IDs for linked rows, with False return values instead. Defaults to True (return IDs). Returns: dict[int, dict[str, Any]]: dictionary of data in the table. """ if writable_only: fields = self.get_writable_fields(table_id) else: fields = self.get_fields(table_id) if user_field_names: names = {f["name"]: f for f in fields} else: names = {f'field_{f["id"]}': f for f in fields} data = self._get_rows_data( table_id=table_id, user_field_names=user_field_names, paginated=paginated, include=include, exclude=exclude, ) # Collect rows with their field names and values, writable_data = { d["id"]: { k: _format_value(v, names[k], use_linked_row_ids) for k, v in d.items() if k in names } for d in data } return writable_data def get_entry( self, table_id: int, row_id: int, linked: bool = False, use_linked_row_ids: bool = True, seen_tables: Optional[list] = None, user_field_names: bool = True, include: Optional[list[str]] = None, exclude: Optional[list[str]] = None, ) -> dict: """Get a single entry from a table. Args: table_id (int): ID of the table of interest. row_id (int): Entry ID for the entry of interest. linked (bool, optional): Whether to fully hydrate the output with linked tables. Defaults to False (no data of linked tables is loaded). use_linked_row_ids (bool, optional): Return IDs for linked rows, with False return values instead. Ignored if linked is True. Defaults to True (return IDs). seen_tables (list, optional): List of already linked tables. These are not loaded again. Defaults to None. user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True (use names). include (list[str], optional): List of fields to include in the response. Defaults to None (all fields). exclude (list[str], optional): List of fields to exclude from the response. Defaults to None (no fields excluded). Returns: dict: Entry data. """ if linked and not use_linked_row_ids: warnings.warn( "Raw value output from 'use_linked_row_ids=False' is ignored because " "full linked information is used instead (linked=True)" ) data = self._get_rows_data( table_id=table_id, row_id=row_id, paginated=False, user_field_names=user_field_names, include=include, exclude=exclude, ) fields = self.get_fields(table_id) # If include or exclude are provided, filter the fields if include: fields = [f for f in fields if f["name"] in include] if exclude: fields = [f for f in fields if f["name"] not in exclude] names = {f["name"]: f for f in fields} names = names | {f'field_{f["id"]}': f for f in fields} formatted_data = { k: _format_value(v, names[k], use_linked_row_ids) for k, v in data.items() if k in names } # Copy to avoid mutating the caller's list across recursive calls. seen_tables_next = list(seen_tables) if seen_tables else [] seen_tables_next.append(table_id) # fully hydrate with linked data # --> recursively get data from linked tables if linked: link_fields = [f for f in fields if f["type"] == "link_row"] for field in link_fields: linked_table_id = field["link_row_table_id"] if linked_table_id in seen_tables_next[:-1]: continue # Raw API key depends on user_field_names. raw_key = ( field["name"] if user_field_names else f"field_{field['id']}" ) linked_refs = data.get(raw_key) or [] if not linked_refs: continue formatted_data[field["name"] if user_field_names else raw_key] = [ self.get_entry( linked_table_id, e_id["id"], linked=False, use_linked_row_ids=use_linked_row_ids, seen_tables=seen_tables_next, user_field_names=user_field_names, include=include, exclude=exclude, ) for e_id in linked_refs ] return formatted_data def add_data( self, table_id: int, data: dict, row_id: Optional[int] = None, user_field_names: bool = True, fail_on_error: bool = False, check_field_compatibility: bool = False, ) -> int: """Add/Change data to a table. Args: table_id (int): Table ID. data (dict): Data to add/change. row_id (int, optional): Row ID where to enter the data. Defaults to None. user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True. fail_on_error (bool, optional): When *check_field_compatibility* is True, raise ``RuntimeError`` on incompatible fields instead of warning. Defaults to False. check_field_compatibility (bool, optional): Validate that payload keys exist as fields on the table before writing. Defaults to False. Returns: int: Row ID. Raises: RuntimeError: If field compatibility fails and *fail_on_error* is True. """ fields = self.get_fields(table_id) if check_field_compatibility: fields = self._validate_field_compatibility( table_id, data, fields=fields, fail_on_error=fail_on_error, ) data_conv = self._convert_selects(data, fields) if row_id: data_conv["id"] = row_id self._update_row(table_id, data_conv, user_field_names=user_field_names) else: row_id = self._create_row( table_id, data_conv, user_field_names=user_field_names ) return row_id def synchronize_data( self, source_table_id: int, source_row_id: int, target_table_id: int, identifier_column: str, *, field_mapping: Optional[dict[str, str]] = None, link_match_column: Optional[Union[str, dict[str, str]]] = None, exclude_fields: Optional[list[str]] = None, include_fields: Optional[list[str]] = None, fail_on_error: bool = False, dry_run: bool = False, ) -> tuple[Optional[int], list[str]]: """Copy one source row into a target table (create or update). Matching on the target uses *identifier_column*: if a target row already has the same identifier value as the source, that row is updated; otherwise a new row is created. Compatible columns (same name by default, or via *field_mapping*) are copied. Read-only target fields, missing columns, and type mismatches are skipped with a warning. Link-row values are remapped by looking up matching entries in the target's linked table (by primary field, or by *link_match_column*). Args: source_table_id: Table containing the source row. source_row_id: Row ID to copy from. target_table_id: Table to write into. identifier_column: Column name used to find an existing target row (must exist on both tables unless mapped). field_mapping: Optional map of source field name → target field name. Unmapped fields with identical names are still copied. link_match_column: Column used to match linked rows in the target linked table. Either a single column name applied to all link fields, or a dict of target link-field name → match column. Defaults to each linked table's primary field. exclude_fields: Optional source field names to skip entirely (e.g. links handled separately by the caller). include_fields: If set, only these source field names are considered for transfer (in addition to the identifier column, which is always written). ``exclude_fields`` is applied after this filter. fail_on_error: If True, raise ``RuntimeError`` when any value cannot be transferred (hard failures such as missing fields, type mismatches, or unmatched links). Informational notices (e.g. ambiguous matches where the first row is used) do not trigger failure. If False (default), warn and continue. dry_run: If True, build the transfer payload and report what would happen, but do not write to the target table. Returns ``(existing_row_id_or_None, messages)``. Returns: Tuple of ``(target_row_id, messages)`` where *messages* contains both hard skip reasons and soft notices. In *dry_run* mode, ``target_row_id`` is the existing match ID if updating, else ``None`` (would create); no writes are performed. Raises: RuntimeError: If the identifier is missing/empty on the source, or if *fail_on_error* is True and hard transfer failures occurred. """ field_mapping = field_mapping or {} exclude_set = set(exclude_fields or []) include_set = set(include_fields) if include_fields is not None else None # Identifier must always be eligible for transfer. if include_set is not None: include_set.add(identifier_column) source_fields = {f["name"]: f for f in self.get_fields(source_table_id)} target_fields = {f["name"]: f for f in self.get_fields(target_table_id)} # Display values for links (primary labels) so we can rematch them. source_entry = self.get_entry( source_table_id, source_row_id, use_linked_row_ids=False, ) id_source_name = identifier_column # Allow mapping the identifier itself. id_target_name = field_mapping.get(identifier_column, identifier_column) if id_source_name not in source_entry: raise RuntimeError( f"Identifier column '{id_source_name}' missing on source row " f"{source_row_id}" ) identifier_value = source_entry[id_source_name] if identifier_value in (None, ""): raise RuntimeError( f"Identifier column '{id_source_name}' is empty on source row " f"{source_row_id}" ) payload: dict[str, Any] = {} hard_skips: list[str] = [] notices: list[str] = [] def _allowed(source_name: str) -> bool: if source_name in exclude_set: return False if include_set is not None and source_name not in include_set: return False return True # Build the set of source→target field pairs to consider. pairs: list[tuple[str, str]] = [] mapped_sources = set(field_mapping.keys()) for source_name, target_name in field_mapping.items(): if not _allowed(source_name): continue pairs.append((source_name, target_name)) for source_name in source_entry: if source_name in mapped_sources or not _allowed(source_name): continue if source_name in target_fields: pairs.append((source_name, source_name)) for source_name, target_name in pairs: if source_name not in source_entry: hard_skips.append( f"Source field '{source_name}' not present on source row" ) continue if target_name not in target_fields: hard_skips.append( f"Cannot transfer '{source_name}': target has no field " f"'{target_name}'" ) continue source_field = source_fields.get(source_name) target_field = target_fields[target_name] if target_field.get("read_only"): hard_skips.append( f"Cannot transfer '{source_name}': target field is read-only" ) continue if ( source_field and source_field.get("type") and target_field.get("type") and source_field["type"] != "formula" and source_field["type"] != target_field["type"] ): hard_skips.append( f"Cannot transfer '{source_name}': type mismatch " f"({source_field['type']} → {target_field['type']})" ) continue value = source_entry[source_name] if target_field["type"] == "link_row": resolved, link_hard, link_soft = self._resolve_link_values_for_sync( value=value, target_field=target_field, target_field_name=target_name, link_match_column=link_match_column, ) hard_skips.extend(link_hard) notices.extend(link_soft) if resolved is not None: payload[target_name] = resolved continue payload[target_name] = value skipped = hard_skips + notices for msg in skipped: warnings.warn(msg, stacklevel=2) if hard_skips and fail_on_error: raise RuntimeError( "synchronize_data could not transfer all values: " + "; ".join(hard_skips) ) # Ensure identifier is always written so upsert can work. if id_target_name not in payload: if id_target_name in target_fields and not target_fields[id_target_name].get( "read_only" ): payload[id_target_name] = identifier_value else: raise RuntimeError( f"Identifier column '{id_target_name}' is not writable on target" ) # Always report which target fields are in the write payload. notices.append(f"transferred fields: {sorted(payload.keys())}") existing = self.find_entries( target_table_id, id_target_name, identifier_value ) if len(existing) > 1: notice = ( f"Multiple target rows match identifier '{identifier_value}' " f"on '{id_target_name}'; updating the first match" ) notices.append(notice) warnings.warn(notice, stacklevel=2) skipped = hard_skips + notices if dry_run: if existing: target_row_id = next(iter(existing)) notices.append( f"dry_run: would update row {target_row_id} " f"(identifier '{identifier_value}')" ) else: target_row_id = None notices.append( f"dry_run: would create new row " f"(identifier '{identifier_value}')" ) skipped = hard_skips + notices for msg in notices: if msg.startswith("dry_run:") or msg.startswith("transferred fields:"): warnings.warn(msg, stacklevel=2) return target_row_id, skipped if existing: target_row_id = next(iter(existing)) return ( self.add_data( target_table_id, payload, row_id=target_row_id, user_field_names=True, ), skipped, ) return ( self.add_data(target_table_id, payload, user_field_names=True), skipped, ) def _resolve_link_values_for_sync( self, value: Any, target_field: dict, target_field_name: str, link_match_column: Optional[Union[str, dict[str, str]]], ) -> tuple[Optional[list[int]], list[str], list[str]]: """Map source link display values to row IDs in the target linked table. Returns: ``(resolved_ids_or_None, hard_skips, soft_notices)``. ``None`` for resolved means the field is omitted from the payload. Soft notices (e.g. ambiguous matches) do not block ``fail_on_error``. """ hard_skips: list[str] = [] soft_notices: list[str] = [] if value in (None, []): return [], hard_skips, soft_notices if not isinstance(value, list): hard_skips.append( f"Cannot transfer '{target_field_name}': expected list of " f"link values, got {type(value).__name__}" ) return None, hard_skips, soft_notices linked_table_id = target_field.get("link_row_table_id") if linked_table_id is None: hard_skips.append( f"Cannot transfer '{target_field_name}': target link field " f"has no linked table" ) return None, hard_skips, soft_notices if isinstance(link_match_column, dict): match_col = link_match_column.get(target_field_name) else: match_col = link_match_column if not match_col: match_col = self._primary_field(linked_table_id)["name"] resolved_ids: list[int] = [] for link_value in value: matches = self.find_entries(linked_table_id, match_col, link_value) if not matches: hard_skips.append( f"Cannot transfer link '{target_field_name}': no match " f"for '{link_value}' in linked table {linked_table_id} " f"(column '{match_col}')" ) continue if len(matches) > 1: soft_notices.append( f"Multiple matches for link '{target_field_name}' value " f"'{link_value}' in table {linked_table_id}; using first" ) resolved_ids.append(next(iter(matches))) if not resolved_ids and value: # All link values failed to resolve. return None, hard_skips, soft_notices return resolved_ids, hard_skips, soft_notices def add_data_batch( self, table_id: int, entries: list[dict], user_field_names: bool = True, fail_on_error: bool = False, check_field_compatibility: bool = False, ) -> tuple[list, list]: """Add/Change data (multiple rows) to a table. Args: table_id (int): ID of the table of interest. entries (list[dict]): List of entries to add/change. user_field_names (bool, optional): Whether to use field names or field IDs. Defaults to True. fail_on_error (bool, optional): Whether to fail if an error appears (HTTP errors after batch attempts, or field-compatibility issues when *check_field_compatibility* is True). Defaults to False. check_field_compatibility (bool, optional): Validate that payload keys exist as fields on the table before writing. Defaults to False. Returns: tuple[list, list]: List of touched IDs and list of errors. Raises: RuntimeError: If errors occur and *fail_on_error* is True, or if field compatibility fails with *fail_on_error* True. """ if not entries: return [], [] def process_entries(input_entries, batch_operation, single_operation): """Helper function to process entries for create or update.""" processed_ids = [] try: processed_ids += batch_operation( table_id, input_entries, user_field_names=user_field_names ) except requests.HTTPError as err: status = err.response.status_code if err.response is not None else None # Batch endpoints sometimes return opaque HTML 500/502/504 for # large or complex payloads; fall back to per-row writes. if status in {500, 502, 503, 504}: warnings.warn( f"Batch write failed with HTTP {status}: " f"{(err.response.text or '')[:300]!r}. " f"Retrying {len(input_entries)} row(s) individually.", stacklevel=2, ) for entry in input_entries: processed_ids.append( single_operation( table_id, entry, user_field_names=user_field_names, ) ) else: raise err return processed_ids if check_field_compatibility: self._validate_field_compatibility( table_id, entries, fail_on_error=fail_on_error, ) fields = self.get_fields(table_id) # Split entries into new and update. Copy each entry so later # mutations (e.g. inside update helpers) cannot affect the caller. # Convert select labels → option IDs (same as add_data). entries_update, entries_new, errors, touched_ids = [], [], [], [] for entry in entries: entry_copy = deepcopy(entry) row_id = entry_copy.pop("id", None) entry_copy = self._convert_selects(entry_copy, fields) if row_id is not None: entry_copy["id"] = row_id entries_update.append(entry_copy) else: entries_new.append(entry_copy) if entries_new: try: touched_ids += process_entries( entries_new, self._create_rows, self._create_row ) except requests.HTTPError as err: err_text = err.response.text if err.response is not None else str(err) errors.append( f"{self._create_rows.__name__} rows ({len(entries_new)}): " f"{err_text}" ) if entries_update: try: touched_ids += process_entries( entries_update, self._update_rows, self._update_row ) except requests.HTTPError as err: err_text = err.response.text if err.response is not None else str(err) errors.append( f"{self._update_rows.__name__} rows ({len(entries_update)}): " f"{err_text}" ) if errors and fail_on_error: raise RuntimeError(errors) return touched_ids, errorsBaserowAPI class: A wrapper around the Baserow API.
Initialize the BaserowApi class. This class is a wrapper around the Baserow API.
- Args
- -----=
database_url:str- URL of the Baserow instance
(e.g.
https://api.baserow.io). token:Optional[str], optional- Token-String for Baserow access. Defaults to None.
token_path:Optional[str], optional- Path to file containing the Token-String. Defaults to None.
jwt_token:bool, optional- Whether JWT-Token is used instead of Token-String. Defaults to False. Schema operations (create/delete database or table) require JWT.
Class variables
var applications_path-
The type of the None singleton.
var fields_path-
The type of the None singleton.
var table_path-
The type of the None singleton.
var tables_path-
The type of the None singleton.
var token_auth_path-
The type of the None singleton.
var workspaces_path-
The type of the None singleton.
Static methods
def from_credentials(database_url: str, email: str, password: str) ‑> BaserowApi-
Create a JWT-authenticated client from email and password.
- Args
- -----=
database_url- Baserow API base URL.
email- Account email.
password- Account password.
Returns -----= Authenticated
BaserowApiinstance (JWT mode). def login(database_url: str, email: str, password: str) ‑> dict-
Expand source code
@staticmethod def login(database_url: str, email: str, password: str) -> dict: """Obtain a JWT access token via email/password. Args: database_url: Baserow API base URL. email: Account email. password: Account password. Returns: Full token-auth response (includes ``access_token`` and ``refresh_token``). Raises: requests.HTTPError: If authentication fails. """ url = f"{database_url.rstrip('/')}/{BaserowApi.token_auth_path}/" resp = requests.post(url, json={"email": email, "password": password}) resp.raise_for_status() return resp.json()Obtain a JWT access token via email/password.
- Args
- -----=
database_url- Baserow API base URL.
email- Account email.
password- Account password.
Returns -----= Full token-auth response (includes
access_tokenandrefresh_token).- Raises
- -----=
requests.HTTPError- If authentication fails.
Methods
def add_data(self,
table_id: int,
data: dict,
row_id: int | None = None,
user_field_names: bool = True,
fail_on_error: bool = False,
check_field_compatibility: bool = False) ‑> int-
Expand source code
def add_data( self, table_id: int, data: dict, row_id: Optional[int] = None, user_field_names: bool = True, fail_on_error: bool = False, check_field_compatibility: bool = False, ) -> int: """Add/Change data to a table. Args: table_id (int): Table ID. data (dict): Data to add/change. row_id (int, optional): Row ID where to enter the data. Defaults to None. user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True. fail_on_error (bool, optional): When *check_field_compatibility* is True, raise ``RuntimeError`` on incompatible fields instead of warning. Defaults to False. check_field_compatibility (bool, optional): Validate that payload keys exist as fields on the table before writing. Defaults to False. Returns: int: Row ID. Raises: RuntimeError: If field compatibility fails and *fail_on_error* is True. """ fields = self.get_fields(table_id) if check_field_compatibility: fields = self._validate_field_compatibility( table_id, data, fields=fields, fail_on_error=fail_on_error, ) data_conv = self._convert_selects(data, fields) if row_id: data_conv["id"] = row_id self._update_row(table_id, data_conv, user_field_names=user_field_names) else: row_id = self._create_row( table_id, data_conv, user_field_names=user_field_names ) return row_idAdd/Change data to a table.
- Args
- -----=
table_id:int- Table ID.
data:dict- Data to add/change.
row_id:int, optional- Row ID where to enter the data. Defaults to None.
user_field_names:bool, optional- Whether to reference columns by name or ID. Defaults to True.
fail_on_error:bool, optional- When check_field_compatibility is True,
raise
RuntimeErroron incompatible fields instead of warning. Defaults to False. check_field_compatibility:bool, optional- Validate that payload keys exist as fields on the table before writing. Defaults to False.
- Returns
- -----=
int- Row ID.
- Raises
- -----=
RuntimeError- If field compatibility fails and fail_on_error is True.
def add_data_batch(self,
table_id: int,
entries: list[dict],
user_field_names: bool = True,
fail_on_error: bool = False,
check_field_compatibility: bool = False) ‑> tuple[list, list]-
Expand source code
def add_data_batch( self, table_id: int, entries: list[dict], user_field_names: bool = True, fail_on_error: bool = False, check_field_compatibility: bool = False, ) -> tuple[list, list]: """Add/Change data (multiple rows) to a table. Args: table_id (int): ID of the table of interest. entries (list[dict]): List of entries to add/change. user_field_names (bool, optional): Whether to use field names or field IDs. Defaults to True. fail_on_error (bool, optional): Whether to fail if an error appears (HTTP errors after batch attempts, or field-compatibility issues when *check_field_compatibility* is True). Defaults to False. check_field_compatibility (bool, optional): Validate that payload keys exist as fields on the table before writing. Defaults to False. Returns: tuple[list, list]: List of touched IDs and list of errors. Raises: RuntimeError: If errors occur and *fail_on_error* is True, or if field compatibility fails with *fail_on_error* True. """ if not entries: return [], [] def process_entries(input_entries, batch_operation, single_operation): """Helper function to process entries for create or update.""" processed_ids = [] try: processed_ids += batch_operation( table_id, input_entries, user_field_names=user_field_names ) except requests.HTTPError as err: status = err.response.status_code if err.response is not None else None # Batch endpoints sometimes return opaque HTML 500/502/504 for # large or complex payloads; fall back to per-row writes. if status in {500, 502, 503, 504}: warnings.warn( f"Batch write failed with HTTP {status}: " f"{(err.response.text or '')[:300]!r}. " f"Retrying {len(input_entries)} row(s) individually.", stacklevel=2, ) for entry in input_entries: processed_ids.append( single_operation( table_id, entry, user_field_names=user_field_names, ) ) else: raise err return processed_ids if check_field_compatibility: self._validate_field_compatibility( table_id, entries, fail_on_error=fail_on_error, ) fields = self.get_fields(table_id) # Split entries into new and update. Copy each entry so later # mutations (e.g. inside update helpers) cannot affect the caller. # Convert select labels → option IDs (same as add_data). entries_update, entries_new, errors, touched_ids = [], [], [], [] for entry in entries: entry_copy = deepcopy(entry) row_id = entry_copy.pop("id", None) entry_copy = self._convert_selects(entry_copy, fields) if row_id is not None: entry_copy["id"] = row_id entries_update.append(entry_copy) else: entries_new.append(entry_copy) if entries_new: try: touched_ids += process_entries( entries_new, self._create_rows, self._create_row ) except requests.HTTPError as err: err_text = err.response.text if err.response is not None else str(err) errors.append( f"{self._create_rows.__name__} rows ({len(entries_new)}): " f"{err_text}" ) if entries_update: try: touched_ids += process_entries( entries_update, self._update_rows, self._update_row ) except requests.HTTPError as err: err_text = err.response.text if err.response is not None else str(err) errors.append( f"{self._update_rows.__name__} rows ({len(entries_update)}): " f"{err_text}" ) if errors and fail_on_error: raise RuntimeError(errors) return touched_ids, errorsAdd/Change data (multiple rows) to a table.
- Args
- -----=
table_id:int- ID of the table of interest.
entries:list[dict]- List of entries to add/change.
user_field_names:bool, optional- Whether to use field names or field IDs. Defaults to True.
fail_on_error:bool, optional- Whether to fail if an error appears (HTTP errors after batch attempts, or field-compatibility issues when check_field_compatibility is True). Defaults to False.
check_field_compatibility:bool, optional- Validate that payload keys exist as fields on the table before writing. Defaults to False.
- Returns
- -----=
tuple[list, list]- List of touched IDs and list of errors.
- Raises
- -----=
RuntimeError- If errors occur and fail_on_error is True, or if field compatibility fails with fail_on_error True.
def create_database(self, workspace_id: int, name: str, init_with_data: bool = False) ‑> dict-
Expand source code
def create_database( self, workspace_id: int, name: str, init_with_data: bool = False ) -> dict: """Create a database application in a workspace. Requires JWT authentication. Args: workspace_id: Target workspace ID. name: Database name. init_with_data: Whether Baserow should seed example data. Returns: Created application dict (includes ``id``). """ url = ( f"{self._database_url}/{self.applications_path}/" f"workspace/{workspace_id}/" ) resp = requests.post( url, headers=self._auth_headers("application/json"), json={"name": name, "type": "database", "init_with_data": init_with_data}, ) resp.raise_for_status() return resp.json()Create a database application in a workspace.
Requires JWT authentication.
- Args
- -----=
workspace_id- Target workspace ID.
name- Database name.
init_with_data- Whether Baserow should seed example data.
Returns -----= Created application dict (includes
id). def create_field(self, table_id: int, field_spec: dict) ‑> dict-
Expand source code
def create_field(self, table_id: int, field_spec: dict) -> dict: """Create a field on a table. Requires JWT authentication. Args: table_id: Target table ID. field_spec: Field payload (must include ``name`` and ``type``; type-specific keys such as ``select_options`` or ``link_row_table_id`` as needed). Returns: Created field dict from the API. """ url = f"{self._database_url}/{self.fields_path}/{table_id}/" resp = requests.post( url, headers=self._auth_headers("application/json"), json=field_spec, ) resp.raise_for_status() return resp.json()Create a field on a table.
Requires JWT authentication.
- Args
- -----=
table_id- Target table ID.
field_spec- Field payload (must include
nameandtype; type-specific keys such asselect_optionsorlink_row_table_idas needed).
Returns -----= Created field dict from the API.
def create_table(self,
database_id: int,
name: str,
primary_field_name: str = 'Name',
fields: list[dict] | None = None) ‑> dict-
Expand source code
def create_table( self, database_id: int, name: str, primary_field_name: str = "Name", fields: Optional[list[dict]] = None, ) -> dict: """Create a table and optionally additional fields. Requires JWT authentication. The table is created with a single primary text field named *primary_field_name*. Any *fields* are then created via :meth:`create_field`. Args: database_id: Parent database application ID. name: Table name. primary_field_name: Name of the primary text field. fields: Optional list of field-create payloads (each must include at least ``name`` and ``type``). Returns: Dict with ``id``, ``name``, and ``fields`` (full field list). """ url = ( f"{self._database_url}/{self.tables_path}/database/{database_id}/" ) resp = requests.post( url, headers=self._auth_headers("application/json"), json={ "name": name, "data": [[primary_field_name], [""]], "first_row_header": True, }, ) resp.raise_for_status() table = resp.json() table_id = table["id"] if fields: for field_spec in fields: self.create_field(table_id, field_spec) return { "id": table_id, "name": name, "fields": self.get_fields(table_id), }Create a table and optionally additional fields.
Requires JWT authentication. The table is created with a single primary text field named primary_field_name. Any fields are then created via :meth:
create_field.- Args
- -----=
database_id- Parent database application ID.
name- Table name.
primary_field_name- Name of the primary text field.
fields- Optional list of field-create payloads
(each must include at least
nameandtype).
Returns -----= Dict with
id,name, andfields(full field list). def delete_database(self, database_id: int) ‑> None-
Expand source code
def delete_database(self, database_id: int) -> None: """Delete a database application. Requires JWT authentication. Args: database_id: Application ID of the database to delete. """ url = f"{self._database_url}/{self.applications_path}/{database_id}/" resp = requests.delete(url, headers=self._auth_headers()) resp.raise_for_status()Delete a database application.
Requires JWT authentication.
- Args
- -----=
database_id- Application ID of the database to delete.
def delete_table(self, table_id: int) ‑> None-
Expand source code
def delete_table(self, table_id: int) -> None: """Delete a table. Requires JWT authentication. Args: table_id: Table ID to delete. """ url = f"{self._database_url}/{self.tables_path}/{table_id}/" resp = requests.delete(url, headers=self._auth_headers()) resp.raise_for_status()Delete a table.
Requires JWT authentication.
- Args
- -----=
table_id- Table ID to delete.
def find_entries(self,
table_id: int,
column: str,
value: Any,
*,
user_field_names: bool = True,
writable_only: bool = False,
use_linked_row_ids: bool = True,
paginated: bool = True) ‑> dict[int, dict[str, typing.Any]]-
Expand source code
def find_entries( self, table_id: int, column: str, value: Any, *, user_field_names: bool = True, writable_only: bool = False, use_linked_row_ids: bool = True, paginated: bool = True, ) -> dict[int, dict[str, Any]]: """Find rows in a table where *column* equals *value*. Uses Baserow's ``filter__field_<id>__equal`` query filter. Args: table_id: Table to search. column: Field name or ``field_<id>`` key to match on. value: Value that must equal the column contents. user_field_names: Return/use human field names when True. writable_only: If True, omit read-only fields from results. use_linked_row_ids: Format link_row values as IDs (True) or primary display values (False). paginated: Load all result pages when True. Returns: Dict mapping row ID → formatted row data (same shape as :meth:`get_data`). """ fields = self.get_fields(table_id) field = self._resolve_field(table_id, column, fields=fields) params = {f"filter__field_{field['id']}__equal": value} if user_field_names: params["user_field_names"] = "true" get_rows_url = f"{self._database_url}/{self.table_path}/{table_id}/" resp = requests.get( get_rows_url, headers=self._auth_headers(), params=params ) resp.raise_for_status() data = resp.json() if "results" not in data: raise RuntimeError(f"Could not get query result data from {get_rows_url}") results = data["results"] if paginated and data.get("next"): results = results + self._get_rows_data( url=data["next"], paginated=True ) if writable_only: names = {f["name"]: f for f in fields if not f["read_only"]} else: names = {f["name"]: f for f in fields} if not user_field_names: names = { f'field_{f["id"]}': f for f in fields if (not writable_only) or (not f["read_only"]) } return { row["id"]: { k: _format_value(v, names[k], use_linked_row_ids) for k, v in row.items() if k in names } for row in results }Find rows in a table where column equals value.
Uses Baserow's
filter__field_<id>__equalquery filter.- Args
- -----=
table_id- Table to search.
column- Field name or
field_<id>key to match on. value- Value that must equal the column contents.
user_field_names- Return/use human field names when True.
writable_only- If True, omit read-only fields from results.
use_linked_row_ids- Format link_row values as IDs (True) or primary display values (False).
paginated- Load all result pages when True.
Returns -----= Dict mapping row ID → formatted row data (same shape as :meth:
get_data). def get_data(self,
table_id: int,
writable_only: bool = True,
user_field_names: bool = True,
paginated: bool = True,
include: list[str] | None = None,
exclude: list[str] | None = None,
use_linked_row_ids: bool = True) ‑> dict[int, dict[str, typing.Any]]-
Expand source code
def get_data( self, table_id: int, writable_only: bool = True, user_field_names: bool = True, paginated: bool = True, include: Optional[list[str]] = None, exclude: Optional[list[str]] = None, use_linked_row_ids: bool = True, ) -> dict[int, dict[str, Any]]: """Get all data from a table. Args: table_id (int): ID of the table of interest. writable_only (bool, optional): Only return fields which can be written to. This excludes all formula and computed fields. Defaults to True (only writable fields). user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True (use names). paginated (bool, optional): Whether to load multiple pages of data. Defaults to True. include (list[str], optional): List of fields to include in the response. Defaults to None (all fields). exclude (list[str], optional): List of fields to exclude from the response. Defaults to None (no fields excluded). use_linked_row_ids (bool, optional): Return IDs for linked rows, with False return values instead. Defaults to True (return IDs). Returns: dict[int, dict[str, Any]]: dictionary of data in the table. """ if writable_only: fields = self.get_writable_fields(table_id) else: fields = self.get_fields(table_id) if user_field_names: names = {f["name"]: f for f in fields} else: names = {f'field_{f["id"]}': f for f in fields} data = self._get_rows_data( table_id=table_id, user_field_names=user_field_names, paginated=paginated, include=include, exclude=exclude, ) # Collect rows with their field names and values, writable_data = { d["id"]: { k: _format_value(v, names[k], use_linked_row_ids) for k, v in d.items() if k in names } for d in data } return writable_dataGet all data from a table.
- Args
- -----=
table_id:int- ID of the table of interest.
writable_only:bool, optional- Only return fields which can be written to. This excludes all formula and computed fields. Defaults to True (only writable fields).
user_field_names:bool, optional- Whether to reference columns by name or ID. Defaults to True (use names).
paginated:bool, optional- Whether to load multiple pages of data. Defaults to True.
include:list[str], optional- List of fields to include in the response. Defaults to None (all fields).
exclude:list[str], optional- List of fields to exclude from the response. Defaults to None (no fields excluded).
use_linked_row_ids:bool, optional- Return IDs for linked rows, with False return values instead. Defaults to True (return IDs).
- Returns
- -----=
dict[int, dict[str, Any]]- dictionary of data in the table.
def get_entry(self,
table_id: int,
row_id: int,
linked: bool = False,
use_linked_row_ids: bool = True,
seen_tables: list | None = None,
user_field_names: bool = True,
include: list[str] | None = None,
exclude: list[str] | None = None) ‑> dict-
Expand source code
def get_entry( self, table_id: int, row_id: int, linked: bool = False, use_linked_row_ids: bool = True, seen_tables: Optional[list] = None, user_field_names: bool = True, include: Optional[list[str]] = None, exclude: Optional[list[str]] = None, ) -> dict: """Get a single entry from a table. Args: table_id (int): ID of the table of interest. row_id (int): Entry ID for the entry of interest. linked (bool, optional): Whether to fully hydrate the output with linked tables. Defaults to False (no data of linked tables is loaded). use_linked_row_ids (bool, optional): Return IDs for linked rows, with False return values instead. Ignored if linked is True. Defaults to True (return IDs). seen_tables (list, optional): List of already linked tables. These are not loaded again. Defaults to None. user_field_names (bool, optional): Whether to reference columns by name or ID. Defaults to True (use names). include (list[str], optional): List of fields to include in the response. Defaults to None (all fields). exclude (list[str], optional): List of fields to exclude from the response. Defaults to None (no fields excluded). Returns: dict: Entry data. """ if linked and not use_linked_row_ids: warnings.warn( "Raw value output from 'use_linked_row_ids=False' is ignored because " "full linked information is used instead (linked=True)" ) data = self._get_rows_data( table_id=table_id, row_id=row_id, paginated=False, user_field_names=user_field_names, include=include, exclude=exclude, ) fields = self.get_fields(table_id) # If include or exclude are provided, filter the fields if include: fields = [f for f in fields if f["name"] in include] if exclude: fields = [f for f in fields if f["name"] not in exclude] names = {f["name"]: f for f in fields} names = names | {f'field_{f["id"]}': f for f in fields} formatted_data = { k: _format_value(v, names[k], use_linked_row_ids) for k, v in data.items() if k in names } # Copy to avoid mutating the caller's list across recursive calls. seen_tables_next = list(seen_tables) if seen_tables else [] seen_tables_next.append(table_id) # fully hydrate with linked data # --> recursively get data from linked tables if linked: link_fields = [f for f in fields if f["type"] == "link_row"] for field in link_fields: linked_table_id = field["link_row_table_id"] if linked_table_id in seen_tables_next[:-1]: continue # Raw API key depends on user_field_names. raw_key = ( field["name"] if user_field_names else f"field_{field['id']}" ) linked_refs = data.get(raw_key) or [] if not linked_refs: continue formatted_data[field["name"] if user_field_names else raw_key] = [ self.get_entry( linked_table_id, e_id["id"], linked=False, use_linked_row_ids=use_linked_row_ids, seen_tables=seen_tables_next, user_field_names=user_field_names, include=include, exclude=exclude, ) for e_id in linked_refs ] return formatted_dataGet a single entry from a table.
- Args
- -----=
table_id:int- ID of the table of interest.
row_id:int- Entry ID for the entry of interest.
linked:bool, optional- Whether to fully hydrate the output with linked tables. Defaults to False (no data of linked tables is loaded).
use_linked_row_ids:bool, optional- Return IDs for linked rows, with False return values instead. Ignored if linked is True. Defaults to True (return IDs).
seen_tables:list, optional- List of already linked tables. These are not loaded again. Defaults to None.
user_field_names:bool, optional- Whether to reference columns by name or ID. Defaults to True (use names).
include:list[str], optional- List of fields to include in the response. Defaults to None (all fields).
exclude:list[str], optional- List of fields to exclude from the response. Defaults to None (no fields excluded).
- Returns
- -----=
dict- Entry data.
def get_fields(self, table_id: int) ‑> list[dict]-
Expand source code
def get_fields(self, table_id: int) -> list[dict]: """Get all fields / column specifications for a table. Args: table_id (int): ID of the table of interest. Returns: list[dict]: List of column specifications (dict of fields) """ get_fields_url = f"{self._database_url}/{self.fields_path}/{table_id}/" resp = requests.get(get_fields_url, headers=self._auth_headers()) resp.raise_for_status() return resp.json()Get all fields / column specifications for a table.
- Args
- -----=
table_id:int- ID of the table of interest.
- Returns
- -----=
list[dict]- List of column specifications (dict of fields)
def get_writable_fields(self, table_id: int) ‑> list[dict]-
Expand source code
def get_writable_fields(self, table_id: int) -> list[dict]: """Get all writable fields in a table. Args: table_id (int): ID of the table of interest. Returns: list[dict]: List of writable fields. """ fields = self.get_fields(table_id) writable_fields = [field for field in fields if not field["read_only"]] return writable_fieldsGet all writable fields in a table.
- Args
- -----=
table_id:int- ID of the table of interest.
- Returns
- -----=
list[dict]- List of writable fields.
def list_workspaces(self) ‑> list[dict]-
Expand source code
def list_workspaces(self) -> list[dict]: """List workspaces visible to the authenticated user. Returns: List of workspace dicts. """ url = f"{self._database_url}/{self.workspaces_path}/" resp = requests.get(url, headers=self._auth_headers()) resp.raise_for_status() return resp.json()List workspaces visible to the authenticated user.
Returns -----= List of workspace dicts.
def synchronize_data(self,
source_table_id: int,
source_row_id: int,
target_table_id: int,
identifier_column: str,
*,
field_mapping: dict[str, str] | None = None,
link_match_column: str | dict[str, str] | None = None,
exclude_fields: list[str] | None = None,
include_fields: list[str] | None = None,
fail_on_error: bool = False,
dry_run: bool = False) ‑> tuple[int | None, list[str]]-
Expand source code
def synchronize_data( self, source_table_id: int, source_row_id: int, target_table_id: int, identifier_column: str, *, field_mapping: Optional[dict[str, str]] = None, link_match_column: Optional[Union[str, dict[str, str]]] = None, exclude_fields: Optional[list[str]] = None, include_fields: Optional[list[str]] = None, fail_on_error: bool = False, dry_run: bool = False, ) -> tuple[Optional[int], list[str]]: """Copy one source row into a target table (create or update). Matching on the target uses *identifier_column*: if a target row already has the same identifier value as the source, that row is updated; otherwise a new row is created. Compatible columns (same name by default, or via *field_mapping*) are copied. Read-only target fields, missing columns, and type mismatches are skipped with a warning. Link-row values are remapped by looking up matching entries in the target's linked table (by primary field, or by *link_match_column*). Args: source_table_id: Table containing the source row. source_row_id: Row ID to copy from. target_table_id: Table to write into. identifier_column: Column name used to find an existing target row (must exist on both tables unless mapped). field_mapping: Optional map of source field name → target field name. Unmapped fields with identical names are still copied. link_match_column: Column used to match linked rows in the target linked table. Either a single column name applied to all link fields, or a dict of target link-field name → match column. Defaults to each linked table's primary field. exclude_fields: Optional source field names to skip entirely (e.g. links handled separately by the caller). include_fields: If set, only these source field names are considered for transfer (in addition to the identifier column, which is always written). ``exclude_fields`` is applied after this filter. fail_on_error: If True, raise ``RuntimeError`` when any value cannot be transferred (hard failures such as missing fields, type mismatches, or unmatched links). Informational notices (e.g. ambiguous matches where the first row is used) do not trigger failure. If False (default), warn and continue. dry_run: If True, build the transfer payload and report what would happen, but do not write to the target table. Returns ``(existing_row_id_or_None, messages)``. Returns: Tuple of ``(target_row_id, messages)`` where *messages* contains both hard skip reasons and soft notices. In *dry_run* mode, ``target_row_id`` is the existing match ID if updating, else ``None`` (would create); no writes are performed. Raises: RuntimeError: If the identifier is missing/empty on the source, or if *fail_on_error* is True and hard transfer failures occurred. """ field_mapping = field_mapping or {} exclude_set = set(exclude_fields or []) include_set = set(include_fields) if include_fields is not None else None # Identifier must always be eligible for transfer. if include_set is not None: include_set.add(identifier_column) source_fields = {f["name"]: f for f in self.get_fields(source_table_id)} target_fields = {f["name"]: f for f in self.get_fields(target_table_id)} # Display values for links (primary labels) so we can rematch them. source_entry = self.get_entry( source_table_id, source_row_id, use_linked_row_ids=False, ) id_source_name = identifier_column # Allow mapping the identifier itself. id_target_name = field_mapping.get(identifier_column, identifier_column) if id_source_name not in source_entry: raise RuntimeError( f"Identifier column '{id_source_name}' missing on source row " f"{source_row_id}" ) identifier_value = source_entry[id_source_name] if identifier_value in (None, ""): raise RuntimeError( f"Identifier column '{id_source_name}' is empty on source row " f"{source_row_id}" ) payload: dict[str, Any] = {} hard_skips: list[str] = [] notices: list[str] = [] def _allowed(source_name: str) -> bool: if source_name in exclude_set: return False if include_set is not None and source_name not in include_set: return False return True # Build the set of source→target field pairs to consider. pairs: list[tuple[str, str]] = [] mapped_sources = set(field_mapping.keys()) for source_name, target_name in field_mapping.items(): if not _allowed(source_name): continue pairs.append((source_name, target_name)) for source_name in source_entry: if source_name in mapped_sources or not _allowed(source_name): continue if source_name in target_fields: pairs.append((source_name, source_name)) for source_name, target_name in pairs: if source_name not in source_entry: hard_skips.append( f"Source field '{source_name}' not present on source row" ) continue if target_name not in target_fields: hard_skips.append( f"Cannot transfer '{source_name}': target has no field " f"'{target_name}'" ) continue source_field = source_fields.get(source_name) target_field = target_fields[target_name] if target_field.get("read_only"): hard_skips.append( f"Cannot transfer '{source_name}': target field is read-only" ) continue if ( source_field and source_field.get("type") and target_field.get("type") and source_field["type"] != "formula" and source_field["type"] != target_field["type"] ): hard_skips.append( f"Cannot transfer '{source_name}': type mismatch " f"({source_field['type']} → {target_field['type']})" ) continue value = source_entry[source_name] if target_field["type"] == "link_row": resolved, link_hard, link_soft = self._resolve_link_values_for_sync( value=value, target_field=target_field, target_field_name=target_name, link_match_column=link_match_column, ) hard_skips.extend(link_hard) notices.extend(link_soft) if resolved is not None: payload[target_name] = resolved continue payload[target_name] = value skipped = hard_skips + notices for msg in skipped: warnings.warn(msg, stacklevel=2) if hard_skips and fail_on_error: raise RuntimeError( "synchronize_data could not transfer all values: " + "; ".join(hard_skips) ) # Ensure identifier is always written so upsert can work. if id_target_name not in payload: if id_target_name in target_fields and not target_fields[id_target_name].get( "read_only" ): payload[id_target_name] = identifier_value else: raise RuntimeError( f"Identifier column '{id_target_name}' is not writable on target" ) # Always report which target fields are in the write payload. notices.append(f"transferred fields: {sorted(payload.keys())}") existing = self.find_entries( target_table_id, id_target_name, identifier_value ) if len(existing) > 1: notice = ( f"Multiple target rows match identifier '{identifier_value}' " f"on '{id_target_name}'; updating the first match" ) notices.append(notice) warnings.warn(notice, stacklevel=2) skipped = hard_skips + notices if dry_run: if existing: target_row_id = next(iter(existing)) notices.append( f"dry_run: would update row {target_row_id} " f"(identifier '{identifier_value}')" ) else: target_row_id = None notices.append( f"dry_run: would create new row " f"(identifier '{identifier_value}')" ) skipped = hard_skips + notices for msg in notices: if msg.startswith("dry_run:") or msg.startswith("transferred fields:"): warnings.warn(msg, stacklevel=2) return target_row_id, skipped if existing: target_row_id = next(iter(existing)) return ( self.add_data( target_table_id, payload, row_id=target_row_id, user_field_names=True, ), skipped, ) return ( self.add_data(target_table_id, payload, user_field_names=True), skipped, )Copy one source row into a target table (create or update).
Matching on the target uses identifier_column: if a target row already has the same identifier value as the source, that row is updated; otherwise a new row is created.
Compatible columns (same name by default, or via field_mapping) are copied. Read-only target fields, missing columns, and type mismatches are skipped with a warning. Link-row values are remapped by looking up matching entries in the target's linked table (by primary field, or by link_match_column).
- Args
- -----=
source_table_id- Table containing the source row.
source_row_id- Row ID to copy from.
target_table_id- Table to write into.
identifier_column- Column name used to find an existing target row (must exist on both tables unless mapped).
field_mapping- Optional map of source field name → target field name. Unmapped fields with identical names are still copied.
link_match_column- Column used to match linked rows in the target linked table. Either a single column name applied to all link fields, or a dict of target link-field name → match column. Defaults to each linked table's primary field.
exclude_fields- Optional source field names to skip entirely (e.g. links handled separately by the caller).
include_fields- If set, only these source field names are
considered for transfer (in addition to the identifier
column, which is always written).
exclude_fieldsis applied after this filter. fail_on_error- If True, raise
RuntimeErrorwhen any value cannot be transferred (hard failures such as missing fields, type mismatches, or unmatched links). Informational notices (e.g. ambiguous matches where the first row is used) do not trigger failure. If False (default), warn and continue. dry_run- If True, build the transfer payload and report what
would happen, but do not write to the target table.
Returns
(existing_row_id_or_None, messages).
Returns -----= Tuple of
(target_row_id, messages)where messages contains both hard skip reasons and soft notices. In dry_run mode,target_row_idis the existing match ID if updating, elseNone(would create); no writes are performed.- Raises
- -----=
RuntimeError- If the identifier is missing/empty on the source, or if fail_on_error is True and hard transfer failures occurred.