metaflow 2.12.38__py2.py3-none-any.whl → 2.13__py2.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 (41) hide show
  1. metaflow/__init__.py +1 -1
  2. metaflow/cli.py +111 -36
  3. metaflow/cli_args.py +2 -2
  4. metaflow/cli_components/run_cmds.py +3 -1
  5. metaflow/datastore/flow_datastore.py +2 -2
  6. metaflow/exception.py +8 -2
  7. metaflow/flowspec.py +48 -36
  8. metaflow/graph.py +28 -27
  9. metaflow/includefile.py +2 -2
  10. metaflow/lint.py +35 -20
  11. metaflow/metaflow_config.py +5 -0
  12. metaflow/parameters.py +11 -4
  13. metaflow/plugins/argo/argo_workflows_deployer_objects.py +47 -1
  14. metaflow/plugins/aws/secrets_manager/aws_secrets_manager_secrets_provider.py +13 -10
  15. metaflow/plugins/aws/step_functions/step_functions_deployer_objects.py +3 -0
  16. metaflow/plugins/cards/card_creator.py +1 -0
  17. metaflow/plugins/cards/card_decorator.py +46 -8
  18. metaflow/plugins/pypi/bootstrap.py +196 -61
  19. metaflow/plugins/pypi/conda_decorator.py +14 -26
  20. metaflow/plugins/pypi/conda_environment.py +76 -21
  21. metaflow/plugins/pypi/micromamba.py +42 -15
  22. metaflow/plugins/pypi/pip.py +8 -3
  23. metaflow/plugins/pypi/pypi_decorator.py +10 -9
  24. metaflow/runner/click_api.py +175 -39
  25. metaflow/runner/deployer.py +1 -1
  26. metaflow/runner/deployer_impl.py +8 -3
  27. metaflow/runner/metaflow_runner.py +10 -2
  28. metaflow/runner/nbdeploy.py +2 -0
  29. metaflow/runner/nbrun.py +1 -1
  30. metaflow/runner/subprocess_manager.py +3 -1
  31. metaflow/runner/utils.py +41 -19
  32. metaflow/user_configs/config_options.py +87 -34
  33. metaflow/user_configs/config_parameters.py +44 -25
  34. metaflow/util.py +2 -2
  35. metaflow/version.py +1 -1
  36. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/METADATA +2 -2
  37. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/RECORD +41 -41
  38. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/LICENSE +0 -0
  39. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/WHEEL +0 -0
  40. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/entry_points.txt +0 -0
  41. {metaflow-2.12.38.dist-info → metaflow-2.13.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ import re
4
4
  import shutil
5
5
  import subprocess
6
6
  import tempfile
7
+ import time
7
8
  from concurrent.futures import ThreadPoolExecutor
8
9
  from itertools import chain, product
9
10
  from urllib.parse import unquote
@@ -50,10 +51,14 @@ INSTALLATION_MARKER = "{prefix}/.pip/id"
50
51
 
51
52
 
52
53
  class Pip(object):
53
- def __init__(self, micromamba=None):
54
+ def __init__(self, micromamba=None, logger=None):
54
55
  # pip is assumed to be installed inside a conda environment managed by
55
56
  # micromamba. pip commands are executed using `micromamba run --prefix`
56
- self.micromamba = micromamba or Micromamba()
57
+ self.micromamba = micromamba or Micromamba(logger)
58
+ if logger:
59
+ self.logger = logger
60
+ else:
61
+ self.logger = lambda *args, **kwargs: None # No-op logger if not provided
57
62
 
58
63
  def solve(self, id_, packages, python, platform):
59
64
  prefix = self.micromamba.path_to_environment(id_)
@@ -123,7 +128,7 @@ class Pip(object):
123
128
  **res,
124
129
  subdir_str=(
125
130
  "#subdirectory=%s" % subdirectory if subdirectory else ""
126
- )
131
+ ),
127
132
  )
128
133
  # used to deduplicate the storage location in case wheel does not
129
134
  # build with enough unique identifiers.
@@ -25,9 +25,10 @@ class PyPIStepDecorator(StepDecorator):
25
25
  defaults = {"packages": {}, "python": None, "disabled": None} # wheels
