From 9bf7de3f9acdb9d82ee0e4b8c7776e22c2c6bcdd Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Fri, 14 Mar 2025 18:00:18 +0000 Subject: [PATCH] Add a search service --- tests/conftest.py | 9 ++++++ tests/unit/search/test_init.py | 21 ++++++++++++- tests/unit/search/test_services.py | 24 +++++++++++++++ warehouse/search/__init__.py | 19 +++++++----- warehouse/search/interfaces.py | 31 +++++++++++++++++++ warehouse/search/services.py | 49 ++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 tests/unit/search/test_services.py create mode 100644 warehouse/search/interfaces.py create mode 100644 warehouse/search/services.py diff --git a/tests/conftest.py b/tests/conftest.py index b25d476147ef..514ae4b808ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,8 @@ from warehouse.organizations.interfaces import IOrganizationService from warehouse.packaging import services as packaging_services from warehouse.packaging.interfaces import IProjectService +from warehouse.search import services as search_services +from warehouse.search.interfaces import ISearchService from warehouse.subscriptions import services as subscription_services from warehouse.subscriptions.interfaces import IBillingService, ISubscriptionService @@ -163,6 +165,7 @@ def pyramid_services( macaroon_service, helpdesk_service, notification_service, + search_service, ): services = _Services() @@ -186,6 +189,7 @@ def pyramid_services( services.register_service(macaroon_service, IMacaroonService, None, name="") services.register_service(helpdesk_service, IHelpDeskService, None) services.register_service(notification_service, IAdminNotificationService) + services.register_service(search_service, ISearchService) return services @@ -524,6 +528,11 @@ def notification_service(): return helpdesk_services.ConsoleAdminNotificationService() +@pytest.fixture +def search_service(): + return search_services.NullSearchService() + + class QueryRecorder: def __init__(self): self.queries = [] diff --git a/tests/unit/search/test_init.py b/tests/unit/search/test_init.py index 71a760bef743..6e07469cb538 100644 --- a/tests/unit/search/test_init.py +++ b/tests/unit/search/test_init.py @@ -155,5 +155,24 @@ def test_includeme(monkeypatch): ] assert config.register_service_factory.calls == [ - pretend.call(RateLimit("10 per second"), IRateLimiter, name="search") + pretend.call(RateLimit("10 per second"), IRateLimiter, name="search"), + pretend.call( + search.services.SearchService.create_service, + iface=search.interfaces.ISearchService, + ), + ] + + +def test_execute_reindex_no_service(): + @pretend.call_recorder + def find_service_factory(interface): + raise LookupError + + config = pretend.stub(find_service_factory=find_service_factory) + session = pretend.stub() + + search.execute_project_reindex(config, session) + + assert find_service_factory.calls == [ + pretend.call(search.interfaces.ISearchService) ] diff --git a/tests/unit/search/test_services.py b/tests/unit/search/test_services.py new file mode 100644 index 000000000000..2564c519f162 --- /dev/null +++ b/tests/unit/search/test_services.py @@ -0,0 +1,24 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend + +from warehouse.search.services import NullSearchService + + +class TestSearchService: + def test_null_service(self): + service = NullSearchService.create_service(pretend.stub(), pretend.stub()) + config = pretend.stub() + + assert service.reindex(config, ["foo", "bar"]) is None + assert service.unindex(config, ["foo", "bar"]) is None diff --git a/warehouse/search/__init__.py b/warehouse/search/__init__.py index a56e22a20000..dc72c61eaab4 100644 --- a/warehouse/search/__init__.py +++ b/warehouse/search/__init__.py @@ -22,6 +22,8 @@ from warehouse import db from warehouse.packaging.models import Project, Release from warehouse.rate_limiting import IRateLimiter, RateLimit +from warehouse.search.interfaces import ISearchService +from warehouse.search.services import SearchService from warehouse.search.utils import get_index @@ -53,16 +55,17 @@ def store_projects_for_project_reindex(config, session, flush_context): @db.listens_for(db.Session, "after_commit") def execute_project_reindex(config, session): + try: + search_service_factory = config.find_service_factory(ISearchService) + except LookupError: + return + projects_to_update = session.info.pop("warehouse.search.project_updates", set()) projects_to_delete = session.info.pop("warehouse.search.project_deletes", set()) - from warehouse.search.tasks import reindex_project, unindex_project - - for project in projects_to_update: - config.task(reindex_project).delay(project.normalized_name) - - for project in projects_to_delete: - config.task(unindex_project).delay(project.normalized_name) + search_service = search_service_factory(None, config) + search_service.reindex(config, projects_to_update) + search_service.unindex(config, projects_to_delete) def opensearch(request): @@ -116,3 +119,5 @@ def includeme(config): from warehouse.search.tasks import reindex config.add_periodic_task(crontab(minute=0, hour=6), reindex) + + config.register_service_factory(SearchService.create_service, iface=ISearchService) diff --git a/warehouse/search/interfaces.py b/warehouse/search/interfaces.py new file mode 100644 index 000000000000..ba3cc6d47965 --- /dev/null +++ b/warehouse/search/interfaces.py @@ -0,0 +1,31 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zope.interface import Interface + + +class ISearchService(Interface): + def create_service(context, request): + """ + Create the service, given the context and request for which it is being + created for. + """ + + def reindex(config, projects_to_update): + """ + Reindexes any projects provided + """ + + def unindex(config, projects_to_delete): + """ + Unindexes any projects provided + """ diff --git a/warehouse/search/services.py b/warehouse/search/services.py new file mode 100644 index 000000000000..617c1dae267b --- /dev/null +++ b/warehouse/search/services.py @@ -0,0 +1,49 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zope.interface import implementer + +from warehouse.search import interfaces, tasks + + +@implementer(interfaces.ISearchService) +class SearchService: + def __init__(self, **kwargs): + pass + + @classmethod + def create_service(cls, context, request): + return cls() + + def reindex(self, config, projects_to_update): + for project in projects_to_update: + config.task(tasks.reindex_project).delay(project.normalized_name) + + def unindex(self, config, projects_to_delete): + for project in projects_to_delete: + config.task(tasks.unindex_project).delay(project.normalized_name) + + +@implementer(interfaces.ISearchService) +class NullSearchService: + def __init__(self, **kwargs): + pass + + @classmethod + def create_service(cls, context, request): + return cls() + + def reindex(self, config, projects_to_update): + pass + + def unindex(self, config, projects_to_delete): + pass