metaflow-netflixext 1.3.2.dev0__tar.gz → 1.3.4.dev0__tar.gz

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 (60) hide show
  1. {metaflow_netflixext-1.3.2.dev0/metaflow_netflixext.egg-info → metaflow_netflixext-1.3.4.dev0}/PKG-INFO +1 -1
  2. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda.py +23 -1
  3. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_common_decorator.py +21 -0
  4. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_environment.py +6 -1
  5. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_flow_mutator.py +30 -7
  6. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_step_decorator.py +14 -0
  7. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/env_descr.py +70 -12
  8. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/envsresolver.py +10 -1
  9. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/conda_resolver.py +6 -0
  10. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/pip_resolver.py +5 -0
  11. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/pylock_toml_resolver.py +1 -1
  12. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/utils.py +12 -0
  13. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0/metaflow_netflixext.egg-info}/PKG-INFO +1 -1
  14. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/LICENSE +0 -0
  15. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/README.md +0 -0
  16. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/__init__.py +0 -0
  17. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/__init__.py +0 -0
  18. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/constants.py +0 -0
  19. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/current_stub_generator.py +0 -0
  20. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/debug_cmd.py +0 -0
  21. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/debug_script_generator.py +0 -0
  22. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/debug_stub_generator.py +0 -0
  23. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/debug_utils.py +0 -0
  24. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/jupyter_instructions_markdown.py +0 -0
  25. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/debug/jupyter_title_markdown.py +0 -0
  26. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/environment/__init__.py +0 -0
  27. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/environment/environment_cmd.py +0 -0
  28. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/environment/utils.py +0 -0
  29. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/cmd/mfextinit_netflixext.py +0 -0
  30. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/config/mfextinit_netflixext.py +0 -0
  31. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/exceptions/__init__.py +0 -0
  32. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/exceptions/decorators.py +0 -0
  33. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/exceptions/http_helpers.py +0 -0
  34. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/generate_vendor.py +0 -0
  35. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/http_helpers.py +0 -0
  36. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/__init__.py +0 -0
  37. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_flow_decorator.py +0 -0
  38. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/conda_lock_micromamba_server.py +0 -0
  39. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/parsers.py +0 -0
  40. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/pypi_package_builder.py +0 -0
  41. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/remote_bootstrap.py +0 -0
  42. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/__init__.py +0 -0
  43. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/builder_envs_resolver.py +0 -0
  44. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/conda_lock_resolver.py +0 -0
  45. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resolvers/micromamba_server_resolver.py +0 -0
  46. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resources/logo-32x32.png +0 -0
  47. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resources/logo-64x64.png +0 -0
  48. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/resources/logo-svg.svg +0 -0
  49. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/conda/terminal_menu.py +0 -0
  50. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/environment_cli.py +0 -0
  51. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/plugins/mfextinit_netflixext.py +0 -0
  52. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/toplevel/mfextinit_netflixext.py +0 -0
  53. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/toplevel/netflixext_toplevel.py +0 -0
  54. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_extensions/netflix_ext/toplevel/netflixext_version.py +0 -0
  55. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_netflixext.egg-info/SOURCES.txt +0 -0
  56. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_netflixext.egg-info/dependency_links.txt +0 -0
  57. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_netflixext.egg-info/requires.txt +0 -0
  58. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/metaflow_netflixext.egg-info/top_level.txt +0 -0
  59. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/setup.cfg +0 -0
  60. {metaflow_netflixext-1.3.2.dev0 → metaflow_netflixext-1.3.4.dev0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metaflow-netflixext
3
- Version: 1.3.2.dev0
3
+ Version: 1.3.4.dev0
4
4
  Summary: Metaflow extensions from Netflix
5
5
  Author: Netflix Metaflow Developers
6
6
  Author-email: metaflow-dev@netflix.com
@@ -685,6 +685,7 @@ class Conda(object):
685
685
  env_id: EnvID,
686
686
  local_only: bool = False,
687
687
  use_latest: str = CONDA_USE_REMOTE_LATEST,
688
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
688
689
  ) -> Optional[ResolvedEnvironment]:
