diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3e291240..7b1173ba 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index c795d408..aa807253 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![downloads](https://img.shields.io/pypi/dm/terminusdb-client.svg?logo=pypi)](https://pypi.python.org/pypi/terminusdb-client/) [![build status](https://img.shields.io/github/workflow/status/terminusdb/terminusdb-client-python/Python%20package?logo=github)](https://github.com/terminusdb/terminusdb-client-python/actions) -[![documentation](https://img.shields.io/github/deployments/terminusdb/terminusdb-client-python/github-pages?label=documentation&logo=github)](https://terminusdb.com/docs/python) +[![documentation](https://img.shields.io/github/deployments/terminusdb/terminusdb-client-python/github-pages?label=documentation&logo=github)](https://terminusdb.org/docs/python) [![code coverage](https://codecov.io/gh/terminusdb/terminusdb-client-python/branch/main/graph/badge.svg?token=BclAUaOPnQ)](https://codecov.io/gh/terminusdb/terminusdb-client-python) [![license](https://img.shields.io/github/license/terminusdb/terminusdb-client-python?color=pink&logo=apache)](https://github.com/terminusdb/terminusdb-client-python/blob/main/LICENSE) @@ -20,24 +20,20 @@ [**TerminusDB**][terminusdb] is an [open-source][terminusdb-repo] graph database and document store. It allows you to link JSON documents in a powerful knowledge -graph all through a simple document API. +graph all through a simple document API, with full git-for-data version control. -[terminusdb]: https://terminusdb.com/ -[terminusdb-docs]: https://terminusdb.com/docs/ +[terminusdb]: https://terminusdb.org/ +[terminusdb-docs]: https://terminusdb.org/docs/ [terminusdb-repo]: https://github.com/terminusdb/terminusdb -[**TerminusCMS**](https://terminusdb.com/terminuscms/) is a hosted headless content management system. It is built upon TerminusDB and is a developer-focused data management platform for complex data and content infrastructure. [Sign up and clone a demo project to see how it works][dashboard]. - -[dashboard]: https://dashboard.terminusdb.com/ - ## Requirements -- [TerminusDB v10.0](https://github.com/terminusdb/terminusdb-server) +- [TerminusDB v11.1](https://github.com/terminusdb/terminusdb-server) - [Python >=3.8](https://www.python.org/downloads) ## Release Notes and Previous Versions -TerminusDB Client v10.0 works with TerminusDB v10.0 and TerminusCMS. Please check the [Release Notes](RELEASE_NOTES.md) to find out what has changed. +TerminusDB Client v11.1 works with TerminusDB v11.1 and the [DFRNT cloud service](https://dfrnt.com). Please check the [Release Notes](RELEASE_NOTES.md) to find out what has changed. ## Installation - TerminusDB Client can be downloaded from PyPI using pip: @@ -76,16 +72,16 @@ client = Client("http://127.0.0.1:6363/") client.connect() ``` -Connect to TerminusCMS +Connect to TerminusDB in the cloud -*check the documentation for TerminusCMS about how to add the [API token](https://terminusdb.com/docs/how-to-connect-terminuscms) to the environment variable* +*check the documentation on the DFRNT support page about how to add your [API token](https://support.dfrnt.com/portal/en/kb/articles/api) to the environment variable* ```Python from terminusdb_client import Client team="MyTeam" -client = Client(f"https://dashboard.terminusdb.com/{team}/") +client = Client(f"https://studio.dfrnt.com/api/hosted/{team}/") client.connect(team="MyTeam", use_token=True) ``` @@ -185,11 +181,11 @@ Do you want to delete 'mydb'? WARNING: This operation is non-reversible. [y/N]: mydb deleted. ``` -### Please check the [full Documentation](https://terminusdb.com/docs/python) for more information. +### Please check the [full Documentation](https://terminusdb.org/docs/python) for more information. ## Guides & Tutorials -Visit our documentation for a range of short how-to guides, [how-to use the Python Client](https://terminusdb.com/docs/use-the-python-client) and [how to use the collaboration features with the Python Client](https://terminusdb.com/docs/collaboration-with-python-client). Alternatively, undertake the [Getting Started with the Python Client Tutorial Series.](https://github.com/terminusdb/terminusdb-tutorials/blob/main/getting_started/python-client/README.md). +Visit our documentation for a range of short how-to guides, [how-to use the Python Client](https://terminusdb.org/docs/use-the-python-client) and [how to use the collaboration features with the Python Client](https://terminusdb.org/docs/collaboration-with-python-client). Alternatively, undertake the [Getting Started with the Python Client Tutorial Series.](https://github.com/terminusdb/terminusdb-tutorials/blob/main/getting_started/python-client/README.md). ## Testing diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4cafea89..78d250a3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,14 @@ # TerminusDB Python Client Release Notes +## v11.1.0 + +- Add support for python 3.12 +- Aligned with TerminusDB 11.1 overall and general preparation for v11.2.0 + +### Bug fixes + +- Fix schema parameter to database construction + ## v10.2.6 ### Bug fixes diff --git a/docs/release_steps.md b/docs/release_steps.md new file mode 100644 index 00000000..0a2c0aa5 --- /dev/null +++ b/docs/release_steps.md @@ -0,0 +1,51 @@ +# Release Steps + +## Quick Release + +1. Update release notes (`RELEASE_NOTES.md`) +2. Bump version (bumpversion [patch|minor|major]) +3. Push new tag (`git push origin main --tags`) + +### Bump version (updates files + creates tag) + +To not create a tag, use `--no-tag`. + +```bash +bumpversion [patch|minor|major] +``` + +### Create and push tag (triggers automated PyPI deployment) + +```bash +git push origin main --tags +``` + +**Monitor**: https://github.com/terminusdb/terminusdb-client-python/actions + +## Details + +### What bumpversion updates +- `terminusdb_client/__version__.py` +- `pyproject.toml` +- `.bumpversion.cfg` + +### Automated deployment +Pushing a tag triggers GitHub Actions to: +- Run tests (Python 3.8-3.12) +- Build with Poetry +- Publish to PyPI + +### Manual deployment (if needed) +```bash +poetry build +poetry publish +``` + +### Troubleshooting + +**Version conflicts:** Never delete published PyPI versions. Create a new patch release instead. + +## Prerequisites + +- Install: `pip install bumpversion` +- PyPI publishing handled via `PYPI_API_TOKEN` secret in GitHub Actions diff --git a/pyproject.toml b/pyproject.toml index d56f8e7f..5b1b4d20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "Apache Software License" readme = "README.md" [tool.poetry.dependencies] -python = ">=3.8.0,<3.12" +python = ">=3.8.0,<3.13" requests = "^2.31.0" numpy = ">= 1.13.0" numpydoc = "*" diff --git a/terminusdb_client/tests/integration_tests/conftest.py b/terminusdb_client/tests/integration_tests/conftest.py index f5bb5fba..c5e5d6e6 100644 --- a/terminusdb_client/tests/integration_tests/conftest.py +++ b/terminusdb_client/tests/integration_tests/conftest.py @@ -5,7 +5,38 @@ import pytest import requests -MAX_CONTAINER_STARTUP_TIME = 30 +MAX_CONTAINER_STARTUP_TIME = 120 # Increased from 30 to 120 seconds for slower systems + + +# Check if a local TerminusDB test server is already running +def is_local_server_running(): + """Check if local TerminusDB server is running at http://127.0.0.1:6363""" + try: + response = requests.get("http://127.0.0.1:6363", timeout=2) + # Server responds with 404 for root path, which means it's running + return response.status_code in [200, 404] + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + return False + + +def is_docker_server_running(): + """Check if Docker TerminusDB server is already running at http://127.0.0.1:6366""" + try: + response = requests.get("http://127.0.0.1:6366", timeout=2) + # Server responds with 404 for root path, which means it's running + return response.status_code in [200, 404] + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + return False + + +def is_jwt_server_running(): + """Check if JWT Docker TerminusDB server is already running at http://127.0.0.1:6367""" + try: + response = requests.get("http://127.0.0.1:6367", timeout=2) + # Server responds with 404 for root path, which means it's running + return response.status_code in [200, 404] + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + return False def is_docker_installed(): @@ -39,6 +70,13 @@ def docker_url_jwt(pytestconfig): # we are using subprocess in case we need to access some of the outputs # most likely jwt_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3RrZXkifQ.eyJodHRwOi8vdGVybWludXNkYi5jb20vc2NoZW1hL3N5c3RlbSNhZ2VudF9uYW1lIjoiYWRtaW4iLCJodHRwOi8vdGVybWludXNkYi5jb20vc2NoZW1hL3N5c3RlbSN1c2VyX2lkZW50aWZpZXIiOiJhZG1pbkB1c2VyLmNvbSIsImlzcyI6Imh0dHBzOi8vdGVybWludXNodWIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImFkbWluIiwiYXVkIjpbImh0dHBzOi8vdGVybWludXNodWIvcmVnaXN0ZXJVc2VyIiwiaHR0cHM6Ly90ZXJtaW51c2h1Yi5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNTkzNzY5MTgzLCJhenAiOiJNSkpuZEdwMHpVZE03bzNQT1RRUG1SSkltWTJobzBhaSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwifQ.Ru03Bi6vSIQ57bC41n6fClSdxlb61m0xX6Q34Yh91gql0_CyfYRWTuqzqPMFoCefe53hPC5E-eoSFdID_u6w1ih_pH-lTTqus9OWgi07Qou3QNs8UZBLiM4pgLqcBKs0N058jfg4y6h9GjIBGVhX9Ni2ez3JGNcz1_U45BhnreE" + + # Check if JWT server is already running (port 6367) + if is_jwt_server_running(): + print("\n✓ Using existing JWT Docker TerminusDB server at http://127.0.0.1:6367") + yield ("http://127.0.0.1:6367", jwt_token) + return # Don't clean up - server was already running + pytestconfig.getoption("docker_compose") output = subprocess.run( [ @@ -79,7 +117,8 @@ def docker_url_jwt(pytestconfig): if service.stdout == b"terminusdb-server\n": try: response = requests.get(test_url) - assert response.status_code == 200 + # Server responds with 404 for root path, which means it's running + assert response.status_code in [200, 404] break except (requests.exceptions.ConnectionError, AssertionError): pass @@ -89,7 +128,7 @@ def docker_url_jwt(pytestconfig): if seconds_waited > MAX_CONTAINER_STARTUP_TIME: clean_up_container() - raise RuntimeError("Container was to slow to startup") + raise RuntimeError(f"JWT Container was too slow to startup (waited {MAX_CONTAINER_STARTUP_TIME}s)") yield (test_url, jwt_token) clean_up_container() @@ -97,8 +136,29 @@ def docker_url_jwt(pytestconfig): @pytest.fixture(scope="module") def docker_url(pytestconfig): - # we are using subprocess in case we need to access some of the outputs - # most likely + """ + Provides a TerminusDB server URL for integration tests. + Prefers local test server if running, otherwise starts Docker container. + + NOTE: This fixture returns just the URL. Tests expect AUTOLOGIN mode (no authentication). + If using local server with authentication, use TERMINUSDB_AUTOLOGIN=true when starting it. + """ + # Check if local test server is already running (port 6363) + if is_local_server_running(): + print("\n✓ Using existing local TerminusDB test server at http://127.0.0.1:6363") + print("⚠️ WARNING: Local server should be started with TERMINUSDB_AUTOLOGIN=true") + print(" Or use: TERMINUSDB_SERVER_AUTOLOGIN=true ./tests/terminusdb-test-server.sh restart") + yield "http://127.0.0.1:6363" + return # Don't clean up - server was already running + + # Check if Docker container is already running (port 6366) + if is_docker_server_running(): + print("\n✓ Using existing Docker TerminusDB server at http://127.0.0.1:6366") + yield "http://127.0.0.1:6366" + return # Don't clean up - server was already running + + # No server found, start Docker container + print("\n⚠ No server found, starting Docker container with AUTOLOGIN...") pytestconfig.getoption("docker_compose") output = subprocess.run( [ @@ -138,7 +198,9 @@ def docker_url(pytestconfig): if service.stdout == b"terminusdb-server\n": try: response = requests.get(test_url) - assert response.status_code == 200 + # Server responds with 404 for root path, which means it's running + assert response.status_code in [200, 404] + print(f"✓ Docker container started successfully after {seconds_waited}s") break except (requests.exceptions.ConnectionError, AssertionError): pass @@ -148,7 +210,7 @@ def docker_url(pytestconfig): if seconds_waited > MAX_CONTAINER_STARTUP_TIME: clean_up_container() - raise RuntimeError("Container was to slow to startup") + raise RuntimeError(f"Container was too slow to startup (waited {MAX_CONTAINER_STARTUP_TIME}s)") yield test_url clean_up_container() diff --git a/terminusdb_client/tests/integration_tests/test-docker-compose-jwt.yml b/terminusdb_client/tests/integration_tests/test-docker-compose-jwt.yml index 9d78b4c9..a5842fb1 100644 --- a/terminusdb_client/tests/integration_tests/test-docker-compose-jwt.yml +++ b/terminusdb_client/tests/integration_tests/test-docker-compose-jwt.yml @@ -1,5 +1,3 @@ -version: "3" - volumes: terminusdb_storage: diff --git a/terminusdb_client/tests/integration_tests/test-docker-compose.yml b/terminusdb_client/tests/integration_tests/test-docker-compose.yml index 3c29e110..43e21b40 100644 --- a/terminusdb_client/tests/integration_tests/test-docker-compose.yml +++ b/terminusdb_client/tests/integration_tests/test-docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - volumes: terminusdb_storage: diff --git a/terminusdb_client/tests/integration_tests/test_client.py b/terminusdb_client/tests/integration_tests/test_client.py index 00661bcb..4b91a52a 100644 --- a/terminusdb_client/tests/integration_tests/test_client.py +++ b/terminusdb_client/tests/integration_tests/test_client.py @@ -332,6 +332,14 @@ def test_get_organization_user_databases(docker_url): client.create_organization(org_name) client.create_database(db_name, team=org_name) client.create_database(db_name2, team=org_name) + + # BEFORE grant: verify our specific databases are NOT accessible to admin user + databases_before = client.get_organization_user_databases(org=org_name, username="admin") + db_names_before = {db['name'] for db in databases_before} + assert db_name not in db_names_before, f"{db_name} should not be accessible before capability grant" + assert db_name2 not in db_names_before, f"{db_name2} should not be accessible before capability grant" + + # Grant capabilities to admin user for the organization capability_change = { "operation": "grant", "scope": f"Organization/{org_name}", @@ -341,10 +349,15 @@ def test_get_organization_user_databases(docker_url): ] } client.change_capabilities(capability_change) - databases = client.get_organization_user_databases(org=org_name, username="admin") - assert len(databases) == 2 - assert databases[0]['name'] == db_name - assert databases[1]['name'] == db_name2 + + # AFTER grant: verify our specific databases ARE accessible to admin user + databases_after = client.get_organization_user_databases(org=org_name, username="admin") + db_names_after = {db['name'] for db in databases_after} + # Check both our databases are now accessible (order not guaranteed) + assert db_name in db_names_after, f"{db_name} should be accessible after capability grant" + assert db_name2 in db_names_after, f"{db_name2} should be accessible after capability grant" + # Verify admin team database does NOT appear in organization results + assert db_name + "admin" not in db_names_after, f"{db_name}admin should not appear in {org_name} results" def test_has_database(docker_url): @@ -414,12 +427,8 @@ def test_diff_ops(docker_url, test_schema): client = Client(docker_url, user_agent=test_user_agent) client.connect(user="admin", team="admin") client.create_database("test_diff_ops") - public_diff = Client( - "https://cloud.terminusdb.com/jsondiff", user_agent=test_user_agent - ) - public_patch = Client( - "https://cloud.terminusdb.com/jsonpatch", user_agent=test_user_agent - ) + # NOTE: Public API endpoints (jsondiff/jsonpatch) no longer exist + # Testing authenticated diff/patch only result_patch = Patch( json='{"@id": "Person/Jane", "name" : { "@op" : "SwapValue", "@before" : "Jane", "@after": "Janine" }}' ) @@ -427,12 +436,7 @@ def test_diff_ops(docker_url, test_schema): {"@id": "Person/Jane", "@type": "Person", "name": "Jane"}, {"@id": "Person/Jane", "@type": "Person", "name": "Janine"}, ) - public_result = public_diff.diff( - {"@id": "Person/Jane", "@type": "Person", "name": "Jane"}, - {"@id": "Person/Jane", "@type": "Person", "name": "Janine"}, - ) assert result.content == result_patch.content - assert public_result.content == result_patch.content Person = test_schema.object.get("Person") jane = Person( @@ -446,7 +450,6 @@ def test_diff_ops(docker_url, test_schema): age=18, ) result = client.diff(jane, janine) - public_result = public_diff.diff(jane, janine) # test commit_id and data_version with after obj test_schema.commit(client) jane_id = client.insert_document(jane)[0] @@ -466,7 +469,6 @@ def test_diff_ops(docker_url, test_schema): commit_id_result_all = client.diff(current_commit, new_commit) data_version_result_all = client.diff(data_version, new_data_version) assert result.content == result_patch.content - assert public_result.content == result_patch.content assert commit_id_result.content == result_patch.content assert commit_id_result2.content == result_patch.content assert data_version_result.content == result_patch.content @@ -476,21 +478,12 @@ def test_diff_ops(docker_url, test_schema): assert client.patch( {"@id": "Person/Jane", "@type": "Person", "name": "Jane"}, result_patch ) == {"@id": "Person/Jane", "@type": "Person", "name": "Janine"} - assert public_patch.patch( - {"@id": "Person/Jane", "@type": "Person", "name": "Jane"}, result_patch - ) == {"@id": "Person/Jane", "@type": "Person", "name": "Janine"} assert client.patch(jane, result_patch) == { "@id": "Person/Jane", "@type": "Person", "name": "Janine", "age": 18, } - assert public_patch.patch(jane, result_patch) == { - "@id": "Person/Jane", - "@type": "Person", - "name": "Janine", - "age": 18, - } my_schema = test_schema.copy() my_schema.object.pop("Employee") assert my_schema.to_dict() != test_schema.to_dict() @@ -545,6 +538,10 @@ def test_diff_ops_no_auth(test_schema, terminusx_token): # assert client.patch(test_schema, result) == my_schema.to_dict() +@pytest.mark.skipif( + os.environ.get("TERMINUSDB_TEST_JWT") is None, + reason="JWT testing not enabled. Set TERMINUSDB_TEST_JWT=1 to enable JWT tests." +) def test_jwt(docker_url_jwt): # create client url = docker_url_jwt[0] diff --git a/terminusdb_client/tests/integration_tests/test_conftest.py b/terminusdb_client/tests/integration_tests/test_conftest.py new file mode 100644 index 00000000..61f9e728 --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_conftest.py @@ -0,0 +1,98 @@ +"""Unit tests for conftest.py helper functions""" +from unittest.mock import patch, Mock +import requests + +from .conftest import ( + is_local_server_running, + is_docker_server_running, + is_jwt_server_running, +) + + +class TestServerDetection: + """Test server detection helper functions""" + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_local_server_running_200(self, mock_get): + """Test local server detection returns True for HTTP 200""" + mock_response = Mock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + assert is_local_server_running() is True + mock_get.assert_called_once_with("http://127.0.0.1:6363", timeout=2) + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_local_server_running_404(self, mock_get): + """Test local server detection returns True for HTTP 404""" + mock_response = Mock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + assert is_local_server_running() is True + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_local_server_not_running_connection_error(self, mock_get): + """Test local server detection returns False on connection error""" + mock_get.side_effect = requests.exceptions.ConnectionError() + + assert is_local_server_running() is False + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_local_server_not_running_timeout(self, mock_get): + """Test local server detection returns False on timeout""" + mock_get.side_effect = requests.exceptions.Timeout() + + assert is_local_server_running() is False + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_docker_server_running_200(self, mock_get): + """Test Docker server detection returns True for HTTP 200""" + mock_response = Mock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + assert is_docker_server_running() is True + mock_get.assert_called_once_with("http://127.0.0.1:6366", timeout=2) + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_docker_server_running_404(self, mock_get): + """Test Docker server detection returns True for HTTP 404""" + mock_response = Mock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + assert is_docker_server_running() is True + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_docker_server_not_running(self, mock_get): + """Test Docker server detection returns False on connection error""" + mock_get.side_effect = requests.exceptions.ConnectionError() + + assert is_docker_server_running() is False + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_jwt_server_running_200(self, mock_get): + """Test JWT server detection returns True for HTTP 200""" + mock_response = Mock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + assert is_jwt_server_running() is True + mock_get.assert_called_once_with("http://127.0.0.1:6367", timeout=2) + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_jwt_server_running_404(self, mock_get): + """Test JWT server detection returns True for HTTP 404""" + mock_response = Mock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + assert is_jwt_server_running() is True + + @patch('terminusdb_client.tests.integration_tests.conftest.requests.get') + def test_jwt_server_not_running(self, mock_get): + """Test JWT server detection returns False on connection error""" + mock_get.side_effect = requests.exceptions.ConnectionError() + + assert is_jwt_server_running() is False diff --git a/terminusdb_client/tests/integration_tests/test_schema.py b/terminusdb_client/tests/integration_tests/test_schema.py index 55988686..63cfe6d1 100644 --- a/terminusdb_client/tests/integration_tests/test_schema.py +++ b/terminusdb_client/tests/integration_tests/test_schema.py @@ -103,7 +103,7 @@ def test_insert_cheuk(docker_url, test_schema): assert item["address_of"]["postal_code"] == "A12 345" assert item["address_of"]["street"] == "123 Abc Street" assert item["name"] == "Cheuk" - assert item["age"] == "21" + assert item["age"] == 21 assert item["contact_number"] == "07777123456" assert item["managed_by"] == item["@id"] else: @@ -185,7 +185,7 @@ def test_insert_cheuk_again(docker_url, test_schema): assert item["address_of"]["postal_code"] == "A12 345" assert item["address_of"]["street"] == "123 Abc Street" assert item["name"] == "Cheuk" - assert item["age"] == "21" + assert item["age"] == 21 assert item["contact_number"] == "07777123456" assert item["managed_by"] == item["@id"] elif item.get("@type") == "Coordinate": diff --git a/terminusdb_client/tests/test_Client.py b/terminusdb_client/tests/test_Client.py index d8d4862b..fadd11dd 100644 --- a/terminusdb_client/tests/test_Client.py +++ b/terminusdb_client/tests/test_Client.py @@ -177,6 +177,7 @@ def test_has_database(mocked_requests, mocked_requests2): f"http://localhost:6363/api/db/admin/{db_name}", auth=("admin", "root"), headers={"user-agent": f"terminusdb-client-python/{__version__}"}, + allow_redirects=True, ) diff --git a/terminusdb_client/tests/test_client_init.py b/terminusdb_client/tests/test_client_init.py new file mode 100644 index 00000000..62e2af0a --- /dev/null +++ b/terminusdb_client/tests/test_client_init.py @@ -0,0 +1,48 @@ +"""Unit tests for Client initialization""" +from terminusdb_client.client import Client + + +class TestClientInitialization: + """Test Client initialization and basic properties""" + + def test_client_init_basic(self): + """Test basic Client initialization""" + client = Client("http://localhost:6363") + assert client.server_url == "http://localhost:6363" + assert client._connected is False + + def test_client_init_with_user_agent(self): + """Test Client initialization with custom user agent""" + client = Client("http://localhost:6363", user_agent="test-agent/1.0") + assert "test-agent/1.0" in client._default_headers.get("user-agent", "") + + def test_client_init_with_trailing_slash(self): + """Test Client handles URLs with trailing slashes""" + client = Client("http://localhost:6363/") + # Should work without errors + assert client.server_url in ["http://localhost:6363", "http://localhost:6363/"] + + def test_client_copy(self): + """Test Client copy method creates independent instance""" + client1 = Client("http://localhost:6363") + client1.team = "test_team" + client1.db = "test_db" + + client2 = client1.copy() + + assert client2.server_url == client1.server_url + assert client2.team == client1.team + assert client2.db == client1.db + # Ensure it's a different object + assert client2 is not client1 + + def test_client_copy_modifications_independent(self): + """Test modifications to copied client don't affect original""" + client1 = Client("http://localhost:6363") + client1.team = "team1" + + client2 = client1.copy() + client2.team = "team2" + + assert client1.team == "team1" + assert client2.team == "team2" diff --git a/terminusdb_client/tests/test_errors.py b/terminusdb_client/tests/test_errors.py new file mode 100644 index 00000000..69a3b716 --- /dev/null +++ b/terminusdb_client/tests/test_errors.py @@ -0,0 +1,234 @@ +"""Tests for errors.py module.""" +import json +from unittest.mock import Mock +from terminusdb_client.errors import ( + Error, + InterfaceError, + DatabaseError, + OperationalError, + AccessDeniedError, + APIError, + InvalidURIError +) + + +def test_base_error_class(): + """Test base Error class can be instantiated.""" + error = Error() + assert isinstance(error, Exception) + + +def test_interface_error(): + """Test InterfaceError with message.""" + message = "Database interface error occurred" + error = InterfaceError(message) + + assert isinstance(error, Error) + assert error.message == message + + +def test_interface_error_string_representation(): + """Test InterfaceError has message attribute.""" + message = "Connection failed" + error = InterfaceError(message) + + assert hasattr(error, 'message') + assert error.message == message + + +def test_database_error_with_empty_response(): + """Test DatabaseError with empty response text.""" + mock_response = Mock() + mock_response.text = "" + mock_response.status_code = 500 + + error = DatabaseError(mock_response) + + assert "Unknown Error" in error.message + assert error.status_code == 500 + + +def test_database_error_with_json_api_message(): + """Test DatabaseError with JSON response containing api:message.""" + mock_response = Mock() + mock_response.text = "Error response" + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = { + "api:message": "Database operation failed", + "details": "Additional context" + } + mock_response.status_code = 400 + + error = DatabaseError(mock_response) + + assert "Database operation failed" in error.message + assert error.status_code == 400 + assert error.error_obj == mock_response.json() + + +def test_database_error_with_vio_message(): + """Test DatabaseError with JSON response containing vio:message.""" + mock_response = Mock() + mock_response.text = "Error response" + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = { + "api:error": { + "vio:message": "Validation failed" + } + } + mock_response.status_code = 422 + + error = DatabaseError(mock_response) + + assert "Validation failed" in error.message + assert error.status_code == 422 + + +def test_database_error_with_unknown_json(): + """Test DatabaseError with JSON response without known message fields.""" + mock_response = Mock() + mock_response.text = "Error response" + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = { + "unknown_field": "some value" + } + mock_response.status_code = 500 + + error = DatabaseError(mock_response) + + assert "Unknown Error" in error.message + assert error.status_code == 500 + + +def test_database_error_with_non_json_response(): + """Test DatabaseError with non-JSON response.""" + mock_response = Mock() + mock_response.text = "Plain text error message" + mock_response.headers = {"content-type": "text/plain"} + mock_response.status_code = 503 + + error = DatabaseError(mock_response) + + assert error.message == "Plain text error message" + assert error.error_obj is None + assert error.status_code == 503 + + +def test_database_error_string_representation(): + """Test DatabaseError __str__ method.""" + mock_response = Mock() + mock_response.text = "Error message" + mock_response.headers = {"content-type": "text/plain"} + mock_response.status_code = 500 + + error = DatabaseError(mock_response) + + assert str(error) == error.message + + +def test_operational_error(): + """Test OperationalError inherits from DatabaseError.""" + mock_response = Mock() + mock_response.text = "Operational error" + mock_response.headers = {"content-type": "text/plain"} + mock_response.status_code = 500 + + error = OperationalError(mock_response) + + assert isinstance(error, DatabaseError) + assert isinstance(error, Error) + assert error.message == "Operational error" + + +def test_access_denied_error(): + """Test AccessDeniedError inherits from DatabaseError.""" + mock_response = Mock() + mock_response.text = "Access denied" + mock_response.headers = {"content-type": "text/plain"} + mock_response.status_code = 403 + + error = AccessDeniedError(mock_response) + + assert isinstance(error, DatabaseError) + assert isinstance(error, Error) + assert error.message == "Access denied" + assert error.status_code == 403 + + +def test_api_error_class_exists(): + """Test that APIError class exists and inherits from DatabaseError.""" + # APIError has a design issue where it calls super().__init__() without response + # Just verify the class exists and has correct inheritance + assert issubclass(APIError, DatabaseError) + assert issubclass(APIError, Error) + + +def test_invalid_uri_error(): + """Test InvalidURIError can be instantiated.""" + error = InvalidURIError() + + assert isinstance(error, Error) + assert isinstance(error, Exception) + + +def test_invalid_uri_error_is_simple_error(): + """Test InvalidURIError is a simple pass-through error.""" + # InvalidURIError is a simple pass class, no custom __init__ + error = InvalidURIError() + + assert isinstance(error, Error) + assert isinstance(error, Exception) + + +def test_database_error_json_formatting(): + """Test that DatabaseError includes formatted JSON details.""" + mock_response = Mock() + mock_response.text = "Error" + mock_response.headers = {"content-type": "application/json"} + error_data = { + "api:message": "Error message", + "code": "ERR_001" + } + mock_response.json.return_value = error_data + mock_response.status_code = 400 + + error = DatabaseError(mock_response) + + # Check that formatted JSON is in the message + formatted = json.dumps(error_data, indent=4, sort_keys=True) + assert formatted in error.message + + +def test_all_error_classes_are_exceptions(): + """Test that all error classes inherit from Exception.""" + errors = [ + Error(), + InterfaceError("test"), + InvalidURIError() + ] + + for error in errors: + assert isinstance(error, Exception) + + +def test_error_inheritance_chain(): + """Test proper inheritance chain for database errors.""" + mock_response = Mock() + mock_response.text = "test" + mock_response.headers = {"content-type": "text/plain"} + mock_response.status_code = 500 + + operational = OperationalError(mock_response) + access_denied = AccessDeniedError(mock_response) + + # All should be DatabaseError + assert isinstance(operational, DatabaseError) + assert isinstance(access_denied, DatabaseError) + + # All should be Error + assert isinstance(operational, Error) + assert isinstance(access_denied, Error) + + # All should be Exception + assert isinstance(operational, Exception) + assert isinstance(access_denied, Exception) diff --git a/terminusdb_client/tests/test_graphtype.py b/terminusdb_client/tests/test_graphtype.py new file mode 100644 index 00000000..a32347d4 --- /dev/null +++ b/terminusdb_client/tests/test_graphtype.py @@ -0,0 +1,26 @@ +"""Unit tests for GraphType enum""" +from terminusdb_client.client import GraphType + + +class TestGraphType: + """Test GraphType enum values""" + + def test_graphtype_instance(self): + """Test GraphType.INSTANCE value""" + assert GraphType.INSTANCE == "instance" + + def test_graphtype_schema(self): + """Test GraphType.SCHEMA value""" + assert GraphType.SCHEMA == "schema" + + def test_graphtype_enum_members(self): + """Test GraphType has exactly two members""" + members = list(GraphType) + assert len(members) == 2 + assert GraphType.INSTANCE in members + assert GraphType.SCHEMA in members + + def test_graphtype_values(self): + """Test all GraphType enum values exist""" + assert hasattr(GraphType, 'INSTANCE') + assert hasattr(GraphType, 'SCHEMA') diff --git a/terminusdb_client/tests/test_query_syntax.py b/terminusdb_client/tests/test_query_syntax.py new file mode 100644 index 00000000..e94a8b93 --- /dev/null +++ b/terminusdb_client/tests/test_query_syntax.py @@ -0,0 +1,79 @@ +"""Tests for query_syntax/query_syntax.py module.""" +from terminusdb_client.query_syntax import query_syntax + + +def test_query_syntax_exports_var(): + """Test that Var is exported.""" + assert 'Var' in query_syntax.__all__ + assert hasattr(query_syntax, 'Var') + + +def test_query_syntax_exports_vars(): + """Test that Vars is exported.""" + assert 'Vars' in query_syntax.__all__ + assert hasattr(query_syntax, 'Vars') + + +def test_query_syntax_exports_doc(): + """Test that Doc is exported.""" + assert 'Doc' in query_syntax.__all__ + assert hasattr(query_syntax, 'Doc') + + +def test_barred_items_not_exported(): + """Test that barred items (re, vars) are not exported.""" + assert 're' not in query_syntax.__all__ + assert 'vars' not in query_syntax.__all__ + + +def test_allowed_dunder_methods_exported(): + """Test that allowed dunder methods are exported.""" + # __and__, __or__, __add__ should be exported + assert '__and__' in query_syntax.__all__ + assert '__or__' in query_syntax.__all__ + assert '__add__' in query_syntax.__all__ + + +def test_dynamic_function_creation(): + """Test that functions are dynamically created from WOQLQuery.""" + # Check that some common WOQLQuery methods are available + woql_methods = ['triple', 'select', 'limit'] + + for method in woql_methods: + assert hasattr(query_syntax, method), f"{method} should be available" + assert method in query_syntax.__all__, f"{method} should be in __all__" + + +def test_created_functions_are_callable(): + """Test that dynamically created functions are callable.""" + # Test that we can call a dynamically created function + if hasattr(query_syntax, 'limit'): + func = getattr(query_syntax, 'limit') + assert callable(func) + + +def test_created_function_returns_woqlquery(): + """Test that created functions return WOQLQuery instances.""" + # Test a simple function that exists on WOQLQuery + if hasattr(query_syntax, 'limit'): + func = getattr(query_syntax, 'limit') + result = func(10) + # Should return a WOQLQuery or similar object + assert result is not None + + +def test_private_attributes_not_exported(): + """Test that private attributes (starting with _) are not exported.""" + for attr in query_syntax.__all__: + # All exported attributes should either be in __ALLOWED or not start with _ + if attr not in ['__and__', '__or__', '__add__']: + assert not attr.startswith('_'), f"{attr} should not start with underscore" + + +def test_module_attributes(): + """Test module has expected attributes.""" + # These are defined in the module + assert hasattr(query_syntax, '__BARRED') + assert hasattr(query_syntax, '__ALLOWED') + assert hasattr(query_syntax, '__module') + assert hasattr(query_syntax, '__exported') diff --git a/terminusdb_client/tests/test_schema_template.py b/terminusdb_client/tests/test_schema_template.py new file mode 100644 index 00000000..f2405bd7 --- /dev/null +++ b/terminusdb_client/tests/test_schema_template.py @@ -0,0 +1,159 @@ +"""Tests for scripts/schema_template.py module.""" +from terminusdb_client.scripts import schema_template +from terminusdb_client.woqlschema import DocumentTemplate, EnumTemplate, TaggedUnion + + +def test_schema_template_imports(): + """Test that schema_template module can be imported.""" + assert schema_template is not None + assert schema_template.__doc__ is not None + + +def test_country_class_exists(): + """Test that Country class is defined.""" + assert hasattr(schema_template, 'Country') + assert issubclass(schema_template.Country, DocumentTemplate) + + +def test_country_has_key(): + """Test that Country has HashKey configured.""" + assert hasattr(schema_template.Country, '_key') + + +def test_country_attributes(): + """Test that Country has expected attributes.""" + country = schema_template.Country + # Check type hints exist + assert hasattr(country, '__annotations__') + assert 'name' in country.__annotations__ + assert 'also_know_as' in country.__annotations__ + + +def test_address_class_exists(): + """Test that Address class is defined.""" + assert hasattr(schema_template, 'Address') + assert issubclass(schema_template.Address, DocumentTemplate) + + +def test_address_is_subdocument(): + """Test that Address is configured as subdocument.""" + assert hasattr(schema_template.Address, '_subdocument') + + +def test_address_attributes(): + """Test that Address has expected attributes.""" + address = schema_template.Address + assert 'street' in address.__annotations__ + assert 'postal_code' in address.__annotations__ + assert 'country' in address.__annotations__ + + +def test_person_class_exists(): + """Test that Person class is defined.""" + assert hasattr(schema_template, 'Person') + assert issubclass(schema_template.Person, DocumentTemplate) + + +def test_person_has_docstring(): + """Test that Person has numpydoc formatted docstring.""" + assert schema_template.Person.__doc__ is not None + assert 'Attributes' in schema_template.Person.__doc__ + + +def test_person_attributes(): + """Test that Person has expected attributes.""" + person = schema_template.Person + assert 'name' in person.__annotations__ + assert 'age' in person.__annotations__ + assert 'friend_of' in person.__annotations__ + + +def test_employee_inherits_person(): + """Test that Employee inherits from Person.""" + assert hasattr(schema_template, 'Employee') + assert issubclass(schema_template.Employee, schema_template.Person) + + +def test_employee_attributes(): + """Test that Employee has expected attributes.""" + employee = schema_template.Employee + assert 'address_of' in employee.__annotations__ + assert 'contact_number' in employee.__annotations__ + assert 'managed_by' in employee.__annotations__ + + +def test_coordinate_class_exists(): + """Test that Coordinate class is defined.""" + assert hasattr(schema_template, 'Coordinate') + assert issubclass(schema_template.Coordinate, DocumentTemplate) + + +def test_coordinate_is_abstract(): + """Test that Coordinate is configured as abstract.""" + assert hasattr(schema_template.Coordinate, '_abstract') + + +def test_coordinate_attributes(): + """Test that Coordinate has x and y attributes.""" + coordinate = schema_template.Coordinate + assert 'x' in coordinate.__annotations__ + assert 'y' in coordinate.__annotations__ + + +def test_location_multiple_inheritance(): + """Test that Location inherits from both Address and Coordinate.""" + assert hasattr(schema_template, 'Location') + assert issubclass(schema_template.Location, schema_template.Address) + assert issubclass(schema_template.Location, schema_template.Coordinate) + + +def test_location_attributes(): + """Test that Location has its own attributes.""" + location = schema_template.Location + assert 'name' in location.__annotations__ + + +def test_team_enum_exists(): + """Test that Team enum is defined.""" + assert hasattr(schema_template, 'Team') + assert issubclass(schema_template.Team, EnumTemplate) + + +def test_team_enum_values(): + """Test that Team has expected enum values.""" + team = schema_template.Team + assert hasattr(team, 'IT') + assert hasattr(team, 'Marketing') + assert team.IT.value == "Information Technology" + + +def test_contact_tagged_union_exists(): + """Test that Contact tagged union is defined.""" + assert hasattr(schema_template, 'Contact') + assert issubclass(schema_template.Contact, TaggedUnion) + + +def test_contact_union_attributes(): + """Test that Contact has union type attributes.""" + contact = schema_template.Contact + assert 'local_number' in contact.__annotations__ + assert 'international' in contact.__annotations__ + + +def test_all_classes_importable(): + """Test that all template classes can be imported.""" + classes = [ + 'Country', 'Address', 'Person', 'Employee', + 'Coordinate', 'Location', 'Team', 'Contact' + ] + for cls_name in classes: + assert hasattr(schema_template, cls_name), f"{cls_name} should be importable" + + +def test_module_docstring(): + """Test that module has docstring with metadata.""" + doc = schema_template.__doc__ + assert doc is not None + assert 'Title:' in doc + assert 'Description:' in doc + assert 'Authors:' in doc diff --git a/terminusdb_client/tests/test_scripts_main.py b/terminusdb_client/tests/test_scripts_main.py new file mode 100644 index 00000000..b5e18ca6 --- /dev/null +++ b/terminusdb_client/tests/test_scripts_main.py @@ -0,0 +1,27 @@ +"""Tests for scripts/__main__.py module.""" +import sys +from unittest.mock import patch, MagicMock + + +def test_main_imports(): + """Test that __main__ can be imported.""" + from terminusdb_client.scripts import __main__ + assert __main__ is not None + + +def test_main_has_tdbpy(): + """Test that __main__ imports tdbpy function.""" + from terminusdb_client.scripts import __main__ + assert hasattr(__main__, 'tdbpy') + + +def test_main_execution(): + """Test that __main__ calls tdbpy when executed.""" + mock_tdbpy = MagicMock() + + with patch('terminusdb_client.scripts.__main__.tdbpy', mock_tdbpy): + # Simulate running as main module + with patch.object(sys, 'argv', ['__main__.py']): + # Import and check the condition would trigger + from terminusdb_client.scripts import __main__ + assert __main__.__name__ == 'terminusdb_client.scripts.__main__' diff --git a/terminusdb_client/tests/test_woql_query_basics.py b/terminusdb_client/tests/test_woql_query_basics.py new file mode 100644 index 00000000..da4e9e5b --- /dev/null +++ b/terminusdb_client/tests/test_woql_query_basics.py @@ -0,0 +1,262 @@ +"""Tests for basic woql_query.py classes and functions.""" +from terminusdb_client.woqlquery.woql_query import Var, Vars, Doc, SHORT_NAME_MAPPING + + +def test_var_creation(): + """Test Var class instantiation.""" + var = Var("myVar") + + assert var.name == "myVar" + + +def test_var_str(): + """Test Var __str__ method.""" + var = Var("testVar") + + assert str(var) == "testVar" + + +def test_var_to_dict(): + """Test Var to_dict method.""" + var = Var("myVariable") + + result = var.to_dict() + + assert result == {"@type": "Value", "variable": "myVariable"} + + +def test_vars_creation(): + """Test Vars class creates attributes for each arg.""" + vars_obj = Vars("x", "y", "z") + + assert hasattr(vars_obj, "x") + assert hasattr(vars_obj, "y") + assert hasattr(vars_obj, "z") + assert isinstance(vars_obj.x, Var) + assert vars_obj.x.name == "x" + assert vars_obj.y.name == "y" + assert vars_obj.z.name == "z" + + +def test_vars_empty(): + """Test Vars with no arguments.""" + vars_obj = Vars() + + # Should create object successfully even with no args + assert vars_obj is not None + + +def test_doc_with_string(): + """Test Doc class with string value.""" + doc = Doc("test string") + + result = doc.to_dict() + + assert result == { + "@type": "Value", + "data": {"@type": "xsd:string", "@value": "test string"} + } + + +def test_doc_with_bool(): + """Test Doc class with boolean value.""" + doc_true = Doc(True) + doc_false = Doc(False) + + assert doc_true.to_dict() == { + "@type": "Value", + "data": {"@type": "xsd:boolean", "@value": True} + } + assert doc_false.to_dict() == { + "@type": "Value", + "data": {"@type": "xsd:boolean", "@value": False} + } + + +def test_doc_with_int(): + """Test Doc class with integer value.""" + doc = Doc(42) + + result = doc.to_dict() + + assert result == { + "@type": "Value", + "data": {"@type": "xsd:integer", "@value": 42} + } + + +def test_doc_with_float(): + """Test Doc class with float value.""" + doc = Doc(3.14) + + result = doc.to_dict() + + assert result == { + "@type": "Value", + "data": {"@type": "xsd:decimal", "@value": 3.14} + } + + +def test_doc_with_none(): + """Test Doc class with None value.""" + doc = Doc(None) + + result = doc.to_dict() + + assert result is None + + +def test_doc_with_list(): + """Test Doc class with list value.""" + doc = Doc([1, "test", True]) + + result = doc.to_dict() + + assert result["@type"] == "Value" + assert "list" in result + assert len(result["list"]) == 3 + # Check first element + assert result["list"][0] == { + "@type": "Value", + "data": {"@type": "xsd:integer", "@value": 1} + } + + +def test_doc_with_var(): + """Test Doc class with Var value.""" + var = Var("myVar") + doc = Doc(var) + + result = doc.to_dict() + + assert result == { + "@type": "Value", + "variable": "myVar" + } + + +def test_doc_with_dict(): + """Test Doc class with dictionary value.""" + doc = Doc({"name": "Alice", "age": 30}) + + result = doc.to_dict() + + assert result["@type"] == "Value" + assert "dictionary" in result + assert result["dictionary"]["@type"] == "DictionaryTemplate" + assert len(result["dictionary"]["data"]) == 2 + + # Find the name field + name_pair = next(p for p in result["dictionary"]["data"] if p["field"] == "name") + assert name_pair["value"] == { + "@type": "Value", + "data": {"@type": "xsd:string", "@value": "Alice"} + } + + +def test_doc_str(): + """Test Doc __str__ method.""" + doc = Doc({"key": "value"}) + + result = str(doc) + + assert result == "{'key': 'value'}" + + +def test_doc_nested_structures(): + """Test Doc with nested list and dict.""" + doc = Doc({ + "list": [1, 2, 3], + "nested": {"inner": "value"} + }) + + result = doc.to_dict() + + assert result["@type"] == "Value" + assert "dictionary" in result + + +def test_short_name_mapping_exists(): + """Test SHORT_NAME_MAPPING dictionary exists and has expected keys.""" + assert "type" in SHORT_NAME_MAPPING + assert SHORT_NAME_MAPPING["type"] == "rdf:type" + + assert "label" in SHORT_NAME_MAPPING + assert SHORT_NAME_MAPPING["label"] == "rdfs:label" + + assert "string" in SHORT_NAME_MAPPING + assert SHORT_NAME_MAPPING["string"] == "xsd:string" + + assert "integer" in SHORT_NAME_MAPPING + assert SHORT_NAME_MAPPING["integer"] == "xsd:integer" + + +def test_doc_with_empty_list(): + """Test Doc with empty list.""" + doc = Doc([]) + + result = doc.to_dict() + + assert result == {"@type": "Value", "list": []} + + +def test_doc_with_empty_dict(): + """Test Doc with empty dictionary.""" + doc = Doc({}) + + result = doc.to_dict() + + assert result == { + "@type": "Value", + "dictionary": {"@type": "DictionaryTemplate", "data": []} + } + + +def test_doc_with_nested_var(): + """Test Doc with Var inside a list.""" + var = Var("x") + doc = Doc([1, var, "test"]) + + result = doc.to_dict() + + assert result["list"][1] == {"@type": "Value", "variable": "x"} + + +def test_doc_with_mixed_types(): + """Test Doc with various mixed types in dict.""" + var = Var("status") + doc = Doc({ + "id": 1, + "name": "Test", + "active": True, + "score": 95.5, + "status": var, + "tags": ["a", "b"], + "metadata": None + }) + + result = doc.to_dict() + + assert result["@type"] == "Value" + assert "dictionary" in result + assert result["dictionary"]["@type"] == "DictionaryTemplate" + assert len(result["dictionary"]["data"]) == 7 + + +def test_vars_multiple_creation(): + """Test Vars creates multiple Var instances.""" + v = Vars("name", "age", "city") + + # All should be Var instances + assert isinstance(v.name, Var) + assert isinstance(v.age, Var) + assert isinstance(v.city, Var) + + # Each should have correct name + assert v.name.name == "name" + assert v.age.name == "age" + assert v.city.name == "city" + + # They should be different objects + assert v.name is not v.age + assert v.age is not v.city diff --git a/terminusdb_client/tests/test_woql_query_methods.py b/terminusdb_client/tests/test_woql_query_methods.py new file mode 100644 index 00000000..26f6c3fd --- /dev/null +++ b/terminusdb_client/tests/test_woql_query_methods.py @@ -0,0 +1,418 @@ +"""Tests for WOQLQuery class methods in woql_query.py.""" +from terminusdb_client.woqlquery.woql_query import WOQLQuery, Var + + +def test_woqlquery_initialization_empty(): + """Test WOQLQuery initialization with no arguments.""" + query = WOQLQuery() + + assert query._query == {} + assert query._cursor == query._query + assert query._chain_ended is False + assert query._contains_update is False + assert query._graph == "schema" + + +def test_woqlquery_initialization_with_query(): + """Test WOQLQuery initialization with query dict.""" + initial_query = {"@type": "Triple", "subject": "v:X"} + query = WOQLQuery(query=initial_query) + + assert query._query == initial_query + assert query._cursor == query._query + + +def test_woqlquery_initialization_with_graph(): + """Test WOQLQuery initialization with custom graph.""" + query = WOQLQuery(graph="instance") + + assert query._graph == "instance" + + +def test_woqlquery_aliases(): + """Test that WOQLQuery has expected method aliases.""" + query = WOQLQuery() + + # Check aliases exist + assert hasattr(query, 'subsumption') + assert hasattr(query, 'equals') + assert hasattr(query, 'substring') + assert hasattr(query, 'update') + assert hasattr(query, 'delete') + assert hasattr(query, 'read') + assert hasattr(query, 'insert') + assert hasattr(query, 'optional') + assert hasattr(query, 'idgenerator') + assert hasattr(query, 'concatenate') + assert hasattr(query, 'typecast') + + +def test_woqlquery_add_operator(): + """Test WOQLQuery __add__ operator.""" + q1 = WOQLQuery({"@type": "Query1"}) + q2 = WOQLQuery({"@type": "Query2"}) + + result = q1 + q2 + + assert isinstance(result, WOQLQuery) + assert result._query.get("@type") == "And" + + +def test_woqlquery_and_operator(): + """Test WOQLQuery __and__ operator.""" + q1 = WOQLQuery({"@type": "Query1"}) + q2 = WOQLQuery({"@type": "Query2"}) + + result = q1 & q2 + + assert isinstance(result, WOQLQuery) + assert result._query.get("@type") == "And" + + +def test_woqlquery_or_operator(): + """Test WOQLQuery __or__ operator.""" + q1 = WOQLQuery({"@type": "Query1"}) + q2 = WOQLQuery({"@type": "Query2"}) + + result = q1 | q2 + + assert isinstance(result, WOQLQuery) + assert result._query.get("@type") == "Or" + + +def test_woqlquery_invert_operator(): + """Test WOQLQuery __invert__ operator (not).""" + q = WOQLQuery({"@type": "Query"}) + + result = ~q + + assert isinstance(result, WOQLQuery) + assert result._query.get("@type") == "Not" + + +def test_add_sub_query_with_query(): + """Test _add_sub_query with a query object.""" + query = WOQLQuery() + sub_query = {"@type": "Triple"} + + result = query._add_sub_query(sub_query) + + assert result is query # Returns self + assert query._cursor["query"] == sub_query + + +def test_add_sub_query_without_query(): + """Test _add_sub_query creates empty object when no query provided.""" + query = WOQLQuery() + original_cursor = query._cursor + + result = query._add_sub_query() + + assert result is query + assert "query" in original_cursor + # Cursor is moved to the new empty object + assert query._cursor == {} + assert original_cursor["query"] == query._cursor + + +def test_contains_update_check_false(): + """Test _contains_update_check returns False for read query.""" + query = WOQLQuery({"@type": "Triple", "subject": "v:X"}) + + assert query._contains_update_check() is False + + +def test_contains_update_check_true_direct(): + """Test _contains_update_check detects update operators.""" + query = WOQLQuery({"@type": "AddTriple"}) + + assert query._contains_update_check() is True + + +def test_contains_update_check_in_consequent(): + """Test _contains_update_check detects updates in consequent.""" + query = WOQLQuery({ + "@type": "When", + "consequent": {"@type": "DeleteTriple"} + }) + + assert query._contains_update_check() is True + + +def test_contains_update_check_in_nested_query(): + """Test _contains_update_check detects updates in nested query.""" + query = WOQLQuery({ + "@type": "Select", + "query": {"@type": "UpdateObject"} + }) + + assert query._contains_update_check() is True + + +def test_contains_update_check_in_and(): + """Test _contains_update_check detects updates in And clause.""" + query = WOQLQuery({ + "@type": "And", + "and": [ + {"@type": "Triple"}, + {"@type": "AddQuad"} + ] + }) + + assert query._contains_update_check() is True + + +def test_contains_update_check_in_or(): + """Test _contains_update_check detects updates in Or clause.""" + query = WOQLQuery({ + "@type": "Or", + "or": [ + {"@type": "Triple"}, + {"@type": "DeleteObject"} + ] + }) + + assert query._contains_update_check() is True + + +def test_contains_update_check_non_dict(): + """Test _contains_update_check returns False for non-dict.""" + query = WOQLQuery() + + assert query._contains_update_check("not a dict") is False + + +def test_updated_method(): + """Test _updated marks query as containing update.""" + query = WOQLQuery() + + assert query._contains_update is False + result = query._updated() + + assert result is query # Returns self + assert query._contains_update is True + + +def test_jlt_default_type(): + """Test _jlt wraps value with default xsd:string type.""" + query = WOQLQuery() + + result = query._jlt("test value") + + assert result == {"@type": "xsd:string", "@value": "test value"} + + +def test_jlt_custom_type(): + """Test _jlt wraps value with custom type.""" + query = WOQLQuery() + + result = query._jlt(42, "xsd:integer") + + assert result == {"@type": "xsd:integer", "@value": 42} + + +def test_jlt_type_without_prefix(): + """Test _jlt adds xsd: prefix when not present.""" + query = WOQLQuery() + + result = query._jlt(3.14, "decimal") + + assert result == {"@type": "xsd:decimal", "@value": 3.14} + + +def test_varj_with_var_object(): + """Test _varj with Var object.""" + query = WOQLQuery() + var = Var("myVar") + + result = query._varj(var) + + assert result == {"@type": "Value", "variable": "myVar"} + + +def test_varj_with_v_prefix(): + """Test _varj strips v: prefix.""" + query = WOQLQuery() + + result = query._varj("v:myVariable") + + assert result == {"@type": "Value", "variable": "myVariable"} + + +def test_varj_with_plain_string(): + """Test _varj with plain string.""" + query = WOQLQuery() + + result = query._varj("varName") + + assert result == {"@type": "Value", "variable": "varName"} + + +def test_varj_multiple_variations(): + """Test _varj handles various variable formats.""" + query = WOQLQuery() + + # Test multiple cases + assert query._varj(Var("a"))["variable"] == "a" + assert query._varj("v:b")["variable"] == "b" + assert query._varj("c")["variable"] == "c" + + +def test_coerce_to_dict_with_to_dict_method(): + """Test _coerce_to_dict calls to_dict() if available.""" + query = WOQLQuery() + var = Var("x") + + result = query._coerce_to_dict(var) + + assert result == {"@type": "Value", "variable": "x"} + + +def test_coerce_to_dict_with_true(): + """Test _coerce_to_dict handles True specially.""" + query = WOQLQuery() + + result = query._coerce_to_dict(True) + + assert result == {"@type": "True"} + + +def test_coerce_to_dict_with_dict(): + """Test _coerce_to_dict returns dict as-is.""" + query = WOQLQuery() + input_dict = {"@type": "Test"} + + result = query._coerce_to_dict(input_dict) + + assert result == input_dict + + +def test_raw_var_with_var_object(): + """Test _raw_var extracts name from Var object.""" + query = WOQLQuery() + var = Var("myVar") + + result = query._raw_var(var) + + assert result == "myVar" + + +def test_raw_var_with_v_prefix(): + """Test _raw_var strips v: prefix.""" + query = WOQLQuery() + + result = query._raw_var("v:varName") + + assert result == "varName" + + +def test_raw_var_with_plain_string(): + """Test _raw_var returns plain string as-is.""" + query = WOQLQuery() + + result = query._raw_var("plainName") + + assert result == "plainName" + + +def test_raw_var_list(): + """Test _raw_var_list processes list of variables.""" + query = WOQLQuery() + vars_list = [Var("x"), "v:y", "z"] + + result = query._raw_var_list(vars_list) + + assert result == ["x", "y", "z"] + + +def test_asv_with_column_index(): + """Test _asv with integer column index.""" + query = WOQLQuery() + + result = query._asv(0, "varName") + + assert result["@type"] == "Column" + assert result["indicator"]["@type"] == "Indicator" + assert result["indicator"]["index"] == 0 + assert result["variable"] == "varName" + + +def test_asv_with_column_name(): + """Test _asv with string column name.""" + query = WOQLQuery() + + result = query._asv("ColName", "varName") + + assert result["@type"] == "Column" + assert result["indicator"]["@type"] == "Indicator" + assert result["indicator"]["name"] == "ColName" + assert result["variable"] == "varName" + + +def test_asv_with_var_object(): + """Test _asv with Var object as variable name.""" + query = WOQLQuery() + var = Var("myVar") + + result = query._asv("ColName", var) + + assert result["variable"] == "myVar" + + +def test_asv_strips_v_prefix(): + """Test _asv strips v: prefix from variable name.""" + query = WOQLQuery() + + result = query._asv("ColName", "v:varName") + + assert result["variable"] == "varName" + + +def test_asv_with_type(): + """Test _asv includes type when provided.""" + query = WOQLQuery() + + result = query._asv("ColName", "varName", "xsd:string") + + assert result["type"] == "xsd:string" + + +def test_wfrom_with_format(): + """Test _wfrom sets format.""" + query = WOQLQuery() + opts = {"format": "csv"} + + query._wfrom(opts) + + assert query._cursor["format"]["@type"] == "Format" + assert query._cursor["format"]["format_type"]["@value"] == "csv" + + +def test_wfrom_with_format_header(): + """Test _wfrom sets format_header.""" + query = WOQLQuery() + opts = {"format": "csv", "format_header": True} + + query._wfrom(opts) + + assert query._cursor["format"]["format_header"]["@value"] is True + assert query._cursor["format"]["format_header"]["@type"] == "xsd:boolean" + + +def test_wfrom_without_format(): + """Test _wfrom does nothing without format option.""" + query = WOQLQuery() + + query._wfrom(None) + + assert "format" not in query._cursor + + +def test_arop_with_dict(): + """Test _arop returns dict as-is.""" + query = WOQLQuery() + input_dict = {"@type": "Value", "data": 42} + + result = query._arop(input_dict) + + assert result == input_dict diff --git a/terminusdb_client/tests/test_woql_utils.py b/terminusdb_client/tests/test_woql_utils.py new file mode 100644 index 00000000..81ebfdc6 --- /dev/null +++ b/terminusdb_client/tests/test_woql_utils.py @@ -0,0 +1,289 @@ +"""Tests for woql_utils.py module.""" +from datetime import datetime +from unittest.mock import Mock +import pytest + +from terminusdb_client.woql_utils import ( + _result2stream, + _args_as_payload, + _finish_response, + _clean_list, + _clean_dict, + _dt_list, + _dt_dict +) +from terminusdb_client.errors import DatabaseError + + +def test_result2stream_basic(): + """Test _result2stream with basic JSON objects.""" + result = '{"a": 1}{"b": 2}' + stream = list(_result2stream(result)) + + assert len(stream) == 2 + assert stream[0] == {"a": 1} + assert stream[1] == {"b": 2} + + +def test_result2stream_with_whitespace(): + """Test _result2stream handles whitespace between objects.""" + result = '{"a": 1} \n {"b": 2}' + stream = list(_result2stream(result)) + + assert len(stream) == 2 + assert stream[0] == {"a": 1} + assert stream[1] == {"b": 2} + + +def test_result2stream_empty(): + """Test _result2stream with empty string.""" + result = '' + stream = list(_result2stream(result)) + + assert len(stream) == 0 + + +def test_args_as_payload_filters_none(): + """Test _args_as_payload filters out None values.""" + args = {"a": 1, "b": None, "c": "test"} + result = _args_as_payload(args) + + assert result == {"a": 1, "c": "test"} + assert "b" not in result + + +def test_args_as_payload_filters_falsy(): + """Test _args_as_payload filters out falsy values.""" + args = {"a": 1, "b": 0, "c": "", "d": False, "e": "test"} + result = _args_as_payload(args) + + # Only truthy values remain + assert result == {"a": 1, "e": "test"} + + +def test_finish_response_success(): + """Test _finish_response with 200 status.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "success" + + result = _finish_response(mock_response) + + assert result == "success" + + +def test_finish_response_with_version(): + """Test _finish_response returns version header.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "data" + mock_response.headers = {"Terminusdb-Data-Version": "v1.0"} + + text, version = _finish_response(mock_response, get_version=True) + + assert text == "data" + assert version == "v1.0" + + +def test_finish_response_streaming(): + """Test _finish_response with streaming=True.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.iter_lines.return_value = iter(["line1", "line2"]) + + result = _finish_response(mock_response, streaming=True) + + # Should return iterator + lines = list(result) + assert lines == ["line1", "line2"] + mock_response.iter_lines.assert_called_once() + + +def test_finish_response_error(): + """Test _finish_response raises DatabaseError on error status.""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "error" + mock_response.headers = {"content-type": "text/plain"} + + with pytest.raises(DatabaseError): + _finish_response(mock_response) + + +def test_clean_list_with_datetime(): + """Test _clean_list converts datetime to isoformat.""" + dt = datetime(2025, 1, 1, 12, 0, 0) + obj = [dt, "string", 123] + + result = _clean_list(obj) + + assert result[0] == dt.isoformat() + assert result[1] == "string" + assert result[2] == 123 + + +def test_clean_list_nested(): + """Test _clean_list with nested structures.""" + dt = datetime(2025, 1, 1) + obj = [ + "string", + {"key": dt}, + [1, 2, dt] + ] + + result = _clean_list(obj) + + assert result[0] == "string" + assert result[1] == {"key": dt.isoformat()} + assert result[2] == [1, 2, dt.isoformat()] + + +def test_clean_dict_with_datetime(): + """Test _clean_dict converts datetime to isoformat.""" + dt = datetime(2025, 1, 1, 12, 0, 0) + obj = { + "date": dt, + "name": "test", + "count": 42 + } + + result = _clean_dict(obj) + + assert result["date"] == dt.isoformat() + assert result["name"] == "test" + assert result["count"] == 42 + + +def test_clean_dict_nested(): + """Test _clean_dict with nested structures.""" + dt = datetime(2025, 1, 1) + obj = { + "name": "test", + "nested": {"date": dt}, + "list": [1, dt, "string"] + } + + result = _clean_dict(obj) + + assert result["name"] == "test" + assert result["nested"] == {"date": dt.isoformat()} + assert result["list"] == [1, dt.isoformat(), "string"] + + +def test_clean_dict_with_iterable(): + """Test _clean_dict handles iterables correctly.""" + obj = { + "tuple": (1, 2, 3), + "list": [4, 5, 6] + } + + result = _clean_dict(obj) + + assert result["tuple"] == [1, 2, 3] + assert result["list"] == [4, 5, 6] + + +def test_dt_list_parses_isoformat(): + """Test _dt_list converts ISO format strings to datetime.""" + obj = ["2025-01-01T12:00:00", "not a date", 123] + + result = _dt_list(obj) + + assert isinstance(result[0], datetime) + assert result[0] == datetime(2025, 1, 1, 12, 0, 0) + assert result[1] == "not a date" + assert result[2] == 123 + + +def test_dt_list_nested(): + """Test _dt_list with nested structures.""" + obj = [ + "2025-01-01", + {"date": "2025-01-01T10:00:00"}, + ["2025-01-01", "text"] + ] + + result = _dt_list(obj) + + assert isinstance(result[0], datetime) + # Note: _dt_list calls _clean_dict on nested dicts, not _dt_dict + # So dates in nested dicts are not parsed + assert result[1] == {"date": "2025-01-01T10:00:00"} + # Note: _dt_list calls _clean_list on nested lists, not _dt_list + # So dates in nested lists are not parsed + assert result[2] == ["2025-01-01", "text"] + + +def test_dt_dict_parses_isoformat(): + """Test _dt_dict converts ISO format strings to datetime.""" + obj = { + "created": "2025-01-01T12:00:00", + "name": "test", + "invalid": "not a date" + } + + result = _dt_dict(obj) + + assert isinstance(result["created"], datetime) + assert result["created"] == datetime(2025, 1, 1, 12, 0, 0) + assert result["name"] == "test" + assert result["invalid"] == "not a date" + + +def test_dt_dict_nested(): + """Test _dt_dict with nested structures.""" + obj = { + "name": "test", + "nested": {"date": "2025-01-01"}, + "list": ["2025-01-01T10:00:00", 123] + } + + result = _dt_dict(obj) + + assert result["name"] == "test" + assert isinstance(result["nested"]["date"], datetime) + assert isinstance(result["list"][0], datetime) + assert result["list"][1] == 123 + + +def test_dt_dict_with_iterable(): + """Test _dt_dict handles iterables with dates.""" + obj = { + "dates": ["2025-01-01", "2025-01-02"], + "mixed": ["2025-01-01", "text", 123] + } + + result = _dt_dict(obj) + + assert isinstance(result["dates"][0], datetime) + assert isinstance(result["dates"][1], datetime) + assert isinstance(result["mixed"][0], datetime) + assert result["mixed"][1] == "text" + + +def test_clean_list_handles_dict_items(): + """Test _clean_list correctly identifies objects with items() method.""" + dt = datetime(2025, 1, 1) + obj = [ + {"key1": "value1"}, + {"key2": dt} + ] + + result = _clean_list(obj) + + assert result[0] == {"key1": "value1"} + assert result[1] == {"key2": dt.isoformat()} + + +def test_dt_list_handles_dict_items(): + """Test _dt_list correctly processes nested dicts.""" + obj = [ + {"date": "2025-01-01"}, + {"name": "test"} + ] + + result = _dt_list(obj) + + # _dt_list calls _clean_dict on dict items + assert result[0] == {"date": "2025-01-01"} + assert result[1] == {"name": "test"} diff --git a/terminusdb_client/tests/test_woqldataframe.py b/terminusdb_client/tests/test_woqldataframe.py new file mode 100644 index 00000000..2dd8ae10 --- /dev/null +++ b/terminusdb_client/tests/test_woqldataframe.py @@ -0,0 +1,174 @@ +"""Tests for woqldataframe/woqlDataframe.py module.""" +import pytest +from unittest.mock import MagicMock, patch +from terminusdb_client.woqldataframe.woqlDataframe import result_to_df +from terminusdb_client.errors import InterfaceError + + +def test_result_to_df_requires_pandas(): + """Test that result_to_df raises ImportError when pandas is not available.""" + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module') as mock_import: + mock_import.side_effect = ImportError("No module named 'pandas'") + + with pytest.raises(ImportError) as exc_info: + result_to_df([{"@id": "test", "@type": "Test"}]) + + assert "pandas" in str(exc_info.value).lower() + assert "terminus-client-python[dataframe]" in str(exc_info.value) + + +def test_result_to_df_requires_client_with_max_embed(): + """Test that result_to_df raises ValueError when max_embed_dep > 0 without client.""" + mock_pd = MagicMock() + mock_pd.DataFrame.return_value.from_records.return_value = mock_pd.DataFrame.return_value + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + with pytest.raises(ValueError) as exc_info: + result_to_df([{"@id": "test", "@type": "Test"}], max_embed_dep=1) + + assert "client need to be provide" in str(exc_info.value) + + +def test_result_to_df_multiple_types_error(): + """Test that result_to_df raises ValueError for multiple document types.""" + mock_pd = MagicMock() + + # Create mock DataFrame with multiple types + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["Type1", "Type2"] + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + with pytest.raises(ValueError) as exc_info: + result_to_df([ + {"@id": "test1", "@type": "Type1"}, + {"@id": "test2", "@type": "Type2"} + ]) + + assert "multiple type" in str(exc_info.value).lower() + + +def test_result_to_df_class_not_in_schema(): + """Test that result_to_df raises InterfaceError when class not found in schema.""" + mock_pd = MagicMock() + mock_client = MagicMock() + + # Setup mock DataFrame + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["UnknownClass"] + mock_df.columns = ["@id", "@type"] + mock_df.rename.return_value = mock_df + mock_df.drop.return_value = mock_df + + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + mock_client.get_existing_classes.return_value = {"KnownClass": {}} + mock_client.db = "testdb" + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + with pytest.raises(InterfaceError) as exc_info: + result_to_df( + [{"@id": "test1", "@type": "UnknownClass"}], + max_embed_dep=1, + client=mock_client + ) + + assert "UnknownClass" in str(exc_info.value) + assert "not found" in str(exc_info.value) + + +def test_result_to_df_basic_conversion(): + """Test basic result_to_df conversion without embedding.""" + mock_pd = MagicMock() + + # Setup mock DataFrame + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["Person"] + mock_df.columns = ["@id", "@type", "name"] + mock_df.rename.return_value = mock_df + mock_df.drop.return_value = mock_df + + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + result = result_to_df([ + {"@id": "Person/Jane", "@type": "Person", "name": "Jane"} + ]) + + # Should return the DataFrame + assert result is not None + + +def test_result_to_df_with_keepid(): + """Test result_to_df with keepid=True.""" + mock_pd = MagicMock() + + # Setup mock DataFrame + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["Person"] + mock_df.columns = ["@id", "@type", "name"] + + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + result = result_to_df([ + {"@id": "Person/Jane", "@type": "Person", "name": "Jane"} + ], keepid=True) + + # Should return the DataFrame + assert result is not None + # Should not call rename when keepid=True + mock_df.rename.assert_not_called() + + +def test_result_to_df_requires_client_for_embedding(): + """Test that result_to_df requires client when max_embed_dep > 0.""" + mock_pd = MagicMock() + + # Setup basic mock + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["Person"] + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + # This tests the ValueError raised at line 18-21 + with pytest.raises(ValueError) as exc_info: + result_to_df( + [{"@id": "Person/Jane", "@type": "Person"}], + max_embed_dep=2, # Requires client + client=None # But no client provided + ) + + assert "client need to be provide" in str(exc_info.value) + + +def test_result_to_df_expand_nested_json(): + """Test that result_to_df expands nested JSON structures.""" + mock_pd = MagicMock() + + # Setup mock DataFrame with nested structure + mock_df = MagicMock() + mock_df.__getitem__.return_value.unique.return_value = ["Person"] + mock_df.columns = ["Document id", "address"] + mock_df.rename.return_value = mock_df + mock_df.drop.return_value = mock_df + mock_df.join.return_value = mock_df + + # Mock json_normalize to simulate expansion + mock_expanded = MagicMock() + mock_expanded.columns = ["@id", "street"] + mock_pd.json_normalize.return_value = mock_expanded + + mock_pd.DataFrame.return_value.from_records.return_value = mock_df + + with patch('terminusdb_client.woqldataframe.woqlDataframe.import_module', return_value=mock_pd): + result = result_to_df([ + { + "@id": "Person/Jane", + "@type": "Person", + "address": {"@id": "Address/1", "street": "Main St"} + } + ]) + + # json_normalize should be called for expansion + assert mock_pd.json_normalize.called + assert result is not None diff --git a/tox.ini b/tox.ini index 5a94850b..8139d4a3 100644 --- a/tox.ini +++ b/tox.ini @@ -27,4 +27,4 @@ passenv = TERMINUSX_TOKEN [flake8] # S501 is turned off for now, check if the verify=false for request is ok ignore = E501,W503,S101,S310,A003,E203,S607,S603,S404,W391,N814,N804,S106,F722,S105,E266,S311,N806 -exclude = .*/,__pycache__,docs/,build/ +exclude = .*/,__pycache__,docs/,build/,venv/,.tox/