26
26
 
27
27
  def __init__(self, attributes=None, statically_defined=False):
28
- self._user_defined_attributes = (
29
- attributes.copy() if attributes is not None else {}
28
+ self._attributes_with_user_values = (
29
+ set(attributes.keys()) if attributes is not None else set()
30
30
  )
31
+
31
32
  super().__init__(attributes, statically_defined)
32
33
 
33
34
  def step_init(self, flow, graph, step, decos, environment, flow_datastore, logger):
@@ -42,10 +43,9 @@ class PyPIStepDecorator(StepDecorator):
42
43
  if "pypi_base" in self.flow._flow_decorators:
43
44
  pypi_base = self.flow._flow_decorators["pypi_base"][0]
44
45
  super_attributes = pypi_base.attributes
45
- self._user_defined_attributes = {
46
- **self._user_defined_attributes,
47
- **pypi_base._user_defined_attributes,
48
- }
46
+ self._attributes_with_user_values.update(
47
+ pypi_base._attributes_with_user_values
48
+ )
49
49
  self.attributes["packages"] = {
50
50
  **super_attributes["packages"],
51
51
  **self.attributes["packages"],
@@ -106,7 +106,7 @@ class PyPIStepDecorator(StepDecorator):
106
106
  environment.set_local_root(LocalStorage.get_datastore_root_from_config(logger))
107
107
 
108
108
  def is_attribute_user_defined(self, name):
109
- return name in self._user_defined_attributes
109
+ return name in self._attributes_with_user_values
110
110
 
111
111
 
112
112
  class PyPIFlowDecorator(FlowDecorator):
@@ -129,9 +129,10 @@ class PyPIFlowDecorator(FlowDecorator):
129
129
  defaults = {"packages": {}, "python": None, "disabled": None}
130
130
 
131
131
  def __init__(self, attributes=None, statically_defined=False):
132
- self._user_defined_attributes = (
133
- attributes.copy() if attributes is not None else {}
132
+ self._attributes_with_user_values = (
133
+ set(attributes.keys()) if attributes is not None else set()
134
134
  )
135
+
135
136
  super().__init__(attributes, statically_defined)
136
137
 
137
138
  def flow_init(
@@ -18,6 +18,7 @@ import json
18
18
  from collections import OrderedDict
19
19
  from typing import Any, Callable, Dict, List, Optional
20
20
  from typing import OrderedDict as TOrderedDict
21
+ from typing import Tuple as TTuple
21
22
  from typing import Union
22
23
 
23
24
  from metaflow import FlowSpec, Parameter
@@ -38,8 +39,16 @@ from metaflow._vendor.typeguard import TypeCheckError, check_type
38
39
  from metaflow.decorators import add_decorator_options
39
40
  from metaflow.exception import MetaflowException
40
41
  from metaflow.includefile import FilePathClass
42
+ from metaflow.metaflow_config import CLICK_API_PROCESS_CONFIG
41
43
  from metaflow.parameters import JSONTypeClass, flow_context
42
- from metaflow.user_configs.config_options import LocalFileInput
44
+ from metaflow.user_configs.config_options import (
45
+ ConfigValue,
46
+ ConvertDictOrStr,
47
+ ConvertPath,
48
+ LocalFileInput,
49
+ MultipleTuple,
50
+ config_options_with_config_input,
51
+ )
43
52
 
44
53
  # Define a recursive type alias for JSON
45
54
  JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None]
@@ -58,6 +67,7 @@ click_to_python_types = {
58
67
  JSONTypeClass: JSON,
59
68
  FilePathClass: str,
60
69
  LocalFileInput: str,
70
+ MultipleTuple: TTuple[str, Union[JSON, ConfigValue]],
61
71
  }
62
72
 
63
73
 
@@ -68,7 +78,7 @@ def _method_sanity_check(
68
78
  defaults: TOrderedDict[str, Any],
69
79
  **kwargs
70
80
  ) -> Dict[str, Any]:
71
- method_params = {"args": {}, "options": {}}
81
+ method_params = {"args": {}, "options": {}, "defaults": defaults}
72
82
 
73
83
  possible_params = OrderedDict()
74
84
  possible_params.update(possible_arg_params)
@@ -90,10 +100,26 @@ def _method_sanity_check(
90
100
  % (supplied_k, annotations[supplied_k], defaults[supplied_k])
91
101
  )
92
102
 
93
- # because Click expects stringified JSON..
94
- supplied_v = (
95
- json.dumps(supplied_v) if annotations[supplied_k] == JSON else supplied_v
96
- )
103
+ # Clean up values to make them into what click expects
104
+ if annotations[supplied_k] == JSON:
105
+ # JSON should be a string (json dumps)
106
+ supplied_v = json.dumps(supplied_v)
107
+ elif supplied_k == "config_value":
108
+ # Special handling of config value because we need to go look in the tuple
109
+ new_list = []
110
+ for cfg_name, cfg_value in supplied_v:
111
+ if isinstance(cfg_value, ConfigValue):
112
+ # ConfigValue should be JSONified and converted to a string
113
+ new_list.append((cfg_name, json.dumps(cfg_value.to_dict())))
114
+ elif isinstance(cfg_value, dict):
115
+ # ConfigValue passed as a dictionary
116
+ new_list.append((cfg_name, json.dumps(cfg_value)))
117
+ else:
118
+ raise TypeError(
119
+ "Invalid type for a config-value, expected a ConfigValue or "
120
+ "dict but got '%s'" % type(cfg_value)
121
+ )
122
+ supplied_v = new_list
97
123
 
98
124
  if supplied_k in possible_arg_params:
99
125
  cli_name = possible_arg_params[supplied_k].opts[0].strip("-")
@@ -188,35 +214,56 @@ loaded_modules = {}
188
214
  def extract_flow_class_from_file(flow_file: str) -> FlowSpec:
189
215
  if not os.path.exists(flow_file):
190
216
  raise FileNotFoundError("Flow file not present at '%s'" % flow_file)
191
- # Check if the module has already been loaded
192
- if flow_file in loaded_modules:
193
- module = loaded_modules[flow_file]
194
- else:
195
- # Load the module if it's not already loaded
196
- spec = importlib.util.spec_from_file_location("module", flow_file)
197
- module = importlib.util.module_from_spec(spec)
198
- spec.loader.exec_module(module)
199
- # Cache the loaded module
200
- loaded_modules[flow_file] = module
201
- classes = inspect.getmembers(module, inspect.isclass)
202
-
203
- flow_cls = None
204
- for _, kls in classes:
205
- if kls != FlowSpec and issubclass(kls, FlowSpec):
206
- if flow_cls is not None:
207
- raise MetaflowException(
208
- "Multiple FlowSpec classes found in %s" % flow_file
209
- )
210
- flow_cls = kls
211
-
212
- return flow_cls
217
+
218
+ flow_dir = os.path.dirname(os.path.abspath(flow_file))
219
+ path_was_added = False
220
+
221
+ # Only add to path if it's not already there
222
+ if flow_dir not in sys.path:
223
+ sys.path.insert(0, flow_dir)
224
+ path_was_added = True
225
+
226
+ try:
227
+ # Check if the module has already been loaded
228
+ if flow_file in loaded_modules:
229
+ module = loaded_modules[flow_file]
230
+ else:
231
+ # Load the module if it's not already loaded
232
+ spec = importlib.util.spec_from_file_location("module", flow_file)
233
+ module = importlib.util.module_from_spec(spec)
234
+ spec.loader.exec_module(module)
235
+ # Cache the loaded module
236
+ loaded_modules[flow_file] = module
237
+ classes = inspect.getmembers(module, inspect.isclass)
238
+
239
+ flow_cls = None
240
+ for _, kls in classes:
241
+ if kls != FlowSpec and issubclass(kls, FlowSpec):
242
+ if flow_cls is not None:
243
+ raise MetaflowException(
244
+ "Multiple FlowSpec classes found in %s" % flow_file
245
+ )
246
+ flow_cls = kls
247
+
248
+ if flow_cls is None:
249
+ raise MetaflowException("No FlowSpec class found in %s" % flow_file)
250
+ return flow_cls
251
+ finally:
252
+ # Only remove from path if we added it
253
+ if path_was_added:
254
+ try:
255
+ sys.path.remove(flow_dir)
256
+ except ValueError:
257
+ # User's code might have removed it already
258
+ pass
213
259
 
214
260
 
215
261
  class MetaflowAPI(object):
216
- def __init__(self, parent=None, flow_cls=None, **kwargs):
262
+ def __init__(self, parent=None, flow_cls=None, config_input=None, **kwargs):
217
263
  self._parent = parent
218
264
  self._chain = [{self._API_NAME: kwargs}]
219
265
  self._flow_cls = flow_cls
266
+ self._config_input = config_input
220
267
  self._cached_computed_parameters = None
221
268
 
222
269
  @property
@@ -238,11 +285,19 @@ class MetaflowAPI(object):
238
285
  flow_cls = extract_flow_class_from_file(flow_file)
239
286
 
240
287
  with flow_context(flow_cls) as _:
241
- add_decorator_options(cli_collection)
288
+ cli_collection, config_input = config_options_with_config_input(
289
+ cli_collection
290
+ )
291
+ cli_collection = add_decorator_options(cli_collection)
242
292
 
243
293
  def getattr_wrapper(_self, name):
244
294
  # Functools.partial do not automatically bind self (no __get__)
245
- return _self._internal_getattr(_self, name)
295
+ with flow_context(flow_cls) as _:
296
+ # We also wrap this in the proper flow context because since commands
297
+ # are loaded lazily, we need the proper flow context to compute things
298
+ # like parameters. If we do not do this, the outer flow's context will
299
+ # be used.
300
+ return _self._internal_getattr(_self, name)
246
301
 
247
302
  class_dict = {
248
303
  "__module__": "metaflow",
@@ -272,7 +327,12 @@ class MetaflowAPI(object):
272
327
  defaults,
273
328
  **kwargs,
274
329
  )
275
- return to_return(parent=None, flow_cls=flow_cls, **method_params)
330
+ return to_return(
331
+ parent=None,
332
+ flow_cls=flow_cls,
333
+ config_input=config_input,
334
+ **method_params,
335
+ )
276
336
 
277
337
  m = _method
278
338
  m.__name__ = cli_collection.name
@@ -313,8 +373,12 @@ class MetaflowAPI(object):
313
373
  for k, v in options.items():
314
374
  if isinstance(v, list):
315
375
  for i in v:
316
- components.append("--%s" % k)
317
- components.append(str(i))
376
+ if isinstance(i, tuple):
377
+ components.append("--%s" % k)
378
+ components.extend(map(str, i))
379
+ else:
380
+ components.append("--%s" % k)
381
+ components.append(str(i))
318
382
  else:
319
383
  components.append("--%s" % k)
320
384
  if v != "flag":
@@ -323,21 +387,93 @@ class MetaflowAPI(object):
323
387
  return components
324
388
 
325
389
  def _compute_flow_parameters(self):
326
- if self._flow_cls is None or self._parent is not None:
390
+ if (
391
+ self._flow_cls is None
392
+ or self._config_input is None
393
+ or self._parent is not None
394
+ ):
327
395
  raise RuntimeError(
328
396
  "Computing flow-level parameters for a non start API. "
329
397
  "Please report to the Metaflow team."
330
398
  )
331
- # TODO: We need to actually compute the new parameters (based on configs) which
332
- # would involve processing the options at least partially. We will do this
333
- # before GA but for now making it work for regular parameters
399
+
334
400
  if self._cached_computed_parameters is not None:
335
401
  return self._cached_computed_parameters
336
402
  self._cached_computed_parameters = []
403
+
404
+ config_options = None
405
+ if CLICK_API_PROCESS_CONFIG:
406
+ with flow_context(self._flow_cls) as _:
407
+ # We are going to resolve the configs first and then get the parameters.
408
+ # Note that configs may update/add parameters so the order is important
409
+ # Since part of the processing of configs happens by click, we need to
410
+ # "fake" it.
411
+
412
+ # Extract any config options as well as datastore and quiet options
413
+ method_params = self._chain[0][self._API_NAME]
414
+ opts = method_params["options"]
415
+ defaults = method_params["defaults"]
416
+
417
+ ds = opts.get("datastore", defaults["datastore"])
418
+ quiet = opts.get("quiet", defaults["quiet"])
419
+ is_default = False
420
+ config_file = opts.get("config-file")
421
+ if config_file is None:
422
+ is_default = True
423
+ config_file = defaults.get("config_file")
424
+
425
+ if config_file:
426
+ config_file = map(
427
+ lambda x: (x[0], ConvertPath.convert_value(x[1], is_default)),
428
+ config_file,
429
+ )
430
+
431
+ is_default = False
432
+ config_value = opts.get("config-value")
433
+ if config_value is None:
434
+ is_default = True
435
+ config_value = defaults.get("config_value")
436
+
437
+ if config_value:
438
+ config_value = map(
439
+ lambda x: (
440
+ x[0],
441
+ ConvertDictOrStr.convert_value(x[1], is_default),
442
+ ),
443
+ config_value,
444
+ )
445
+
446
+ if (config_file is None) ^ (config_value is None):
447
+ # If we have one, we should have the other
448
+ raise MetaflowException(
449
+ "Options were not properly set -- this is an internal error."
450
+ )
451
+
452
+ if config_file:
453
+ # Process both configurations; the second one will return all the merged
454
+ # configuration options properly processed.
455
+ self._config_input.process_configs(
456
+ self._flow_cls.__name__, "config_file", config_file, quiet, ds
457
+ )
458
+ config_options = self._config_input.process_configs(
459
+ self._flow_cls.__name__, "config_value", config_value, quiet, ds
460
+ )
461
+
462
+ # At this point, we are like in start() in cli.py -- we obtained the
463
+ # properly processed config_options which we can now use to process
464
+ # the config decorators (including CustomStep/FlowDecorators)
465
+ # Note that if CLICK_API_PROCESS_CONFIG is False, we still do this because
466
+ # it will init all parameters (config_options will be None)
467
+ # We ignore any errors if we don't check the configs in the click API.
468
+ new_cls = self._flow_cls._process_config_decorators(
469
+ config_options, ignore_errors=not CLICK_API_PROCESS_CONFIG
470
+ )
471
+ if new_cls:
472
+ self._flow_cls = new_cls
473
+
337
474
  for _, param in self._flow_cls._get_parameters():
338
475
  if param.IS_CONFIG_PARAMETER:
339
476
  continue
340
- param.init()
341
477
  self._cached_computed_parameters.append(param)
342
478
  return self._cached_computed_parameters
343
479
 
@@ -64,7 +64,7 @@ class Deployer(metaclass=DeployerMeta):
64
64
  The directory to run the subprocess in; if not specified, the current
65
65
  directory is used.
66
66
  file_read_timeout : int, default 3600
67
- The timeout until which we try to read the deployer attribute file.
67
+ The timeout until which we try to read the deployer attribute file (in seconds).
68
68
  **kwargs : Any
69
69
  Additional arguments that you would pass to `python myflow.py` before
70
70
  the deployment command.
@@ -37,7 +37,7 @@ class DeployerImpl(object):
37
37
  The directory to run the subprocess in; if not specified, the current
38
38
  directory is used.
39
39
  file_read_timeout : int, default 3600
40
- The timeout until which we try to read the deployer attribute file.
40
+ The timeout until which we try to read the deployer attribute file (in seconds).
41
41
  **kwargs : Any
42
42
  Additional arguments that you would pass to `python myflow.py` before
43
43
  the deployment command.
@@ -61,8 +61,13 @@ class DeployerImpl(object):
61
61
  "of DeployerImpl."
62
62
  )
63
63
 
64
+ from metaflow.parameters import flow_context
65
+
64
66
  if "metaflow.cli" in sys.modules:
65
- importlib.reload(sys.modules["metaflow.cli"])
67
+ # Reload the CLI with an "empty" flow -- this will remove any configuration
68
+ # options. They are re-added in from_cli (called below).
69
+ with flow_context(None) as _:
70
+ importlib.reload(sys.modules["metaflow.cli"])
66
71
  from metaflow.cli import start
67
72
  from metaflow.runner.click_api import MetaflowAPI
68
73
 
@@ -144,7 +149,7 @@ class DeployerImpl(object):
144
149
  # Additional info is used to pass additional deployer specific information.
145
150
  # It is used in non-OSS deployers (extensions).
146
151
  self.additional_info = content.get("additional_info", {})
147
-
152
+ command_obj.sync_wait()
148
153
  if command_obj.process.returncode == 0:
149
154
  return create_class(deployer=self)
150
155
 
@@ -221,7 +221,7 @@ class Runner(object):
221
221
  The directory to run the subprocess in; if not specified, the current
222
222
  directory is used.
223
223
  file_read_timeout : int, default 3600
224
- The timeout until which we try to read the runner attribute file.
224
+ The timeout until which we try to read the runner attribute file (in seconds).
225
225
  **kwargs : Any
226
226
  Additional arguments that you would pass to `python myflow.py` before
227
227
  the `run` command.
@@ -245,8 +245,13 @@ class Runner(object):
245
245
  # This ability is made possible by the statement:
246
246
  # 'from .metaflow_runner import Runner' in '__init__.py'
247
247
 
248
+ from metaflow.parameters import flow_context
249
+
248
250
  if "metaflow.cli" in sys.modules:
249
- importlib.reload(sys.modules["metaflow.cli"])
251
+ # Reload the CLI with an "empty" flow -- this will remove any configuration
252
+ # options. They are re-added in from_cli (called below).
253
+ with flow_context(None) as _:
254
+ importlib.reload(sys.modules["metaflow.cli"])
250
255
  from metaflow.cli import start
251
256
  from metaflow.runner.click_api import MetaflowAPI
252
257
 
@@ -272,6 +277,9 @@ class Runner(object):
272
277
 
273
278
  def __get_executing_run(self, attribute_file_fd, command_obj):
274
279
  content = handle_timeout(attribute_file_fd, command_obj, self.file_read_timeout)
280
+
281
+ command_obj.sync_wait()
282
+
275
283
  content = json.loads(content)
276
284
  pathspec = "%s/%s" % (content.get("flow_name"), content.get("run_id"))
277
285
 
@@ -46,6 +46,8 @@ class NBDeployer(object):
46
46
  base_dir : str, optional, default None
47
47
  The directory to run the subprocess in; if not specified, the current
48
48
  working directory is used.
49
+ file_read_timeout : int, default 3600
50
+ The timeout until which we try to read the deployer attribute file (in seconds).
49
51
  **kwargs : Any
50
52
  Additional arguments that you would pass to `python myflow.py` i.e. options
51
53
  listed in `python myflow.py --help`
metaflow/runner/nbrun.py CHANGED
@@ -44,7 +44,7 @@ class NBRunner(object):
44
44
  The directory to run the subprocess in; if not specified, the current
45
45
  working directory is used.
46
46
  file_read_timeout : int, default 3600
47
- The timeout until which we try to read the runner attribute file.
47
+ The timeout until which we try to read the runner attribute file (in seconds).
48
48
  **kwargs : Any
49
49
  Additional arguments that you would pass to `python myflow.py` before
50
50
  the `run` command.
@@ -120,6 +120,9 @@ class SubprocessManager(object):
120
120
  """
121
121
  Run a command synchronously and return its process ID.
122
122
 
123
+ Note: in no case does this wait for the process to *finish*. Use sync_wait()
124
+ to wait for the command to finish.
125
+
123
126
  Parameters
124
127
  ----------
125
128
  command : List[str]
@@ -145,7 +148,6 @@ class SubprocessManager(object):
145
148
  command_obj = CommandManager(command, env, cwd)
146
149
  pid = command_obj.run(show_output=show_output)
147
150
  self.commands[pid] = command_obj
148
- command_obj.sync_wait()
149
151
  return pid
150
152
 
151
153
  async def async_run_command(
metaflow/runner/utils.py CHANGED
@@ -91,7 +91,7 @@ def read_from_fifo_when_ready(
91
91
  encoding : str, optional
92
92
  Encoding to use while reading the file, by default "utf-8".
93
93
  timeout : int, optional
94
- Timeout for reading the file in milliseconds, by default 3600.
94
+ Timeout for reading the file in seconds, by default 3600.
95
95
 
96
96
  Returns
97
97
  -------
@@ -107,30 +107,52 @@ def read_from_fifo_when_ready(
107
107
  content to the FIFO.
108
108
  """
109
109
  content = bytearray()
110
-
111
110
  poll = select.poll()
112
111
  poll.register(fifo_fd, select.POLLIN)
113
-
112
+ max_timeout = 3 # Wait for 10 * 3 = 30 ms after last write
114
113
  while True:
115
- poll_begin = time.time()
116
- poll.poll(timeout)
117
- timeout -= 1000 * (time.time() - poll_begin)
114
+ if check_process_exited(command_obj) and command_obj.process.returncode != 0:
115
+ raise CalledProcessError(
116
+ command_obj.process.returncode, command_obj.command
117
+ )
118
118
 
119
- if timeout <= 0:
119
+ if timeout < 0:
120
120
  raise TimeoutError("Timeout while waiting for the file content")
121
121
 
122
+ poll_begin = time.time()
123
+ # We poll for a very short time to be also able to check if the file was closed
124
+ # If the file is closed, we assume that we only have one writer so if we have
125
+ # data, we break out. This is to work around issues in macos
126
+ events = poll.poll(min(10, timeout * 1000))
127
+ timeout -= time.time() - poll_begin
128
+
122
129
  try:
123
- data = os.read(fifo_fd, 128)
124
- while data:
130
+ data = os.read(fifo_fd, 8192)
131
+ if data:
125
132
  content += data
126
- data = os.read(fifo_fd, 128)
127
-
128
- # Read from a non-blocking closed FIFO returns an empty byte array
129
- break
130
-
133
+ else:
134
+ if len(events):
135
+ # We read an EOF -- consider the file done
136
+ break
137
+ else:
138
+ # We had no events (just a timeout) and the read didn't return
139
+ # an exception so the file is still open; we continue waiting for data
140
+ # Unfortunately, on MacOS, it seems that even *after* the file is
141
+ # closed on the other end, we still don't get a BlockingIOError so
142
+ # we hack our way and timeout if there is no write in 30ms which is
143
+ # a relative eternity for file writes.
144
+ if content:
145
+ if max_timeout <= 0:
146
+ break
147
+ max_timeout -= 1
148
+ continue
131
149
  except BlockingIOError:
132
- # FIFO is open but no data is available yet
133
- continue
150
+ has_blocking_error = True
151
+ if content:
152
+ # The file was closed
153
+ break
154
+ # else, if we have no content, we continue waiting for the file to be open
155
+ # and written to.
134
156
 
135
157
  if not content and check_process_exited(command_obj):
136
158
  raise CalledProcessError(command_obj.process.returncode, command_obj.command)
@@ -156,7 +178,7 @@ async def async_read_from_fifo_when_ready(
156
178
  encoding : str, optional
157
179
  Encoding to use while reading the file, by default "utf-8".
158
180
  timeout : int, optional
159
- Timeout for reading the file in milliseconds, by default 3600.
181
+ Timeout for reading the file in seconds, by default 3600.
160
182
 
161
183
  Returns
162
184
  -------
@@ -206,7 +228,7 @@ def handle_timeout(
206
228
  command_obj : CommandManager
207
229
  Command manager object that encapsulates the running command details.
208
230
  file_read_timeout : int
209
- Timeout for reading the file.
231
+ Timeout for reading the file, in seconds
210
232
 
211
233
  Returns
212
234
  -------
@@ -243,7 +265,7 @@ async def async_handle_timeout(
243
265
  command_obj : CommandManager
244
266
  Command manager object that encapsulates the running command details.
245
267
  file_read_timeout : int
246
- Timeout for reading the file.
268
+ Timeout for reading the file, in seconds
247
269
 
248
270
  Returns
249
271
  -------