689
690
  """
690
691
  Returns the resolved environment for a given environment ID.
@@ -712,7 +713,9 @@ class Conda(object):
712
713
  The ResolvedEnvironment corresponding to the input EnvID
713
714
  """
714
715
  # First look if we have from_env_id locally
715
- env = self._cached_environment.env_for(*env_id)
716
+ env = self._cached_environment.env_for(
717
+ *env_id, full_id_unique_keys=full_id_unique_keys
718
+ )
716
719
 
717
720
  debug.conda_exec("%s%sfound locally" % (str(env_id), " " if env else " not "))
718
721
  if env:
@@ -760,6 +763,25 @@ class Conda(object):
760
763
  "%s found as latest remotely: %s"
761
764
  % (str(env_id), str(env.env_id))
762
765
  )
766
+
767
+ # In case the resolved files changed but the user dependency stays the same,
768
+ # which happens when pyproject.toml stays the same but the user does
769
+ # `uv lock --upgrade` -- the full_id_unique_keys which includes information
770
+ # from the resolved files hash will be different, and the cache environment
771
+ # will be invalidated.
772
+ if full_id_unique_keys and env:
773
+ if env._full_id_unique_keys != full_id_unique_keys:
774
+ debug.conda_exec(
775
+ "%s found remotely but unique keys do not match, unique key in cache is %s"
776
+ " unique key in request is %s"
777
+ % (
778
+ str(env.env_id),
779
+ str(env._full_id_unique_keys),
780
+ str(full_id_unique_keys),
781
+ )
782
+ )
783
+ return None
784
+
763
785
  return env
764
786
 
765
787
  def environments(
@@ -45,6 +45,10 @@ class StepRequirementIface:
45
45
  def file_paths(self) -> Dict[str, List[str]]:
46
46
  return {}
47
47
 
48
+ @property
49
+ def full_id_unique_keys(self) -> Dict[str, str]:
50
+ return {}
51
+
48
52
  @property
49
53
  def extras(self) -> Dict[str, List[str]]:
50
54
  return {}
@@ -102,6 +106,7 @@ class StepRequirement(StepRequirementIface):
102
106
  self._sources = {} # type: Dict[str, List[str]]
103
107
  self._file_paths = {} # type: Dict[str, List[str]]
104
108
  self._extras = {} # type: Dict[str, List[str]]
109
+ self._full_id_unique_keys = {} # type: Dict[str, str]
105
110
  self._default_disabled = {
106
111
  UBF_CONTROL: None,
107
112
  UBF_TASK: None,
@@ -117,6 +122,7 @@ class StepRequirement(StepRequirementIface):
117
122
  n._packages = copy.deepcopy(self._packages)
118
123
  n._sources = copy.deepcopy(self._sources)
119
124
  n._file_paths = copy.deepcopy(self._file_paths)
125
+ n._full_id_unique_keys = copy.deepcopy(self._full_id_unique_keys)
120
126
  n._extras = copy.deepcopy(self._extras)
121
127
  n._default_disabled = copy.deepcopy(self._default_disabled)
122
128
  return n
@@ -195,6 +201,14 @@ class StepRequirement(StepRequirementIface):
195
201
  def file_paths(self, value: Dict[str, List[str]]):
196
202
  self._file_paths = value
197
203
 
204
+ @property
205
+ def full_id_unique_keys(self) -> Dict[str, str]:
206
+ return copy.deepcopy(self._full_id_unique_keys)
207
+
208
+ @full_id_unique_keys.setter
209
+ def full_id_unique_keys(self, value: Dict[str, str]):
210
+ self._full_id_unique_keys = value
211
+
198
212
  @property
199
213
  def extras(self) -> Dict[str, List[str]]:
200
214
  return copy.deepcopy(self._extras)
@@ -305,6 +319,13 @@ class StepRequirement(StepRequirementIface):
305
319
  for category, extras in other_extras.items():
306
320
  self._extras.setdefault(category, []).extend(extras or [])
307
321
 
322
+ other_full_id_unique_keys = other.full_id_unique_keys
323
+ for key, value in other_full_id_unique_keys.items():
324
+ # We use override rather than list extension here for same keys,
325
+ # as we assume it rare that different decorators will have same
326
+ # full_id_unique_keys.
327
+ self._full_id_unique_keys[key] = value
328
+
308
329
  # Special handling for pathspec/name
309
330
  if other.from_name is not None and other.from_pathspec is not None:
310
331
  raise InvalidEnvironmentException(
@@ -135,6 +135,7 @@ class CondaEnvironment(MetaflowEnvironment):
135
135
  step.name,
136
136
  base_env,
137
137
  base_from_full_id=base_from_full_id,
138
+ full_id_unique_keys=req.full_id_unique_keys,
138
139
  )
139
140
 
140
141
  resolver.resolve_environments(echo)
@@ -372,7 +373,11 @@ class CondaEnvironment(MetaflowEnvironment):
372
373
  elif step_info[2]:
373
374
  # In this case, we should know about the environment -- it will have
374
375
  # _default flag at this time
375
- resolved_env = conda.environment(step_info[2][0], local_only=True)
376
+ resolved_env = conda.environment(
377
+ step_info[2][0],
378
+ local_only=True,
379
+ full_id_unique_keys=step_info[1].full_id_unique_keys,
380
+ )
376
381
  if resolved_env:
377
382
  return resolved_env.env_id
378
383
  return None
@@ -9,7 +9,11 @@ from metaflow import FlowMutator
9
9
  from .conda_common_decorator import StepRequirementMixin
10
10
  from .conda_flow_decorator import PackageRequirementFlowDecorator
11
11
  from .parsers import req_parser, toml_parser
12
- from .utils import call_binary, determine_uv_env_python_version
12
+ from .utils import (
13
+ call_binary,
14
+ determine_uv_env_python_version,
15
+ compute_file_hash,
16
+ )
13
17
 
14
18
 
15
19
  if TYPE_CHECKING:
@@ -171,6 +175,9 @@ class ResolvedEnvironmentBaseFlowMutator(FlowMutator):
171
175
  # See _parse_requirements_txt() for the format of this dict.
172
176
  user_deps: Dict[str, str],
173
177
  user_sources: List[str],
178
+ # takes a full_id_unique_keys dict rather than uv_content_hash
179
+ # to allow more flexibility in the future.
180
+ full_id_unique_keys: Dict[str, str],
174
181
  ):
175
182
  ResolvedEnvironmentBaseFlowMutator._verify_no_conflict_step_decorators(
176
183
  step_name, step
@@ -181,6 +188,7 @@ class ResolvedEnvironmentBaseFlowMutator(FlowMutator):
181
188
  "path": pylock_toml_path,
182
189
  "user_deps_for_hash": user_deps,
183
190
  "user_sources_for_hash": user_sources,
191
+ "full_id_unique_keys": full_id_unique_keys,
184
192
  },
185
193
  )
