From bef7699a68df45def9b3774b2499afe8d759752b Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:42:15 -0700 Subject: [PATCH 1/4] feat: accept a bare session template ID The Sessions API accepts only fully qualified session template resource names, so callers had to write the project and region a second time inside the template name they passed to sessionTemplate(): ManagedSparkSession.builder .projectId(PROJECT) .location(REGION) .sessionTemplate( f"projects/{PROJECT}/locations/{REGION}/sessionTemplates/t") A bare template ID is now expanded against the session's own project and region, so the above becomes .sessionTemplate("t"). Expansion happens in _get_session_config() rather than in sessionTemplate() so that the builder stays order-independent, and so that templates set through sessionConfig() are resolved as well. Values containing a path separator - the projects/... resource name and the https://... URL forms - are passed through unchanged, which keeps existing callers working and leaves cross-project templates expressible. subnetwork() is deliberately not given the same treatment: Dataproc already resolves a bare subnet name server-side, and expanding it client-side would break Shared VPC users whose subnet lives in a host project rather than the session project. --- README.md | 26 +++- google/cloud/managed_spark_connect/session.py | 39 ++++- tests/unit/test_session.py | 137 ++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c111792..d14e05e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,30 @@ in your code using the builder API: spark = ManagedSparkSession.builder.projectId('my-project').location('us-central1').sessionConfig(session_config).getOrCreate() ``` +5. To start from a [Session Template](https://cloud.google.com/dataproc-serverless/docs/concepts/session-templates), + pass its ID. The template is resolved against the project and region you + configured, so you don't have to repeat them: + + ```python + from google.cloud.managed_spark_connect import ManagedSparkSession + spark = ( + ManagedSparkSession.builder + .projectId('my-project') + .location('us-central1') + .sessionTemplate('my-template') + .getOrCreate() + ) + ``` + + A full resource name is still accepted, and is required when the template + lives in a different project or region than the session: + + ```python + spark = ManagedSparkSession.builder.sessionTemplate( + 'projects/other-project/locations/us-east1/sessionTemplates/my-template' + ).projectId('my-project').location('us-central1').getOrCreate() + ``` + ### Builder Configuration The `ManagedSparkSession.builder` provides a fluent API to configure the session. Below is a list of available methods: @@ -85,7 +109,7 @@ The `ManagedSparkSession.builder` provides a fluent API to configure the session | `projectId(project_id)` | Sets the Google Cloud project ID. | | `runtimeVersion(version)` | Sets the Managed Spark runtime version (e.g., "3.0"). | | `serviceAccount(account)` | Sets the service account for the session. | -| `sessionTemplate(profile)` | Sets the Session Template to use. | +| `sessionTemplate(profile)` | Sets the Session Template to use. Accepts a bare template ID or a full resource name. | | `subnetwork(subnet)` | Sets the subnetwork URI for the session. | | `ttl(duration)` | Sets the time-to-live (TTL) for the session using a `datetime.timedelta` object. | diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 76d2700..bd3d0e6 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -108,6 +108,25 @@ def _is_valid_session_id(session_id: str) -> bool: return bool(re.match(pattern, session_id)) +def _qualify_session_template( + template: str, project_id: str, region: str +) -> str: + """ + Resolve a session template to a fully qualified resource name. + + The Sessions API accepts only resource names that include the project and + location, so a bare template ID is expanded against the session's own + project and region. Values that already contain a path separator (the + ``projects/...`` resource name and the ``https://...`` URL forms) are + returned unchanged. + """ + if not template or "/" in template: + return template + return ( + f"projects/{project_id}/locations/{region}/sessionTemplates/{template}" + ) + + class ManagedSparkSession(SparkSession): """The entry point to programming Spark with the Dataset and DataFrame API. @@ -258,7 +277,18 @@ def idleTtlSeconds(self, seconds: int): return self def sessionTemplate(self, profile: str): - """Set the Session Template to use for the session.""" + """Set the Session Template to use for the session. + + Accepts either a bare template ID, which is resolved against the + session's project and region, or a fully qualified resource name. + + Args: + profile: The template ID (``my-template``) or resource name + (``projects/p/locations/r/sessionTemplates/my-template``) + + Returns: + This Builder instance for method chaining + """ self.session_config.session_template = profile return self @@ -650,6 +680,13 @@ def _get_session_config(self): for k, v in self._options.items(): session_config.runtime_config.properties[k] = v session_config.spark_connect_session = sessions.SparkConnectConfig() + # Resolved here rather than in sessionTemplate() so that the + # template may be set before the project and region are. + session_config.session_template = _qualify_session_template( + session_config.session_template, + self._project_id, + self._region, + ) if not session_config.runtime_config.version: session_config.runtime_config.version = ( ManagedSparkSession._DEFAULT_RUNTIME_VERSION diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index a88bd7a..69957ac 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -2064,6 +2064,59 @@ def test_builder_pattern_ttl_with_timedelta( ) self.stopSession(mock_session_controller_client_instance, session) + @mock.patch("google.auth.default") + @mock.patch("google.cloud.dataproc_v1.SessionControllerClient") + @mock.patch("pyspark.sql.connect.client.SparkConnectClient.config") + @mock.patch( + "google.cloud.managed_spark_connect.ManagedSparkSession.Builder.generate_session_id" + ) + @mock.patch( + "google.cloud.managed_spark_connect.session.is_s8s_session_active" + ) + def test_create_session_with_bare_session_template_id( + self, + mock_is_s8s_session_active, + mock_session_id, + mock_client_config, + mock_session_controller_client, + mock_credentials, + ): + """A bare template ID is expanded before the session is created.""" + session = None + mock_session_controller_client_instance = ( + self._setup_session_creation_mocks( + mock_is_s8s_session_active, + mock_session_id, + mock_client_config, + mock_session_controller_client, + mock_credentials, + ) + ) + + try: + session = ( + ManagedSparkSession.builder.projectId("test-project") + .location("us-central1") + .sessionTemplate("test-template") + .getOrCreate() + ) + + create_session_request = mock_session_controller_client_instance.create_session.call_args[ + 0 + ][ + 0 + ] + self.assertEqual( + create_session_request.session.session_template, + "projects/test-project/locations/us-central1/sessionTemplates/test-template", + ) + + finally: + mock_session_controller_client_instance.terminate_session.return_value = ( + mock.Mock() + ) + self.stopSession(mock_session_controller_client_instance, session) + @mock.patch("google.auth.default") @mock.patch("google.cloud.dataproc_v1.SessionControllerClient") @mock.patch("pyspark.sql.connect.client.SparkConnectClient.config") @@ -2641,5 +2694,89 @@ def test_session_skip_terminated(self, mock_session_controller_client): mock_client.get_session.assert_called_once() +class SessionTemplateExpansionTests(unittest.TestCase): + """Test cases for resolving bare session template IDs to resource names.""" + + _EXPANDED = ( + "projects/test-project/locations/test-region/sessionTemplates/tmpl" + ) + _QUALIFIED = ( + "projects/other-project/locations/other-region/sessionTemplates/tmpl" + ) + _URL = ( + "https://www.googleapis.com/compute/v1/projects/other-project" + "/locations/other-region/sessionTemplates/tmpl" + ) + + def setUp(self): + self.original_environment = dict(os.environ) + os.environ.clear() + + def tearDown(self): + os.environ.clear() + os.environ.update(self.original_environment) + + @staticmethod + def _builder(): + builder = ManagedSparkSession.Builder() + builder._project_id = "test-project" + builder._region = "test-region" + return builder + + def test_bare_template_id_is_expanded(self): + """A bare template ID resolves against the session project and region.""" + builder = self._builder().sessionTemplate("tmpl") + self.assertEqual( + builder._get_session_config().session_template, self._EXPANDED + ) + + def test_resource_name_is_left_unchanged(self): + """A fully qualified resource name is passed through untouched.""" + builder = self._builder().sessionTemplate(self._QUALIFIED) + self.assertEqual( + builder._get_session_config().session_template, self._QUALIFIED + ) + + def test_url_is_left_unchanged(self): + """The googleapis.com URL form is passed through untouched.""" + builder = self._builder().sessionTemplate(self._URL) + self.assertEqual( + builder._get_session_config().session_template, self._URL + ) + + def test_unset_template_is_left_unset(self): + """A session without a template does not get an empty template name.""" + builder = self._builder() + self.assertEqual(builder._get_session_config().session_template, "") + + def test_expansion_is_independent_of_builder_call_order(self): + """The template may be set before or after the project and region.""" + template_first = ManagedSparkSession.Builder() + template_first.sessionTemplate("tmpl") + template_first.projectId("test-project").location("test-region") + + template_last = ManagedSparkSession.Builder() + template_last.projectId("test-project").location("test-region") + template_last.sessionTemplate("tmpl") + + self.assertEqual( + template_first._get_session_config().session_template, + self._EXPANDED, + ) + self.assertEqual( + template_last._get_session_config().session_template, + self._EXPANDED, + ) + + def test_bare_template_id_in_session_config_is_expanded(self): + """A template set through sessionConfig() is expanded too.""" + session_config = Session() + session_config.session_template = "tmpl" + builder = self._builder().sessionConfig(session_config) + self.assertEqual( + builder._get_session_config().session_template, self._EXPANDED + ) + + if __name__ == "__main__": unittest.main() From a142aaee7dd7ca422e2644e5bbe6c89f1f4df0c2 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:53:18 -0700 Subject: [PATCH 2/4] fix: reject a bare session template with no project or region An unset project or region was already caught by getOrCreate() before expansion could run, but a set-but-empty one was not: os.getenv returns "" for a variable that is exported without a value, and "" is not None, so it slipped past the guard and produced projects//locations//sessionTemplates/my-template which the server rejects with an opaque InvalidArgument about a malformed resource name. Tighten both layers. getOrCreate() now tests project and region for truthiness rather than None, so an empty value fails with the same clear message an absent one already did. _qualify_session_template() refuses to interpolate a missing project or region, naming the fields that are missing and offering the full resource name as the alternative. The guard sits after the pass-through check, so a fully qualified template name - which carries its own project and location - still needs neither, and a session with no template at all is unaffected. Note that the empty-string hole predates bare template IDs: it corrupts session_config.name the same way. Fixing it in getOrCreate() covers both. --- google/cloud/managed_spark_connect/session.py | 33 +++++++-- tests/unit/test_session.py | 71 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index bd3d0e6..38988b1 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -109,7 +109,7 @@ def _is_valid_session_id(session_id: str) -> bool: def _qualify_session_template( - template: str, project_id: str, region: str + template: str, project_id: Optional[str], region: Optional[str] ) -> str: """ Resolve a session template to a fully qualified resource name. @@ -117,11 +117,32 @@ def _qualify_session_template( The Sessions API accepts only resource names that include the project and location, so a bare template ID is expanded against the session's own project and region. Values that already contain a path separator (the - ``projects/...`` resource name and the ``https://...`` URL forms) are - returned unchanged. + ``projects/...`` resource name and the ``https://...`` URL forms) carry + their own project and location, and are returned unchanged. + + Raises: + ManagedSparkConnectException: If a bare template ID was given but the + project or region needed to resolve it is missing. """ if not template or "/" in template: return template + + missing = [ + field + for field, value in (("project ID", project_id), ("location", region)) + if not value + ] + if missing: + raise ManagedSparkConnectException( + f"Error while creating Managed Spark Session: cannot resolve the" + f" '{template}' session template because the" + f" {' and '.join(missing)}" + f" {'are' if len(missing) > 1 else 'is'} not set." + f" Either set the project and location, or pass the template's" + f" full resource name" + f" (projects//locations//sessionTemplates/{template})." + ) + return ( f"projects/{project_id}/locations/{region}/sessionTemplates/{template}" ) @@ -633,12 +654,14 @@ def getOrCreate(self) -> "ManagedSparkSession": session = PySparkSQLSession.builder.getOrCreate() return session # type: ignore - if self._project_id is None: + # Falsy rather than None: an environment variable that is set + # but empty reads back as "", which is just as unusable. + if not self._project_id: raise ManagedSparkConnectException( f"Error while creating Managed Spark Session: project ID is not set" ) - if self._region is None: + if not self._region: raise ManagedSparkConnectException( f"Error while creating Managed Spark Session: location is not set" ) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 69957ac..8b21c95 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1486,6 +1486,24 @@ def test_create_session_without_location(self): except ManagedSparkConnectException as e: self.assertIn("location is not set", str(e)) + def test_create_session_with_empty_project_id(self): + """Tests that a set-but-empty project ID is treated as not provided.""" + os.environ.clear() + os.environ["GOOGLE_CLOUD_PROJECT"] = "" + os.environ["GOOGLE_CLOUD_REGION"] = "test-region" + with self.assertRaises(ManagedSparkConnectException) as context: + ManagedSparkSession.builder.getOrCreate() + self.assertIn("project ID is not set", str(context.exception)) + + def test_create_session_with_empty_location(self): + """Tests that a set-but-empty location is treated as not provided.""" + os.environ.clear() + os.environ["GOOGLE_CLOUD_PROJECT"] = "test-project" + os.environ["GOOGLE_CLOUD_REGION"] = "" + with self.assertRaises(ManagedSparkConnectException) as context: + ManagedSparkSession.builder.getOrCreate() + self.assertIn("location is not set", str(context.exception)) + def test_create_session_without_application_default_credentials(self): """Tests that an exception is raised when application default credentials is not provided.""" os.environ.clear() @@ -2777,6 +2795,59 @@ def test_bare_template_id_in_session_config_is_expanded(self): builder._get_session_config().session_template, self._EXPANDED ) + def test_bare_template_id_without_project_is_rejected(self): + """A bare template ID cannot be resolved without a project.""" + builder = self._builder().sessionTemplate("tmpl") + builder._project_id = None + with self.assertRaises(ManagedSparkConnectException) as context: + builder._get_session_config() + message = str(context.exception) + self.assertIn("'tmpl' session template", message) + self.assertIn("project ID is not set", message) + + def test_bare_template_id_without_region_is_rejected(self): + """A bare template ID cannot be resolved without a location.""" + builder = self._builder().sessionTemplate("tmpl") + builder._region = None + with self.assertRaises(ManagedSparkConnectException) as context: + builder._get_session_config() + self.assertIn("location is not set", str(context.exception)) + + def test_bare_template_id_without_either_names_both(self): + """The error names every field that is missing.""" + builder = self._builder().sessionTemplate("tmpl") + builder._project_id = None + builder._region = None + with self.assertRaises(ManagedSparkConnectException) as context: + builder._get_session_config() + self.assertIn( + "project ID and location are not set", str(context.exception) + ) + + def test_empty_project_is_rejected(self): + """A set-but-empty project is treated as missing, not interpolated.""" + builder = self._builder().sessionTemplate("tmpl") + builder._project_id = "" + with self.assertRaises(ManagedSparkConnectException) as context: + builder._get_session_config() + self.assertIn("project ID is not set", str(context.exception)) + + def test_resource_name_needs_no_project_or_region(self): + """A full resource name carries its own project and location.""" + builder = self._builder().sessionTemplate(self._QUALIFIED) + builder._project_id = None + builder._region = None + self.assertEqual( + builder._get_session_config().session_template, self._QUALIFIED + ) + + def test_unset_template_needs_no_project_or_region(self): + """A session without a template is unaffected by the guard.""" + builder = self._builder() + builder._project_id = None + builder._region = None + self.assertEqual(builder._get_session_config().session_template, "") + if __name__ == "__main__": unittest.main() From b300e4e4a54082f600cce0963a53a879bc05baab Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:20:49 -0700 Subject: [PATCH 3/4] refactor: let the service resolve bare session templates The backend now accepts a bare session template ID on CreateSession and resolves it against the session's own project and location, so the client-side expansion added earlier in this branch is redundant. Drop it. Resolving in the client was also the wrong seam. It froze the meaning of a bare name at whatever the client believed at build time, which would prevent the service from ever evolving the rule, and it fixed the behaviour only for callers who happen to use this library rather than for every caller of the CreateSession API. What remains is the part that was never about templates: getOrCreate() tests the project and region for truthiness rather than None, so a variable that is exported without a value ("" rather than None) is reported as unset instead of being interpolated into a malformed resource name. That bug corrupts session_config.name independently of session templates. The builder docstring and README still document that a bare ID works, since that is now true of the API itself, and the end-to-end test now asserts the client forwards a bare ID unchanged rather than rewriting it. --- README.md | 4 +- google/cloud/managed_spark_connect/session.py | 53 +------ tests/unit/test_session.py | 141 +----------------- 3 files changed, 8 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index d14e05e..4de2cbd 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ in your code using the builder API: ``` 5. To start from a [Session Template](https://cloud.google.com/dataproc-serverless/docs/concepts/session-templates), - pass its ID. The template is resolved against the project and region you - configured, so you don't have to repeat them: + pass its ID. The service resolves it against the session's own project and + region, so you don't have to repeat them: ```python from google.cloud.managed_spark_connect import ManagedSparkSession diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 38988b1..20facf5 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -108,46 +108,6 @@ def _is_valid_session_id(session_id: str) -> bool: return bool(re.match(pattern, session_id)) -def _qualify_session_template( - template: str, project_id: Optional[str], region: Optional[str] -) -> str: - """ - Resolve a session template to a fully qualified resource name. - - The Sessions API accepts only resource names that include the project and - location, so a bare template ID is expanded against the session's own - project and region. Values that already contain a path separator (the - ``projects/...`` resource name and the ``https://...`` URL forms) carry - their own project and location, and are returned unchanged. - - Raises: - ManagedSparkConnectException: If a bare template ID was given but the - project or region needed to resolve it is missing. - """ - if not template or "/" in template: - return template - - missing = [ - field - for field, value in (("project ID", project_id), ("location", region)) - if not value - ] - if missing: - raise ManagedSparkConnectException( - f"Error while creating Managed Spark Session: cannot resolve the" - f" '{template}' session template because the" - f" {' and '.join(missing)}" - f" {'are' if len(missing) > 1 else 'is'} not set." - f" Either set the project and location, or pass the template's" - f" full resource name" - f" (projects//locations//sessionTemplates/{template})." - ) - - return ( - f"projects/{project_id}/locations/{region}/sessionTemplates/{template}" - ) - - class ManagedSparkSession(SparkSession): """The entry point to programming Spark with the Dataset and DataFrame API. @@ -300,8 +260,10 @@ def idleTtlSeconds(self, seconds: int): def sessionTemplate(self, profile: str): """Set the Session Template to use for the session. - Accepts either a bare template ID, which is resolved against the - session's project and region, or a fully qualified resource name. + Accepts either a bare template ID, which the service resolves + against the session's own project and location, or a fully + qualified resource name. Pass the resource name when the template + lives in a different project or location than the session. Args: profile: The template ID (``my-template``) or resource name @@ -703,13 +665,6 @@ def _get_session_config(self): for k, v in self._options.items(): session_config.runtime_config.properties[k] = v session_config.spark_connect_session = sessions.SparkConnectConfig() - # Resolved here rather than in sessionTemplate() so that the - # template may be set before the project and region are. - session_config.session_template = _qualify_session_template( - session_config.session_template, - self._project_id, - self._region, - ) if not session_config.runtime_config.version: session_config.runtime_config.version = ( ManagedSparkSession._DEFAULT_RUNTIME_VERSION diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 8b21c95..aeee855 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -2099,7 +2099,7 @@ def test_create_session_with_bare_session_template_id( mock_session_controller_client, mock_credentials, ): - """A bare template ID is expanded before the session is created.""" + """A bare template ID reaches the service unchanged for it to resolve.""" session = None mock_session_controller_client_instance = ( self._setup_session_creation_mocks( @@ -2126,7 +2126,7 @@ def test_create_session_with_bare_session_template_id( ] self.assertEqual( create_session_request.session.session_template, - "projects/test-project/locations/us-central1/sessionTemplates/test-template", + "test-template", ) finally: @@ -2712,142 +2712,5 @@ def test_session_skip_terminated(self, mock_session_controller_client): mock_client.get_session.assert_called_once() -class SessionTemplateExpansionTests(unittest.TestCase): - """Test cases for resolving bare session template IDs to resource names.""" - - _EXPANDED = ( - "projects/test-project/locations/test-region/sessionTemplates/tmpl" - ) - _QUALIFIED = ( - "projects/other-project/locations/other-region/sessionTemplates/tmpl" - ) - _URL = ( - "https://www.googleapis.com/compute/v1/projects/other-project" - "/locations/other-region/sessionTemplates/tmpl" - ) - - def setUp(self): - self.original_environment = dict(os.environ) - os.environ.clear() - - def tearDown(self): - os.environ.clear() - os.environ.update(self.original_environment) - - @staticmethod - def _builder(): - builder = ManagedSparkSession.Builder() - builder._project_id = "test-project" - builder._region = "test-region" - return builder - - def test_bare_template_id_is_expanded(self): - """A bare template ID resolves against the session project and region.""" - builder = self._builder().sessionTemplate("tmpl") - self.assertEqual( - builder._get_session_config().session_template, self._EXPANDED - ) - - def test_resource_name_is_left_unchanged(self): - """A fully qualified resource name is passed through untouched.""" - builder = self._builder().sessionTemplate(self._QUALIFIED) - self.assertEqual( - builder._get_session_config().session_template, self._QUALIFIED - ) - - def test_url_is_left_unchanged(self): - """The googleapis.com URL form is passed through untouched.""" - builder = self._builder().sessionTemplate(self._URL) - self.assertEqual( - builder._get_session_config().session_template, self._URL - ) - - def test_unset_template_is_left_unset(self): - """A session without a template does not get an empty template name.""" - builder = self._builder() - self.assertEqual(builder._get_session_config().session_template, "") - - def test_expansion_is_independent_of_builder_call_order(self): - """The template may be set before or after the project and region.""" - template_first = ManagedSparkSession.Builder() - template_first.sessionTemplate("tmpl") - template_first.projectId("test-project").location("test-region") - - template_last = ManagedSparkSession.Builder() - template_last.projectId("test-project").location("test-region") - template_last.sessionTemplate("tmpl") - - self.assertEqual( - template_first._get_session_config().session_template, - self._EXPANDED, - ) - self.assertEqual( - template_last._get_session_config().session_template, - self._EXPANDED, - ) - - def test_bare_template_id_in_session_config_is_expanded(self): - """A template set through sessionConfig() is expanded too.""" - session_config = Session() - session_config.session_template = "tmpl" - builder = self._builder().sessionConfig(session_config) - self.assertEqual( - builder._get_session_config().session_template, self._EXPANDED - ) - - def test_bare_template_id_without_project_is_rejected(self): - """A bare template ID cannot be resolved without a project.""" - builder = self._builder().sessionTemplate("tmpl") - builder._project_id = None - with self.assertRaises(ManagedSparkConnectException) as context: - builder._get_session_config() - message = str(context.exception) - self.assertIn("'tmpl' session template", message) - self.assertIn("project ID is not set", message) - - def test_bare_template_id_without_region_is_rejected(self): - """A bare template ID cannot be resolved without a location.""" - builder = self._builder().sessionTemplate("tmpl") - builder._region = None - with self.assertRaises(ManagedSparkConnectException) as context: - builder._get_session_config() - self.assertIn("location is not set", str(context.exception)) - - def test_bare_template_id_without_either_names_both(self): - """The error names every field that is missing.""" - builder = self._builder().sessionTemplate("tmpl") - builder._project_id = None - builder._region = None - with self.assertRaises(ManagedSparkConnectException) as context: - builder._get_session_config() - self.assertIn( - "project ID and location are not set", str(context.exception) - ) - - def test_empty_project_is_rejected(self): - """A set-but-empty project is treated as missing, not interpolated.""" - builder = self._builder().sessionTemplate("tmpl") - builder._project_id = "" - with self.assertRaises(ManagedSparkConnectException) as context: - builder._get_session_config() - self.assertIn("project ID is not set", str(context.exception)) - - def test_resource_name_needs_no_project_or_region(self): - """A full resource name carries its own project and location.""" - builder = self._builder().sessionTemplate(self._QUALIFIED) - builder._project_id = None - builder._region = None - self.assertEqual( - builder._get_session_config().session_template, self._QUALIFIED - ) - - def test_unset_template_needs_no_project_or_region(self): - """A session without a template is unaffected by the guard.""" - builder = self._builder() - builder._project_id = None - builder._region = None - self.assertEqual(builder._get_session_config().session_template, "") - - if __name__ == "__main__": unittest.main() From 54dc3537a6de923b0da1db794ae8715ed474d4fc Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:13:19 -0700 Subject: [PATCH 4/4] update comments --- README.md | 14 ++------------ google/cloud/managed_spark_connect/session.py | 3 +-- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4de2cbd..1db7429 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,8 @@ in your code using the builder API: spark = ManagedSparkSession.builder.projectId('my-project').location('us-central1').sessionConfig(session_config).getOrCreate() ``` -5. To start from a [Session Template](https://cloud.google.com/dataproc-serverless/docs/concepts/session-templates), - pass its ID. The service resolves it against the session's own project and - region, so you don't have to repeat them: +5. To start from a Session Template, pass its ID. The service resolves it against the session's own project and + region: ```python from google.cloud.managed_spark_connect import ManagedSparkSession @@ -84,15 +83,6 @@ in your code using the builder API: ) ``` - A full resource name is still accepted, and is required when the template - lives in a different project or region than the session: - - ```python - spark = ManagedSparkSession.builder.sessionTemplate( - 'projects/other-project/locations/us-east1/sessionTemplates/my-template' - ).projectId('my-project').location('us-central1').getOrCreate() - ``` - ### Builder Configuration The `ManagedSparkSession.builder` provides a fluent API to configure the session. Below is a list of available methods: diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 20facf5..379ac5d 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -262,8 +262,7 @@ def sessionTemplate(self, profile: str): Accepts either a bare template ID, which the service resolves against the session's own project and location, or a fully - qualified resource name. Pass the resource name when the template - lives in a different project or location than the session. + qualified resource name. Args: profile: The template ID (``my-template``) or resource name