luminarycloud 0.22.0__py3-none-any.whl → 0.22.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. luminarycloud/_client/authentication_plugin.py +49 -0
  2. luminarycloud/_client/client.py +33 -8
  3. luminarycloud/_client/http_client.py +1 -1
  4. luminarycloud/_client/retry_interceptor.py +64 -2
  5. luminarycloud/_helpers/download.py +11 -0
  6. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +132 -132
  7. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +36 -8
  8. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.py +53 -23
  9. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.pyi +54 -1
  10. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2.py +195 -0
  11. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2.pyi +361 -0
  12. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2_grpc.py +172 -0
  13. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2_grpc.pyi +66 -0
  14. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2.py +88 -65
  15. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2.pyi +42 -0
  16. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2_grpc.py +34 -0
  17. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2_grpc.pyi +12 -0
  18. luminarycloud/_proto/base/base_pb2.py +7 -6
  19. luminarycloud/_proto/base/base_pb2.pyi +4 -0
  20. luminarycloud/_proto/client/simulation_pb2.py +3 -3
  21. luminarycloud/_proto/physicsaiinferenceservice/physicsaiinferenceservice_pb2.py +30 -0
  22. luminarycloud/_proto/physicsaiinferenceservice/physicsaiinferenceservice_pb2.pyi +7 -0
  23. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2.py +2 -2
  24. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.py +34 -0
  25. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.pyi +12 -0
  26. luminarycloud/enum/vis_enums.py +6 -0
  27. luminarycloud/geometry.py +4 -0
  28. luminarycloud/geometry_version.py +4 -0
  29. luminarycloud/mesh.py +4 -0
  30. luminarycloud/meshing/mesh_generation_params.py +5 -6
  31. luminarycloud/meshing/sizing_strategy/sizing_strategies.py +1 -2
  32. luminarycloud/physics_ai/solution.py +4 -0
  33. luminarycloud/pipelines/api.py +99 -8
  34. luminarycloud/pipelines/core.py +1 -1
  35. luminarycloud/pipelines/stages.py +22 -9
  36. luminarycloud/project.py +5 -6
  37. luminarycloud/types/vector3.py +1 -2
  38. luminarycloud/vis/data_extraction.py +7 -7
  39. luminarycloud/vis/interactive_report.py +163 -7
  40. luminarycloud/vis/report.py +113 -1
  41. luminarycloud/volume_selection.py +10 -2
  42. {luminarycloud-0.22.0.dist-info → luminarycloud-0.22.1.dist-info}/METADATA +1 -1
  43. {luminarycloud-0.22.0.dist-info → luminarycloud-0.22.1.dist-info}/RECORD +44 -39
  44. {luminarycloud-0.22.0.dist-info → luminarycloud-0.22.1.dist-info}/WHEEL +1 -1
  45. luminarycloud/pipeline_util/dictable.py +0 -27
@@ -38,3 +38,52 @@ class AuthenticationPlugin(grpc.AuthMetadataPlugin):
38
38
  callback(metadata, None)
39
39
  except Exception as err:
40
40
  callback(None, err)