186
194
 
@@ -290,14 +298,23 @@ class ResolvedUVEnvFlowDecorator(ResolvedEnvironmentBaseFlowMutator):
290
298
  f"must exist in the specified path {proj_config_dir}"
291
299
  )
292
300
 
293
- temp_toml_file_path, user_deps, user_sources = self._handle_uv_lock_scenario(
294
- os.path.join(proj_config_dir, "uv.lock"),
295
- os.path.join(proj_config_dir, "pyproject.toml"),
301
+ temp_toml_file_path, user_deps, user_sources, uv_lock_file_content_hash = (
302
+ self._handle_uv_lock_scenario(
303
+ os.path.join(proj_config_dir, "uv.lock"),
304
+ os.path.join(proj_config_dir, "pyproject.toml"),
305
+ )
296
306
  )
297
307
 
298
308
  for step_name, step in mutable_flow.steps:
299
309
  ResolvedEnvironmentBaseFlowMutator._mutate_step(
300
- step_name, step, temp_toml_file_path, user_deps, user_sources
310
+ step_name,
311
+ step,
312
+ temp_toml_file_path,
313
+ user_deps,
314
+ user_sources,
315
+ full_id_unique_keys={
316
+ "uv_lock_content_hash": uv_lock_file_content_hash,
317
+ },
301
318
  )
302
319
 
303
320
  # @staticmethod
@@ -306,10 +323,11 @@ class ResolvedUVEnvFlowDecorator(ResolvedEnvironmentBaseFlowMutator):
306
323
  # ) -> tuple[Optional[str], Dict[str, str]]:
307
324
  # raise NotImplementedError("This will be supported in a stacked PR soon.")
308
325
 
326
+ # Returns: (temp_pylock_toml_path, user_deps_dict, user_sources_list, uv_lock_content_hash)
309
327
  @staticmethod
310
328
  def _handle_uv_lock_scenario(
311
329
  uv_lock_path: str, pyproject_toml_path: str
312
- ) -> Tuple[Optional[str], Dict[str, str], List[str]]:
330
+ ) -> Tuple[str, Dict[str, str], List[str], str]:
313
331
  # 1. read pyproject.toml
