From b8dee7191e595e86a73ca91bc37320cbd4926e04 Mon Sep 17 00:00:00 2001 From: Dennis Giegold Date: Sun, 16 Nov 2025 22:19:12 +0100 Subject: [PATCH 1/5] feat: Add document history command to python client --- terminusdb_client/client/Client.py | 103 ++++++++++++++++++ .../tests/integration_tests/test_client.py | 64 +++++++++++ terminusdb_client/tests/test_Client.py | 36 ++++++ 3 files changed, 203 insertions(+) diff --git a/terminusdb_client/client/Client.py b/terminusdb_client/client/Client.py index 1b7ea8d6..f7860b11 100644 --- a/terminusdb_client/client/Client.py +++ b/terminusdb_client/client/Client.py @@ -563,6 +563,109 @@ def get_commit_history(self, max_history: int = 500) -> list: raise ValueError("max_history needs to be non-negative.") return self.log(count=max_history) + def get_document_history( + self, + doc_id: str, + team: Optional[str] = None, + db: Optional[str] = None, + start: int = 0, + count: int = -1, + created: bool = False, + updated: bool = False, + ) -> list: + """Get the commit history for a specific document + + Returns the history of changes made to a document, ordered backwards + in time from the most recent change. Only commits where the specified + document was created, modified, or deleted are included. + + Parameters + ---------- + doc_id : str + The document ID (IRI) to retrieve history for (e.g., "Person/alice") + team : str, optional + The team from which the database is. Defaults to the class property. + db : str, optional + The database. Defaults to the class property. + start : int, optional + Starting index for pagination. Defaults to 0. + count : int, optional + Maximum number of history entries to return. Defaults to -1 (all). + created : bool, optional + If True, return only the creation time. Defaults to False. + updated : bool, optional + If True, return only the last update time. Defaults to False. + + Raises + ------ + InterfaceError + If the client is not connected to a database + DatabaseError + If the API request fails or document is not found + + Returns + ------- + list + List of history entry dictionaries containing commit information + for the specified document: + ``` + [ + { + "author": "admin", + "identifier": "tbn15yq6rw1l4e9bgboyu3vwcoxgri5", + "message": "Updated document", + "timestamp": datetime.datetime(2023, 4, 6, 19, 1, 14, 324928) + }, + { + "author": "admin", + "identifier": "3v3naa8jrt8612dg5zryu4vjqwa2w9s", + "message": "Created document", + "timestamp": datetime.datetime(2023, 4, 6, 19, 0, 47, 406387) + } + ] + ``` + + Example + ------- + >>> from terminusdb_client import Client + >>> client = Client("http://127.0.0.1:6363") + >>> client.connect(db="example_db") + >>> history = client.get_document_history("Person/Jane") + >>> print(f"Document modified {len(history)} times") + >>> print(f"Last change by: {history[0]['author']}") + """ + self._check_connection(check_db=(not team or not db)) + team = team if team else self.team + db = db if db else self.db + + params = { + 'id': doc_id, + 'start': start, + 'count': count, + } + + if created: + params['created'] = created + if updated: + params['updated'] = updated + + result = self._session.get( + f"{self.api}/history/{team}/{db}", + params=params, + headers=self._default_headers, + auth=self._auth(), + ) + + history = json.loads(_finish_response(result)) + + # Post-process timestamps from Unix timestamp to datetime objects + if isinstance(history, list): + for entry in history: + if 'timestamp' in entry and isinstance(entry['timestamp'], (int, float)): + entry['timestamp'] = datetime.fromtimestamp(entry['timestamp']) + + return history + def _get_current_commit(self): descriptor = self.db if self.branch: diff --git a/terminusdb_client/tests/integration_tests/test_client.py b/terminusdb_client/tests/integration_tests/test_client.py index 2762b65d..63997ae2 100644 --- a/terminusdb_client/tests/integration_tests/test_client.py +++ b/terminusdb_client/tests/integration_tests/test_client.py @@ -211,6 +211,70 @@ def test_log(docker_url): assert log[0]['@type'] == 'InitialCommit' +def test_get_document_history(docker_url): + # Create client + client = Client(docker_url, user_agent=test_user_agent) + client.connect() + + # Create test database + db_name = "testDB" + str(random()) + client.create_database(db_name, team="admin") + client.connect(db=db_name) + + # Add a schema + schema = { + "@type": "Class", + "@id": "Person", + "name": "xsd:string", + "age": "xsd:integer" + } + client.insert_document(schema, graph_type=GraphType.SCHEMA) + + # Insert a document + person = {"@type": "Person", "@id": "Person/Jane", "name": "Jane", "age": 30} + client.insert_document(person, commit_msg="Created Person/Jane") + + # Update the document to create history + person["name"] = "Jane Doe" + person["age"] = 31 + client.update_document(person, commit_msg="Updated Person/Jane name and age") + + # Update again + person["age"] = 32 + client.update_document(person, commit_msg="Updated Person/Jane age") + + # Get document history + history = client.get_document_history("Person/Jane") + + # Assertions + assert isinstance(history, list) + assert len(history) >= 3 # At least insert and two updates + assert all('timestamp' in entry for entry in history) + assert all(isinstance(entry['timestamp'], dt.datetime) for entry in history) + assert all('author' in entry for entry in history) + assert all('message' in entry for entry in history) + assert all('identifier' in entry for entry in history) + + # Verify messages are in the history (order may vary) + messages = [entry['message'] for entry in history] + assert "Created Person/Jane" in messages + assert "Updated Person/Jane name and age" in messages + assert "Updated Person/Jane age" in messages + + # Test with pagination + paginated_history = client.get_document_history("Person/Jane", start=0, count=2) + assert len(paginated_history) == 2 + + # Test with team/db override + history_override = client.get_document_history( + "Person/Jane", team="admin", db=db_name + ) + assert len(history_override) == len(history) + + # Cleanup + client.delete_database(db_name, "admin") + + def test_get_triples(docker_url): client = Client(docker_url, user_agent=test_user_agent, team="admin") client.connect() diff --git a/terminusdb_client/tests/test_Client.py b/terminusdb_client/tests/test_Client.py index fadd11dd..a1e459a6 100644 --- a/terminusdb_client/tests/test_Client.py +++ b/terminusdb_client/tests/test_Client.py @@ -712,3 +712,39 @@ def test_get_user(mocked_requests): auth=("admin", "root"), headers={"user-agent": f"terminusdb-client-python/{__version__}"}, ) + + +@mock.patch.object(requests.Session, 'head', side_effect=mocked_request_success) +@mock.patch.object(requests.Session, 'get', side_effect=mocked_request_success) +def test_get_document_history(mocked_get, mocked_head): + client = Client( + "http://localhost:6363", user="admin", key="root", team="admin" + ) + client.connect(db="myDBName") + + client.get_document_history("Person/Jane", start=0, count=10) + + # Get the last call to get (should be our get_document_history call) + last_call = client._session.get.call_args_list[-1] + assert last_call[0][0] == "http://localhost:6363/api/history/admin/myDBName" + assert last_call[1]["params"] == {"id": "Person/Jane", "start": 0, "count": 10} + assert last_call[1]["headers"] == {"user-agent": f"terminusdb-client-python/{__version__}"} + assert last_call[1]["auth"] == ("admin", "root") + + +@mock.patch.object(requests.Session, 'head', side_effect=mocked_request_success) +@mock.patch.object(requests.Session, 'get', side_effect=mocked_request_success) +def test_get_document_history_with_created_updated(mocked_get, mocked_head): + client = Client( + "http://localhost:6363", user="admin", key="root", team="admin" + ) + client.connect(db="myDBName") + + client.get_document_history("Person/Jane", created=True, updated=True) + + # Get the last call to get (should be our get_document_history call) + last_call = client._session.get.call_args_list[-1] + assert last_call[0][0] == "http://localhost:6363/api/history/admin/myDBName" + assert last_call[1]["params"] == {"id": "Person/Jane", "start": 0, "count": -1, "created": True, "updated": True} + assert last_call[1]["headers"] == {"user-agent": f"terminusdb-client-python/{__version__}"} + assert last_call[1]["auth"] == ("admin", "root") From 5d4a85c5862e01a599afd72c22d7bdbd397587aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Fri, 2 Jan 2026 20:56:32 +0100 Subject: [PATCH 2/5] Skip the venv folder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b93b4039..ee07059f 100755 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # converage file cov.xml +.venv + .mypy_cache/ .pytest_cache/ From 52e223bc5a38ee9d89a69768fc139633aa04512e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Fri, 2 Jan 2026 21:13:07 +0100 Subject: [PATCH 3/5] Fix linting errors --- terminusdb_client/client/Client.py | 1 - terminusdb_client/schema/schema.py | 4 ++-- terminusdb_client/scripts/scripts.py | 4 ++-- terminusdb_client/woql_type.py | 4 ---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/terminusdb_client/client/Client.py b/terminusdb_client/client/Client.py index f7860b11..17898bd4 100644 --- a/terminusdb_client/client/Client.py +++ b/terminusdb_client/client/Client.py @@ -643,7 +643,6 @@ def get_document_history( 'start': start, 'count': count, } - if created: params['created'] = created if updated: diff --git a/terminusdb_client/schema/schema.py b/terminusdb_client/schema/schema.py index 686a7617..95fdd25c 100644 --- a/terminusdb_client/schema/schema.py +++ b/terminusdb_client/schema/schema.py @@ -98,7 +98,7 @@ def _check_mismatch_type(prop, prop_value, prop_type): f"Property {prop} should be of type {prop_type_id} but got value of type {prop_value_id}" ) else: - if prop_type == int: + if prop_type is int: prop_value = int(prop_value) # TODO: This is now broken # check_type(prop, prop_value, prop_type) @@ -224,7 +224,7 @@ class DocumentTemplate(metaclass=TerminusClass): def __setattr__(self, name, value): if name[0] != "_" and value is not None: correct_type = self._annotations.get(name) - if correct_type == int: + if correct_type is int: try: value = int(value) except ValueError: diff --git a/terminusdb_client/scripts/scripts.py b/terminusdb_client/scripts/scripts.py index 2c0d8591..4002d0ae 100644 --- a/terminusdb_client/scripts/scripts.py +++ b/terminusdb_client/scripts/scripts.py @@ -242,7 +242,7 @@ def add_docstring(self, obj_dict): result_obj.script += f" {value} = ()\n" else: result_obj.script += ( - f" {value.replace(' ','_')} = '{value}'\n" + f" {value.replace(' ', '_')} = '{value}'\n" ) if obj.get("@documentation"): result_obj.add_docstring(obj) @@ -469,7 +469,7 @@ def _df_to_schema(class_name, df): converted_type = class_name else: converted_type = np_to_buildin[dtype.type] - if converted_type == object: + if converted_type is object: converted_type = str # pandas treats all string as objects converted_type = wt.to_woql_type(converted_type) diff --git a/terminusdb_client/woql_type.py b/terminusdb_client/woql_type.py index d4aac9ef..a67ed977 100644 --- a/terminusdb_client/woql_type.py +++ b/terminusdb_client/woql_type.py @@ -25,7 +25,6 @@ nonNegativeInteger = NewType('nonNegativeInteger', int) # noqa: N816 base64Binary = NewType('base64Binary', str) # noqa: N816 hexBinary = NewType('hexBinary', str) # noqa: N816 -anyURI = NewType('anyURI', str) # noqa: N816 language = NewType('language', str) normalizedString = NewType('normalizedString', str) # noqa: N816 token = NewType('token', str) @@ -38,9 +37,7 @@ bool: "xsd:boolean", float: "xsd:double", int: "xsd:integer", - long: "xsd:long", dict: "sys:JSON", - gYear: "xsd:gYear", dt.datetime: "xsd:dateTime", dt.date: "xsd:date", dt.time: "xsd:time", @@ -68,7 +65,6 @@ nonNegativeInteger : "xsd:nonNegativeInteger", base64Binary : "xsd:base64Binary", hexBinary : "xsd:hexBinary", - anyURI : "xsd:anyURI", language : "xsd:language", normalizedString : "xsd:normalizedString", token : "xsd:token", From 699941e6272c7c74f78ad5b12ce169d50183927c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Fri, 2 Jan 2026 21:13:49 +0100 Subject: [PATCH 4/5] Add accurate history endpoint defaults --- terminusdb_client/client/Client.py | 4 ++-- terminusdb_client/tests/test_Client.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/terminusdb_client/client/Client.py b/terminusdb_client/client/Client.py index 17898bd4..5d6ce4b7 100644 --- a/terminusdb_client/client/Client.py +++ b/terminusdb_client/client/Client.py @@ -569,7 +569,7 @@ def get_document_history( team: Optional[str] = None, db: Optional[str] = None, start: int = 0, - count: int = -1, + count: int = 10, created: bool = False, updated: bool = False, ) -> list: @@ -590,7 +590,7 @@ def get_document_history( start : int, optional Starting index for pagination. Defaults to 0. count : int, optional - Maximum number of history entries to return. Defaults to -1 (all). + Maximum number of history entries to return. Defaults to 10. created : bool, optional If True, return only the creation time. Defaults to False. updated : bool, optional diff --git a/terminusdb_client/tests/test_Client.py b/terminusdb_client/tests/test_Client.py index a1e459a6..eeb28aba 100644 --- a/terminusdb_client/tests/test_Client.py +++ b/terminusdb_client/tests/test_Client.py @@ -745,6 +745,6 @@ def test_get_document_history_with_created_updated(mocked_get, mocked_head): # Get the last call to get (should be our get_document_history call) last_call = client._session.get.call_args_list[-1] assert last_call[0][0] == "http://localhost:6363/api/history/admin/myDBName" - assert last_call[1]["params"] == {"id": "Person/Jane", "start": 0, "count": -1, "created": True, "updated": True} + assert last_call[1]["params"] == {"id": "Person/Jane", "start": 0, "count": 10, "created": True, "updated": True} assert last_call[1]["headers"] == {"user-agent": f"terminusdb-client-python/{__version__}"} assert last_call[1]["auth"] == ("admin", "root") From 2bf89ec0dabbeea739d12f2db14ddc94ebf36b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Fri, 2 Jan 2026 21:13:59 +0100 Subject: [PATCH 5/5] Fix lint errors --- terminusdb_client/woqlquery/woql_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index d54ea363..f2d8e48e 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2850,7 +2850,7 @@ def group_by(self, group_vars, template, output, groupquery=None): if self._cursor.get("@type"): self._wrap_cursor_with_and() self._cursor["@type"] = "GroupBy" - if not type(group_vars) is list: + if type(group_vars) is not list: group_vars = [group_vars] self._cursor["group_by"] = self._raw_var_list(group_vars) self._cursor["template"] = self._clean_object(template)