41
+
42
+
43
+ class AuthInterceptor(
44
+ grpc.UnaryUnaryClientInterceptor,
45
+ grpc.UnaryStreamClientInterceptor,
46
+ grpc.StreamUnaryClientInterceptor,
47
+ grpc.StreamStreamClientInterceptor,
48
+ ):
49
+ """
50
+ I need this as a workaround for container-to-host connections because I need to create a channel
51
+ that uses CallCredentials but doesn't use any ChannelCredentials. I.e. I need to authenticate
52
+ the requests, but I need the connection to be unencrypted. This is because the grpc server on
53
+ the native host isn't using SSL, so I can't use grpc.ssl_channel_credentials(), but it's also
54
+ not reachable on a loopback interface, so I can't use grpc.local_channel_credentials() either.
55
+ So I need to use a grpc.insecure_channel(), but you can't use CallCredentials with an insecure
56
+ channel. So the workaround is to use an interceptor instead of CallCredentials.
57
+
58
+ Also, I don't care about auth0, so I'm only supporting an API key.
59
+ """
60
+
61
+ def __init__(self, api_key: str):
62
+ self._api_key = api_key
63
+
64
+ def _augment(self, metadata):
65
+ return metadata + [("x-api-key", self._api_key)]
66
+
67
+ def intercept_unary_unary(self, continuation, client_call_details, request):
68
+ new_details = client_call_details._replace(
69
+ metadata=self._augment(client_call_details.metadata or [])
70
+ )
71
+ return continuation(new_details, request)
72
+
73
+ def intercept_unary_stream(self, continuation, client_call_details, request):
74
+ new_details = client_call_details._replace(
75
+ metadata=self._augment(client_call_details.metadata or [])
76
+ )
77
+ return continuation(new_details, request)
78
+
79
+ def intercept_stream_unary(self, continuation, client_call_details, request_iter):
80
+ new_details = client_call_details._replace(
81
+ metadata=self._augment(client_call_details.metadata or [])
82
+ )
83
+ return continuation(new_details, request_iter)
84
+
85
+ def intercept_stream_stream(self, continuation, client_call_details, request_iter):
86
+ new_details = client_call_details._replace(
87
+ metadata=self._augment(client_call_details.metadata or [])
88
+ )
89
+ return continuation(new_details, request_iter)
@@ -47,7 +47,7 @@ from .._proto.api.v0.luminarycloud.vis.vis_pb2_grpc import VisAPIServiceStub
47
47
  from .._proto.api.v0.luminarycloud.feature_flag.feature_flag_pb2_grpc import (
48
48
  FeatureFlagServiceStub,
49
49
  )
50
- from .authentication_plugin import AuthenticationPlugin
50
+ from .authentication_plugin import AuthenticationPlugin, AuthInterceptor
51
51
  from .config import LC_DOMAIN, LC_API_KEY
52
52
  from .logging_interceptor import LoggingInterceptor
53
53
  from .retry_interceptor import RetryInterceptor