314
332
  user_deps, user_srcs = (
315
333
  ResolvedUVEnvFlowDecorator._parse_pyproject_toml_to_user_deps_dict(
@@ -337,7 +355,12 @@ class ResolvedUVEnvFlowDecorator(ResolvedEnvironmentBaseFlowMutator):
337
355
  cwd=cwd,
338
356
  )
339
357
 
340
- return temp_path, user_deps, user_srcs
358
+ # Calculate uv lock file content hash, to be passed to PylockToml Resolver,
359
+ # and eventually ResolvedEnvironment, to be a component of full_id hash calculation,
360
+ # and will be used to invalidate the cache if pyproject.toml is the same but uv.lock changes.
361
+ uv_lock_content_hash = compute_file_hash(uv_lock_path)
362
+
363
+ return temp_path, user_deps, user_srcs, uv_lock_content_hash
341
364
 
342
365
  @staticmethod
343
366
  def _parse_pyproject_toml_to_user_deps_dict(
@@ -392,6 +392,16 @@ class PylockTomlInternalDecorator(StepRequirementMixin, StepDecorator):
392
392
  "user_deps_for_hash": {},
393
393
  # The purpose is similar to user_deps_for_hash.
394
394
  "user_sources_for_hash": [],
395
+ # When a pylock_internal decorator is generated, the originating
396
+ # ResolvedUvEnvFlowDecorator calculates the hash of uv.lock, assigns it
397
+ # to the decorator's attributes["full_id_unique_keys"]["uv_content_hash"],
398
+ # and eventually this uv content hash value will be passed to
399
+ # PylockTomlInternalDecorator.resolve()'s parameters.
400
+ #
401
+ # This is used to detect a uv.lock change with unchanged user
402
+ # dependencies in pyproject.toml, caused by design by uv's non-determinism
403
+ # in resolution when ">=version" specifiers are involved.
404
+ "full_id_unique_keys": {},
395
405
  }
396
406
 
397
407
  name = "pylock_toml_internal"
@@ -445,6 +455,10 @@ class PylockTomlInternalDecorator(StepRequirementMixin, StepDecorator):
445
455
  def python(self) -> Optional[str]:
446
456
  return self.attributes["user_deps_for_hash"].get("python")
447
457
 
458
+ @property
459
+ def full_id_unique_keys(self) -> Dict[str, str]:
460
+ return cast(Dict[str, str], self.attributes["full_id_unique_keys"])
461
+
448
462
 
449
463
  class CondaEnvInternalDecorator(StepDecorator):
450
464
  name = "conda_env_internal"
@@ -793,6 +793,8 @@ class ResolvedEnvironment:
793
793
  co_resolved: Optional[List[str]] = None,
794
794
  env_type: EnvType = EnvType.MIXED,
795
795
  accurate_source: bool = True,
796
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
797
+ # Sample format: {'uv_lock_content': '33746974cb80adfea6047b5c5713c59c0dbc721da3179501c1bad261241cd3d3'}
796
798
  ):
797
799
  self._env_type = env_type
798
800
  self._user_dependencies = dict_to_tstr(user_dependencies)
@@ -803,6 +805,7 @@ class ResolvedEnvironment:
803
805
  self._user_extra_args = dict_to_tstr(user_extra_args) if user_extra_args else []
804
806
 
805
807
  self._accurate_source = accurate_source
808
+ self._full_id_unique_keys = full_id_unique_keys or {}
806
809
 
807
810
  if not env_id:
