@@ -612,3 +612,372 @@ def test_client_config_to_from_dict():
612612 assert empty_config_dict == {}
613613 new_empty_config = ClientConfig .from_dict (empty_config_dict )
614614 assert empty_config == new_empty_config
615+
616+
617+ def test_grpc_metadata_normalization_from_toml ():
618+ """Test that gRPC metadata keys get normalized from TOML."""
619+ toml_config = textwrap .dedent (
620+ """
621+ [profile.default]
622+ address = "localhost:7233"
623+ namespace = "default"
624+
625+ [profile.default.grpc_meta]
626+ "Custom-Header" = "custom-value"
627+ "ANOTHER_HEADER_KEY" = "another-value"
628+ "mixed_Case-header" = "mixed-value"
629+ """
630+ )
631+
632+ profile = ClientConfigProfile .load (config_source = toml_config )
633+
634+ # Keys should be normalized: uppercase -> lowercase, underscores -> hyphens
635+ assert profile .grpc_meta ["custom-header" ] == "custom-value"
636+ assert profile .grpc_meta ["another-header-key" ] == "another-value"
637+ assert profile .grpc_meta ["mixed-case-header" ] == "mixed-value"
638+
639+ # Original case variations should not exist
640+ assert "Custom-Header" not in profile .grpc_meta
641+ assert "ANOTHER_HEADER_KEY" not in profile .grpc_meta
642+ assert "mixed_Case-header" not in profile .grpc_meta
643+
644+ config = profile .to_client_connect_config ()
645+ rpc_metadata = config .get ("rpc_metadata" )
646+ assert rpc_metadata is not None
647+ assert rpc_metadata ["custom-header" ] == "custom-value"
648+ assert rpc_metadata ["another-header-key" ] == "another-value"
649+
650+
651+ def test_grpc_metadata_deletion_via_empty_env_value (base_config_file : Path ):
652+ """Test that empty environment variable values delete existing gRPC metadata."""
653+ env = {
654+ # Empty value should remove the header
655+ "TEMPORAL_GRPC_META_CUSTOM_HEADER" : "" ,
656+ # Non-empty value should set the header
657+ "TEMPORAL_GRPC_META_NEW_HEADER" : "new-value" ,
658+ }
659+ profile = ClientConfigProfile .load (
660+ config_source = base_config_file , profile = "custom" , override_env_vars = env
661+ )
662+
663+ # custom-header should be removed by empty env value
664+ assert "custom-header" not in profile .grpc_meta
665+ # new-header should be added
666+ assert profile .grpc_meta ["new-header" ] == "new-value"
667+
668+ config = profile .to_client_connect_config ()
669+ rpc_metadata = config .get ("rpc_metadata" )
670+ if rpc_metadata :
671+ assert "custom-header" not in rpc_metadata
672+ assert rpc_metadata ["new-header" ] == "new-value"
673+
674+
675+ def test_default_profile_not_found_returns_empty_profile ():
676+ """Test that requesting missing 'default' profile returns empty profile instead of error."""
677+ toml_config = textwrap .dedent (
678+ """
679+ [profile.existing]
680+ address = "my-address"
681+ """
682+ )
683+ profile = ClientConfigProfile .load (config_source = toml_config )
684+ assert profile .address is None
685+ assert profile .namespace is None
686+ assert profile .api_key is None
687+ assert not profile .grpc_meta
688+ assert profile .tls is None
689+
690+
691+ def test_tls_conflict_across_sources_path_in_toml_data_in_env ():
692+ """Test error when cert path in TOML conflicts with cert data in env var."""
693+ toml_config = textwrap .dedent (
694+ """
695+ [profile.default]
696+ address = "localhost:7233"
697+ [profile.default.tls]
698+ client_cert_path = "/path/to/cert"
699+ """
700+ )
701+
702+ env = {
703+ "TEMPORAL_TLS_CLIENT_CERT_DATA" : "cert-data-from-env"
704+ }
705+
706+ with pytest .raises (RuntimeError , match = "Cannot specify cert data via TEMPORAL_TLS_CLIENT_CERT_DATA when cert path is already specified" ):
707+ ClientConfigProfile .load (
708+ config_source = toml_config ,
709+ override_env_vars = env
710+ )
711+
712+
713+ def test_tls_conflict_across_sources_data_in_toml_path_in_env ():
714+ """Test error when cert data in TOML conflicts with cert path in env var."""
715+ toml_config = textwrap .dedent (
716+ """
717+ [profile.default]
718+ address = "localhost:7233"
719+ [profile.default.tls]
720+ client_cert_data = "cert-data-from-toml"
721+ """
722+ )
723+
724+ env = {
725+ "TEMPORAL_TLS_CLIENT_CERT_PATH" : "/path/from/env"
726+ }
727+
728+ with pytest .raises (RuntimeError , match = "Cannot specify cert path via TEMPORAL_TLS_CLIENT_CERT_PATH when cert data is already specified" ):
729+ ClientConfigProfile .load (
730+ config_source = toml_config ,
731+ override_env_vars = env
732+ )
733+
734+
735+ def test_load_client_connect_options_convenience_api (base_config_file : Path ):
736+ """Test the convenience API for loading client connect configuration."""
737+ # Test default profile with file
738+ config = ClientConfig .load_client_connect_config (config_file = str (base_config_file ))
739+ assert config .get ("target_host" ) == "default-address"
740+ assert config .get ("namespace" ) == "default-namespace"
741+
742+ # Test with environment overrides
743+ env = {"TEMPORAL_NAMESPACE" : "env-override-namespace" }
744+ config_with_env = ClientConfig .load_client_connect_config (
745+ config_file = str (base_config_file ), override_env_vars = env
746+ )
747+ assert config_with_env .get ("target_host" ) == "default-address"
748+ assert config_with_env .get ("namespace" ) == "env-override-namespace"
749+
750+ # Test with specific profile
751+ config_custom = ClientConfig .load_client_connect_config (
752+ profile = "custom" , config_file = str (base_config_file )
753+ )
754+ assert config_custom .get ("target_host" ) == "custom-address"
755+ assert config_custom .get ("namespace" ) == "custom-namespace"
756+ assert config_custom .get ("api_key" ) == "custom-api-key"
757+
758+
759+ def test_load_client_connect_options_e2e_validation ():
760+ """Test comprehensive end-to-end configuration loading with all features."""
761+ toml_content = textwrap .dedent (
762+ """
763+ [profile.production]
764+ address = "prod.temporal.com:443"
765+ namespace = "production-ns"
766+ api_key = "prod-api-key"
767+
768+ [profile.production.tls]
769+ server_name = "prod.temporal.com"
770+ server_ca_cert_data = "prod-ca-cert"
771+
772+ [profile.production.grpc_meta]
773+ authorization = "Bearer prod-token"
774+ "x-custom-header" = "prod-value"
775+ """
776+ )
777+
778+ env_overrides = {
779+ "TEMPORAL_GRPC_META_X_ENVIRONMENT" : "production" ,
780+ "TEMPORAL_TLS_SERVER_NAME" : "override.temporal.com"
781+ }
782+
783+ config = ClientConfig .load_client_connect_config (
784+ profile = "production" ,
785+ config_file = None , # Use config_source directly
786+ override_env_vars = env_overrides ,
787+ disable_file = True # Load from config_source instead
788+ )
789+
790+ # First load the profile to get the raw config, then convert
791+ profile = ClientConfigProfile .load (
792+ profile = "production" ,
793+ config_source = toml_content ,
794+ override_env_vars = env_overrides
795+ )
796+ config = profile .to_client_connect_config ()
797+
798+ # Validate all configuration aspects
799+ assert config .get ("target_host" ) == "prod.temporal.com:443"
800+ assert config .get ("namespace" ) == "production-ns"
801+ assert config .get ("api_key" ) == "prod-api-key"
802+
803+ # TLS configuration (API key should auto-enable TLS)
804+ assert config .get ("tls" ) is not None
805+ tls_config = config .get ("tls" )
806+ assert isinstance (tls_config , TLSConfig )
807+ assert tls_config .domain == "override.temporal.com" # Env override
808+ assert tls_config .server_root_ca_cert == b"prod-ca-cert"
809+
810+ # gRPC metadata with normalization and env overrides
811+ assert config .get ("rpc_metadata" ) is not None
812+ rpc_metadata = config .get ("rpc_metadata" )
813+ assert rpc_metadata is not None
814+ assert rpc_metadata ["authorization" ] == "Bearer prod-token"
815+ assert rpc_metadata ["x-custom-header" ] == "prod-value"
816+ assert rpc_metadata ["x-environment" ] == "production" # From env
817+
818+
819+ async def test_e2e_basic_development_profile_client_connection (client : Client ):
820+ """Test basic development profile with actual client connection."""
821+ # Get connection details from the fixture client
822+ target_host = client .service_client .config .target_host
823+ namespace = client .namespace
824+
825+ toml_content = textwrap .dedent (
826+ f"""
827+ [profile.development]
828+ address = "{ target_host } "
829+ namespace = "{ namespace } "
830+
831+ [profile.development.grpc_meta]
832+ "x-test-source" = "envconfig-python-dev"
833+ """
834+ )
835+
836+ profile = ClientConfigProfile .load (
837+ profile = "development" ,
838+ config_source = toml_content
839+ )
840+
841+ config = profile .to_client_connect_config ()
842+
843+ # Create actual Temporal client using envconfig
844+ new_client = await Client .connect (** config )
845+
846+ # Verify client configuration matches envconfig
847+ assert new_client .service_client .config .target_host == target_host
848+ assert new_client .namespace == namespace
849+ if new_client .service_client .config .rpc_metadata :
850+ assert new_client .service_client .config .rpc_metadata ["x-test-source" ] == "envconfig-python-dev"
851+
852+
853+ async def test_e2e_production_tls_api_key_client_connection (client : Client ):
854+ """Test production profile with TLS and API key with actual client connection."""
855+ # Get connection details from the fixture client
856+ target_host = client .service_client .config .target_host
857+
858+ toml_content = textwrap .dedent (
859+ f"""
860+ [profile.production]
861+ address = "{ target_host } "
862+ namespace = "production-namespace"
863+ api_key = "prod-api-key-123"
864+
865+ [profile.production.tls]
866+ disabled = true
867+
868+ [profile.production.grpc_meta]
869+ authorization = "Bearer prod-token"
870+ "x-environment" = "production"
871+ """
872+ )
873+
874+ profile = ClientConfigProfile .load (
875+ profile = "production" ,
876+ config_source = toml_content
877+ )
878+
879+ config = profile .to_client_connect_config ()
880+
881+ # Create TLS-enabled client with API key
882+ new_client = await Client .connect (** config )
883+
884+ # Verify production configuration
885+ assert new_client .service_client .config .target_host == target_host
886+ assert new_client .namespace == "production-namespace"
887+ assert new_client .service_client .config .api_key == "prod-api-key-123"
888+ if new_client .service_client .config .rpc_metadata :
889+ assert new_client .service_client .config .rpc_metadata ["authorization" ] == "Bearer prod-token"
890+ assert new_client .service_client .config .rpc_metadata ["x-environment" ] == "production"
891+
892+
893+ async def test_e2e_environment_overrides_client_connection (client : Client ):
894+ """Test environment overrides with actual client connection."""
895+ # Get connection details from the fixture client
896+ target_host = client .service_client .config .target_host
897+
898+ toml_content = textwrap .dedent (
899+ """
900+ [profile.staging]
901+ address = "staging.temporal.com:443"
902+ namespace = "staging-namespace"
903+
904+ [profile.staging.grpc_meta]
905+ "x-deployment" = "staging"
906+ authorization = "Bearer staging-token"
907+ """
908+ )
909+
910+ env_overrides = {
911+ "TEMPORAL_ADDRESS" : target_host ,
912+ "TEMPORAL_NAMESPACE" : "override-namespace" ,
913+ "TEMPORAL_GRPC_META_X_DEPLOYMENT" : "canary" ,
914+ "TEMPORAL_GRPC_META_AUTHORIZATION" : "Bearer override-token"
915+ }
916+
917+ profile = ClientConfigProfile .load (
918+ profile = "staging" ,
919+ config_source = toml_content ,
920+ override_env_vars = env_overrides
921+ )
922+
923+ config = profile .to_client_connect_config ()
924+
925+ # Create client with environment overrides
926+ new_client = await Client .connect (** config )
927+
928+ # Verify environment overrides took effect
929+ assert new_client .service_client .config .target_host == target_host
930+ assert new_client .namespace == "override-namespace"
931+ if new_client .service_client .config .rpc_metadata :
932+ assert new_client .service_client .config .rpc_metadata ["x-deployment" ] == "canary"
933+ assert new_client .service_client .config .rpc_metadata ["authorization" ] == "Bearer override-token"
934+
935+
936+ async def test_e2e_multi_profile_different_client_connections (client : Client ):
937+ """Test multiple profiles creating different client connections."""
938+ # Get connection details from the fixture client
939+ target_host = client .service_client .config .target_host
940+
941+ toml_content = textwrap .dedent (
942+ f"""
943+ [profile.development]
944+ address = "{ target_host } "
945+ namespace = "dev"
946+
947+ [profile.production]
948+ address = "{ target_host } "
949+ namespace = "prod"
950+ api_key = "prod-key"
951+
952+ [profile.production.tls]
953+ disabled = true
954+ """
955+ )
956+
957+ # Load and create development client
958+ dev_profile = ClientConfigProfile .load (
959+ profile = "development" ,
960+ config_source = toml_content
961+ )
962+
963+ dev_config = dev_profile .to_client_connect_config ()
964+ dev_client = await Client .connect (** dev_config )
965+
966+ # Load and create production client
967+ prod_profile = ClientConfigProfile .load (
968+ profile = "production" ,
969+ config_source = toml_content
970+ )
971+
972+ prod_config = prod_profile .to_client_connect_config ()
973+ prod_client = await Client .connect (** prod_config )
974+
975+ # Verify different configurations for each client
976+ assert dev_client .service_client .config .target_host == target_host
977+ assert dev_client .namespace == "dev"
978+ assert dev_client .service_client .config .api_key is None
979+ assert dev_client .service_client .config .tls is False
980+
981+ assert prod_client .service_client .config .target_host == target_host
982+ assert prod_client .namespace == "prod"
983+ assert prod_client .service_client .config .api_key == "prod-key"
0 commit comments