@@ -93,11 +93,20 @@ class Client(
93
93
  The URL of the HTTP REST server. If not provided, it will default to the `target`.
94
94
  localhost : bool
95
95
  True if the API server is running locally.
96
+ insecure_grpc_channel : bool
97
+ True to use an unencrypted gRPC channel, even though requests are authenticated. There's no
98
+ legitimate reason to do this outside of a local development situation where the SDK is
99
+ running from a container and connecting to an API server that is running on the host.
96
100
  grpc_channel_options : Optional[Iterable[tuple[str, str]]]
97
101
  A list of gRPC channel args. The full list is available here:
98
102
  https://github.com/grpc/grpc/blob/v1.46.x/include/grpc/impl/codegen/grpc_types.h
99
103
  api_key : Optional[str]
100
104
  The API key to use for authentication.
105
+ log_retries : bool
106
+ True to log each retriable error response. There are some errors the API server may return
107
+ that are known to be transient, and the client will always retry requests when it gets one
108
+ of them. By default, the client retries silently. Set this to True to log the error
109
+ responses (at INFO level) for the retriable errors.
101
110
  **kwargs : dict, optional
102
111
  Additional arguments are passed to Auth0Client. See _auth/auth.py.
103
112
 
@@ -116,9 +125,11 @@ class Client(
116
125
  target: str = LC_DOMAIN,
117
126
  http_target: str | None = None,
118
127
  localhost: bool = False,
128
+ insecure_grpc_channel: bool = False,
119
129
  grpc_channel_options: Optional[Iterable[tuple[str, Union[str, int]]]] = None,
120
130
  channel_credentials: Optional[grpc.ChannelCredentials] = None,
121
131
  api_key: Optional[str] = LC_API_KEY,
132
+ log_retries: bool = False,
122
133
  **kwargs: Any,
123
134
  ):
124
135
  self._target = target
@@ -139,7 +150,12 @@ class Client(
139
150
  if grpc_channel_options:
140
151
  grpc_channel_options_with_keep_alive.extend(grpc_channel_options)
141
152
  self._channel = self._create_channel(
142
- localhost, grpc_channel_options_with_keep_alive, channel_credentials, api_key
153
+ localhost,
154
+ insecure_grpc_channel,
155
+ grpc_channel_options_with_keep_alive,
156
+ channel_credentials,
157
+ api_key,
158
+ log_retries,
143
159
  )
144
160
  self._context_tokens: list[Token] = []
145
161
  self.__register_rpcs()
@@ -193,9 +209,11 @@ class Client(
193
209
  def _create_channel(
194
210
  self,
195
211
  localhost: bool = False,
212
+ insecure: bool = False,
196
213
  grpc_channel_options: Optional[Iterable[tuple[str, Union[str, int]]]] = None,
197
214
  channel_credentials: Optional[grpc.ChannelCredentials] = None,
198
215
  api_key: Optional[str] = None,
216
+ log_retries: bool = False,
199
217
  ) -> grpc.Channel:
200
218
  if channel_credentials is None:
201
219
  if localhost:
@@ -217,15 +235,22 @@ class Client(
217
235
  call_creds = grpc.metadata_call_credentials(auth_plugin)
218
236
  composite_creds = grpc.composite_channel_credentials(channel_credentials, call_creds)
219
237
  options = grpc_channel_options and list(grpc_channel_options)
220
- channel = grpc.secure_channel(
221
- self._target,
222
- composite_creds,
223
- options=options,
224
- )
238
+ if insecure:
239
+ channel = grpc.insecure_channel(self._target, options=options)
240
+ channel = grpc.intercept_channel(
241
+ channel,
242
+ AuthInterceptor(api_key),
243
+ )
244
+ else:
245
+ channel = grpc.secure_channel(
246
+ self._target,
247
+ composite_creds,
248
+ options=options,
249
+ )
225
250
  intercepted_channel = grpc.intercept_channel(
226
251
  channel,
227
252
  LoggingInterceptor(),
228
- RetryInterceptor(),
253
+ RetryInterceptor(log_retries),
229
254
  )
230
255
  return add_instrumentation(
231
256
  intercepted_channel,
@@ -32,7 +32,7 @@ class HttpClient:
32
32
  api_key: str | None = None,
33
33
  auth0_client: Auth0Client | None = None,
34
34
  *,
35
- timeout: int = 10,
35
+ timeout: int | None = 300,
36
36
  retries: int = 3,
37
37
  backoff_factor: float = 0.3,
38
38
  retriable_status_codes: tuple = (500, 502, 503, 504, 429),
@@ -2,19 +2,68 @@
2
2
  from collections.abc import Callable
3
3
  from time import sleep
4
4
  from typing import Any
5
+ import logging
5
6
 
6
7
  import grpc
7
8
  from grpc import (
8
9
  ClientCallDetails,
9
10
  UnaryUnaryClientInterceptor,
10
11
  )
12
+ from grpc_status.rpc_status import GRPC_DETAILS_METADATA_KEY
11
13
 
12
14
  from luminarycloud.exceptions import AuthenticationError
15
+ from luminarycloud._proto.base import base_pb2
16
+
17
+
18
+ def _is_rate_limited(call: grpc.Call) -> bool:
19
+ """
20
+ Check if a gRPC call failed due to rate limiting.
21
+
22
+ Rate limit errors are identified with the SUBCODE_RATE_LIMITED subcode and UNAVAILABLE status.
23
+
24
+ Args:
25
+ call: The gRPC call to check
26
+
27
+ Returns:
28
+ True if the error is a rate limit error, False otherwise
29
+ """
30
+ if call.code() != grpc.StatusCode.UNAVAILABLE:
31
+ return False
32
+
33
+ try:
34
+ # Get the trailing metadata which contains error details
35
+ # Metadata is a sequence of tuples (key, value)
36
+ for key, value in call.trailing_metadata() or []:
37
+ if key != GRPC_DETAILS_METADATA_KEY or not isinstance(value, bytes):
38
+ continue
39
+
40
+ status = base_pb2.Status()
41
+ status.ParseFromString(value)
42
+ for any_detail in status.details:
43
+ if any_detail.Is(base_pb2.StatusPayload.DESCRIPTOR):
44
+ payload = base_pb2.StatusPayload()
45
+ any_detail.Unpack(payload)
46
+ if payload.subcode == base_pb2.SUBCODE_RATE_LIMITED:
47
+ return True
48
+ except Exception:
49
+ pass
50
+ return False
51
+
52
+
53
+ logger = logging.getLogger(__name__)
13
54
 
14
55
 
15
56
  class RetryInterceptor(UnaryUnaryClientInterceptor):
57
+ def __init__(self, log_retries: bool = False):
58
+ self.log_retries = log_retries
59
+ super().__init__()
60
+
16
61
  """
17
- A retry interceptor that retries on status code RESOURCE_EXHAUSTED (i.e. rate-limited).
62
+ A retry interceptor that retries on rate limit errors and other retryable errors.
63
+
64
+ This interceptor handles:
65
+ 1. Rate limit errors (UNAVAILABLE with SUBCODE_RATE_LIMITED) - always retried
66
+ 2. [grpc.StatusCode.RESOURCE_EXHAUSTED, grpc.StatusCode.UNAVAILABLE] - retried
18
67
 
19
68
  This is required because, while the retry policy for the gRPC client is configurable via
20
69
  https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto,
@@ -43,7 +92,10 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
43
92
  n_max_retries = 20
44
93
  max_retry_seconds = 20
45
94
  backoffs = [min(i * 2, max_retry_seconds) for i in range(1, n_max_retries)]
46
- for backoff in backoffs: # in seconds
95
+ backoff_index = 0
96
+ while True:
97
+ if backoff_index >= len(backoffs):
98
+ break
47
99
  call = continuation(client_call_details, request)
48
100
  if call.code() not in retryable_codes:
49
101
  break
@@ -54,7 +106,17 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
54
106
  details = call.details() or ""
55
107
  if "InteractiveAuthException" in details:
56
108
  break
109
+ backoff = backoffs[backoff_index]
110
+ if self.log_retries:
111
+ logger.info(
112
+ f"Retrying {client_call_details.method} in {backoff} seconds (last response: {call.code()}, {call.details()})"
113
+ )
57
114
  sleep(backoff)
115
+ # Keep retrying rate-limited calls while increasing the backoff up to the max.
116
+ backoff_index += 1
117
+ if _is_rate_limited(call):
118
+ backoff_index = max(min(backoff_index, len(backoffs) - 2), 0)
119
+
58
120
  try:
59
121
  call.result()
60
122
  except grpc.RpcError as e:
@@ -17,6 +17,7 @@ from .._proto.api.v0.luminarycloud.solution.solution_pb2 import (
17
17
  )
18
18
  from .._proto.api.v0.luminarycloud.physics_ai.physics_ai_pb2 import (
19
19
  GetSolutionDataPhysicsAIRequest,
20
+ SurfaceGroup,
20
21
  )
21
22
  from .._client import Client
22
23
  from ..enum.quantity_type import QuantityType
@@ -140,6 +141,7 @@ def download_solution_physics_ai(
140
141
  process_volume: bool = False,
141
142
  single_precision: bool = False,
142
143
  internal_options: Optional[Dict[str, str]] = None,
144
+ export_surface_groups: Optional[Dict[str, List[str]]] = None,
143
145
  ) -> Optional[FileChunkStream]:
144
146
  """
145
147
  Returns the download as a file-like object, or None if destination_url is provided.
@@ -165,6 +167,9 @@ def download_solution_physics_ai(
165
167
  Whether to process volume meshes during physics AI processing.
166
168
  single_precision: bool
167
169
  If True, the solution will be downloaded in single precision.
170
+ export_surface_groups: Optional[Dict[str, List[str]]]
171
+ Dictionary mapping group names to lists of surface names.
172
+ Each group will be exported as an individual STL file.
168
173
 
169
174
  Examples
170
175
  --------
@@ -173,6 +178,11 @@ def download_solution_physics_ai(
173
178
  ... fp.write(dl.read())
174
179
  """
175
180
 
181
+ surface_groups_pb = []
182
+ if export_surface_groups:
183
+ for group_name, surfaces in export_surface_groups.items():
184
+ surface_groups_pb.append(SurfaceGroup(name=group_name, surfaces=surfaces))
185
+
176
186
  request = GetSolutionDataPhysicsAIRequest(
177
187
  solution_id=solution_id,
178
188
  exclude_surfaces=exclude_surfaces or [],
@@ -186,6 +196,7 @@ def download_solution_physics_ai(
186
196
  process_volume=process_volume,
187
197
  single_precision=single_precision,
188
198
  internal_options=internal_options or {},
199
+ export_surface_groups=surface_groups_pb,
189
200
  )
190
201
  response = client.GetSolutionDataPhysicsAI(request)
191
202