From 3088c54b8e200fd102cce06e3f5bad2da1b943dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Cant=C3=B9?= Date: Wed, 6 Dec 2023 13:46:15 +0100 Subject: [PATCH 1/6] wip: implement extend service and suggest properties --- src/datasette_reconcile/__init__.py | 20 ++++-- src/datasette_reconcile/reconcile.py | 92 ++++++++++++++++++++++++---- src/datasette_reconcile/settings.py | 2 + 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/datasette_reconcile/__init__.py b/src/datasette_reconcile/__init__.py index b74d17c..2413665 100644 --- a/src/datasette_reconcile/__init__.py +++ b/src/datasette_reconcile/__init__.py @@ -4,7 +4,7 @@ from datasette_reconcile.utils import check_config, check_permissions -async def reconcile(request, datasette): +async def get_api(request, datasette): database = request.url_vars["db_name"] table = request.url_vars["db_table"] db = datasette.get_database(database) @@ -25,10 +25,22 @@ async def reconcile(request, datasette): ) # get the reconciliation API and call it - reconcile_api = ReconcileAPI(config, database, table, datasette) - return await reconcile_api.get(request) + return ReconcileAPI(config, database, table, datasette) + + +async def reconcile(request, datasette): + reconcile_api = await get_api(request, datasette) + return await reconcile_api.reconcile(request) + + +async def properties(request, datasette): + reconcile_api = await get_api(request, datasette) + return await reconcile_api.properties(request) @hookimpl def register_routes(): - return [(r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile$", reconcile)] + return [ + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile$", reconcile), + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/properties$", properties), + ] diff --git a/src/datasette_reconcile/reconcile.py b/src/datasette_reconcile/reconcile.py index 39cc53b..e27c3e3 100644 --- a/src/datasette_reconcile/reconcile.py +++ b/src/datasette_reconcile/reconcile.py @@ -9,6 +9,7 @@ DEFAULT_LIMIT, DEFAULT_SCHEMA_SPACE, DEFAULT_TYPE, + DEFAULT_PROPERTY_SETTINGS, ) from datasette_reconcile.utils import get_select_fields, get_view_url @@ -22,18 +23,37 @@ def __init__(self, config, database, table, datasette): self.db = datasette.get_database(database) self.table = table self.datasette = datasette - - async def get(self, request): + + async def reconcile(self, request): """ Takes a request and returns a response based on the queries. """ - # work out if we are looking for queries - queries = await self._get_queries(request) + post_vars = await request.post_vars() + queries = post_vars.get("queries", request.args.get("queries")) + extend = post_vars.get("extend", request.args.get("extend")) + if queries: - return self._response({q[0]: {"result": q[1]} async for q in self._reconcile_queries(queries)}) + return self._response({q[0]: {"result": q[1]} async for q in self._reconcile_queries(json.loads(queries))}) + elif extend: + response = await self._extend(json.loads(extend)) + return self._response(response) + else: # if we're not then just return the service specification - return self._response(self._service_manifest(request)) + return self._response(self._service_manifest(request)) + + + async def properties(self, request): + limit = request.args.get('limit', DEFAULT_LIMIT) + type = request.args.get('type', None) + + properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + + return self._response({ + "limit": limit, + "type": type, + "properties": [{"id": p.get('name'), "name": p.get('label')} for p in properties] + }) def _response(self, response): return Response.json( @@ -42,12 +62,46 @@ def _response(self, response): "Access-Control-Allow-Origin": "*", }, ) + + async def _extend(self, data): + ids = data['ids'] + data_properties = data['properties'] + properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + PROPERTIES = {p['name']: p for p in properties} + id_field = self.config.get("id_field", "id") - async def _get_queries(self, request): - post_vars = await request.post_vars() - queries = post_vars.get("queries", request.args.get("queries")) - if queries: - return json.loads(queries) + select_fields = [id_field] + [p['id'] for p in data_properties] + + query_sql = """ + select {fields} + from {table} + where {where_clause} + """.format( + table=escape_sqlite(self.table), + where_clause=f'{escape_sqlite(id_field)} in ({",".join(ids)})', + fields=','.join([escape_sqlite(f) for f in select_fields]) + ) + params = {} + query_results = await self.db.execute(query_sql, params) + + rows = {} + for row in query_results: + values = {} + for p in data_properties: + values[p['id']] = [ + { + "str": row[p['id']] + } + ] + + rows[row[id_field]] = values + + response = { + 'meta': [{"id": p['id'], 'name': PROPERTIES[p['id']]['label']} for p in data_properties], + 'rows': rows + } + + return response async def _reconcile_queries(self, queries): select_fields = get_select_fields(self.config) @@ -122,7 +176,10 @@ def _service_manifest(self, request): view_url = self.config.get("view_url") if not view_url: view_url = self.datasette.absolute_url(request, get_view_url(self.datasette, self.database, self.table)) - return { + + properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + + manifest = { "versions": ["0.1", "0.2"], "name": self.config.get( "service_name", @@ -133,3 +190,14 @@ def _service_manifest(self, request): "defaultTypes": self.config.get("type_default", [DEFAULT_TYPE]), "view": {"url": view_url}, } + + if properties: + manifest["extend"] = { + "propose_properties": { + "service_url": f'{request.scheme}://{request.host}{self.datasette.setting("base_url")}', + "service_path": f'{self.database}/{self.table}/-/reconcile/properties' + }, + "property_settings": self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + } + + return manifest \ No newline at end of file diff --git a/src/datasette_reconcile/settings.py b/src/datasette_reconcile/settings.py index ddae2c5..822004d 100644 --- a/src/datasette_reconcile/settings.py +++ b/src/datasette_reconcile/settings.py @@ -7,3 +7,5 @@ DEFAULT_SCHEMA_SPACE = "http://rdf.freebase.com/ns/type.object.id" SQLITE_VERSION_WARNING = (3, 30, 0) SUPPORTED_API_VERSIONS = ["0.1", "0.2"] + +DEFAULT_PROPERTY_SETTINGS = [] From 0b36b9d0f3165b16ac906651d1565fc337a295f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Cant=C3=B9?= Date: Mon, 11 Dec 2023 08:33:58 +0100 Subject: [PATCH 2/6] generate https urls behind proxy --- src/datasette_reconcile/reconcile.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/datasette_reconcile/reconcile.py b/src/datasette_reconcile/reconcile.py index e27c3e3..f0ba510 100644 --- a/src/datasette_reconcile/reconcile.py +++ b/src/datasette_reconcile/reconcile.py @@ -173,9 +173,14 @@ def _get_query_result(self, row, query): def _service_manifest(self, request): # @todo: if type_field is set then get a list of types to use in the "defaultTypes" item below. + # handle X-FORWARDED-PROTO in Datasette: https://github.com/simonw/datasette/issues/2215 + scheme = request.scheme + if 'x-forwarded-proto' in request.headers: + scheme = request.headers.get('x-forwarded-proto') + view_url = self.config.get("view_url") if not view_url: - view_url = self.datasette.absolute_url(request, get_view_url(self.datasette, self.database, self.table)) + view_url = f"{scheme}://{request.host}{get_view_url(self.datasette, self.database, self.table)}" properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) @@ -190,11 +195,11 @@ def _service_manifest(self, request): "defaultTypes": self.config.get("type_default", [DEFAULT_TYPE]), "view": {"url": view_url}, } - + if properties: manifest["extend"] = { "propose_properties": { - "service_url": f'{request.scheme}://{request.host}{self.datasette.setting("base_url")}', + "service_url": f'{scheme}://{request.host}{self.datasette.setting("base_url")}', "service_path": f'{self.database}/{self.table}/-/reconcile/properties' }, "property_settings": self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) From a7819553e58fa33a8c31d0fb8063e64eb465b003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Cant=C3=B9?= Date: Mon, 11 Dec 2023 15:03:40 +0100 Subject: [PATCH 3/6] implement tests --- tests/test_reconcile.py | 112 +++++++++++++++++++++++++++++++++ tests/test_reconcile_schema.py | 21 +++++++ 2 files changed, 133 insertions(+) diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index fba4fbf..7bfb1ba 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -7,6 +7,16 @@ from tests.conftest import plugin_metadata +PROPERTY_SETTINGS = { + "properties": [ + { + "name": "status", + "label": "Status", + "type": "text" + } + ] +} + @pytest.mark.asyncio async def test_plugin_is_installed(): app = Datasette([], memory=True).app() @@ -51,6 +61,27 @@ async def test_servce_manifest_view_url_default(db_path): assert data["view"]["url"] == "http://localhost/test/dogs/{{id}}" + +@pytest.mark.asyncio +async def test_servce_manifest_https(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("https://localhost/test/dogs/-/reconcile") + assert 200 == response.status_code + data = response.json() + assert data["view"]["url"] == "https://localhost/test/dogs/{{id}}" + + +@pytest.mark.asyncio +async def test_servce_manifest_x_forwarded_proto_https(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile", headers={'x-forwarded-proto': 'https'}) + assert 200 == response.status_code + data = response.json() + assert data["view"]["url"] == "https://localhost/test/dogs/{{id}}" + + @pytest.mark.asyncio async def test_servce_manifest_view_url_custom(db_path): custom_view_url = "https://example.com/{{id}}" @@ -70,6 +101,43 @@ async def test_servce_manifest_view_url_custom(db_path): assert data["view"]["url"] == custom_view_url +@pytest.mark.asyncio +async def test_servce_manifest_view_no_extend(db_path): + app = Datasette( + [db_path], + metadata=plugin_metadata( + { + "name_field": "name", + } + ), + ).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile") + assert 200 == response.status_code + data = response.json() + assert "extend" not in data + + +@pytest.mark.asyncio +async def test_servce_manifest_view_extend(db_path): + app = Datasette( + [db_path], + metadata=plugin_metadata( + { + "name_field": "name", + **PROPERTY_SETTINGS + } + ), + ).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile") + assert 200 == response.status_code + data = response.json() + assert "extend" in data + assert data["extend"]["propose_properties"]["service_url"] == "http://localhost/" + assert data["extend"]["property_settings"][0]["name"] == "status" + + @pytest.mark.asyncio async def test_response_queries_post(db_path): app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() @@ -144,3 +212,47 @@ async def test_response_queries_no_results_get(db_path): assert "q0" in data.keys() assert len(data["q0"]["result"]) == 0 assert response.headers["Access-Control-Allow-Origin"] == "*" + + +@pytest.mark.asyncio +async def test_response_propose_properties(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", **PROPERTY_SETTINGS})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get(f"http://localhost/test/dogs/-/reconcile/properties?type=object") + assert 200 == response.status_code + data = response.json() + assert len(data["properties"]) == 1 + result = data["properties"][0] + assert result["name"] == "Status" + assert result["id"] == "status" + assert response.headers["Access-Control-Allow-Origin"] == "*" + + +@pytest.mark.asyncio +async def test_response_extend(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", **PROPERTY_SETTINGS})).app() + async with httpx.AsyncClient(app=app) as client: + extend = {"extend": json.dumps({ + "ids": ["1", "2", "3", "4"], + "properties": [{"id": "status"}] + })} + response = await client.post(f"http://localhost/test/dogs/-/reconcile", data=extend) + assert 200 == response.status_code + data = response.json() + + assert "meta" in data + assert data["meta"][0]["id"] == "status" + assert data["meta"][0]["name"] == "Status" + assert "rows" in data + + expect = { + "1": "good dog", + "2": "bad dog", + "3": "bad dog", + "4": "good dog", + } + + for key in expect.keys(): + assert data["rows"][key]["status"][0]["str"] == expect[key] + + assert response.headers["Access-Control-Allow-Origin"] == "*" diff --git a/tests/test_reconcile_schema.py b/tests/test_reconcile_schema.py index 58c81fa..0043ebe 100644 --- a/tests/test_reconcile_schema.py +++ b/tests/test_reconcile_schema.py @@ -26,6 +26,27 @@ async def test_schema_manifest(schema_version, schema, db_path): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version, schema", get_schema("manifest.json").items()) +async def test_schema_manifest_extend(schema_version, schema, db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", "properties": [ + { + "name": "status", + "label": "Status", + "type": "text" + } + ]})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile") + data = response.json() + logging.info(f"Schema version: {schema_version}") + jsonschema.validate( + instance=data, + schema=schema, + cls=jsonschema.Draft7Validator, + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("schema_version, schema", get_schema("reconciliation-result-batch.json").items()) async def test_response_queries_schema_post(schema_version, schema, db_path): From 54185be802e365607a4e80d5bfe4c7a20e571942 Mon Sep 17 00:00:00 2001 From: David Kane Date: Wed, 20 Dec 2023 09:39:55 +0000 Subject: [PATCH 4/6] update extend endpoint add suggest endpoints --- .github/workflows/publish.yml | 67 ++++++----- .github/workflows/test.yml | 58 +++++----- pyproject.toml | 17 +-- specs | 2 +- src/datasette_reconcile/__init__.py | 20 +++- src/datasette_reconcile/reconcile.py | 167 +++++++++++++++++++-------- src/datasette_reconcile/settings.py | 2 - tests/test_reconcile.py | 114 ++++++++++-------- tests/test_reconcile_config.py | 22 ++++ tests/test_reconcile_schema.py | 78 +++++++++++-- tests/test_reconcile_utils.py | 11 ++ 11 files changed, 379 insertions(+), 179 deletions(-) create mode 100644 tests/test_reconcile_utils.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index de4d05d..d5953c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,42 +10,41 @@ jobs: strategy: max-parallel: 4 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 - with: - submodules: recursive - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - name: Install dependencies - run: | - pip install -e . - pip install hatch - - name: Run tests - run: | - hatch run test + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + pip install -e . + pip install hatch + - name: Run tests + run: | + hatch run test deploy: runs-on: ubuntu-latest needs: [test] steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - cache: pip - - name: Install dependencies - run: | - pip install -e . - pip install hatch - - name: Publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - hatch build - hatch publish - + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: pip + - name: Install dependencies + run: | + pip install -e . + pip install hatch + - name: Publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + hatch build + hatch publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 038b470..194de33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,23 +8,23 @@ jobs: strategy: max-parallel: 4 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 - with: - submodules: recursive - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - name: Install dependencies - run: | - pip install -e . - pip install hatch - - name: Run tests - run: | - hatch run test + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + pip install -e . + pip install hatch + - name: Run tests + run: | + hatch run test lint: runs-on: ubuntu-latest strategy: @@ -32,16 +32,16 @@ jobs: matrix: python-version: ["3.10"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - name: Install dependencies - run: | - pip install -e .[lint] - pip install hatch - - name: Run lint - run: | - hatch run lint:style + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + pip install -e .[lint] + pip install hatch + - name: Run lint + run: | + hatch run lint:style diff --git a/pyproject.toml b/pyproject.toml index bbd9980..c5e75db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,11 @@ authors = [{ name = "David Kane", email = "david@dkane.net" }] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -34,7 +34,7 @@ test = [ "sqlite-utils", "jsonschema", ] -lint = ["black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"] +lint = ["mypy>=1.0.0", "ruff>=0.1.8"] [project.entry-points.datasette] reconcile = "datasette_reconcile" @@ -67,7 +67,7 @@ cov-html = [ ] [[tool.hatch.envs.all.matrix]] -python = ["3.7", "3.8", "3.9", "3.10", "3.11"] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] [tool.hatch.envs.lint] detached = true @@ -75,17 +75,12 @@ features = ["lint"] [tool.hatch.envs.lint.scripts] typing = "mypy --install-types --non-interactive {args:src/datasette_reconcile tests}" -style = ["ruff {args:.}", "black --check --diff {args:.}"] -fmt = ["black {args:.}", "ruff --fix {args:.}", "style"] +style = ["ruff {args:.}", "ruff format --check {args:.}"] +fmt = ["ruff format {args:.}", "ruff --fix {args:.}", "style"] all = ["style", "typing"] -[tool.black] -target-version = ["py37"] -line-length = 120 -skip-string-normalization = true - [tool.ruff] -target-version = "py37" +target-version = "py38" line-length = 120 select = [ "A", diff --git a/specs b/specs index aaf439d..1812f84 160000 --- a/specs +++ b/specs @@ -1 +1 @@ -Subproject commit aaf439dd10cce35a13947a1d9f62347fbbc1f2ff +Subproject commit 1812f84d7f34a8342c0b7f2899ef032bcad6601a diff --git a/src/datasette_reconcile/__init__.py b/src/datasette_reconcile/__init__.py index 2413665..32ce94e 100644 --- a/src/datasette_reconcile/__init__.py +++ b/src/datasette_reconcile/__init__.py @@ -38,9 +38,27 @@ async def properties(request, datasette): return await reconcile_api.properties(request) +async def suggest_entity(request, datasette): + reconcile_api = await get_api(request, datasette) + return await reconcile_api.suggest_entity(request) + + +async def suggest_property(request, datasette): + reconcile_api = await get_api(request, datasette) + return await reconcile_api.suggest_property(request) + + +async def suggest_type(request, datasette): + reconcile_api = await get_api(request, datasette) + return await reconcile_api.suggest_type(request) + + @hookimpl def register_routes(): return [ (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile$", reconcile), - (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/properties$", properties), + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/extend/propose$", properties), + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/suggest/entity$", suggest_entity), + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/suggest/property$", suggest_property), + (r"/(?P[^/]+)/(?P[^/]+?)/-/reconcile/suggest/type$", suggest_type), ] diff --git a/src/datasette_reconcile/reconcile.py b/src/datasette_reconcile/reconcile.py index f0ba510..64d310d 100644 --- a/src/datasette_reconcile/reconcile.py +++ b/src/datasette_reconcile/reconcile.py @@ -9,7 +9,6 @@ DEFAULT_LIMIT, DEFAULT_SCHEMA_SPACE, DEFAULT_TYPE, - DEFAULT_PROPERTY_SETTINGS, ) from datasette_reconcile.utils import get_select_fields, get_view_url @@ -23,7 +22,7 @@ def __init__(self, config, database, table, datasette): self.db = datasette.get_database(database) self.table = table self.datasette = datasette - + async def reconcile(self, request): """ Takes a request and returns a response based on the queries. @@ -39,21 +38,64 @@ async def reconcile(self, request): response = await self._extend(json.loads(extend)) return self._response(response) else: - # if we're not then just return the service specification - return self._response(self._service_manifest(request)) - + # if we're not then just return the service specification + return self._response(await self._service_manifest(request)) async def properties(self, request): - limit = request.args.get('limit', DEFAULT_LIMIT) - type = request.args.get('type', None) + limit = request.args.get("limit", DEFAULT_LIMIT) + type_ = request.args.get("type", DEFAULT_TYPE) + + return self._response( + { + "limit": limit, + "type": type_, + "properties": [{"id": p["id"], "name": p["name"]} async for p in self._get_properties()], + } + ) - properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + async def suggest_entity(self, request): + prefix = request.args.get("prefix") + cursor = int(request.args.get("cursor", 0)) + + name_field = self.config["name_field"] + id_field = self.config.get("id_field", "id") + query_sql = f""" + select {escape_sqlite(id_field)} as id, {escape_sqlite(name_field)} as name + from {escape_sqlite(self.table)} + where {escape_sqlite(name_field)} like :search_query + limit {DEFAULT_LIMIT} offset {cursor} + """ # noqa: S608 + params = {"search_query": f"{prefix}%"} + + return self._response( + {"result": [{"id": r["id"], "name": r["name"]} for r in await self.db.execute(query_sql, params)]} + ) - return self._response({ - "limit": limit, - "type": type, - "properties": [{"id": p.get('name'), "name": p.get('label')} for p in properties] - }) + async def suggest_property(self, request): + prefix = request.args.get("prefix") + cursor = request.args.get("cursor", 0) + + properties = [ + {"id": p["id"], "name": p["name"]} + async for p in self._get_properties() + if p["name"].startswith(prefix) or p["id"].startswith(prefix) + ][cursor : cursor + DEFAULT_LIMIT] + + return self._response({"result": properties}) + + async def suggest_type(self, request): + prefix = request.args.get("prefix") # noqa: F841 + + return self._response({"result": []}) + + async def _get_properties(self): + column_descriptions = self.datasette.table_metadata(self.database, self.table).get("columns") or {} + for column in await self.db.table_column_details(self.table): + yield { + "id": column.name, + "name": column_descriptions.get(column.name, column.name), + "type": column.type, + } def _response(self, response): return Response.json( @@ -62,43 +104,43 @@ def _response(self, response): "Access-Control-Allow-Origin": "*", }, ) - + async def _extend(self, data): - ids = data['ids'] - data_properties = data['properties'] - properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) - PROPERTIES = {p['name']: p for p in properties} + ids = data["ids"] + data_properties = data["properties"] + properties = {p["name"]: p async for p in self._get_properties()} id_field = self.config.get("id_field", "id") - select_fields = [id_field] + [p['id'] for p in data_properties] + select_fields = [id_field] + [p["id"] for p in data_properties] query_sql = """ select {fields} from {table} where {where_clause} - """.format( + """.format( # noqa: S608 table=escape_sqlite(self.table), - where_clause=f'{escape_sqlite(id_field)} in ({",".join(ids)})', - fields=','.join([escape_sqlite(f) for f in select_fields]) + where_clause=f"{escape_sqlite(id_field)} in ({','.join(['?'] * len(ids))})", + fields=",".join([escape_sqlite(f) for f in select_fields]), ) - params = {} - query_results = await self.db.execute(query_sql, params) + query_results = await self.db.execute(query_sql, ids) rows = {} for row in query_results: values = {} for p in data_properties: - values[p['id']] = [ - { - "str": row[p['id']] - } - ] + property_ = properties[p["id"]] + if property_["type"] == "INTEGER": + values[p["id"]] = [{"int": row[p["id"]]}] + elif property_["type"] == "FLOAT": + values[p["id"]] = [{"float": row[p["id"]]}] + else: + values[p["id"]] = [{"str": row[p["id"]]}] rows[row[id_field]] = values response = { - 'meta': [{"id": p['id'], 'name': PROPERTIES[p['id']]['label']} for p in data_properties], - 'rows': rows + "meta": [{"id": p["id"], "name": properties[p["id"]]["name"]} for p in data_properties], + "rows": rows, } return response @@ -171,18 +213,22 @@ def _get_query_result(self, row, query): "match": name_match == query_match, } - def _service_manifest(self, request): + async def _service_manifest(self, request): # @todo: if type_field is set then get a list of types to use in the "defaultTypes" item below. # handle X-FORWARDED-PROTO in Datasette: https://github.com/simonw/datasette/issues/2215 scheme = request.scheme - if 'x-forwarded-proto' in request.headers: - scheme = request.headers.get('x-forwarded-proto') - + if "x-forwarded-proto" in request.headers: + scheme = request.headers.get("x-forwarded-proto") + + service_url = ( + f'{scheme}://{request.host}{self.datasette.setting("base_url")}/{self.database}/{self.table}/-/reconcile' + ) + view_url = self.config.get("view_url") if not view_url: view_url = f"{scheme}://{request.host}{get_view_url(self.datasette, self.database, self.table)}" - properties = self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) + properties = self._get_properties() manifest = { "versions": ["0.1", "0.2"], @@ -194,15 +240,46 @@ def _service_manifest(self, request): "schemaSpace": self.config.get("schemaSpace", DEFAULT_SCHEMA_SPACE), "defaultTypes": self.config.get("type_default", [DEFAULT_TYPE]), "view": {"url": view_url}, - } - - if properties: - manifest["extend"] = { + "extend": { "propose_properties": { - "service_url": f'{scheme}://{request.host}{self.datasette.setting("base_url")}', - "service_path": f'{self.database}/{self.table}/-/reconcile/properties' + "service_url": service_url, + "service_path": "/extend/propose", }, - "property_settings": self.config.get("properties", DEFAULT_PROPERTY_SETTINGS) - } + "property_settings": [ + { + "name": p["id"], + "label": p["name"], + "type": "number" if p["type"] in ["INTEGER", "FLOAT"] else "text", + } + async for p in properties + ], + }, + "suggest": { + "entity": ( + { + "service_url": service_url, + "service_path": "/suggest/entity", + } + if self.api_version in ["0.1", "0.2"] + else True + ), + "type": ( + { + "service_url": service_url, + "service_path": "/suggest/type", + } + if self.api_version in ["0.1", "0.2"] + else True + ), + "property": ( + { + "service_url": service_url, + "service_path": "/suggest/property", + } + if self.api_version in ["0.1", "0.2"] + else True + ), + }, + } - return manifest \ No newline at end of file + return manifest diff --git a/src/datasette_reconcile/settings.py b/src/datasette_reconcile/settings.py index 822004d..ddae2c5 100644 --- a/src/datasette_reconcile/settings.py +++ b/src/datasette_reconcile/settings.py @@ -7,5 +7,3 @@ DEFAULT_SCHEMA_SPACE = "http://rdf.freebase.com/ns/type.object.id" SQLITE_VERSION_WARNING = (3, 30, 0) SUPPORTED_API_VERSIONS = ["0.1", "0.2"] - -DEFAULT_PROPERTY_SETTINGS = [] diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 7bfb1ba..c48350f 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -7,16 +7,6 @@ from tests.conftest import plugin_metadata -PROPERTY_SETTINGS = { - "properties": [ - { - "name": "status", - "label": "Status", - "type": "text" - } - ] -} - @pytest.mark.asyncio async def test_plugin_is_installed(): app = Datasette([], memory=True).app() @@ -61,7 +51,6 @@ async def test_servce_manifest_view_url_default(db_path): assert data["view"]["url"] == "http://localhost/test/dogs/{{id}}" - @pytest.mark.asyncio async def test_servce_manifest_https(db_path): app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() @@ -76,7 +65,7 @@ async def test_servce_manifest_https(db_path): async def test_servce_manifest_x_forwarded_proto_https(db_path): app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() async with httpx.AsyncClient(app=app) as client: - response = await client.get("http://localhost/test/dogs/-/reconcile", headers={'x-forwarded-proto': 'https'}) + response = await client.get("http://localhost/test/dogs/-/reconcile", headers={"x-forwarded-proto": "https"}) assert 200 == response.status_code data = response.json() assert data["view"]["url"] == "https://localhost/test/dogs/{{id}}" @@ -101,41 +90,20 @@ async def test_servce_manifest_view_url_custom(db_path): assert data["view"]["url"] == custom_view_url -@pytest.mark.asyncio -async def test_servce_manifest_view_no_extend(db_path): - app = Datasette( - [db_path], - metadata=plugin_metadata( - { - "name_field": "name", - } - ), - ).app() - async with httpx.AsyncClient(app=app) as client: - response = await client.get("http://localhost/test/dogs/-/reconcile") - assert 200 == response.status_code - data = response.json() - assert "extend" not in data - - @pytest.mark.asyncio async def test_servce_manifest_view_extend(db_path): app = Datasette( [db_path], - metadata=plugin_metadata( - { - "name_field": "name", - **PROPERTY_SETTINGS - } - ), + metadata=plugin_metadata({"name_field": "name"}), ).app() async with httpx.AsyncClient(app=app) as client: response = await client.get("http://localhost/test/dogs/-/reconcile") assert 200 == response.status_code data = response.json() assert "extend" in data - assert data["extend"]["propose_properties"]["service_url"] == "http://localhost/" - assert data["extend"]["property_settings"][0]["name"] == "status" + assert data["extend"]["propose_properties"]["service_url"] == "http://localhost//test/dogs/-/reconcile" + assert data["extend"]["property_settings"][3]["name"] == "status" + assert len(data["suggest"]) == 3 @pytest.mark.asyncio @@ -216,33 +184,30 @@ async def test_response_queries_no_results_get(db_path): @pytest.mark.asyncio async def test_response_propose_properties(db_path): - app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", **PROPERTY_SETTINGS})).app() + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() async with httpx.AsyncClient(app=app) as client: - response = await client.get(f"http://localhost/test/dogs/-/reconcile/properties?type=object") + response = await client.get("http://localhost/test/dogs/-/reconcile/extend/propose?type=object") assert 200 == response.status_code data = response.json() - assert len(data["properties"]) == 1 - result = data["properties"][0] - assert result["name"] == "Status" + assert len(data["properties"]) == 4 + result = data["properties"][3] + assert result["name"] == "status" assert result["id"] == "status" assert response.headers["Access-Control-Allow-Origin"] == "*" @pytest.mark.asyncio async def test_response_extend(db_path): - app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", **PROPERTY_SETTINGS})).app() + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() async with httpx.AsyncClient(app=app) as client: - extend = {"extend": json.dumps({ - "ids": ["1", "2", "3", "4"], - "properties": [{"id": "status"}] - })} - response = await client.post(f"http://localhost/test/dogs/-/reconcile", data=extend) + extend = {"extend": json.dumps({"ids": ["1", "2", "3", "4"], "properties": [{"id": "status"}, {"id": "age"}]})} + response = await client.post("http://localhost/test/dogs/-/reconcile", data=extend) assert 200 == response.status_code data = response.json() assert "meta" in data assert data["meta"][0]["id"] == "status" - assert data["meta"][0]["name"] == "Status" + assert data["meta"][0]["name"] == "status" assert "rows" in data expect = { @@ -255,4 +220,55 @@ async def test_response_extend(db_path): for key in expect.keys(): assert data["rows"][key]["status"][0]["str"] == expect[key] + expect_nums = { + "1": 5, + "2": 4, + "3": 3, + "4": 3, + } + + for key in expect_nums.keys(): + assert data["rows"][key]["age"][0]["int"] == expect_nums[key] + + assert response.headers["Access-Control-Allow-Origin"] == "*" + + +@pytest.mark.asyncio +async def test_response_suggest_entity(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/entity?prefix=f") + assert 200 == response.status_code + data = response.json() + + assert "result" in data + assert data["result"][0]["id"] == 3 + assert data["result"][0]["name"] == "Fido" + assert response.headers["Access-Control-Allow-Origin"] == "*" + + +@pytest.mark.asyncio +async def test_response_suggest_property(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/property?prefix=a") + assert 200 == response.status_code + data = response.json() + + assert "result" in data + assert data["result"][0]["id"] == "age" + assert data["result"][0]["name"] == "age" + assert response.headers["Access-Control-Allow-Origin"] == "*" + + +@pytest.mark.asyncio +async def test_response_suggest_type(db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/type?prefix=a") + assert 200 == response.status_code + data = response.json() + + assert "result" in data + assert len(data["result"]) == 0 assert response.headers["Access-Control-Allow-Origin"] == "*" diff --git a/tests/test_reconcile_config.py b/tests/test_reconcile_config.py index 16b3ef4..de54cd0 100644 --- a/tests/test_reconcile_config.py +++ b/tests/test_reconcile_config.py @@ -36,6 +36,28 @@ async def test_plugin_configuration_use_pk(ds): assert "type_field" not in config +@pytest.mark.asyncio +async def test_plugin_configuration_max_limit(ds): + with pytest.raises(TypeError, match="max_limit in reconciliation config must be an integer"): + await check_config({"name_field": "name", "max_limit": "BLAH"}, ds.get_database("test"), "dogs") + + +@pytest.mark.asyncio +async def test_plugin_configuration_type_default(ds): + with pytest.raises(ReconcileError, match="type_default should be a list of objects"): + await check_config({"name_field": "name", "type_default": "BLAH"}, ds.get_database("test"), "dogs") + with pytest.raises(ReconcileError, match="type_default values should be objects"): + await check_config({"name_field": "name", "type_default": ["BLAH"]}, ds.get_database("test"), "dogs") + with pytest.raises(ReconcileError, match="type_default 'id' values should be strings"): + await check_config( + {"name_field": "name", "type_default": [{"id": 1, "name": "test"}]}, ds.get_database("test"), "dogs" + ) + with pytest.raises(ReconcileError, match="type_default 'name' values should be strings"): + await check_config( + {"name_field": "name", "type_default": [{"name": 1, "id": "test"}]}, ds.get_database("test"), "dogs" + ) + + @pytest.mark.asyncio async def test_plugin_configuration_use_id_field(ds): config = await check_config( diff --git a/tests/test_reconcile_schema.py b/tests/test_reconcile_schema.py index 0043ebe..dbfea06 100644 --- a/tests/test_reconcile_schema.py +++ b/tests/test_reconcile_schema.py @@ -29,13 +29,12 @@ async def test_schema_manifest(schema_version, schema, db_path): @pytest.mark.asyncio @pytest.mark.parametrize("schema_version, schema", get_schema("manifest.json").items()) async def test_schema_manifest_extend(schema_version, schema, db_path): - app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name", "properties": [ - { - "name": "status", - "label": "Status", - "type": "text" - } - ]})).app() + app = Datasette( + [db_path], + metadata=plugin_metadata( + {"name_field": "name", "properties": [{"name": "status", "label": "Status", "type": "text"}]} + ), + ).app() async with httpx.AsyncClient(app=app) as client: response = await client.get("http://localhost/test/dogs/-/reconcile") data = response.json() @@ -117,3 +116,68 @@ async def test_response_queries_no_results_schema_get(schema_version, schema, db schema=schema, cls=jsonschema.Draft7Validator, ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version, schema", get_schema("data-extension-response.json").items()) +async def test_extend_schema_post(schema_version, schema, db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + extend = {"extend": json.dumps({"ids": ["1", "2", "3", "4"], "properties": [{"id": "status"}, {"id": "age"}]})} + response = await client.post("http://localhost/test/dogs/-/reconcile", data=extend) + assert 200 == response.status_code + data = response.json() + logging.info(f"Schema version: {schema_version}") + jsonschema.validate( + instance=data, + schema=schema, + cls=jsonschema.Draft7Validator, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version, schema", get_schema("suggest-properties-response.json").items()) +async def test_suggest_property_schema(schema_version, schema, db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/property?prefix=a") + assert 200 == response.status_code + data = response.json() + logging.info(f"Schema version: {schema_version}") + jsonschema.validate( + instance=data, + schema=schema, + cls=jsonschema.Draft7Validator, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version, schema", get_schema("suggest-entities-response.json").items()) +async def test_suggest_entity_schema(schema_version, schema, db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/entity?prefix=a") + assert 200 == response.status_code + data = response.json() + logging.info(f"Schema version: {schema_version}") + jsonschema.validate( + instance=data, + schema=schema, + cls=jsonschema.Draft7Validator, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("schema_version, schema", get_schema("suggest-types-response.json").items()) +async def test_suggest_type_schema(schema_version, schema, db_path): + app = Datasette([db_path], metadata=plugin_metadata({"name_field": "name"})).app() + async with httpx.AsyncClient(app=app) as client: + response = await client.get("http://localhost/test/dogs/-/reconcile/suggest/type?prefix=a") + assert 200 == response.status_code + data = response.json() + logging.info(f"Schema version: {schema_version}") + jsonschema.validate( + instance=data, + schema=schema, + cls=jsonschema.Draft7Validator, + ) diff --git a/tests/test_reconcile_utils.py b/tests/test_reconcile_utils.py new file mode 100644 index 0000000..2f699ff --- /dev/null +++ b/tests/test_reconcile_utils.py @@ -0,0 +1,11 @@ +from datasette_reconcile.utils import get_select_fields + + +def test_get_select_fields(): + config = { + "id_field": "id", + "name_field": "name", + "type_field": "type", + "type_default": [{"id": "default", "name": "Default"}], + } + assert get_select_fields(config) == ["id", "name", "type"] From fa6cd856f82d72a72c5f8c6b47bbc7447ae4a119 Mon Sep 17 00:00:00 2001 From: David Kane Date: Wed, 20 Dec 2023 12:05:41 +0000 Subject: [PATCH 5/6] add new features to readme & acknowledgements --- README.md | 200 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 165 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7f7a3d6..3f82c4c 100644 --- a/README.md +++ b/README.md @@ -28,28 +28,30 @@ Add a `datasette-reconcile` object under `plugins` in `metadata.json`. This shou ```json { - "databases": { - "sf-trees": { - "tables": { - "Street_Tree_List": { - "plugins": { - "datasette-reconcile": { - "id_field": "id", - "name_field": "name", - "type_field": "type", - "type_default": [{ - "id": "tree", - "name": "Tree", - }], - "max_limit": 5, - "service_name": "Tree reconciliation", - "view_url": "https://example.com/trees/{{id}}" - } - } + "databases": { + "sf-trees": { + "tables": { + "Street_Tree_List": { + "plugins": { + "datasette-reconcile": { + "id_field": "id", + "name_field": "name", + "type_field": "type", + "type_default": [ + { + "id": "tree", + "name": "Tree" } + ], + "max_limit": 5, + "service_name": "Tree reconciliation", + "view_url": "https://example.com/trees/{{id}}" } + } } + } } + } } ``` @@ -110,20 +112,24 @@ The result of the GET or POST `queries` requests described above is a json objec "name": "Urbaniak, Regina", "score": 53.015232, "match": false, - "type": [{ - "id": "person", - "name": "Person", - }] + "type": [ + { + "id": "person", + "name": "Person" + } + ] }, { "id": "1127147390", "name": "Urbaniak, Jan", "score": 52.357353, "match": false, - "type": [{ - "id": "person", - "name": "Person", - }] + "type": [ + { + "id": "person", + "name": "Person" + } + ] } ] }, @@ -134,20 +140,24 @@ The result of the GET or POST `queries` requests described above is a json objec "name": "Schwanhold, Ernst", "score": 86.43497, "match": true, - "type": [{ - "id": "person", - "name": "Person", - }] + "type": [ + { + "id": "person", + "name": "Person" + } + ] }, { "id": "116362988X", "name": "Schwanhold, Nadine", "score": 62.04763, "match": false, - "type": [{ - "id": "person", - "name": "Person", - }] + "type": [ + { + "id": "person", + "name": "Person" + } + ] } ] } @@ -165,7 +175,7 @@ select , from inner join ( select "rowid", "rank" - from + from where MATCH '"test"' ) as "a" on
."rowid" = a."rowid" order by a.rank @@ -181,6 +191,117 @@ where like '%test%' limit 5 ``` +### Extend endpoint + +You can also use the reconciliation API [Data extension service](https://www.w3.org/community/reports/reconciliation/CG-FINAL-specs-0.2-20230410/#data-extension-service) to find additional properties for a set of entities, given an ID. + +Send a GET request to the `//
/-/reconcile/extend/propose` endpoint to find a list of the possible properties you can select. The properties are all the columns in the table (excluding any that have been hidden). An example response would look like: + +```json +{ + "limit": 5, + "type": "Person", + "properties": [ + { + "id": "preferredName", + "name": "preferredName" + }, + { + "id": "professionOrOccupation", + "name": "professionOrOccupation" + }, + { + "id": "wikidataId", + "name": "wikidataId" + } + ] +} +``` + +Then send a POST request to the `//
/-/reconcile` endpoint with an `extend` argument. The `extend` argument should be a JSON object with a set of `ids` to lookup and `properties` to return. For example: + +```json +{ + "ids": ["10662041X", "1064905412"], + "properties": [ + { + "id": "professionOrOccupation" + }, + { + "id": "wikidataId" + } + ] +} +``` + +The endpoint will return a result that looks like: + +```json +{ + "meta": [ + { + "id": "professionOrOccupation", + "name": "professionOrOccupation" + }, + { + "id": "wikidataId", + "name": "wikidataId" + } + ], + "rows": { + "10662041X": { + "professionOrOccupation": [ + { + "str": "Doctor" + } + ], + "wikidataId": [ + { + "str": "Q3874347" + } + ] + }, + "1064905412": { + "professionOrOccupation": [ + { + "str": "Architect" + } + ], + "wikidataId": [ + { + "str": "Q3874347" + } + ] + } + } +} +``` + +### Suggest endpoints + +You can also use the [suggest endpoints](https://www.w3.org/community/reports/reconciliation/CG-FINAL-specs-0.2-20230410/#suggest-services) to get quick suggestions, for example for an auto-complete dropdown menu. The following endpoints are available: + +- `//
/-/reconcile/suggest/property` - looks up in a list of table columns +- `//
/-/reconcile/suggest/entity` - looks up in a list of table rows +- `//
/-/reconcile/suggest/type` - not currently implemented + +Each endpoint takes a `prefix` argument which can be used in a GET request. For example, the GET request `//
/-/reconcile/suggest/entity?prefix=abc` will produce a response such as: + +```json +{ + "result": [ + { + "name": "abc company limited", + "id": "Q123456" + }, + { + "name": "abc other company limited", + "id": "Q123457" + } + ] +} +``` + ## Development This plugin uses hatch for build and testing. To set up this plugin locally, first checkout the code. @@ -220,3 +341,12 @@ To run any autoformatting possible: hatch publish git tag v git push origin v + +## Acknowledgements + +Thanks for [@simonw](https://github.com/simonw/) for developing datasette and the datasette ecosystem. + +Other contributions from: + +- [@JBPressac](https://github.com/JBPressac/) +- [@nicokant](https://github.com/nicokant/) - implementation of extend service From 381edd196b9cf80ac100c78db51a02a160e72572 Mon Sep 17 00:00:00 2001 From: David Kane Date: Wed, 20 Dec 2023 12:08:46 +0000 Subject: [PATCH 6/6] add datasette framework to classifiers --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c5e75db..08ce184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", + "Framework :: Datasette", ] dependencies = ["datasette", "fuzzywuzzy[speedup]"]