ob-metaflow 2.15.0.1__py2.py3-none-any.whl → 2.15.5.1__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.

Potentially problematic release.


This version of ob-metaflow might be problematic. Click here for more details.

Files changed (35) hide show
  1. metaflow/__init__.py +6 -0
  2. metaflow/cmd/code/__init__.py +230 -0
  3. metaflow/cmd/develop/stub_generator.py +5 -2
  4. metaflow/cmd/main_cli.py +1 -0
  5. metaflow/cmd/make_wrapper.py +35 -3
  6. metaflow/extension_support/plugins.py +1 -0
  7. metaflow/metaflow_config.py +2 -0
  8. metaflow/metaflow_environment.py +3 -1
  9. metaflow/mflog/__init__.py +4 -3
  10. metaflow/plugins/__init__.py +14 -0
  11. metaflow/plugins/argo/argo_client.py +9 -2
  12. metaflow/plugins/argo/argo_workflows.py +79 -28
  13. metaflow/plugins/argo/argo_workflows_cli.py +16 -25
  14. metaflow/plugins/argo/argo_workflows_deployer_objects.py +5 -2
  15. metaflow/plugins/cards/card_modules/main.js +52 -50
  16. metaflow/plugins/kubernetes/kubernetes_decorator.py +2 -1
  17. metaflow/plugins/kubernetes/kubernetes_jobsets.py +2 -0
  18. metaflow/plugins/metadata_providers/service.py +16 -7
  19. metaflow/plugins/pypi/bootstrap.py +17 -26
  20. metaflow/plugins/pypi/conda_environment.py +8 -8
  21. metaflow/plugins/pypi/parsers.py +268 -0
  22. metaflow/plugins/pypi/utils.py +18 -0
  23. metaflow/runner/click_api.py +5 -1
  24. metaflow/runner/deployer.py +3 -2
  25. metaflow/version.py +1 -1
  26. {ob_metaflow-2.15.0.1.data → ob_metaflow-2.15.5.1.data}/data/share/metaflow/devtools/Makefile +36 -17
  27. {ob_metaflow-2.15.0.1.data → ob_metaflow-2.15.5.1.data}/data/share/metaflow/devtools/Tiltfile +29 -10
  28. ob_metaflow-2.15.5.1.dist-info/METADATA +87 -0
  29. {ob_metaflow-2.15.0.1.dist-info → ob_metaflow-2.15.5.1.dist-info}/RECORD +34 -32
  30. {ob_metaflow-2.15.0.1.dist-info → ob_metaflow-2.15.5.1.dist-info}/WHEEL +1 -1
  31. ob_metaflow-2.15.0.1.dist-info/METADATA +0 -94
  32. {ob_metaflow-2.15.0.1.data → ob_metaflow-2.15.5.1.data}/data/share/metaflow/devtools/pick_services.sh +0 -0
  33. {ob_metaflow-2.15.0.1.dist-info → ob_metaflow-2.15.5.1.dist-info}/LICENSE +0 -0
  34. {ob_metaflow-2.15.0.1.dist-info → ob_metaflow-2.15.5.1.dist-info}/entry_points.txt +0 -0
  35. {ob_metaflow-2.15.0.1.dist-info → ob_metaflow-2.15.5.1.dist-info}/top_level.txt +0 -0
@@ -29,6 +29,7 @@ from metaflow.metaflow_config import (
29
29
  KUBERNETES_SHARED_MEMORY,
30
30
  KUBERNETES_TOLERATIONS,
31
31
  KUBERNETES_QOS,
32
+ KUBERNETES_CONDA_ARCH,
32
33
  )
33
34
  from metaflow.plugins.resources_decorator import ResourcesDecorator
34
35
  from metaflow.plugins.timeout_decorator import get_run_time_limit_for_task
@@ -160,7 +161,7 @@ class KubernetesDecorator(StepDecorator):
160
161
 
161
162
  # Conda environment support
162
163
  supports_conda_environment = True
163
- target_platform = "linux-64"
164
+ target_platform = KUBERNETES_CONDA_ARCH or "linux-64"
164
165
 
165
166
  def init(self):
166
167
  super(KubernetesDecorator, self).init()
@@ -319,6 +319,8 @@ class RunningJobSet(object):
319
319
  def kill(self):