808
811
  env_req_id = ResolvedEnvironment.get_req_id(
@@ -813,13 +816,10 @@ class ResolvedEnvironment:
813
816
 
814
817
  env_full_id = "_unresolved"
815
818
  if all_packages is not None:
816
- env_full_id = self._compute_hash(
817
- [
818
- "%s#%s" % (p.filename, p.pkg_hash(p.url_format))
819
- for p in sorted(all_packages, key=lambda p: p.filename)
820
- ]
821
- + [arch or arch_id()]
819
+ env_full_id = self._compute_full_id(
820
+ all_packages, arch or arch_id(), self._full_id_unique_keys
822
821
  )
822
+
823
823
  self._env_id = EnvID(
824
824
  req_id=env_req_id, full_id=env_full_id, arch=arch or arch_id()
825
825
  )
@@ -937,10 +937,12 @@ class ResolvedEnvironment:
937
937
  Unique identifier for this environment.
938
938
  """
939
939
  if self._env_id.full_id in ("_default", "_unresolved") and self._all_packages:
940
- all_packages = sorted(self._all_packages, key=lambda p: p.filename)
941
- env_full_id = self._compute_hash(
942
- [p.filename for p in all_packages] + [self._env_id.arch or arch_id()]
940
+ env_full_id = self._compute_full_id(
941
+ self._all_packages,
942
+ self._env_id.arch or arch_id(),
943
+ self._full_id_unique_keys,
943
944
  )
945
+
944
946
  self._env_id = self._env_id._replace(full_id=env_full_id)
945
947
  return self._env_id
946
948
 
@@ -1225,6 +1227,7 @@ class ResolvedEnvironment:
1225
1227
  "resolved_archs": self._co_resolved,
1226
1228
  "env_type": self._env_type.value,
1227
1229
  "accurate_source": self._accurate_source,
1230
+ "full_id_unique_keys": self._full_id_unique_keys,
1228
1231
  }
1229
1232
 
1230
1233
  @classmethod
@@ -1267,12 +1270,54 @@ class ResolvedEnvironment:
1267
1270
  co_resolved=d["resolved_archs"],
1268
1271
  env_type=env_type,
1269
1272
  accurate_source=d.get("accurate_source", True),
1273
+ full_id_unique_keys=d.get("full_id_unique_keys", None),
1270
1274
  )
1271
1275
 
1272
1276
  @staticmethod
1273
- def _compute_hash(inputs: Iterable[str]):
1277
+ def _compute_hash(inputs: Iterable[str]) -> str:
1274
1278
  return sha1(b" ".join([s.encode("ascii") for s in inputs])).hexdigest()
1275
1279
 
1280
+ @staticmethod
1281
+ def _unique_keys_to_list(
1282
+ unique_keys: Dict[str, str],
1283
+ ) -> List[str]:
1284
+ # Using "{k}:{v}" not "{k}#{v}" to avoid mixing with package filename#hash,
1285
+ # in case this helps debuggability.
1286
+ return [f"{k}:{v}" for k, v in sorted(unique_keys.items())]
1287
+
1288
+ # There are two levels of hashing for the environment:
1289
+ # * the req_id which is based on the user-requested dependencies and sources.
1290
+ # * the full_id which is based on the resolved user dependencies.
1291
+ #
1292
+ # Originally, full_id is composed of only the resolved dependencies and the
1293
+ # architecture, and a req_id can be mapped to a recently resolved full_id.
1294
+ # This justifies the caching mechanism such as the "_default" key under the
1295
+ # req_id entry in the environment manifest.
1296
+ #
1297
+ # However, as we introduced uv,
1298
+ # we found operations such as `uv lock --upgrade` can introduce different
1299
+ # resolved dependencies while keeping the same user-requested dependencies.
1300
+ # As a result, full_id needs to take into consideration full_id_unique_keys
1301
+ # as well.
1302
+ @staticmethod
1303
+ def _compute_full_id(
1304
+ all_packages: Sequence[PackageSpecification],
1305
+ arch: str,
1306
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
1307
+ ) -> str:
1308
+ to_hash_list = [
1309
+ "%s#%s" % (p.filename, p.pkg_hash(p.url_format))
1310
+ for p in sorted(all_packages, key=lambda p: p.filename)
1311
+ ] + [arch]
1312
+ to_hash_list.extend(
1313
+ ResolvedEnvironment._unique_keys_to_list(full_id_unique_keys)
1314
+ if full_id_unique_keys
1315
+ else []
1316
+ )
1317
+
1318
+ env_full_id = ResolvedEnvironment._compute_hash(to_hash_list)
1319
+ return env_full_id
1320
+
1276
1321
 
1277
1322
  class MetaflowResolvedEnvironment:
1278
1323
  """
@@ -1511,7 +1556,11 @@ class CachedEnvironmentInfo:
1511
1556
  # Missing AliasType.REQ_FULL_ID but we don't record aliases for that.
1512
1557
 
1513
1558
  def env_for(
1514
- self, req_id: str, full_id: str = "_default", arch: Optional[str] = None
1559
+ self,
1560
+ req_id: str,
1561
+ full_id: str = "_default",
1562
+ arch: Optional[str] = None,
1563
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
1515
1564
  ) -> Optional[ResolvedEnvironment]:
1516
1565
  arch = arch or arch_id()
1517
1566
  per_arch_envs = self._resolved_environments.get(arch)
@@ -1520,7 +1569,16 @@ class CachedEnvironmentInfo:
1520
1569
  if per_req_id_envs:
1521
1570
  if full_id == "_default":
1522
1571
  full_id = per_req_id_envs.get("_default", "_invalid") # type: ignore
1523
- return per_req_id_envs.get(full_id) # type: ignore
1572
+ env = per_req_id_envs.get(full_id) # type: ignore
1573
+
1574
+ if (
1575
+ env
1576
+ and isinstance(env, ResolvedEnvironment)
1577
+ and env._full_id_unique_keys != full_id_unique_keys
1578
+ ):
1579
+ env = None
1580
+ return env # type: ignore
1581
+
1524
1582
  return None
1525
1583
 
1526
1584
  def envs_for(
@@ -97,6 +97,7 @@ class EnvsResolver(object):
97
97
  force: bool = False,
98
98
  local_only: bool = False,
99
99
  use_latest: str = CONDA_USE_REMOTE_LATEST,
100
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
100
101
  ) -> Tuple[
101
102
  EnvType,
102
103
  EnvID,
@@ -184,11 +185,14 @@ class EnvsResolver(object):
184
185
  co_resolved=base_env.co_resolved_archs,
185
186
  env_type=base_env.env_type,
186
187
  accurate_source=base_env.is_info_accurate,
188
+ full_id_unique_keys=full_id_unique_keys,
187
189
  )
188
190
  resolved_env.dirty = True
189
191
  else:
190
192
  resolved_env = (
191
- conda.environment(env_id, local_only, use_latest) if not force else None
193
+ conda.environment(env_id, local_only, use_latest, full_id_unique_keys)
194
+ if not force
195
+ else None
192
196
  )
193
197
 
194
198
  return (
@@ -216,6 +220,7 @@ class EnvsResolver(object):
216
220
  use_latest: str = CONDA_USE_REMOTE_LATEST,
217
221
  force: bool = False,
218
222
  force_co_resolve: bool = False,
223
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
219
224
  ):
220
225
  """
221
226
  Add an environment to resolve to this EnvsResolver. The EnvsResolver will resolve
@@ -306,6 +311,7 @@ class EnvsResolver(object):
306
311
  force=force,
307
312
  local_only=local_only,
308
313
  use_latest=use_latest,
314
+ full_id_unique_keys=full_id_unique_keys,
309
315
  )
310
316
 
311
317
  # Check if we have already requested this environment
@@ -331,6 +337,7 @@ class EnvsResolver(object):
331
337
  "sources": user_sources,
332
338
  "extras": extras,
333
339
  "file_paths": file_paths,
340
+ "full_id_unique_keys": full_id_unique_keys,
334
341
  "conda_format": (
335
342
  [CONDA_PREFERRED_FORMAT]
336
343
  if CONDA_PREFERRED_FORMAT and CONDA_PREFERRED_FORMAT != "none"
@@ -764,6 +771,7 @@ class EnvsResolver(object):
764
771
  "resolved": builder_env,
765
772
  "need_caching": builder_env is None,
766
773
  "env_type": EnvType.CONDA_ONLY,
774
+ "full_id_unique_keys": {},
767
775
  }
768
776
  self._builder_envs[builder_env_id] = builder_env_info
769
777
  builders_by_req_id.setdefault(env_id.req_id, []).append(builder_env_id)
@@ -926,6 +934,7 @@ class EnvsResolver(object):
926
934
  builder_envs,
927
935
  env_desc["base"],
928
936
  env_desc["file_paths"],
937
+ env_desc["full_id_unique_keys"],
929
938
  )
930
939
 
931
940
  if env_desc["base"]:
@@ -47,6 +47,8 @@ class CondaResolver(Resolver):
47
47
  builder_envs: Optional[List[ResolvedEnvironment]] = None,
48
48
  base_env: Optional[ResolvedEnvironment] = None,
49
49
  file_paths: Dict[str, List[str]] = {},
50
+ # full_id_unique_keys is not used in CondaResolver.
51
+ full_id_unique_keys: Optional[Dict[str, str]] = None,
50
52
  ) -> Tuple[ResolvedEnvironment, Optional[List[ResolvedEnvironment]]]:
51
53
  if base_env:
52
54
  local_packages = [
@@ -174,6 +176,10 @@ class CondaResolver(Resolver):
174
176
  architecture,
175
177
  all_packages=packages,
176
178
  env_type=env_type,
179
+ # full_id_unique_keys is a uv specific cache invalidation mechanism,
180
+ # and PipResolver shouldn't need it. We still pass this along as {}
181
+ # to make code consistent.
182
+ full_id_unique_keys=full_id_unique_keys,
177
183
  ),
178
184
  builder_envs,
179
185
  )
@@ -62,6 +62,7 @@ class PipResolver(Resolver):
62
62
  builder_envs: Optional[List[ResolvedEnvironment]] = None,
63
63
  base_env: Optional[ResolvedEnvironment] = None,
64
64
  file_paths: Dict[str, List[str]] = {},
65
+ full_id_unique_keys: Dict[str, str] = {},
65
66
  ) -> Tuple[ResolvedEnvironment, Optional[List[ResolvedEnvironment]]]:
66
67
  if base_env:
67
68
  # For base environments, we may have built packages already so for those
@@ -594,6 +595,10 @@ class PipResolver(Resolver):
594
595
  architecture,
595
596
  all_packages=packages,
596
597
  env_type=env_type,
598
+ # full_id_unique_keys is a uv specific cache invalidation mechanism,
599
+ # and PipResolver shouldn't need it. We still pass this along as {}
600
+ # to make code consistent.
601
+ full_id_unique_keys=full_id_unique_keys,
597
602
  ),
598
603
  builder_envs,
599
604
  )
@@ -18,7 +18,7 @@ from metaflow._vendor.packaging.tags import (
18
18
  Tag,
19
19
  )
20
20
 
21
- from metaflow_extensions.netflix_ext.plugins.conda.conda import CondaException
21
+ from ..utils import CondaException
22
22
 
23
23
  from . import Resolver
24
24
  from typing import Callable, Dict, Iterable, List, Any, Optional, Tuple
@@ -1415,6 +1415,18 @@ def filter_user_reqs_by_markers(
1415
1415
  return new_deps
1416
1416
 
1417
1417
 
1418
+ def compute_file_hash(file_path: str) -> str:
1419
+ if not file_path:
1420
+ return ""
1421
+
1422
+ try:
1423
+ with open(file_path, "rb") as f:
1424
+ file_content_hash = hashlib.sha256(f.read()).hexdigest()
1425
+ return file_content_hash
1426
+ except (IOError, OSError):
1427
+ raise IOError(f"Could not read file '{file_path}' for hashing") from None
1428
+
1429
+
1418
1430
  class WithDir:
1419
1431
  # WARNING: os.chdir is not compatible with thread processing so do not use in
1420
1432
  # a context where multiple threads can exist.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metaflow-netflixext
3
- Version: 1.3.2.dev0
3
+ Version: 1.3.4.dev0
4
4
  Summary: Metaflow extensions from Netflix
5
5
  Author: Netflix Metaflow Developers
6
6
  Author-email: metaflow-dev@netflix.com