diff --git a/python/lsst/daf/butler/remote_butler/_remote_butler.py b/python/lsst/daf/butler/remote_butler/_remote_butler.py index a9a0273618..e6a1bb4429 100644 --- a/python/lsst/daf/butler/remote_butler/_remote_butler.py +++ b/python/lsst/daf/butler/remote_butler/_remote_butler.py @@ -33,6 +33,7 @@ import httpx from lsst.daf.butler import __version__ +from lsst.daf.butler.repo_relocation import replaceRoot from lsst.resources import ResourcePath, ResourcePathExpression from lsst.utils.introspection import get_full_type_name @@ -69,6 +70,16 @@ def __init__( **kwargs: Any, ): butler_config = ButlerConfig(config, searchPaths, without_datastore=True) + # There is a convention in Butler config files where in a + # configuration option refers to the directory containing the + # configuration file. We allow this for the remote butler's URL so + # that the server doesn't have to know which hostname it is being + # accessed from + server_url_key = ("remote_butler", "url") + if server_url_key in butler_config: + butler_config[server_url_key] = replaceRoot( + butler_config[server_url_key], butler_config.configDir + ) self._config = RemoteButlerConfigModel.model_validate(butler_config) self._dimensions: DimensionUniverse | None = None # TODO: RegistryDefaults should have finish() called on it, but this diff --git a/python/lsst/daf/butler/remote_butler/server/_server.py b/python/lsst/daf/butler/remote_butler/server/_server.py index 4552c541b4..64bfc5f969 100644 --- a/python/lsst/daf/butler/remote_butler/server/_server.py +++ b/python/lsst/daf/butler/remote_butler/server/_server.py @@ -77,6 +77,30 @@ async def get_index() -> Metadata: return get_metadata(package_name="lsst.daf.butler", application_name="butler") +@app.get( + "/butler/butler.yaml", + description=( + "Returns a Butler YAML configuration file that can be used to instantiate a Butler client" + " pointing at this server" + ), + summary="Client configuration file", + response_model=dict[str, Any], +) +@app.get( + "/butler/butler.json", + description=( + "Returns a Butler JSON configuration file that can be used to instantiate a Butler client" + " pointing at this server" + ), + summary="Client configuration file", + response_model=dict[str, Any], +) +async def get_client_config() -> dict[str, Any]: + # We can return JSON data for both the YAML and JSON case because all JSON + # files are parseable as YAML. + return {"cls": "lsst.daf.butler.remote_butler.RemoteButler", "remote_butler": {"url": ""}} + + @app.get("/butler/v1/universe", response_model=dict[str, Any]) def get_dimension_universe(factory: Factory = Depends(factory_dependency)) -> dict[str, Any]: """Allow remote client to get dimensions definition.""" diff --git a/tests/test_server.py b/tests/test_server.py index 32c84a9c3a..622bd85954 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -37,8 +37,11 @@ TestClient = None app = None +from unittest.mock import patch + from lsst.daf.butler import Butler from lsst.daf.butler.tests.utils import MetricTestRepo, makeTestTempDir, removeTestTempDir +from lsst.resources.http import HttpResourcePath TESTDIR = os.path.abspath(os.path.dirname(__file__)) @@ -96,6 +99,29 @@ def test_remote_butler(self): universe = self.butler.dimensions self.assertEqual(universe.namespace, "daf_butler") + def test_instantiate_via_butler_http_search(self): + """Ensure that the primary Butler constructor's automatic search logic + correctly locates and reads the configuration file and ends up with a + RemoteButler pointing to the correct URL + """ + + # This is kind of a fragile test. Butler's search logic does a lot of + # manipulations involving creating new ResourcePaths, and ResourcePath + # doesn't use httpx so we can't easily inject the TestClient in there. + # We don't have an actual valid HTTP URL to give to the constructor + # because the test instance of the server is accessed via ASGI. + # + # Instead we just monkeypatch the HTTPResourcePath 'read' method and + # hope that all ResourcePath HTTP reads during construction are going + # to the server under test. + def override_read(http_resource_path): + return self.client.get(http_resource_path.geturl()).content + + with patch.object(HttpResourcePath, "read", override_read): + butler = Butler("https://test.example/butler") + assert isinstance(butler, RemoteButler) + assert str(butler._config.remote_butler.url) == "https://test.example/butler/" + if __name__ == "__main__": unittest.main()