320
320
  plural = "jobsets"
321
321
  client = self._client.get()
322
+ if not (self.is_running or self.is_waiting):
323
+ return
322
324
  try:
323
325
  # Killing the control pod will trigger the jobset to mark everything as failed.
324
326
  # Since jobsets have a successPolicy set to `All` which ensures that everything has
@@ -350,23 +350,32 @@ class ServiceMetadataProvider(MetadataProvider):
350
350
  List[str]
351
351
  List of task pathspecs that satisfy the query
352
352
  """
353
- query_params = {
354
- "metadata_field_name": field_name,
355
- "pattern": pattern,
356
- "step_name": step_name,
357
- }
353
+ query_params = {}
354
+
355
+ if pattern == ".*":
356
+ # we do not need to filter tasks at all if pattern allows 'any'
357
+ query_params = {}
358
+ else:
359
+ if field_name:
360
+ query_params["metadata_field_name"] = field_name
361
+ if pattern:
362
+ query_params["pattern"] = pattern
363
+
358
364
  url = ServiceMetadataProvider._obj_path(flow_name, run_id, step_name)
359
365
  url = f"{url}/filtered_tasks?{urlencode(query_params)}"
366
+
360
367
  try:
361
- resp = cls._request(None, url, "GET")
368
+ resp, _ = cls._request(None, url, "GET")
362
369
  except Exception as e:
363
370
  if e.http_code == 404:
364
371
  # filter_tasks_by_metadata endpoint does not exist in the version of metadata service
365
372
  # deployed currently. Raise a more informative error message.
366
373
  raise MetaflowInternalError(
367
374
  "The version of metadata service deployed currently does not support filtering tasks by metadata. "
368
- "Upgrade Metadata service to version 2.15 or greater to use this feature."
375
+ "Upgrade Metadata service to version 2.5.0 or greater to use this feature."
369
376
  ) from e
377
+ # Other unknown exception
378
+ raise e
370
379
  return resp
371
380
 
372
381
  @staticmethod
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  import tarfile
10
10
  import time
11
+ import platform
11
12
  from urllib.error import URLError
12
13
  from urllib.request import urlopen
13
14
  from metaflow.metaflow_config import DATASTORE_LOCAL_DIR, CONDA_USE_FAST_INIT
@@ -36,29 +37,6 @@ def timer(func):
36
37
 
37
38
 
38
39
  if __name__ == "__main__":
39
- # TODO: Detect architecture on the fly when dealing with arm architectures.
40
- # ARCH=$(uname -m)
41
- # OS=$(uname)
42
-
43
- # if [[ "$OS" == "Linux" ]]; then
44
- # PLATFORM="linux"
45
- # if [[ "$ARCH" == "aarch64" ]]; then
46
- # ARCH="aarch64";
47
- # elif [[ $ARCH == "ppc64le" ]]; then
48
- # ARCH="ppc64le";
49
- # else
50
- # ARCH="64";
51
- # fi
52
- # fi
53
-
54
- # if [[ "$OS" == "Darwin" ]]; then
55
- # PLATFORM="osx";
56
- # if [[ "$ARCH" == "arm64" ]]; then
57
- # ARCH="arm64";
58
- # else
59
- # ARCH="64"
60
- # fi
61
- # fi
62
40
 
63
41
  def run_cmd(cmd, stdin_str=None):
64
42
  result = subprocess.run(
@@ -350,12 +328,25 @@ if __name__ == "__main__":
350
328
  cmd = f"fast-initializer --prefix {prefix} --packages-dir {pkgs_dir}"
351
329
  run_cmd(cmd, all_package_urls)
352
330
 
353
- if len(sys.argv) != 5:
354
- print("Usage: bootstrap.py <flow_name> <id> <datastore_type> <architecture>")
331
+ if len(sys.argv) != 4:
332
+ print("Usage: bootstrap.py <flow_name> <id> <datastore_type>")
355
333
  sys.exit(1)
356
334
 
357
335
  try:
358
- _, flow_name, id_, datastore_type, architecture = sys.argv
336
+ _, flow_name, id_, datastore_type = sys.argv
337
+
338
+ system = platform.system().lower()
339
+ arch_machine = platform.machine().lower()
340
+
341
+ if system == "darwin" and arch_machine == "arm64":
342
+ architecture = "osx-arm64"
343
+ elif system == "darwin":
344
+ architecture = "osx-64"
345
+ elif system == "linux" and arch_machine == "aarch64":
346
+ architecture = "linux-aarch64"
347
+ else:
348
+ # default fallback
349
+ architecture = "linux-64"
359
350
 
360
351
  prefix = os.path.join(os.getcwd(), architecture, id_)
361
352
  pkgs_dir = os.path.join(os.getcwd(), ".pkgs")
@@ -190,7 +190,6 @@ class CondaEnvironment(MetaflowEnvironment):
190
190
  # 4. Start PyPI solves in parallel after each conda environment is created
191
191
  # 5. Download PyPI packages sequentially
192
192
  # 6. Create and cache PyPI environments in parallel
193
-
194
193
  with ThreadPoolExecutor() as executor:
195
194
  # Start all conda solves in parallel
196
195
  conda_futures = [
@@ -213,14 +212,14 @@ class CondaEnvironment(MetaflowEnvironment):
213
212
 
214
213
  # Queue PyPI solve to start after conda create
215
214
  if result[0] in pypi_envs:
215
+ # solve pypi envs uniquely
216
+ pypi_env = pypi_envs.pop(result[0])
216
217
 
217
218
  def pypi_solve(env):
218
219
  create_future.result() # Wait for conda create
219
220
  return solve(*env, "pypi")
220
221
 
221
- pypi_futures.append(
222
- executor.submit(pypi_solve, pypi_envs[result[0]])
223
- )
222
+ pypi_futures.append(executor.submit(pypi_solve, pypi_env))
224
223
 
225
224
  # Process PyPI results sequentially for downloads
226
225
  for solve_future in pypi_futures:
@@ -242,7 +241,7 @@ class CondaEnvironment(MetaflowEnvironment):
242
241
  if id_:
243
242
  # bootstrap.py is responsible for ensuring the validity of this executable.
244
243
  # -s is important! Can otherwise leak packages to other environments.
245
- return os.path.join("linux-64", id_, "bin/python -s")
244
+ return os.path.join("$MF_ARCH", id_, "bin/python -s")
246
245
  else:
247
246
  # for @conda/@pypi(disabled=True).
248
247
  return super().executable(step_name, default)
@@ -315,7 +314,6 @@ class CondaEnvironment(MetaflowEnvironment):
315
314
  # 5. All resolved packages (Conda or PyPI) are cached
316
315
  # 6. PyPI packages are only installed for local platform
317
316
 
318
- # Resolve `linux-64` Conda environments if @batch or @kubernetes are in play
319
317
  target_platform = conda_platform()
320
318
  for decorator in step.decorators:
321
319
  # NOTE: Keep the list of supported decorator names for backward compatibility purposes.
@@ -329,7 +327,6 @@ class CondaEnvironment(MetaflowEnvironment):
329
327
  "snowpark",
330
328
  "slurm",
331
329
  ]:
332
- # TODO: Support arm architectures
333
330
  target_platform = getattr(decorator, "target_platform", "linux-64")
334
331
  break
335
332
 
@@ -427,15 +424,18 @@ class CondaEnvironment(MetaflowEnvironment):
427
424
  if id_:
428
425
  return [
429
426
  "echo 'Bootstrapping virtual environment...'",
427
+ "flush_mflogs",
430
428
  # We have to prevent the tracing module from loading,
431
429
  # as the bootstrapping process uses the internal S3 client which would fail to import tracing
432
430
  # due to the required dependencies being bundled into the conda environment,
433
431
  # which is yet to be initialized at this point.
434
- 'DISABLE_TRACING=True python -m metaflow.plugins.pypi.bootstrap "%s" %s "%s" linux-64'
432
+ 'DISABLE_TRACING=True python -m metaflow.plugins.pypi.bootstrap "%s" %s "%s"'
435
433
  % (self.flow.name, id_, self.datastore_type),
436
434
  "echo 'Environment bootstrapped.'",
435
+ "flush_mflogs",
437
436
  # To avoid having to install micromamba in the PATH in micromamba.py, we add it to the PATH here.
438
437
  "export PATH=$PATH:$(pwd)/micromamba/bin",
438
+ "export MF_ARCH=$(case $(uname)/$(uname -m) in Darwin/arm64)echo osx-arm64;;Darwin/*)echo osx-64;;Linux/aarch64)echo linux-aarch64;;*)echo linux-64;;esac)",
439
439
  ]
440
440
  else:
441
441
  # for @conda/@pypi(disabled=True).
@@ -0,0 +1,268 @@
1
+ # this file can be overridden by extensions as is (e.g. metaflow-nflx-extensions)
2
+ from metaflow.exception import MetaflowException
3
+
4
+
5
+ class ParserValueError(MetaflowException):
6
+ headline = "Value error"
7
+
8
+
9
+ def requirements_txt_parser(content: str):
10
+ """
11
+ Parse non-comment lines from a requirements.txt file as strictly valid
12
+ PEP 508 requirements.
13
+
14
+ Recognizes direct references (e.g. "my_lib @ git+https://..."), extras
15
+ (e.g. "requests[security]"), and version specifiers (e.g. "==2.0"). If
16
+ the package name is "python", its specifier is stored in the "python"
17
+ key instead of "packages".
18
+
19
+ Parameters
20
+ ----------
21
+ content : str
22
+ Contents of a requirements.txt file.
23
+
24
+ Returns
25
+ -------
26
+ dict
27
+ A dictionary with two keys:
28
+ - "packages": dict(str -> str)
29
+ Mapping from package name (plus optional extras/references) to a
30
+ version specifier string.
31
+ - "python": str or None
32
+ The Python version constraints if present, otherwise None.
33
+
34
+ Raises
35
+ ------
36
+ ParserValueError
37
+ If a requirement line is invalid PEP 508 or if environment markers are
38
+ detected, or if multiple Python constraints are specified.
39
+ """
40
+ import re
41
+ from metaflow._vendor.packaging.requirements import Requirement, InvalidRequirement
42
+
43
+ parsed = {"packages": {}, "python": None}
44
+
45
+ inline_comment_pattern = re.compile(r"\s+#.*$")
46
+ for line in content.splitlines():
47
+ line = line.strip()
48
+
49
+ # support Rye lockfiles by skipping lines not compliant with requirements
50
+ if line == "-e file:.":
51
+ continue
52
+
53
+ if not line or line.startswith("#"):
54
+ continue
55
+
56
+ line = inline_comment_pattern.sub("", line).strip()
57
+ if not line:
58
+ continue
59
+
60
+ try:
61
+ req = Requirement(line)
62
+ except InvalidRequirement:
63
+ raise ParserValueError(f"Not a valid PEP 508 requirement: '{line}'")
64
+
65
+ if req.marker is not None:
66
+ raise ParserValueError(
67
+ "Environment markers (e.g. 'platform_system==\"Linux\"') "
68
+ f"are not supported for line: '{line}'"
69
+ )
70
+
71
+ dep_key = req.name
72
+ if req.extras:
73
+ dep_key += f"[{','.join(req.extras)}]"
74
+ if req.url:
75
+ dep_key += f"@{req.url}"
76
+
77
+ dep_spec = str(req.specifier).lstrip(" =")
78
+
79
+ if req.name.lower() == "python":
80
+ if parsed["python"] is not None and dep_spec:
81
+ raise ParserValueError(
82
+ f"Multiple Python version specs not allowed: '{line}'"
83
+ )
84
+ parsed["python"] = dep_spec or None
85
+ else:
86
+ parsed["packages"][dep_key] = dep_spec
87
+
88
+ return parsed
89
+
90
+
91
+ def pyproject_toml_parser(content: str):
92
+ """
93
+ Parse a pyproject.toml file per PEP 621.
94
+
95
+ Reads the 'requires-python' and 'dependencies' fields from the "[project]" section.
96
+ Each dependency line must be a valid PEP 508 requirement. If the package name is
97
+ "python", its specifier is stored in the "python" key instead of "packages".
98
+
99
+ Parameters
100
+ ----------
101
+ content : str
102
+ Contents of a pyproject.toml file.
103
+
104
+ Returns
105
+ -------
106
+ dict
107
+ A dictionary with two keys:
108
+ - "packages": dict(str -> str)
109
+ Mapping from package name (plus optional extras/references) to a
110
+ version specifier string.
111
+ - "python": str or None
112
+ The Python version constraints if present, otherwise None.
113
+
114
+ Raises
115
+ ------
116
+ RuntimeError
117
+ If no TOML library (tomllib in Python 3.11+ or tomli in earlier versions) is found.
118
+ ParserValueError
119
+ If a dependency is not valid PEP 508, if environment markers are used, or if
120
+ multiple Python constraints are specified.
121
+ """
122
+ try:
123
+ import tomllib as toml # Python 3.11+
124
+ except ImportError:
125
+ try:
126
+ import tomli as toml # Python < 3.11 (requires "tomli" package)
127
+ except ImportError:
128
+ raise RuntimeError(
129
+ "Could not import a TOML library. For Python <3.11, please install 'tomli'."
130
+ )
131
+ from metaflow._vendor.packaging.requirements import Requirement, InvalidRequirement
132
+
133
+ data = toml.loads(content)
134
+
135
+ project = data.get("project", {})
136
+ requirements = project.get("dependencies", [])
137
+ requires_python = project.get("requires-python")
138
+
139
+ parsed = {"packages": {}, "python": None}
140
+
141
+ if requires_python is not None:
142
+ # If present, store verbatim; note that PEP 621 does not necessarily
143
+ # require "python" to be a dependency in the usual sense.
144
+ # Example: "requires-python" = ">=3.7,<4"
145
+ parsed["python"] = requires_python.lstrip("=").strip()
146
+
147
+ for dep_line in requirements:
148
+ dep_line_stripped = dep_line.strip()
149
+ try:
150
+ req = Requirement(dep_line_stripped)
151
+ except InvalidRequirement:
152
+ raise ParserValueError(
153
+ f"Not a valid PEP 508 requirement: '{dep_line_stripped}'"
154
+ )
155
+
156
+ if req.marker is not None:
157
+ raise ParserValueError(
158
+ f"Environment markers not supported for line: '{dep_line_stripped}'"
159
+ )
160
+
161
+ dep_key = req.name
162
+ if req.extras:
163
+ dep_key += f"[{','.join(req.extras)}]"
164
+ if req.url:
165
+ dep_key += f"@{req.url}"
166
+
167
+ dep_spec = str(req.specifier).lstrip("=")
168
+
169
+ if req.name.lower() == "python":
170
+ if parsed["python"] is not None and dep_spec:
171
+ raise ParserValueError(
172
+ f"Multiple Python version specs not allowed: '{dep_line_stripped}'"
173
+ )
174
+ parsed["python"] = dep_spec or None
175
+ else:
176
+ parsed["packages"][dep_key] = dep_spec
177
+
178
+ return parsed
179
+
180
+
181
+ def conda_environment_yml_parser(content: str):
182
+ """
183
+ Parse a minimal environment.yml file under strict assumptions.
184
+
185
+ The file must contain a 'dependencies:' line, after which each dependency line
186
+ appears with a '- ' prefix. Python can appear as 'python=3.9', etc.; other
187
+ packages as 'numpy=1.21.2' or simply 'numpy'. Non-compliant lines raise ParserValueError.
188
+
189
+ Parameters
190
+ ----------
191
+ content : str
192
+ Contents of a environment.yml file.
193
+
194
+ Returns
195
+ -------
196
+ dict
197
+ A dictionary with keys:
198
+ {
199
+ "packages": dict(str -> str),
200
+ "python": str or None
201
+ }
202
+
203
+ Raises
204
+ ------
205
+ ParserValueError
206
+ If the file has malformed lines or unsupported sections.
207
+ """
208
+ import re
209
+
210
+ packages = {}
211
+ python_version = None
212
+
213
+ inside_dependencies = False
214
+
215
+ # Basic pattern for lines like "numpy=1.21.2"
216
+ # Group 1: package name
217
+ # Group 2: optional operator + version (could be "=1.21.2", "==1.21.2", etc.)
218
+ line_regex = re.compile(r"^([A-Za-z0-9_\-\.]+)(\s*[=<>!~].+\s*)?$")
219
+ inline_comment_pattern = re.compile(r"\s+#.*$")
220
+
221
+ for line in content.splitlines():
222
+ line = line.strip()
223
+ if not line or line.startswith("#"):
224
+ continue
225
+
226
+ line = inline_comment_pattern.sub("", line).strip()
227
+ if not line:
228
+ continue
229
+
230
+ if line.lower().startswith("dependencies:"):
231
+ inside_dependencies = True
232
+ continue
233
+
234
+ if inside_dependencies and not line.startswith("-"):
235
+ inside_dependencies = False
236
+ continue
237
+
238
+ if not inside_dependencies:
239
+ continue
240
+
241
+ dep_line = line.lstrip("-").strip()
242
+ if dep_line.endswith(":"):
243
+ raise ParserValueError(
244
+ f"Unsupported subsection '{dep_line}' in environment.yml."
245
+ )
246
+
247
+ match = line_regex.match(dep_line)
248
+ if not match:
249
+ raise ParserValueError(
250
+ f"Line '{dep_line}' is not a valid conda package specifier."
251
+ )
252
+
253
+ pkg_name, pkg_version_part = match.groups()
254
+ version_spec = pkg_version_part.strip() if pkg_version_part else ""
255
+
256
+ if version_spec.startswith("="):
257
+ version_spec = version_spec.lstrip("=").strip()
258
+
259
+ if pkg_name.lower() == "python":
260
+ if python_version is not None and version_spec:
261
+ raise ParserValueError(
262
+ f"Multiple Python version specs detected: '{dep_line}'"
263
+ )
264
+ python_version = version_spec
265
+ else:
266
+ packages[pkg_name] = version_spec
267
+
268
+ return {"packages": packages, "python": python_version}
@@ -72,6 +72,24 @@ def pip_tags(python_version, mamba_platform):
72
72
  )
73
73
  ]
74
74
  platforms.append("linux_x86_64")
75
+ elif mamba_platform == "linux-aarch64":
76
+ platforms = [
77
+ "manylinux%s_aarch64" % s
78
+ for s in (
79
+ "2014",
80
+ "_2_17",
81
+ "_2_18",
82
+ "_2_19",
83
+ "_2_20",
84
+ "_2_21",
85
+ "_2_23",
86
+ "_2_24",
87
+ "_2_25",
88
+ "_2_26",
89
+ "_2_27",
90
+ )
91
+ ]
92
+ platforms.append("linux_aarch64")
75
93
  elif mamba_platform == "osx-64":
76
94
  platforms = tags.mac_platforms(arch="x86_64")
77
95
  elif mamba_platform == "osx-arm64":
@@ -219,7 +219,11 @@ def get_inspect_param_obj(p: Union[click.Argument, click.Option], kind: str):
219
219
  default=inspect.Parameter.empty if is_vararg else p.default,
220
220
  annotation=annotation,
221
221
  ),
222
- Optional[TTuple[annotation]] if is_vararg else annotation,
222
+ (
223
+ Optional[Union[TTuple[annotation], List[annotation]]]
224
+ if is_vararg
225
+ else annotation
226
+ ),
223
227
  )
224
228
 
225
229
 
@@ -106,7 +106,7 @@ class TriggeredRun(object):
106
106
  self.pathspec = content_json.get("pathspec")
107
107
  self.name = content_json.get("name")
108
108
 
109
- def wait_for_run(self, timeout: Optional[int] = None):
109
+ def wait_for_run(self, check_interval: int = 5, timeout: Optional[int] = None):
110
110
  """
111
111
  Wait for the `run` property to become available.
112
112
 
@@ -115,6 +115,8 @@ class TriggeredRun(object):
115
115
 
116
116
  Parameters
117
117
  ----------
118
+ check_interval: int, default: 5
119
+ Frequency of checking for the `run` to become available, in seconds.
118
120
  timeout : int, optional, default None
119
121
  Maximum time to wait for the `run` to become available, in seconds. If
120
122
  None, wait indefinitely.
@@ -125,7 +127,6 @@ class TriggeredRun(object):
125
127
  If the `run` is not available within the specified timeout.
126
128
  """
127
129
  start_time = time.time()
128
- check_interval = 5
129
130
  while True:
130
131
  if self.run is not None:
131
132
  return self.run
metaflow/version.py CHANGED
@@ -1 +1 @@
1
- metaflow_version = "2.15.0.1"
1
+ metaflow_version = "2.15.5.1"