pipu-cli 0.1.dev7__py3-none-any.whl → 0.2.0__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.
- pipu_cli/__init__.py +2 -2
- pipu_cli/cache.py +316 -0
- pipu_cli/cli.py +863 -813
- pipu_cli/config.py +7 -58
- pipu_cli/config_file.py +80 -0
- pipu_cli/output.py +99 -0
- pipu_cli/package_management.py +1145 -0
- pipu_cli/pretty.py +286 -0
- pipu_cli/requirements.py +100 -0
- pipu_cli/rollback.py +110 -0
- pipu_cli-0.2.0.dist-info/METADATA +422 -0
- pipu_cli-0.2.0.dist-info/RECORD +16 -0
- pipu_cli/common.py +0 -4
- pipu_cli/internals.py +0 -815
- pipu_cli/package_constraints.py +0 -2296
- pipu_cli/thread_safe.py +0 -243
- pipu_cli/ui/__init__.py +0 -51
- pipu_cli/ui/apps.py +0 -1464
- pipu_cli/ui/constants.py +0 -33
- pipu_cli/ui/modal_dialogs.py +0 -1375
- pipu_cli/ui/table_widgets.py +0 -344
- pipu_cli/utils.py +0 -169
- pipu_cli-0.1.dev7.dist-info/METADATA +0 -517
- pipu_cli-0.1.dev7.dist-info/RECORD +0 -19
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/WHEEL +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pipu_cli-0.1.dev7.dist-info → pipu_cli-0.2.0.dist-info}/top_level.txt +0 -0
pipu_cli/package_constraints.py
DELETED
|
@@ -1,2296 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import os
|
|
3
|
-
import configparser
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Dict, Optional, List, Tuple, Set
|
|
6
|
-
import re
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
import importlib.metadata
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# ============================================================================
|
|
13
|
-
# Utility Functions for Common Patterns
|
|
14
|
-
# ============================================================================
|
|
15
|
-
|
|
16
|
-
def _get_installed_packages() -> Set[str]:
|
|
17
|
-
"""
|
|
18
|
-
Get set of all installed package names (canonically normalized).
|
|
19
|
-
|
|
20
|
-
:returns: Set of installed package names in canonical form (lowercase with hyphens)
|
|
21
|
-
:raises RuntimeError: If unable to enumerate installed packages
|
|
22
|
-
"""
|
|
23
|
-
import logging
|
|
24
|
-
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
from packaging.utils import canonicalize_name
|
|
28
|
-
installed_packages = set()
|
|
29
|
-
|
|
30
|
-
for dist in importlib.metadata.distributions():
|
|
31
|
-
try:
|
|
32
|
-
package_name = dist.metadata['Name']
|
|
33
|
-
canonical_name = canonicalize_name(package_name)
|
|
34
|
-
installed_packages.add(canonical_name)
|
|
35
|
-
except (KeyError, AttributeError) as e:
|
|
36
|
-
# Skip packages with malformed metadata
|
|
37
|
-
logger.debug(f"Skipping package with invalid metadata: {e}")
|
|
38
|
-
continue
|
|
39
|
-
except Exception as e:
|
|
40
|
-
# Log unexpected errors but continue
|
|
41
|
-
logger.warning(f"Unexpected error processing package metadata: {e}")
|
|
42
|
-
continue
|
|
43
|
-
|
|
44
|
-
return installed_packages
|
|
45
|
-
except ImportError as e:
|
|
46
|
-
raise RuntimeError(f"Failed to import required packaging module: {e}")
|
|
47
|
-
except OSError as e:
|
|
48
|
-
raise RuntimeError(f"Failed to access package metadata: {e}")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def validate_package_exists(package_name: str, skip_validation: bool = False) -> Tuple[bool, str]:
|
|
52
|
-
"""
|
|
53
|
-
Validate that a package exists in the current environment.
|
|
54
|
-
|
|
55
|
-
:param package_name: Package name to validate
|
|
56
|
-
:param skip_validation: Skip validation (useful for testing)
|
|
57
|
-
:returns: Tuple of (exists, error_message)
|
|
58
|
-
"""
|
|
59
|
-
from .config import SKIP_PACKAGE_VALIDATION
|
|
60
|
-
import logging
|
|
61
|
-
|
|
62
|
-
logger = logging.getLogger(__name__)
|
|
63
|
-
|
|
64
|
-
# Skip validation if requested or configured
|
|
65
|
-
if skip_validation or SKIP_PACKAGE_VALIDATION:
|
|
66
|
-
return True, ""
|
|
67
|
-
|
|
68
|
-
# Check for test environment - be permissive in tests
|
|
69
|
-
import sys
|
|
70
|
-
if 'pytest' in sys.modules or hasattr(sys, '_called_from_test'):
|
|
71
|
-
# In test environment, allow common test packages
|
|
72
|
-
test_packages = {'flask', 'django', 'requests', 'urllib3', 'other', 'third', 'testpackage'}
|
|
73
|
-
if package_name.lower() in test_packages:
|
|
74
|
-
return True, ""
|
|
75
|
-
|
|
76
|
-
try:
|
|
77
|
-
installed_packages = _get_installed_packages()
|
|
78
|
-
normalized_name = package_name.lower()
|
|
79
|
-
|
|
80
|
-
if normalized_name not in installed_packages:
|
|
81
|
-
# In tests, be more permissive
|
|
82
|
-
if 'pytest' in sys.modules:
|
|
83
|
-
return True, "" # Allow in pytest
|
|
84
|
-
return False, f"Package '{package_name}' is not installed in the current environment"
|
|
85
|
-
|
|
86
|
-
return True, ""
|
|
87
|
-
except (OSError, RuntimeError) as e:
|
|
88
|
-
# If we can't get installed packages, log but don't fail
|
|
89
|
-
logger.warning(f"Failed to validate package existence: {e}")
|
|
90
|
-
return True, "" # Be permissive if validation fails
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def validate_constraint_packages(constraint_specs: List[str]) -> Tuple[List[str], List[str]]:
|
|
94
|
-
"""
|
|
95
|
-
Validate that all packages in constraint specifications exist.
|
|
96
|
-
|
|
97
|
-
:param constraint_specs: List of constraint specifications like "package==1.0.0"
|
|
98
|
-
:returns: Tuple of (valid_specs, error_messages)
|
|
99
|
-
"""
|
|
100
|
-
valid_specs = []
|
|
101
|
-
error_messages = []
|
|
102
|
-
|
|
103
|
-
for spec in constraint_specs:
|
|
104
|
-
parsed = parse_requirement_line(spec)
|
|
105
|
-
if not parsed:
|
|
106
|
-
error_messages.append(f"Invalid constraint specification: {spec}")
|
|
107
|
-
continue
|
|
108
|
-
|
|
109
|
-
package_name = parsed['name']
|
|
110
|
-
exists, error_msg = validate_package_exists(package_name)
|
|
111
|
-
if not exists:
|
|
112
|
-
error_messages.append(error_msg)
|
|
113
|
-
continue
|
|
114
|
-
|
|
115
|
-
valid_specs.append(spec)
|
|
116
|
-
|
|
117
|
-
return valid_specs, error_messages
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def validate_existing_constraints_and_triggers(env_name: Optional[str] = None) -> Tuple[List[str], Dict[str, List[str]]]:
|
|
121
|
-
"""
|
|
122
|
-
Validate existing constraints and invalidation triggers for removed/renamed packages.
|
|
123
|
-
|
|
124
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
125
|
-
:returns: Tuple of (invalid_constraint_packages, invalid_trigger_packages_map) where
|
|
126
|
-
invalid_constraint_packages is a list of package names with invalid constraints
|
|
127
|
-
and invalid_trigger_packages_map maps constraint packages to lists of invalid trigger packages
|
|
128
|
-
"""
|
|
129
|
-
logger = logging.getLogger(__name__)
|
|
130
|
-
|
|
131
|
-
invalid_constraint_packages = []
|
|
132
|
-
invalid_trigger_packages = {}
|
|
133
|
-
installed_packages = _get_installed_packages()
|
|
134
|
-
|
|
135
|
-
try:
|
|
136
|
-
# Check if config file exists - if not, there's nothing to validate
|
|
137
|
-
config_path = get_recommended_pip_config_path()
|
|
138
|
-
if not config_path.exists():
|
|
139
|
-
return invalid_constraint_packages, invalid_trigger_packages
|
|
140
|
-
|
|
141
|
-
# Check constraints
|
|
142
|
-
section_name = _get_section_name(env_name)
|
|
143
|
-
config, _ = _load_config(create_if_missing=False)
|
|
144
|
-
|
|
145
|
-
if not config.has_section(section_name):
|
|
146
|
-
return invalid_constraint_packages, invalid_trigger_packages
|
|
147
|
-
|
|
148
|
-
# Check constraint packages
|
|
149
|
-
if config.has_option(section_name, 'constraints'):
|
|
150
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
151
|
-
if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
152
|
-
constraints_dict = parse_inline_constraints(constraints_value)
|
|
153
|
-
for package_name in constraints_dict.keys():
|
|
154
|
-
if package_name not in installed_packages:
|
|
155
|
-
invalid_constraint_packages.append(package_name)
|
|
156
|
-
|
|
157
|
-
# Check invalidation trigger packages
|
|
158
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
159
|
-
if triggers_value and triggers_value.strip():
|
|
160
|
-
all_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
161
|
-
for constrained_package, triggers in all_triggers.items():
|
|
162
|
-
invalid_triggers_for_package = []
|
|
163
|
-
for trigger in triggers:
|
|
164
|
-
parsed = parse_invalidation_trigger(trigger)
|
|
165
|
-
if parsed:
|
|
166
|
-
from packaging.utils import canonicalize_name
|
|
167
|
-
trigger_package = canonicalize_name(parsed['name'])
|
|
168
|
-
if trigger_package not in installed_packages:
|
|
169
|
-
invalid_triggers_for_package.append(trigger_package)
|
|
170
|
-
|
|
171
|
-
if invalid_triggers_for_package:
|
|
172
|
-
invalid_trigger_packages[constrained_package] = invalid_triggers_for_package
|
|
173
|
-
|
|
174
|
-
except (OSError, ValueError, KeyError) as e:
|
|
175
|
-
# If validation fails, log and return empty results to avoid disrupting the app
|
|
176
|
-
logger.warning(f"Failed to validate constraints and triggers: {e}")
|
|
177
|
-
except Exception as e:
|
|
178
|
-
# Unexpected error - log it
|
|
179
|
-
logger.error(f"Unexpected error validating constraints: {e}")
|
|
180
|
-
|
|
181
|
-
return invalid_constraint_packages, invalid_trigger_packages
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def cleanup_invalid_constraints_and_triggers(env_name: Optional[str] = None) -> Tuple[int, int, str]:
|
|
185
|
-
"""
|
|
186
|
-
Remove constraints and triggers for packages that no longer exist.
|
|
187
|
-
|
|
188
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
189
|
-
:returns: Tuple of (removed_constraints_count, removed_triggers_count, summary_message)
|
|
190
|
-
"""
|
|
191
|
-
logger = logging.getLogger(__name__)
|
|
192
|
-
invalid_constraints, invalid_triggers = validate_existing_constraints_and_triggers(env_name)
|
|
193
|
-
|
|
194
|
-
removed_constraints_count = 0
|
|
195
|
-
removed_triggers_count = 0
|
|
196
|
-
|
|
197
|
-
try:
|
|
198
|
-
# Remove invalid constraints
|
|
199
|
-
if invalid_constraints:
|
|
200
|
-
_, removed_constraints, removed_trigger_map = remove_constraints_from_config(invalid_constraints, env_name)
|
|
201
|
-
removed_constraints_count = len(removed_constraints)
|
|
202
|
-
# Count removed triggers from constraint cleanup
|
|
203
|
-
for triggers in removed_trigger_map.values():
|
|
204
|
-
removed_triggers_count += len(triggers)
|
|
205
|
-
|
|
206
|
-
# Clean up remaining invalid triggers (those not removed by constraint cleanup)
|
|
207
|
-
if invalid_triggers:
|
|
208
|
-
section_name = _get_section_name(env_name)
|
|
209
|
-
config, config_path = _load_config(create_if_missing=False)
|
|
210
|
-
|
|
211
|
-
if config.has_section(section_name):
|
|
212
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
213
|
-
if not triggers_value:
|
|
214
|
-
return
|
|
215
|
-
all_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
216
|
-
|
|
217
|
-
# Remove invalid triggers while keeping valid ones
|
|
218
|
-
updated_triggers = {}
|
|
219
|
-
for constrained_package, triggers in all_triggers.items():
|
|
220
|
-
valid_triggers = []
|
|
221
|
-
invalid_packages_for_constraint = invalid_triggers.get(constrained_package, [])
|
|
222
|
-
|
|
223
|
-
for trigger in triggers:
|
|
224
|
-
parsed = parse_invalidation_trigger(trigger)
|
|
225
|
-
if parsed:
|
|
226
|
-
trigger_package = parsed['name'].lower()
|
|
227
|
-
if trigger_package not in invalid_packages_for_constraint:
|
|
228
|
-
valid_triggers.append(trigger)
|
|
229
|
-
else:
|
|
230
|
-
removed_triggers_count += 1
|
|
231
|
-
|
|
232
|
-
if valid_triggers:
|
|
233
|
-
updated_triggers[constrained_package] = valid_triggers
|
|
234
|
-
|
|
235
|
-
# Update config with cleaned triggers
|
|
236
|
-
if updated_triggers:
|
|
237
|
-
# Get current constraints for formatting
|
|
238
|
-
current_constraints = {}
|
|
239
|
-
if config.has_option(section_name, 'constraints'):
|
|
240
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
241
|
-
if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
242
|
-
current_constraints = parse_inline_constraints(constraints_value)
|
|
243
|
-
|
|
244
|
-
# Format trigger entries
|
|
245
|
-
trigger_entries = []
|
|
246
|
-
for package_name, triggers in updated_triggers.items():
|
|
247
|
-
if package_name in current_constraints:
|
|
248
|
-
package_constraint = current_constraints[package_name]
|
|
249
|
-
formatted_entry = format_invalidation_triggers(f"{package_name}{package_constraint}", triggers)
|
|
250
|
-
if formatted_entry:
|
|
251
|
-
trigger_entries.append(formatted_entry)
|
|
252
|
-
|
|
253
|
-
new_triggers_value = ','.join(trigger_entries) if trigger_entries else ''
|
|
254
|
-
_set_constraint_invalid_when(config, section_name, new_triggers_value)
|
|
255
|
-
_write_config_file(config, config_path)
|
|
256
|
-
else:
|
|
257
|
-
# No valid triggers left, remove the option
|
|
258
|
-
_set_constraint_invalid_when(config, section_name, '')
|
|
259
|
-
_write_config_file(config, config_path)
|
|
260
|
-
|
|
261
|
-
except (OSError, configparser.Error) as e:
|
|
262
|
-
# If cleanup fails, log and return what we detected
|
|
263
|
-
logger.warning(f"Failed to clean up invalid constraints: {e}")
|
|
264
|
-
except Exception as e:
|
|
265
|
-
logger.error(f"Unexpected error during constraint cleanup: {e}")
|
|
266
|
-
|
|
267
|
-
# Create summary message
|
|
268
|
-
summary_parts = []
|
|
269
|
-
if removed_constraints_count > 0:
|
|
270
|
-
summary_parts.append(f"{removed_constraints_count} invalid constraint(s)")
|
|
271
|
-
if removed_triggers_count > 0:
|
|
272
|
-
summary_parts.append(f"{removed_triggers_count} invalid trigger(s)")
|
|
273
|
-
|
|
274
|
-
if summary_parts:
|
|
275
|
-
summary_message = f"Removed {' and '.join(summary_parts)} for packages that are no longer installed"
|
|
276
|
-
else:
|
|
277
|
-
summary_message = ""
|
|
278
|
-
|
|
279
|
-
return removed_constraints_count, removed_triggers_count, summary_message
|
|
280
|
-
|
|
281
|
-
def _get_section_name(env_name: Optional[str]) -> str:
|
|
282
|
-
"""
|
|
283
|
-
Determine config section name from environment.
|
|
284
|
-
|
|
285
|
-
:param env_name: Environment name or None to auto-detect
|
|
286
|
-
:returns: Section name for pip config
|
|
287
|
-
"""
|
|
288
|
-
if env_name is None:
|
|
289
|
-
env_name = get_current_environment_name()
|
|
290
|
-
return env_name if env_name else 'global'
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _write_config_file(config: configparser.ConfigParser, config_path: Path) -> None:
|
|
294
|
-
"""
|
|
295
|
-
Write config file with consistent error handling.
|
|
296
|
-
|
|
297
|
-
:param config: ConfigParser instance to write
|
|
298
|
-
:param config_path: Path to write the config file
|
|
299
|
-
:raises IOError: If config file cannot be written
|
|
300
|
-
"""
|
|
301
|
-
try:
|
|
302
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
303
|
-
config.write(f)
|
|
304
|
-
except IOError as e:
|
|
305
|
-
raise IOError(f"Failed to write pip config file '{config_path}': {e}")
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def _load_config(create_if_missing: bool = False) -> Tuple[configparser.ConfigParser, Path]:
|
|
309
|
-
"""
|
|
310
|
-
Load pip config with consistent setup.
|
|
311
|
-
|
|
312
|
-
:param create_if_missing: Whether to allow missing config files
|
|
313
|
-
:returns: Tuple of (ConfigParser instance, config file path)
|
|
314
|
-
"""
|
|
315
|
-
config_path = get_recommended_pip_config_path()
|
|
316
|
-
config = configparser.ConfigParser()
|
|
317
|
-
if config_path.exists():
|
|
318
|
-
config.read(config_path)
|
|
319
|
-
elif not create_if_missing:
|
|
320
|
-
raise ValueError(f"No pip configuration file found at {config_path}")
|
|
321
|
-
return config, config_path
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _get_constraint_invalid_when(config: configparser.ConfigParser, section_name: str) -> Optional[str]:
|
|
325
|
-
"""
|
|
326
|
-
Get constraint_invalid_when value from config with consistent pattern.
|
|
327
|
-
|
|
328
|
-
:param config: ConfigParser instance
|
|
329
|
-
:param section_name: Name of section to read from
|
|
330
|
-
:returns: Constraint invalid when value or None if not present
|
|
331
|
-
"""
|
|
332
|
-
if config.has_option(section_name, 'constraint_invalid_when'):
|
|
333
|
-
return config.get(section_name, 'constraint_invalid_when')
|
|
334
|
-
return None
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _set_constraint_invalid_when(config: configparser.ConfigParser, section_name: str, value: str) -> None:
|
|
338
|
-
"""
|
|
339
|
-
Set constraint_invalid_when value in config.
|
|
340
|
-
|
|
341
|
-
:param config: ConfigParser instance
|
|
342
|
-
:param section_name: Name of section to write to
|
|
343
|
-
:param value: Value to set (will remove option if empty)
|
|
344
|
-
"""
|
|
345
|
-
if value and value.strip():
|
|
346
|
-
config.set(section_name, 'constraint_invalid_when', value)
|
|
347
|
-
elif config.has_option(section_name, 'constraint_invalid_when'):
|
|
348
|
-
config.remove_option(section_name, 'constraint_invalid_when')
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def _ensure_section_exists(config: configparser.ConfigParser, section_name: str) -> None:
|
|
352
|
-
"""
|
|
353
|
-
Ensure a config section exists, creating it if necessary.
|
|
354
|
-
|
|
355
|
-
:param config: ConfigParser instance
|
|
356
|
-
:param section_name: Name of section to ensure exists
|
|
357
|
-
"""
|
|
358
|
-
if not config.has_section(section_name):
|
|
359
|
-
config.add_section(section_name)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _validate_section_exists(config: configparser.ConfigParser, section_name: str, item_type: str) -> None:
|
|
363
|
-
"""
|
|
364
|
-
Validate that a config section exists, raising error if not.
|
|
365
|
-
|
|
366
|
-
:param config: ConfigParser instance
|
|
367
|
-
:param section_name: Name of section to validate
|
|
368
|
-
:param item_type: Type of items (for error message)
|
|
369
|
-
:raises ValueError: If section doesn't exist
|
|
370
|
-
"""
|
|
371
|
-
if not config.has_section(section_name):
|
|
372
|
-
raise ValueError(f"No {item_type} section found for environment '{section_name}'")
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _format_inline_constraints(constraints: Dict[str, str]) -> str:
|
|
376
|
-
"""
|
|
377
|
-
Format constraints dictionary as multiline inline constraints.
|
|
378
|
-
|
|
379
|
-
:param constraints: Dictionary mapping package names to constraints
|
|
380
|
-
:returns: Formatted string for pip config
|
|
381
|
-
"""
|
|
382
|
-
if not constraints:
|
|
383
|
-
return ""
|
|
384
|
-
constraints_lines = [f"{pkg}{constr}" for pkg, constr in sorted(constraints.items())]
|
|
385
|
-
return '\n\t' + '\n\t'.join(constraints_lines)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def _format_inline_ignores(ignores: Set[str]) -> str:
|
|
389
|
-
"""
|
|
390
|
-
Format ignores set as multiline inline ignores.
|
|
391
|
-
|
|
392
|
-
:param ignores: Set of package names to ignore
|
|
393
|
-
:returns: Formatted string for pip config
|
|
394
|
-
"""
|
|
395
|
-
if not ignores:
|
|
396
|
-
return ""
|
|
397
|
-
ignores_lines = sorted(ignores)
|
|
398
|
-
return '\n\t' + '\n\t'.join(ignores_lines)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def _cleanup_invalidation_triggers(config: configparser.ConfigParser, section_name: str, removed_packages: List[str]) -> Dict[str, List[str]]:
|
|
402
|
-
"""
|
|
403
|
-
Clean up invalidation triggers for removed constraint packages.
|
|
404
|
-
|
|
405
|
-
:param config: ConfigParser instance
|
|
406
|
-
:param section_name: Section name to clean triggers from
|
|
407
|
-
:param removed_packages: List of package names that were removed
|
|
408
|
-
:returns: Dictionary mapping package names to their removed triggers
|
|
409
|
-
"""
|
|
410
|
-
removed_triggers = {}
|
|
411
|
-
|
|
412
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
413
|
-
if not triggers_value or not triggers_value.strip():
|
|
414
|
-
return removed_triggers
|
|
415
|
-
|
|
416
|
-
# Parse existing triggers
|
|
417
|
-
existing_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
418
|
-
|
|
419
|
-
# Remove triggers for packages that were removed
|
|
420
|
-
for package in removed_packages:
|
|
421
|
-
if package in existing_triggers:
|
|
422
|
-
removed_triggers[package] = existing_triggers[package]
|
|
423
|
-
del existing_triggers[package]
|
|
424
|
-
|
|
425
|
-
# Update or remove the triggers option
|
|
426
|
-
if existing_triggers:
|
|
427
|
-
# Rebuild the triggers storage format
|
|
428
|
-
trigger_entries = []
|
|
429
|
-
for pkg_name, triggers in existing_triggers.items():
|
|
430
|
-
# Find the constraint for this package to rebuild the storage format
|
|
431
|
-
if config.has_option(section_name, 'constraints'):
|
|
432
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
433
|
-
constraints_dict = parse_inline_constraints(constraints_value)
|
|
434
|
-
if pkg_name in constraints_dict:
|
|
435
|
-
constraint_spec = f"{pkg_name}{constraints_dict[pkg_name]}"
|
|
436
|
-
formatted_entry = format_invalidation_triggers(constraint_spec, triggers)
|
|
437
|
-
if formatted_entry:
|
|
438
|
-
trigger_entries.append(formatted_entry)
|
|
439
|
-
|
|
440
|
-
new_triggers_value = ','.join(trigger_entries) if trigger_entries else ''
|
|
441
|
-
else:
|
|
442
|
-
new_triggers_value = ''
|
|
443
|
-
_set_constraint_invalid_when(config, section_name, new_triggers_value)
|
|
444
|
-
|
|
445
|
-
return removed_triggers
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
# ============================================================================
|
|
449
|
-
# Original Functions
|
|
450
|
-
# ============================================================================
|
|
451
|
-
|
|
452
|
-
def get_current_environment_name() -> Optional[str]:
|
|
453
|
-
"""
|
|
454
|
-
Detect the current virtual environment name.
|
|
455
|
-
|
|
456
|
-
Supports detection of mamba, micromamba, conda, poetry, and virtualenv environments.
|
|
457
|
-
|
|
458
|
-
:returns: Environment name if detected, None otherwise
|
|
459
|
-
"""
|
|
460
|
-
# Check for conda/mamba/micromamba environments
|
|
461
|
-
conda_env = os.environ.get('CONDA_DEFAULT_ENV')
|
|
462
|
-
if conda_env and conda_env != 'base':
|
|
463
|
-
return conda_env
|
|
464
|
-
|
|
465
|
-
# Check for poetry environments
|
|
466
|
-
poetry_env = os.environ.get('POETRY_ACTIVE')
|
|
467
|
-
if poetry_env:
|
|
468
|
-
# Try to get poetry environment name
|
|
469
|
-
try:
|
|
470
|
-
result = subprocess.run(['poetry', 'env', 'info', '--name'],
|
|
471
|
-
capture_output=True, text=True, check=True)
|
|
472
|
-
return result.stdout.strip()
|
|
473
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
474
|
-
pass
|
|
475
|
-
|
|
476
|
-
# Check for virtualenv/venv environments
|
|
477
|
-
virtual_env = os.environ.get('VIRTUAL_ENV')
|
|
478
|
-
if virtual_env:
|
|
479
|
-
return Path(virtual_env).name
|
|
480
|
-
|
|
481
|
-
return None
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def get_pip_config_paths() -> List[Path]:
|
|
485
|
-
"""
|
|
486
|
-
Get possible pip configuration file paths.
|
|
487
|
-
|
|
488
|
-
:returns: List of possible pip config file paths in order of precedence
|
|
489
|
-
"""
|
|
490
|
-
paths = []
|
|
491
|
-
|
|
492
|
-
# User-specific config
|
|
493
|
-
if sys.platform == "win32":
|
|
494
|
-
appdata = os.environ.get('APPDATA')
|
|
495
|
-
if appdata:
|
|
496
|
-
paths.append(Path(appdata) / 'pip' / 'pip.ini')
|
|
497
|
-
else:
|
|
498
|
-
home = Path.home()
|
|
499
|
-
paths.extend([
|
|
500
|
-
home / '.config' / 'pip' / 'pip.conf',
|
|
501
|
-
home / '.pip' / 'pip.conf'
|
|
502
|
-
])
|
|
503
|
-
|
|
504
|
-
# Global config
|
|
505
|
-
if sys.platform == "win32":
|
|
506
|
-
# Use proper Windows system drive (usually C:, but can be different)
|
|
507
|
-
systemdrive = os.environ.get('SYSTEMDRIVE', 'C:')
|
|
508
|
-
paths.append(Path(systemdrive) / 'ProgramData' / 'pip' / 'pip.ini')
|
|
509
|
-
else:
|
|
510
|
-
paths.extend([
|
|
511
|
-
Path('/etc/pip.conf'),
|
|
512
|
-
Path('/etc/pip/pip.conf')
|
|
513
|
-
])
|
|
514
|
-
|
|
515
|
-
return paths
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
def read_pip_config_ignore(env_name: Optional[str] = None) -> Optional[Tuple[str, str]]:
|
|
519
|
-
"""
|
|
520
|
-
Read ignore file path or ignore list from pip configuration.
|
|
521
|
-
|
|
522
|
-
Checks for ignore setting in environment-specific section first,
|
|
523
|
-
then falls back to [global] section. If "ignores" contains newlines or
|
|
524
|
-
multiple packages, it is treated as inline ignores. Otherwise it is
|
|
525
|
-
treated as a file path.
|
|
526
|
-
|
|
527
|
-
:param env_name: Environment name to look for specific ignore setting
|
|
528
|
-
:returns: Tuple of (type, value) where type is 'file' or 'inline', None if not found
|
|
529
|
-
"""
|
|
530
|
-
for config_path in get_pip_config_paths():
|
|
531
|
-
if not config_path.exists():
|
|
532
|
-
continue
|
|
533
|
-
|
|
534
|
-
try:
|
|
535
|
-
config = configparser.ConfigParser()
|
|
536
|
-
config.read(config_path)
|
|
537
|
-
|
|
538
|
-
# Check environment-specific section first
|
|
539
|
-
if env_name and config.has_section(env_name):
|
|
540
|
-
if config.has_option(env_name, 'ignore'):
|
|
541
|
-
value = config.get(env_name, 'ignore')
|
|
542
|
-
return ('file', value)
|
|
543
|
-
if config.has_option(env_name, 'ignores'):
|
|
544
|
-
value = config.get(env_name, 'ignores')
|
|
545
|
-
# Check if it looks like inline ignores (contains newlines or multiple packages)
|
|
546
|
-
if '\n' in value or ' ' in value.strip():
|
|
547
|
-
return ('inline', value)
|
|
548
|
-
else:
|
|
549
|
-
return ('file', value)
|
|
550
|
-
|
|
551
|
-
# Fall back to global section
|
|
552
|
-
if config.has_section('global'):
|
|
553
|
-
if config.has_option('global', 'ignore'):
|
|
554
|
-
value = config.get('global', 'ignore')
|
|
555
|
-
return ('file', value)
|
|
556
|
-
if config.has_option('global', 'ignores'):
|
|
557
|
-
value = config.get('global', 'ignores')
|
|
558
|
-
# Check if it looks like inline ignores
|
|
559
|
-
if '\n' in value or ' ' in value.strip():
|
|
560
|
-
return ('inline', value)
|
|
561
|
-
else:
|
|
562
|
-
return ('file', value)
|
|
563
|
-
|
|
564
|
-
except (configparser.Error, IOError):
|
|
565
|
-
continue
|
|
566
|
-
|
|
567
|
-
return None
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def read_pip_config_constraint(env_name: Optional[str] = None) -> Optional[Tuple[str, str]]:
|
|
571
|
-
"""
|
|
572
|
-
Read constraint file path or constraint list from pip configuration.
|
|
573
|
-
|
|
574
|
-
Checks for constraint setting in environment-specific section first,
|
|
575
|
-
then falls back to [global] section. If "constraints" contains newlines or
|
|
576
|
-
multiple lines, it is treated as inline constraints. Otherwise it is
|
|
577
|
-
treated as a file path.
|
|
578
|
-
|
|
579
|
-
:param env_name: Environment name to look for specific constraint
|
|
580
|
-
:returns: Tuple of (type, value) where type is 'file' or 'inline', None if not found
|
|
581
|
-
"""
|
|
582
|
-
for config_path in get_pip_config_paths():
|
|
583
|
-
if not config_path.exists():
|
|
584
|
-
continue
|
|
585
|
-
|
|
586
|
-
try:
|
|
587
|
-
config = configparser.ConfigParser()
|
|
588
|
-
config.read(config_path)
|
|
589
|
-
|
|
590
|
-
# Check environment-specific section first
|
|
591
|
-
if env_name and config.has_section(env_name):
|
|
592
|
-
if config.has_option(env_name, 'constraint'):
|
|
593
|
-
value = config.get(env_name, 'constraint')
|
|
594
|
-
return ('file', value)
|
|
595
|
-
if config.has_option(env_name, 'constraints'):
|
|
596
|
-
value = config.get(env_name, 'constraints')
|
|
597
|
-
# Check if it looks like inline constraints (contains newlines or multiple packages)
|
|
598
|
-
if '\n' in value or any(op in value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
599
|
-
return ('inline', value)
|
|
600
|
-
else:
|
|
601
|
-
return ('file', value)
|
|
602
|
-
|
|
603
|
-
# Fall back to global section
|
|
604
|
-
if config.has_section('global'):
|
|
605
|
-
if config.has_option('global', 'constraint'):
|
|
606
|
-
value = config.get('global', 'constraint')
|
|
607
|
-
return ('file', value)
|
|
608
|
-
if config.has_option('global', 'constraints'):
|
|
609
|
-
value = config.get('global', 'constraints')
|
|
610
|
-
# Check if it looks like inline constraints
|
|
611
|
-
if '\n' in value or any(op in value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
612
|
-
return ('inline', value)
|
|
613
|
-
else:
|
|
614
|
-
return ('file', value)
|
|
615
|
-
|
|
616
|
-
except (configparser.Error, IOError):
|
|
617
|
-
continue
|
|
618
|
-
|
|
619
|
-
return None
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def find_project_root() -> Optional[Path]:
|
|
623
|
-
"""
|
|
624
|
-
Find the project root directory by looking for pyproject.toml or setup.py.
|
|
625
|
-
|
|
626
|
-
Starts from the current working directory and walks up the directory tree
|
|
627
|
-
until it finds a directory containing pyproject.toml or setup.py.
|
|
628
|
-
|
|
629
|
-
:returns: Path to the project root directory, or None if not found
|
|
630
|
-
"""
|
|
631
|
-
current_dir = Path.cwd()
|
|
632
|
-
|
|
633
|
-
# Walk up the directory tree
|
|
634
|
-
for parent in [current_dir] + list(current_dir.parents):
|
|
635
|
-
if (parent / "pyproject.toml").exists() or (parent / "setup.py").exists():
|
|
636
|
-
return parent
|
|
637
|
-
|
|
638
|
-
return None
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def parse_requirement_line(line: str) -> Optional[Dict[str, str]]:
|
|
642
|
-
"""
|
|
643
|
-
Parse a single requirement line from constraints file.
|
|
644
|
-
|
|
645
|
-
Supports basic requirement formats like:
|
|
646
|
-
- package==1.0.0
|
|
647
|
-
- package>=1.0.0,<2.0.0
|
|
648
|
-
- package~=1.0.0
|
|
649
|
-
|
|
650
|
-
:param line: A single line from the constraints file
|
|
651
|
-
:returns: Dictionary with 'name' and 'constraint' keys, or None if invalid
|
|
652
|
-
"""
|
|
653
|
-
# Remove comments and whitespace
|
|
654
|
-
line = line.split('#')[0].strip()
|
|
655
|
-
|
|
656
|
-
if not line:
|
|
657
|
-
return None
|
|
658
|
-
|
|
659
|
-
# Basic regex to match package name and version constraints
|
|
660
|
-
# Package names must start with a letter, then can contain letters, numbers, hyphens, underscores, dots
|
|
661
|
-
# Matches patterns like: package==1.0.0, package>=1.0.0,<2.0.0, package>1.0, "package == 1.0.0" etc.
|
|
662
|
-
# Does not allow additional package names in the constraint part
|
|
663
|
-
pattern = r'^([a-zA-Z][a-zA-Z0-9._-]*)\s*([><=!~][=]?\s*[0-9][0-9a-zA-Z.,:;\s*+-]*(?:[,\s]*[><=!~][=]?\s*[0-9][0-9a-zA-Z.,:;\s*+-]*)*)$'
|
|
664
|
-
match = re.match(pattern, line)
|
|
665
|
-
|
|
666
|
-
if match:
|
|
667
|
-
package_name = match.group(1).strip().lower() # Normalize to lowercase
|
|
668
|
-
constraint = match.group(2).strip()
|
|
669
|
-
|
|
670
|
-
# Additional validation: check if constraint contains what looks like another package name
|
|
671
|
-
# This prevents parsing lines like "requests==2.25.0 numpy>=1.20.0" as valid
|
|
672
|
-
if re.search(r'\s+[a-zA-Z][a-zA-Z0-9._-]*[><=!~]', constraint):
|
|
673
|
-
return None # Invalid - contains multiple package specifications
|
|
674
|
-
|
|
675
|
-
return {
|
|
676
|
-
'name': package_name,
|
|
677
|
-
'constraint': constraint
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return None
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def parse_inline_ignores(ignores_text: str) -> Set[str]:
|
|
684
|
-
"""
|
|
685
|
-
Parse inline ignores from a text string.
|
|
686
|
-
|
|
687
|
-
Supports space-separated or newline-separated package names like:
|
|
688
|
-
- "requests numpy flask"
|
|
689
|
-
- "requests\nnumpy\nflask"
|
|
690
|
-
|
|
691
|
-
:param ignores_text: Text containing package names to ignore
|
|
692
|
-
:returns: Set of package names to ignore (normalized to lowercase)
|
|
693
|
-
"""
|
|
694
|
-
ignores = set()
|
|
695
|
-
|
|
696
|
-
# Split by both newlines and spaces, handle multiple whitespace
|
|
697
|
-
for line in ignores_text.strip().split('\n'):
|
|
698
|
-
line = line.strip()
|
|
699
|
-
if not line or line.startswith('#'):
|
|
700
|
-
continue
|
|
701
|
-
|
|
702
|
-
# Split by whitespace to handle space-separated package names
|
|
703
|
-
for package_name in line.split():
|
|
704
|
-
package_name = package_name.strip()
|
|
705
|
-
if package_name and not package_name.startswith('#'):
|
|
706
|
-
# Normalize package name to canonical form
|
|
707
|
-
from packaging.utils import canonicalize_name
|
|
708
|
-
ignores.add(canonicalize_name(package_name))
|
|
709
|
-
|
|
710
|
-
return ignores
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
def parse_inline_constraints(constraints_text: str) -> Dict[str, str]:
|
|
714
|
-
"""
|
|
715
|
-
Parse inline constraints from a text string.
|
|
716
|
-
|
|
717
|
-
:param constraints_text: String containing constraint specifications, one per line
|
|
718
|
-
:returns: Dictionary mapping package names to version constraints
|
|
719
|
-
"""
|
|
720
|
-
constraints = {}
|
|
721
|
-
|
|
722
|
-
# Split by newlines and process each line
|
|
723
|
-
lines = constraints_text.split('\n')
|
|
724
|
-
for line_num, line in enumerate(lines, 1):
|
|
725
|
-
parsed = parse_requirement_line(line)
|
|
726
|
-
if parsed:
|
|
727
|
-
from packaging.utils import canonicalize_name
|
|
728
|
-
package_name = canonicalize_name(parsed['name']) # Normalize to canonical form
|
|
729
|
-
constraint = parsed['constraint']
|
|
730
|
-
|
|
731
|
-
if package_name in constraints:
|
|
732
|
-
# Log warning about duplicate but don't fail
|
|
733
|
-
print(f"Warning: Duplicate constraint for '{package_name}' in inline constraints line {line_num}")
|
|
734
|
-
|
|
735
|
-
constraints[package_name] = constraint
|
|
736
|
-
|
|
737
|
-
return constraints
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def read_ignores_file(ignores_path: str) -> List[str]:
|
|
741
|
-
"""
|
|
742
|
-
Read and parse an ignores file.
|
|
743
|
-
|
|
744
|
-
Each line should contain a package name to ignore. Supports comments with #.
|
|
745
|
-
|
|
746
|
-
:param ignores_path: Path to the ignores file
|
|
747
|
-
:returns: List of package names to ignore (normalized to lowercase)
|
|
748
|
-
:raises FileNotFoundError: If the ignores file doesn't exist
|
|
749
|
-
:raises PermissionError: If the ignores file can't be read
|
|
750
|
-
"""
|
|
751
|
-
ignores = []
|
|
752
|
-
|
|
753
|
-
try:
|
|
754
|
-
with open(ignores_path, 'r', encoding='utf-8') as f:
|
|
755
|
-
for _, line in enumerate(f, 1):
|
|
756
|
-
line = line.strip()
|
|
757
|
-
|
|
758
|
-
# Skip empty lines and comments
|
|
759
|
-
if not line or line.startswith('#'):
|
|
760
|
-
continue
|
|
761
|
-
|
|
762
|
-
# Remove inline comments
|
|
763
|
-
if '#' in line:
|
|
764
|
-
line = line.split('#')[0].strip()
|
|
765
|
-
|
|
766
|
-
# Extract package name (ignore any version specs or extras for ignores)
|
|
767
|
-
package_name = line.split()[0] if line.split() else ''
|
|
768
|
-
if package_name:
|
|
769
|
-
from packaging.utils import canonicalize_name
|
|
770
|
-
ignores.append(canonicalize_name(package_name))
|
|
771
|
-
|
|
772
|
-
except (FileNotFoundError, PermissionError) as e:
|
|
773
|
-
# Re-raise these as they indicate configuration issues
|
|
774
|
-
raise e
|
|
775
|
-
except Exception as e:
|
|
776
|
-
# For other errors (encoding issues, etc.), log and continue
|
|
777
|
-
print(f"Warning: Error reading ignores file {ignores_path}: {e}")
|
|
778
|
-
return []
|
|
779
|
-
|
|
780
|
-
return ignores
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
def read_constraints_file(constraints_path: str) -> Dict[str, str]:
|
|
784
|
-
"""
|
|
785
|
-
Read and parse a constraints file.
|
|
786
|
-
|
|
787
|
-
:param constraints_path: Path to the constraints file
|
|
788
|
-
:returns: Dictionary mapping package names to version constraints
|
|
789
|
-
:raises IOError: If the constraints file cannot be read
|
|
790
|
-
"""
|
|
791
|
-
constraints = {}
|
|
792
|
-
|
|
793
|
-
try:
|
|
794
|
-
with open(constraints_path, 'r', encoding='utf-8') as f:
|
|
795
|
-
for line_num, line in enumerate(f, 1):
|
|
796
|
-
parsed = parse_requirement_line(line)
|
|
797
|
-
if parsed:
|
|
798
|
-
package_name = parsed['name'].lower() # Normalize to lowercase
|
|
799
|
-
constraint = parsed['constraint']
|
|
800
|
-
|
|
801
|
-
if package_name in constraints:
|
|
802
|
-
# Log warning about duplicate but don't fail
|
|
803
|
-
print(f"Warning: Duplicate constraint for '{package_name}' on line {line_num}")
|
|
804
|
-
|
|
805
|
-
constraints[package_name] = constraint
|
|
806
|
-
|
|
807
|
-
except IOError as e:
|
|
808
|
-
raise IOError(f"Failed to read constraints file '{constraints_path}': {e}")
|
|
809
|
-
|
|
810
|
-
return constraints
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
def read_constraints(constraints_file: str = "constraints.txt", include_auto: bool = True) -> Dict[str, str]:
|
|
814
|
-
"""
|
|
815
|
-
Read package constraints from various sources in order of preference.
|
|
816
|
-
|
|
817
|
-
Searches for constraint files or inline constraints in the following order:
|
|
818
|
-
1. PIP_CONSTRAINT environment variable - if set, uses the specified file path
|
|
819
|
-
2. Pip configuration file - looks for 'constraint' or 'constraints' setting in
|
|
820
|
-
environment-specific section (e.g., [main] for the 'main' environment) based on
|
|
821
|
-
detected virtual environment (supports mamba, micromamba, conda, poetry, virtualenv)
|
|
822
|
-
3. Pip configuration file - falls back to 'constraint' or 'constraints' setting in [global] section
|
|
823
|
-
4. Project root constraints file - legacy fallback, looks for constraints file in
|
|
824
|
-
the project root directory (where pyproject.toml or setup.py is located)
|
|
825
|
-
5. Auto-discovered constraints (if include_auto=True) - automatically discovers constraints
|
|
826
|
-
from currently installed packages and their dependencies
|
|
827
|
-
|
|
828
|
-
The pip configuration format expected is:
|
|
829
|
-
[environment_name]
|
|
830
|
-
constraint = /path/to/constraints.txt
|
|
831
|
-
constraints = /path/to/constraints.txt
|
|
832
|
-
# OR for inline constraints:
|
|
833
|
-
constraints =
|
|
834
|
-
requests>=2.25.0,<3.0.0
|
|
835
|
-
numpy>=1.20.0
|
|
836
|
-
|
|
837
|
-
[global]
|
|
838
|
-
constraint = /path/to/constraints.txt
|
|
839
|
-
constraints = /path/to/constraints.txt
|
|
840
|
-
# OR for inline constraints:
|
|
841
|
-
constraints =
|
|
842
|
-
requests>=2.25.0,<3.0.0
|
|
843
|
-
numpy>=1.20.0
|
|
844
|
-
|
|
845
|
-
:param constraints_file: Name of the constraints file to read (used for legacy fallback only)
|
|
846
|
-
:param include_auto: If True, automatically discover and merge constraints from installed packages
|
|
847
|
-
:returns: Dictionary mapping package names to version constraints, empty dict if no constraints found
|
|
848
|
-
"""
|
|
849
|
-
# 1. Check PIP_CONSTRAINT environment variable first
|
|
850
|
-
manual_constraints = {}
|
|
851
|
-
pip_constraint_env = os.environ.get('PIP_CONSTRAINT')
|
|
852
|
-
if pip_constraint_env:
|
|
853
|
-
constraint_path = Path(pip_constraint_env)
|
|
854
|
-
if constraint_path.exists():
|
|
855
|
-
manual_constraints = read_constraints_file(str(constraint_path))
|
|
856
|
-
|
|
857
|
-
# 2. Check pip configuration file (if not found from PIP_CONSTRAINT)
|
|
858
|
-
if not manual_constraints:
|
|
859
|
-
env_name = get_current_environment_name()
|
|
860
|
-
pip_config_result = read_pip_config_constraint(env_name)
|
|
861
|
-
if pip_config_result:
|
|
862
|
-
constraint_type, constraint_value = pip_config_result
|
|
863
|
-
if constraint_type == 'inline':
|
|
864
|
-
manual_constraints = parse_inline_constraints(constraint_value)
|
|
865
|
-
elif constraint_type == 'file':
|
|
866
|
-
constraint_path = Path(constraint_value)
|
|
867
|
-
if constraint_path.exists():
|
|
868
|
-
manual_constraints = read_constraints_file(str(constraint_path))
|
|
869
|
-
|
|
870
|
-
# 3. Legacy fallback: look in project root (if not found from config)
|
|
871
|
-
if not manual_constraints:
|
|
872
|
-
project_root = find_project_root()
|
|
873
|
-
if project_root is not None:
|
|
874
|
-
constraints_path = project_root / constraints_file
|
|
875
|
-
if constraints_path.exists():
|
|
876
|
-
manual_constraints = read_constraints_file(str(constraints_path))
|
|
877
|
-
|
|
878
|
-
# 4. If include_auto is True, discover and merge auto-constraints
|
|
879
|
-
if include_auto:
|
|
880
|
-
auto_constraints_list = discover_auto_constraints()
|
|
881
|
-
auto_constraints = {}
|
|
882
|
-
for constraint_spec, _ in auto_constraints_list:
|
|
883
|
-
parsed = parse_requirement_line(constraint_spec)
|
|
884
|
-
if parsed:
|
|
885
|
-
package_name = parsed['name'].lower()
|
|
886
|
-
constraint = parsed['constraint']
|
|
887
|
-
# Only add auto-constraint if no manual constraint exists for this package
|
|
888
|
-
if package_name not in manual_constraints:
|
|
889
|
-
auto_constraints[package_name] = constraint
|
|
890
|
-
|
|
891
|
-
# Merge auto-constraints with manual constraints (manual takes precedence)
|
|
892
|
-
merged_constraints = auto_constraints.copy()
|
|
893
|
-
merged_constraints.update(manual_constraints)
|
|
894
|
-
return merged_constraints
|
|
895
|
-
|
|
896
|
-
return manual_constraints
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
def get_auto_constraint_triggers() -> Dict[str, List[str]]:
|
|
900
|
-
"""
|
|
901
|
-
Get invalidation triggers for auto-discovered constraints.
|
|
902
|
-
|
|
903
|
-
Returns a mapping of package names to their invalidation trigger conditions.
|
|
904
|
-
These are transient and not stored in config - they're discovered on each run.
|
|
905
|
-
|
|
906
|
-
IMPORTANT: Only returns triggers for packages that DON'T have manual constraints.
|
|
907
|
-
If a package has a manual constraint, we respect that and don't add auto triggers.
|
|
908
|
-
|
|
909
|
-
:returns: Dictionary mapping canonical package names to lists of invalidation triggers
|
|
910
|
-
Example: {'urllib3': ['requests>2.28.0'], 'numpy': ['pandas>1.5.0']}
|
|
911
|
-
"""
|
|
912
|
-
# Get manual constraints to exclude them from auto triggers
|
|
913
|
-
manual_constraints = read_constraints(include_auto=False)
|
|
914
|
-
|
|
915
|
-
auto_constraints_list = discover_auto_constraints()
|
|
916
|
-
triggers_map = {}
|
|
917
|
-
|
|
918
|
-
for constraint_spec, invalidation_trigger in auto_constraints_list:
|
|
919
|
-
parsed = parse_requirement_line(constraint_spec)
|
|
920
|
-
if parsed:
|
|
921
|
-
package_name = parsed['name'].lower()
|
|
922
|
-
|
|
923
|
-
# Skip if this package has a manual constraint - respect the manual constraint
|
|
924
|
-
if package_name in manual_constraints:
|
|
925
|
-
continue
|
|
926
|
-
|
|
927
|
-
if package_name not in triggers_map:
|
|
928
|
-
triggers_map[package_name] = []
|
|
929
|
-
triggers_map[package_name].append(invalidation_trigger)
|
|
930
|
-
|
|
931
|
-
return triggers_map
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
def get_recommended_pip_config_path() -> Path:
|
|
935
|
-
"""
|
|
936
|
-
Get the recommended pip configuration file path for the current platform.
|
|
937
|
-
|
|
938
|
-
Returns the user-specific config path that pip would check first.
|
|
939
|
-
Creates parent directories if they don't exist.
|
|
940
|
-
|
|
941
|
-
:returns: Path to the recommended pip config file
|
|
942
|
-
"""
|
|
943
|
-
if sys.platform == "win32":
|
|
944
|
-
appdata = os.environ.get('APPDATA')
|
|
945
|
-
if appdata:
|
|
946
|
-
config_dir = Path(appdata) / 'pip'
|
|
947
|
-
config_path = config_dir / 'pip.ini'
|
|
948
|
-
else:
|
|
949
|
-
# Fallback if APPDATA is not set
|
|
950
|
-
config_dir = Path.home() / 'AppData' / 'Roaming' / 'pip'
|
|
951
|
-
config_path = config_dir / 'pip.ini'
|
|
952
|
-
else:
|
|
953
|
-
config_dir = Path.home() / '.config' / 'pip'
|
|
954
|
-
config_path = config_dir / 'pip.conf'
|
|
955
|
-
|
|
956
|
-
# Create parent directory if it doesn't exist
|
|
957
|
-
config_dir.mkdir(parents=True, exist_ok=True)
|
|
958
|
-
|
|
959
|
-
return config_path
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
def add_constraints_to_config(
|
|
963
|
-
constraint_specs: List[str],
|
|
964
|
-
env_name: Optional[str] = None,
|
|
965
|
-
skip_validation: bool = False
|
|
966
|
-
) -> Tuple[Path, Dict[str, Tuple[str, str]]]:
|
|
967
|
-
"""
|
|
968
|
-
Add or update constraints in the pip configuration file.
|
|
969
|
-
|
|
970
|
-
Parses constraint specifications and adds them to the appropriate section
|
|
971
|
-
in the pip config file. If constraints already exist for a package, they
|
|
972
|
-
are updated. Uses inline constraints format in the config file.
|
|
973
|
-
|
|
974
|
-
:param constraint_specs: List of constraint strings like "package==1.0.0"
|
|
975
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
976
|
-
:param skip_validation: If True, skip validation that packages exist (useful for auto-discovered constraints)
|
|
977
|
-
:returns: Tuple of (config_file_path, changes_dict) where changes_dict maps
|
|
978
|
-
package names to (action, constraint) tuples. Actions are 'added' or 'updated'.
|
|
979
|
-
:raises ValueError: If constraint specifications are invalid or packages don't exist (unless skip_validation=True)
|
|
980
|
-
:raises IOError: If config file cannot be written
|
|
981
|
-
"""
|
|
982
|
-
# Validate that all packages exist before processing (unless skipped)
|
|
983
|
-
if skip_validation:
|
|
984
|
-
valid_specs = constraint_specs
|
|
985
|
-
else:
|
|
986
|
-
valid_specs, error_messages = validate_constraint_packages(constraint_specs)
|
|
987
|
-
if error_messages:
|
|
988
|
-
raise ValueError(f"Package validation failed: {'; '.join(error_messages)}")
|
|
989
|
-
|
|
990
|
-
# Parse all constraint specifications
|
|
991
|
-
parsed_constraints = {}
|
|
992
|
-
for spec in valid_specs:
|
|
993
|
-
parsed = parse_requirement_line(spec)
|
|
994
|
-
if not parsed:
|
|
995
|
-
raise ValueError(f"Invalid constraint specification: {spec}")
|
|
996
|
-
|
|
997
|
-
package_name = parsed['name'].lower()
|
|
998
|
-
constraint = parsed['constraint']
|
|
999
|
-
parsed_constraints[package_name] = constraint
|
|
1000
|
-
|
|
1001
|
-
# Use utility functions for common patterns
|
|
1002
|
-
section_name = _get_section_name(env_name)
|
|
1003
|
-
config, config_path = _load_config(create_if_missing=True)
|
|
1004
|
-
_ensure_section_exists(config, section_name)
|
|
1005
|
-
|
|
1006
|
-
# Get existing constraints from the config
|
|
1007
|
-
existing_constraints = {}
|
|
1008
|
-
if config.has_option(section_name, 'constraints'):
|
|
1009
|
-
existing_value = config.get(section_name, 'constraints')
|
|
1010
|
-
# Check if it's inline constraints (contains operators)
|
|
1011
|
-
if any(op in existing_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
1012
|
-
existing_constraints = parse_inline_constraints(existing_value)
|
|
1013
|
-
|
|
1014
|
-
# Track changes
|
|
1015
|
-
changes = {}
|
|
1016
|
-
|
|
1017
|
-
# Add or update constraints
|
|
1018
|
-
for package_name, constraint in parsed_constraints.items():
|
|
1019
|
-
if package_name in existing_constraints:
|
|
1020
|
-
if existing_constraints[package_name] != constraint:
|
|
1021
|
-
changes[package_name] = ('updated', constraint)
|
|
1022
|
-
existing_constraints[package_name] = constraint
|
|
1023
|
-
# If constraint is the same, no change needed
|
|
1024
|
-
else:
|
|
1025
|
-
changes[package_name] = ('added', constraint)
|
|
1026
|
-
existing_constraints[package_name] = constraint
|
|
1027
|
-
|
|
1028
|
-
# Update config with formatted constraints
|
|
1029
|
-
if existing_constraints:
|
|
1030
|
-
constraints_value = _format_inline_constraints(existing_constraints)
|
|
1031
|
-
config.set(section_name, 'constraints', constraints_value)
|
|
1032
|
-
|
|
1033
|
-
# Write the config file using utility function
|
|
1034
|
-
_write_config_file(config, config_path)
|
|
1035
|
-
|
|
1036
|
-
return config_path, changes
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
def remove_constraints_from_config(
|
|
1040
|
-
package_names: List[str],
|
|
1041
|
-
env_name: Optional[str] = None
|
|
1042
|
-
) -> Tuple[Path, Dict[str, str], Dict[str, List[str]]]:
|
|
1043
|
-
"""
|
|
1044
|
-
Remove constraints from the pip configuration file.
|
|
1045
|
-
|
|
1046
|
-
Removes constraints for specified packages from the appropriate section
|
|
1047
|
-
in the pip config file. If no constraints remain, removes the constraints
|
|
1048
|
-
option from the section.
|
|
1049
|
-
|
|
1050
|
-
:param package_names: List of package names to remove constraints for
|
|
1051
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
1052
|
-
:returns: Tuple of (config_file_path, removed_constraints_dict, removed_triggers_dict) where
|
|
1053
|
-
removed_constraints_dict maps package names to their removed constraint values and
|
|
1054
|
-
removed_triggers_dict maps package names to their removed trigger lists
|
|
1055
|
-
:raises ValueError: If no constraints exist for specified packages
|
|
1056
|
-
:raises IOError: If config file cannot be written
|
|
1057
|
-
"""
|
|
1058
|
-
# Normalize package names to lowercase
|
|
1059
|
-
package_names = [name.lower() for name in package_names]
|
|
1060
|
-
|
|
1061
|
-
# Use utility functions for common patterns
|
|
1062
|
-
section_name = _get_section_name(env_name)
|
|
1063
|
-
config, config_path = _load_config(create_if_missing=False)
|
|
1064
|
-
_validate_section_exists(config, section_name, "constraints")
|
|
1065
|
-
|
|
1066
|
-
# Get existing constraints from the config
|
|
1067
|
-
existing_constraints = {}
|
|
1068
|
-
if config.has_option(section_name, 'constraints'):
|
|
1069
|
-
existing_value = config.get(section_name, 'constraints')
|
|
1070
|
-
# Check if it's inline constraints (contains newlines or operators)
|
|
1071
|
-
if '\n' in existing_value or any(op in existing_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
1072
|
-
existing_constraints = parse_inline_constraints(existing_value)
|
|
1073
|
-
|
|
1074
|
-
if not existing_constraints:
|
|
1075
|
-
raise ValueError(f"No constraints found in environment '{section_name}'")
|
|
1076
|
-
|
|
1077
|
-
# Track what was removed
|
|
1078
|
-
removed_constraints = {}
|
|
1079
|
-
|
|
1080
|
-
# Remove constraints for specified packages
|
|
1081
|
-
for package_name in package_names:
|
|
1082
|
-
if package_name in existing_constraints:
|
|
1083
|
-
removed_constraints[package_name] = existing_constraints[package_name]
|
|
1084
|
-
del existing_constraints[package_name]
|
|
1085
|
-
else:
|
|
1086
|
-
# Package not found in constraints - this is not an error, just skip
|
|
1087
|
-
continue
|
|
1088
|
-
|
|
1089
|
-
if not removed_constraints:
|
|
1090
|
-
raise ValueError(f"None of the specified packages have constraints in environment '{section_name}'")
|
|
1091
|
-
|
|
1092
|
-
# Clean up associated invalidation triggers and capture what was removed
|
|
1093
|
-
removed_triggers = _cleanup_invalidation_triggers(config, section_name, list(removed_constraints.keys()))
|
|
1094
|
-
|
|
1095
|
-
# Update config with remaining constraints
|
|
1096
|
-
if existing_constraints:
|
|
1097
|
-
constraints_value = _format_inline_constraints(existing_constraints)
|
|
1098
|
-
config.set(section_name, 'constraints', constraints_value)
|
|
1099
|
-
else:
|
|
1100
|
-
# No constraints left, remove the constraints option
|
|
1101
|
-
config.remove_option(section_name, 'constraints')
|
|
1102
|
-
|
|
1103
|
-
# If section is now empty, remove it
|
|
1104
|
-
if not config.options(section_name):
|
|
1105
|
-
config.remove_section(section_name)
|
|
1106
|
-
|
|
1107
|
-
# Write the config file using utility function
|
|
1108
|
-
_write_config_file(config, config_path)
|
|
1109
|
-
|
|
1110
|
-
return config_path, removed_constraints, removed_triggers
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
def remove_all_constraints_from_config(env_name: Optional[str] = None) -> Tuple[Path, Dict[str, Dict[str, str]], Dict[str, Dict[str, List[str]]]]:
|
|
1114
|
-
"""
|
|
1115
|
-
Remove all constraints from pip configuration file.
|
|
1116
|
-
|
|
1117
|
-
If env_name is provided, removes all constraints from that environment.
|
|
1118
|
-
If env_name is None, removes all constraints from all environments.
|
|
1119
|
-
|
|
1120
|
-
:param env_name: Environment name for section, or None to remove from all environments
|
|
1121
|
-
:returns: Tuple of (config_file_path, removed_constraints_dict, removed_triggers_dict) where
|
|
1122
|
-
removed_constraints_dict maps environment names to their removed constraint dictionaries
|
|
1123
|
-
and removed_triggers_dict maps environment names to their removed trigger dictionaries
|
|
1124
|
-
:raises ValueError: If no constraints exist
|
|
1125
|
-
:raises IOError: If config file cannot be written
|
|
1126
|
-
"""
|
|
1127
|
-
# Get the recommended config file path
|
|
1128
|
-
config_path = get_recommended_pip_config_path()
|
|
1129
|
-
|
|
1130
|
-
# Read existing config
|
|
1131
|
-
config = configparser.ConfigParser()
|
|
1132
|
-
if not config_path.exists():
|
|
1133
|
-
# If file doesn't exist, no constraints to remove
|
|
1134
|
-
if env_name:
|
|
1135
|
-
raise ValueError(f"No constraints found in environment '{env_name}'")
|
|
1136
|
-
else:
|
|
1137
|
-
raise ValueError("No constraints found in any environment")
|
|
1138
|
-
|
|
1139
|
-
config.read(config_path)
|
|
1140
|
-
|
|
1141
|
-
# Track what was removed
|
|
1142
|
-
removed_constraints = {}
|
|
1143
|
-
removed_triggers = {}
|
|
1144
|
-
|
|
1145
|
-
# Determine which environments to process
|
|
1146
|
-
if env_name:
|
|
1147
|
-
# Remove from specific environment
|
|
1148
|
-
environments_to_process = [env_name]
|
|
1149
|
-
else:
|
|
1150
|
-
# Remove from all environments that have constraints
|
|
1151
|
-
environments_to_process = []
|
|
1152
|
-
for section_name in config.sections():
|
|
1153
|
-
if config.has_option(section_name, 'constraints'):
|
|
1154
|
-
environments_to_process.append(section_name)
|
|
1155
|
-
|
|
1156
|
-
if not environments_to_process:
|
|
1157
|
-
if env_name:
|
|
1158
|
-
raise ValueError(f"No constraints found in environment '{env_name}'")
|
|
1159
|
-
else:
|
|
1160
|
-
raise ValueError("No constraints found in any environment")
|
|
1161
|
-
|
|
1162
|
-
# Remove constraints from each environment
|
|
1163
|
-
for environment in environments_to_process:
|
|
1164
|
-
if not config.has_section(environment):
|
|
1165
|
-
continue
|
|
1166
|
-
|
|
1167
|
-
# Get existing constraints
|
|
1168
|
-
existing_constraints = {}
|
|
1169
|
-
if config.has_option(environment, 'constraints'):
|
|
1170
|
-
existing_value = config.get(environment, 'constraints')
|
|
1171
|
-
# Check if it's inline constraints (contains newlines or operators)
|
|
1172
|
-
if '\n' in existing_value or any(op in existing_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
1173
|
-
existing_constraints = parse_inline_constraints(existing_value)
|
|
1174
|
-
|
|
1175
|
-
if existing_constraints:
|
|
1176
|
-
removed_constraints[environment] = existing_constraints
|
|
1177
|
-
|
|
1178
|
-
# Clean up all invalidation triggers for this environment and capture what was removed
|
|
1179
|
-
triggers_value = _get_constraint_invalid_when(config, environment)
|
|
1180
|
-
if triggers_value and triggers_value.strip():
|
|
1181
|
-
existing_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
1182
|
-
if existing_triggers:
|
|
1183
|
-
removed_triggers[environment] = existing_triggers
|
|
1184
|
-
_set_constraint_invalid_when(config, environment, '')
|
|
1185
|
-
|
|
1186
|
-
# Remove the constraints option
|
|
1187
|
-
config.remove_option(environment, 'constraints')
|
|
1188
|
-
|
|
1189
|
-
# If section is now empty, remove it
|
|
1190
|
-
if not config.options(environment):
|
|
1191
|
-
config.remove_section(environment)
|
|
1192
|
-
|
|
1193
|
-
if not removed_constraints:
|
|
1194
|
-
if env_name:
|
|
1195
|
-
raise ValueError(f"No constraints found in environment '{env_name}'")
|
|
1196
|
-
else:
|
|
1197
|
-
raise ValueError("No constraints found in any environment")
|
|
1198
|
-
|
|
1199
|
-
# Write the config file using utility function
|
|
1200
|
-
_write_config_file(config, config_path)
|
|
1201
|
-
|
|
1202
|
-
return config_path, removed_constraints, removed_triggers
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
def remove_all_ignores_from_config(env_name: Optional[str] = None) -> Tuple[Path, Dict[str, List[str]]]:
|
|
1206
|
-
"""
|
|
1207
|
-
Remove all ignores from pip configuration file.
|
|
1208
|
-
|
|
1209
|
-
If env_name is provided, removes all ignores from that environment.
|
|
1210
|
-
If env_name is None, removes all ignores from all environments.
|
|
1211
|
-
|
|
1212
|
-
:param env_name: Environment name for section, or None to remove from all environments
|
|
1213
|
-
:returns: Tuple of (config_file_path, removed_ignores_dict) where removed_ignores_dict
|
|
1214
|
-
maps environment names to their removed ignore lists
|
|
1215
|
-
:raises ValueError: If no ignores exist
|
|
1216
|
-
:raises IOError: If config file cannot be written
|
|
1217
|
-
"""
|
|
1218
|
-
# Get the recommended config file path
|
|
1219
|
-
config_path = get_recommended_pip_config_path()
|
|
1220
|
-
|
|
1221
|
-
# Read existing config
|
|
1222
|
-
config = configparser.ConfigParser()
|
|
1223
|
-
if not config_path.exists():
|
|
1224
|
-
# If file doesn't exist, no ignores to remove
|
|
1225
|
-
if env_name:
|
|
1226
|
-
raise ValueError(f"No ignores found in environment '{env_name}'")
|
|
1227
|
-
else:
|
|
1228
|
-
raise ValueError("No ignores found in any environment")
|
|
1229
|
-
|
|
1230
|
-
config.read(config_path)
|
|
1231
|
-
|
|
1232
|
-
# Track what was removed
|
|
1233
|
-
removed_ignores = {}
|
|
1234
|
-
|
|
1235
|
-
# Determine which environments to process
|
|
1236
|
-
if env_name:
|
|
1237
|
-
# Remove from specific environment
|
|
1238
|
-
environments_to_process = [env_name]
|
|
1239
|
-
else:
|
|
1240
|
-
# Remove from all environments that have ignores
|
|
1241
|
-
environments_to_process = []
|
|
1242
|
-
for section_name in config.sections():
|
|
1243
|
-
if config.has_option(section_name, 'ignores'):
|
|
1244
|
-
environments_to_process.append(section_name)
|
|
1245
|
-
|
|
1246
|
-
if not environments_to_process:
|
|
1247
|
-
if env_name:
|
|
1248
|
-
raise ValueError(f"No ignores found in environment '{env_name}'")
|
|
1249
|
-
else:
|
|
1250
|
-
raise ValueError("No ignores found in any environment")
|
|
1251
|
-
|
|
1252
|
-
# Remove ignores from each environment
|
|
1253
|
-
for environment in environments_to_process:
|
|
1254
|
-
if not config.has_section(environment):
|
|
1255
|
-
continue
|
|
1256
|
-
|
|
1257
|
-
# Get existing ignores
|
|
1258
|
-
existing_ignores = set()
|
|
1259
|
-
if config.has_option(environment, 'ignores'):
|
|
1260
|
-
existing_value = config.get(environment, 'ignores')
|
|
1261
|
-
existing_ignores = parse_inline_ignores(existing_value)
|
|
1262
|
-
|
|
1263
|
-
if existing_ignores:
|
|
1264
|
-
removed_ignores[environment] = list(existing_ignores)
|
|
1265
|
-
|
|
1266
|
-
# Remove the ignores option
|
|
1267
|
-
config.remove_option(environment, 'ignores')
|
|
1268
|
-
|
|
1269
|
-
# If section is now empty, remove it
|
|
1270
|
-
if not config.options(environment):
|
|
1271
|
-
config.remove_section(environment)
|
|
1272
|
-
|
|
1273
|
-
if not removed_ignores:
|
|
1274
|
-
if env_name:
|
|
1275
|
-
raise ValueError(f"No ignores found in environment '{env_name}'")
|
|
1276
|
-
else:
|
|
1277
|
-
raise ValueError("No ignores found in any environment")
|
|
1278
|
-
|
|
1279
|
-
# Write the config file using utility function
|
|
1280
|
-
_write_config_file(config, config_path)
|
|
1281
|
-
|
|
1282
|
-
return config_path, removed_ignores
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
def add_ignores_to_config(
|
|
1286
|
-
package_names: List[str],
|
|
1287
|
-
env_name: Optional[str] = None
|
|
1288
|
-
) -> Tuple[Path, Dict[str, str]]:
|
|
1289
|
-
"""
|
|
1290
|
-
Add or update package ignores in the pip configuration file.
|
|
1291
|
-
|
|
1292
|
-
Adds package names to the ignore list in the appropriate section
|
|
1293
|
-
in the pip config file. Uses inline ignores format in the config file.
|
|
1294
|
-
|
|
1295
|
-
:param package_names: List of package names to ignore
|
|
1296
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
1297
|
-
:returns: Tuple of (config_file_path, changes_dict) where changes_dict maps
|
|
1298
|
-
package names to action ('added' or 'already_exists')
|
|
1299
|
-
:raises IOError: If config file cannot be written
|
|
1300
|
-
"""
|
|
1301
|
-
# Normalize package names to lowercase
|
|
1302
|
-
package_names = [name.lower().strip() for name in package_names]
|
|
1303
|
-
|
|
1304
|
-
# Use utility functions for common patterns
|
|
1305
|
-
section_name = _get_section_name(env_name)
|
|
1306
|
-
config, config_path = _load_config(create_if_missing=True)
|
|
1307
|
-
_ensure_section_exists(config, section_name)
|
|
1308
|
-
|
|
1309
|
-
# Get existing ignores from the config
|
|
1310
|
-
existing_ignores = set()
|
|
1311
|
-
if config.has_option(section_name, 'ignores'):
|
|
1312
|
-
existing_value = config.get(section_name, 'ignores')
|
|
1313
|
-
existing_ignores = parse_inline_ignores(existing_value)
|
|
1314
|
-
|
|
1315
|
-
# Track changes
|
|
1316
|
-
changes = {}
|
|
1317
|
-
|
|
1318
|
-
# Add new ignores
|
|
1319
|
-
for package_name in package_names:
|
|
1320
|
-
if package_name in existing_ignores:
|
|
1321
|
-
changes[package_name] = 'already_exists'
|
|
1322
|
-
else:
|
|
1323
|
-
changes[package_name] = 'added'
|
|
1324
|
-
existing_ignores.add(package_name)
|
|
1325
|
-
|
|
1326
|
-
# Update config with formatted ignores
|
|
1327
|
-
if existing_ignores:
|
|
1328
|
-
ignores_value = _format_inline_ignores(existing_ignores)
|
|
1329
|
-
config.set(section_name, 'ignores', ignores_value)
|
|
1330
|
-
|
|
1331
|
-
# Write the config file using utility function
|
|
1332
|
-
_write_config_file(config, config_path)
|
|
1333
|
-
|
|
1334
|
-
return config_path, changes
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
def remove_ignores_from_config(
|
|
1338
|
-
package_names: List[str],
|
|
1339
|
-
env_name: Optional[str] = None
|
|
1340
|
-
) -> Tuple[Path, List[str]]:
|
|
1341
|
-
"""
|
|
1342
|
-
Remove package ignores from the pip configuration file.
|
|
1343
|
-
|
|
1344
|
-
Removes package names from the ignore list in the appropriate section
|
|
1345
|
-
in the pip config file. If no ignores remain, removes the ignores
|
|
1346
|
-
option from the section.
|
|
1347
|
-
|
|
1348
|
-
:param package_names: List of package names to remove from ignores
|
|
1349
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
1350
|
-
:returns: Tuple of (config_file_path, removed_packages_list)
|
|
1351
|
-
:raises ValueError: If no ignores exist for specified packages
|
|
1352
|
-
:raises IOError: If config file cannot be written
|
|
1353
|
-
"""
|
|
1354
|
-
# Normalize package names to lowercase
|
|
1355
|
-
package_names = [name.lower().strip() for name in package_names]
|
|
1356
|
-
|
|
1357
|
-
# Use utility functions for common patterns
|
|
1358
|
-
section_name = _get_section_name(env_name)
|
|
1359
|
-
config, config_path = _load_config(create_if_missing=False)
|
|
1360
|
-
_validate_section_exists(config, section_name, "ignores")
|
|
1361
|
-
|
|
1362
|
-
# Get existing ignores from the config
|
|
1363
|
-
existing_ignores = set()
|
|
1364
|
-
if config.has_option(section_name, 'ignores'):
|
|
1365
|
-
existing_value = config.get(section_name, 'ignores')
|
|
1366
|
-
existing_ignores = parse_inline_ignores(existing_value)
|
|
1367
|
-
|
|
1368
|
-
if not existing_ignores:
|
|
1369
|
-
raise ValueError(f"No ignores found in environment '{section_name}'")
|
|
1370
|
-
|
|
1371
|
-
# Track what was removed
|
|
1372
|
-
removed_packages = []
|
|
1373
|
-
|
|
1374
|
-
# Remove ignores for specified packages
|
|
1375
|
-
for package_name in package_names:
|
|
1376
|
-
if package_name in existing_ignores:
|
|
1377
|
-
removed_packages.append(package_name)
|
|
1378
|
-
existing_ignores.remove(package_name)
|
|
1379
|
-
|
|
1380
|
-
if not removed_packages:
|
|
1381
|
-
raise ValueError(f"None of the specified packages are ignored in environment '{section_name}'")
|
|
1382
|
-
|
|
1383
|
-
# Update config with remaining ignores
|
|
1384
|
-
if existing_ignores:
|
|
1385
|
-
# Update config with formatted ignores
|
|
1386
|
-
ignores_value = _format_inline_ignores(existing_ignores)
|
|
1387
|
-
config.set(section_name, 'ignores', ignores_value)
|
|
1388
|
-
else:
|
|
1389
|
-
# No ignores left, remove the ignores option
|
|
1390
|
-
config.remove_option(section_name, 'ignores')
|
|
1391
|
-
|
|
1392
|
-
# If section is now empty, remove it
|
|
1393
|
-
if not config.options(section_name):
|
|
1394
|
-
config.remove_section(section_name)
|
|
1395
|
-
|
|
1396
|
-
# Write the config file using utility function
|
|
1397
|
-
_write_config_file(config, config_path)
|
|
1398
|
-
|
|
1399
|
-
return config_path, removed_packages
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
def list_all_ignores(env_name: Optional[str] = None) -> Dict[str, List[str]]:
|
|
1403
|
-
"""
|
|
1404
|
-
List all package ignores from pip configuration files.
|
|
1405
|
-
|
|
1406
|
-
If env_name is provided, returns ignores only for that environment.
|
|
1407
|
-
Otherwise, returns ignores for all environments found in config files.
|
|
1408
|
-
|
|
1409
|
-
:param env_name: Specific environment name to list, or None for all environments
|
|
1410
|
-
:returns: Dictionary mapping environment names to their ignore lists
|
|
1411
|
-
"""
|
|
1412
|
-
all_ignores = {}
|
|
1413
|
-
|
|
1414
|
-
for config_path in get_pip_config_paths():
|
|
1415
|
-
if not config_path.exists():
|
|
1416
|
-
continue
|
|
1417
|
-
|
|
1418
|
-
try:
|
|
1419
|
-
config = configparser.ConfigParser()
|
|
1420
|
-
config.read(config_path)
|
|
1421
|
-
|
|
1422
|
-
# If specific environment requested, only check that one
|
|
1423
|
-
if env_name:
|
|
1424
|
-
if config.has_section(env_name):
|
|
1425
|
-
ignores = _get_ignores_from_section(config, env_name)
|
|
1426
|
-
if ignores:
|
|
1427
|
-
all_ignores[env_name] = ignores
|
|
1428
|
-
else:
|
|
1429
|
-
# Check all sections for ignores
|
|
1430
|
-
for section_name in config.sections():
|
|
1431
|
-
ignores = _get_ignores_from_section(config, section_name)
|
|
1432
|
-
if ignores:
|
|
1433
|
-
all_ignores[section_name] = ignores
|
|
1434
|
-
|
|
1435
|
-
except (configparser.Error, IOError):
|
|
1436
|
-
continue
|
|
1437
|
-
|
|
1438
|
-
return all_ignores
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
def _get_ignores_from_section(config: configparser.ConfigParser, section_name: str) -> List[str]:
|
|
1442
|
-
"""
|
|
1443
|
-
Extract ignores from a specific config section.
|
|
1444
|
-
|
|
1445
|
-
:param config: ConfigParser instance with loaded configuration
|
|
1446
|
-
:param section_name: Name of the section to check for ignores
|
|
1447
|
-
:returns: List of package names to ignore
|
|
1448
|
-
"""
|
|
1449
|
-
ignores = []
|
|
1450
|
-
|
|
1451
|
-
# Check for 'ignore' option (file path)
|
|
1452
|
-
if config.has_option(section_name, 'ignore'):
|
|
1453
|
-
ignore_path = Path(config.get(section_name, 'ignore'))
|
|
1454
|
-
if ignore_path.exists():
|
|
1455
|
-
try:
|
|
1456
|
-
ignores.extend(read_ignores_file(str(ignore_path)))
|
|
1457
|
-
except (FileNotFoundError, PermissionError):
|
|
1458
|
-
pass
|
|
1459
|
-
|
|
1460
|
-
# Check for 'ignores' option (file path or inline)
|
|
1461
|
-
if config.has_option(section_name, 'ignores'):
|
|
1462
|
-
value = config.get(section_name, 'ignores')
|
|
1463
|
-
# Check if it looks like inline ignores
|
|
1464
|
-
if '\n' in value or ' ' in value.strip():
|
|
1465
|
-
ignores.extend(list(parse_inline_ignores(value)))
|
|
1466
|
-
else:
|
|
1467
|
-
# Treat as file path
|
|
1468
|
-
ignore_path = Path(value)
|
|
1469
|
-
if ignore_path.exists():
|
|
1470
|
-
try:
|
|
1471
|
-
ignores.extend(read_ignores_file(str(ignore_path)))
|
|
1472
|
-
except (FileNotFoundError, PermissionError):
|
|
1473
|
-
pass
|
|
1474
|
-
|
|
1475
|
-
return ignores
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
def read_ignores() -> Set[str]:
|
|
1479
|
-
"""
|
|
1480
|
-
Read package ignores from pip configuration files.
|
|
1481
|
-
|
|
1482
|
-
Searches for ignore settings in the following order:
|
|
1483
|
-
1. Pip configuration file - looks for 'ignore' or 'ignores' setting in
|
|
1484
|
-
environment-specific section (e.g., [main] for the 'main' environment) based on
|
|
1485
|
-
detected virtual environment (supports mamba, micromamba, conda, poetry, virtualenv)
|
|
1486
|
-
2. Pip configuration file - falls back to 'ignore' or 'ignores' setting in [global] section
|
|
1487
|
-
|
|
1488
|
-
The pip configuration format expected is:
|
|
1489
|
-
[environment_name]
|
|
1490
|
-
ignore = /path/to/ignores.txt
|
|
1491
|
-
ignores = /path/to/ignores.txt
|
|
1492
|
-
# OR for inline ignores (space or newline separated):
|
|
1493
|
-
ignores = requests numpy flask
|
|
1494
|
-
# OR multiline:
|
|
1495
|
-
ignores =
|
|
1496
|
-
requests
|
|
1497
|
-
numpy
|
|
1498
|
-
flask
|
|
1499
|
-
|
|
1500
|
-
[global]
|
|
1501
|
-
ignore = /path/to/ignores.txt
|
|
1502
|
-
ignores = /path/to/ignores.txt
|
|
1503
|
-
# OR for inline ignores:
|
|
1504
|
-
ignores = requests numpy flask
|
|
1505
|
-
|
|
1506
|
-
:returns: Set of package names to ignore (normalized to lowercase), empty set if no ignores found
|
|
1507
|
-
"""
|
|
1508
|
-
# Check pip configuration file
|
|
1509
|
-
env_name = get_current_environment_name()
|
|
1510
|
-
pip_config_result = read_pip_config_ignore(env_name)
|
|
1511
|
-
if pip_config_result:
|
|
1512
|
-
ignore_type, ignore_value = pip_config_result
|
|
1513
|
-
if ignore_type == 'inline':
|
|
1514
|
-
return parse_inline_ignores(ignore_value)
|
|
1515
|
-
elif ignore_type == 'file':
|
|
1516
|
-
ignore_path = Path(ignore_value)
|
|
1517
|
-
if ignore_path.exists():
|
|
1518
|
-
try:
|
|
1519
|
-
return set(read_ignores_file(str(ignore_path)))
|
|
1520
|
-
except (FileNotFoundError, PermissionError):
|
|
1521
|
-
# Ignore file access issues and continue
|
|
1522
|
-
pass
|
|
1523
|
-
|
|
1524
|
-
# No ignores found
|
|
1525
|
-
return set()
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
def read_invalidation_triggers() -> Dict[str, List[str]]:
|
|
1529
|
-
"""
|
|
1530
|
-
Read invalidation triggers from pip configuration and auto-discovered constraints.
|
|
1531
|
-
|
|
1532
|
-
Returns a dictionary mapping package names to lists of trigger packages.
|
|
1533
|
-
When any of the trigger packages are updated, the constrained package's constraint
|
|
1534
|
-
becomes invalid and should be removed.
|
|
1535
|
-
|
|
1536
|
-
:returns: Dictionary mapping package names to lists of trigger package names
|
|
1537
|
-
"""
|
|
1538
|
-
triggers_map = {}
|
|
1539
|
-
|
|
1540
|
-
try:
|
|
1541
|
-
# First, load manual triggers from config
|
|
1542
|
-
section_name = _get_section_name(None)
|
|
1543
|
-
config, _ = _load_config(create_if_missing=False)
|
|
1544
|
-
|
|
1545
|
-
if config.has_section(section_name):
|
|
1546
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
1547
|
-
if triggers_value and triggers_value.strip():
|
|
1548
|
-
triggers_map = parse_invalidation_triggers_storage(triggers_value)
|
|
1549
|
-
|
|
1550
|
-
except Exception:
|
|
1551
|
-
pass
|
|
1552
|
-
|
|
1553
|
-
# Now merge in auto-discovered constraint triggers
|
|
1554
|
-
try:
|
|
1555
|
-
auto_triggers = get_auto_constraint_triggers()
|
|
1556
|
-
for package_name, triggers in auto_triggers.items():
|
|
1557
|
-
if package_name in triggers_map:
|
|
1558
|
-
# Merge triggers (manual + auto), avoiding duplicates
|
|
1559
|
-
existing = set(triggers_map[package_name])
|
|
1560
|
-
for trigger in triggers:
|
|
1561
|
-
if trigger not in existing:
|
|
1562
|
-
triggers_map[package_name].append(trigger)
|
|
1563
|
-
else:
|
|
1564
|
-
# Add new auto-discovered triggers
|
|
1565
|
-
triggers_map[package_name] = triggers
|
|
1566
|
-
except Exception:
|
|
1567
|
-
pass
|
|
1568
|
-
|
|
1569
|
-
return triggers_map
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
def list_all_constraints(env_name: Optional[str] = None) -> Dict[str, Dict[str, str]]:
|
|
1573
|
-
"""
|
|
1574
|
-
List all constraints from pip configuration files.
|
|
1575
|
-
|
|
1576
|
-
If env_name is provided, returns constraints only for that environment.
|
|
1577
|
-
Otherwise, returns constraints for all environments found in config files.
|
|
1578
|
-
|
|
1579
|
-
:param env_name: Specific environment name to list, or None for all environments
|
|
1580
|
-
:returns: Dictionary mapping environment names to their constraint dictionaries
|
|
1581
|
-
"""
|
|
1582
|
-
all_constraints = {}
|
|
1583
|
-
|
|
1584
|
-
for config_path in get_pip_config_paths():
|
|
1585
|
-
if not config_path.exists():
|
|
1586
|
-
continue
|
|
1587
|
-
|
|
1588
|
-
try:
|
|
1589
|
-
config = configparser.ConfigParser()
|
|
1590
|
-
config.read(config_path)
|
|
1591
|
-
|
|
1592
|
-
# If specific environment requested, only check that one
|
|
1593
|
-
if env_name:
|
|
1594
|
-
if config.has_section(env_name):
|
|
1595
|
-
constraints = _get_constraints_from_section(config, env_name)
|
|
1596
|
-
if constraints:
|
|
1597
|
-
all_constraints[env_name] = constraints
|
|
1598
|
-
else:
|
|
1599
|
-
# Check all sections for constraints
|
|
1600
|
-
for section_name in config.sections():
|
|
1601
|
-
constraints = _get_constraints_from_section(config, section_name)
|
|
1602
|
-
if constraints:
|
|
1603
|
-
all_constraints[section_name] = constraints
|
|
1604
|
-
|
|
1605
|
-
except (configparser.Error, IOError):
|
|
1606
|
-
continue
|
|
1607
|
-
|
|
1608
|
-
return all_constraints
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
def _get_constraints_from_section(config: configparser.ConfigParser, section_name: str) -> Dict[str, str]:
|
|
1612
|
-
"""
|
|
1613
|
-
Extract constraints from a specific config section.
|
|
1614
|
-
|
|
1615
|
-
:param config: ConfigParser instance with loaded configuration
|
|
1616
|
-
:param section_name: Name of the section to check for constraints
|
|
1617
|
-
:returns: Dictionary mapping package names to version constraints
|
|
1618
|
-
"""
|
|
1619
|
-
constraints = {}
|
|
1620
|
-
|
|
1621
|
-
# Check for 'constraint' option (file path)
|
|
1622
|
-
if config.has_option(section_name, 'constraint'):
|
|
1623
|
-
constraint_path = Path(config.get(section_name, 'constraint'))
|
|
1624
|
-
if constraint_path.exists():
|
|
1625
|
-
try:
|
|
1626
|
-
constraints.update(read_constraints_file(str(constraint_path)))
|
|
1627
|
-
except IOError:
|
|
1628
|
-
pass
|
|
1629
|
-
|
|
1630
|
-
# Check for 'constraints' option (file path or inline)
|
|
1631
|
-
if config.has_option(section_name, 'constraints'):
|
|
1632
|
-
value = config.get(section_name, 'constraints')
|
|
1633
|
-
# Check if it looks like inline constraints
|
|
1634
|
-
if '\n' in value or any(op in value for op in ['>=', '<=', '==', '!=', '~=', '>']):
|
|
1635
|
-
constraints.update(parse_inline_constraints(value))
|
|
1636
|
-
else:
|
|
1637
|
-
# Treat as file path
|
|
1638
|
-
constraint_path = Path(value)
|
|
1639
|
-
if constraint_path.exists():
|
|
1640
|
-
try:
|
|
1641
|
-
constraints.update(read_constraints_file(str(constraint_path)))
|
|
1642
|
-
except IOError:
|
|
1643
|
-
pass
|
|
1644
|
-
|
|
1645
|
-
return constraints
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
def parse_invalidation_trigger(trigger: str) -> Optional[Dict[str, str]]:
|
|
1649
|
-
"""
|
|
1650
|
-
Parse a single invalidation trigger specification.
|
|
1651
|
-
|
|
1652
|
-
:param trigger: A trigger specification like "package>=1.0.0"
|
|
1653
|
-
:returns: Dictionary with 'name' and 'constraint' keys, or None if invalid
|
|
1654
|
-
"""
|
|
1655
|
-
return parse_requirement_line(trigger)
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
def format_invalidation_triggers(package_name: str, triggers: List[str]) -> str:
|
|
1659
|
-
"""
|
|
1660
|
-
Format invalidation triggers for storage in pip configuration.
|
|
1661
|
-
|
|
1662
|
-
Format: "constrained_package<version:trigger1|trigger2|trigger3"
|
|
1663
|
-
|
|
1664
|
-
:param package_name: Name of the constrained package
|
|
1665
|
-
:param triggers: List of trigger specifications
|
|
1666
|
-
:returns: Formatted string for storage
|
|
1667
|
-
"""
|
|
1668
|
-
if not triggers:
|
|
1669
|
-
return ""
|
|
1670
|
-
|
|
1671
|
-
return f"{package_name}:{('|'.join(triggers))}"
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
def parse_invalidation_triggers_storage(storage_value: str) -> Dict[str, List[str]]:
|
|
1675
|
-
"""
|
|
1676
|
-
Parse stored invalidation triggers from pip configuration.
|
|
1677
|
-
|
|
1678
|
-
Expected format: "package1<version:trigger1|trigger2,package2>version:trigger3|trigger4"
|
|
1679
|
-
|
|
1680
|
-
:param storage_value: Stored value from pip config
|
|
1681
|
-
:returns: Dictionary mapping package names to lists of trigger specifications
|
|
1682
|
-
"""
|
|
1683
|
-
triggers_map = {}
|
|
1684
|
-
|
|
1685
|
-
if not storage_value.strip():
|
|
1686
|
-
return triggers_map
|
|
1687
|
-
|
|
1688
|
-
# Split by comma to get individual package trigger sets
|
|
1689
|
-
package_entries = storage_value.split(',')
|
|
1690
|
-
|
|
1691
|
-
for entry in package_entries:
|
|
1692
|
-
entry = entry.strip()
|
|
1693
|
-
if ':' not in entry:
|
|
1694
|
-
continue
|
|
1695
|
-
|
|
1696
|
-
try:
|
|
1697
|
-
# Split package spec from triggers
|
|
1698
|
-
package_spec, triggers_part = entry.split(':', 1)
|
|
1699
|
-
|
|
1700
|
-
# Extract package name from the constraint spec
|
|
1701
|
-
parsed = parse_requirement_line(package_spec)
|
|
1702
|
-
if not parsed:
|
|
1703
|
-
continue
|
|
1704
|
-
|
|
1705
|
-
package_name = parsed['name'].lower()
|
|
1706
|
-
|
|
1707
|
-
# Parse triggers
|
|
1708
|
-
trigger_list = [t.strip() for t in triggers_part.split('|') if t.strip()]
|
|
1709
|
-
|
|
1710
|
-
if trigger_list:
|
|
1711
|
-
triggers_map[package_name] = trigger_list
|
|
1712
|
-
|
|
1713
|
-
except ValueError:
|
|
1714
|
-
# Skip malformed entries
|
|
1715
|
-
continue
|
|
1716
|
-
|
|
1717
|
-
return triggers_map
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
def merge_invalidation_triggers(existing_triggers: List[str], new_triggers: List[str]) -> List[str]:
|
|
1721
|
-
"""
|
|
1722
|
-
Merge existing and new invalidation triggers, removing duplicates.
|
|
1723
|
-
|
|
1724
|
-
:param existing_triggers: Current list of trigger specifications
|
|
1725
|
-
:param new_triggers: New trigger specifications to merge
|
|
1726
|
-
:returns: Merged list of unique triggers
|
|
1727
|
-
"""
|
|
1728
|
-
# Use a set to remove duplicates while preserving order
|
|
1729
|
-
merged = []
|
|
1730
|
-
seen = set()
|
|
1731
|
-
|
|
1732
|
-
# Add existing triggers first
|
|
1733
|
-
for trigger in existing_triggers:
|
|
1734
|
-
if trigger not in seen:
|
|
1735
|
-
merged.append(trigger)
|
|
1736
|
-
seen.add(trigger)
|
|
1737
|
-
|
|
1738
|
-
# Add new triggers
|
|
1739
|
-
for trigger in new_triggers:
|
|
1740
|
-
if trigger not in seen:
|
|
1741
|
-
merged.append(trigger)
|
|
1742
|
-
seen.add(trigger)
|
|
1743
|
-
|
|
1744
|
-
return merged
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
def validate_invalidation_triggers(triggers: List[str]) -> List[str]:
|
|
1748
|
-
"""
|
|
1749
|
-
Validate invalidation trigger specifications.
|
|
1750
|
-
|
|
1751
|
-
Only ">=" and ">" operators are allowed for invalidation triggers since
|
|
1752
|
-
package updates move to higher versions, not lower ones.
|
|
1753
|
-
|
|
1754
|
-
:param triggers: List of trigger specifications to validate
|
|
1755
|
-
:returns: List of valid trigger specifications
|
|
1756
|
-
:raises ValueError: If any trigger specification is invalid or uses unsupported operators
|
|
1757
|
-
"""
|
|
1758
|
-
valid_triggers = []
|
|
1759
|
-
|
|
1760
|
-
for trigger in triggers:
|
|
1761
|
-
parsed = parse_invalidation_trigger(trigger)
|
|
1762
|
-
if not parsed:
|
|
1763
|
-
raise ValueError(f"Invalid invalidation trigger specification: {trigger}")
|
|
1764
|
-
|
|
1765
|
-
# Check that only ">=" and ">" operators are used
|
|
1766
|
-
constraint = parsed['constraint']
|
|
1767
|
-
|
|
1768
|
-
# Extract the operator(s) from the constraint
|
|
1769
|
-
# Valid operators for triggers: ">=" and ">"
|
|
1770
|
-
has_valid_operator = False
|
|
1771
|
-
has_invalid_operator = False
|
|
1772
|
-
|
|
1773
|
-
# Check for valid operators
|
|
1774
|
-
if '>=' in constraint or '>' in constraint:
|
|
1775
|
-
has_valid_operator = True
|
|
1776
|
-
|
|
1777
|
-
# Check for invalid operators (but not if they're part of >=)
|
|
1778
|
-
invalid_ops = ['<=', '==', '!=', '~=', '<']
|
|
1779
|
-
for op in invalid_ops:
|
|
1780
|
-
if op in constraint:
|
|
1781
|
-
has_invalid_operator = True
|
|
1782
|
-
break
|
|
1783
|
-
|
|
1784
|
-
# Special case: standalone '<' that's not part of '<='
|
|
1785
|
-
if '<' in constraint and '<=' not in constraint:
|
|
1786
|
-
has_invalid_operator = True
|
|
1787
|
-
|
|
1788
|
-
if not has_valid_operator or has_invalid_operator:
|
|
1789
|
-
raise ValueError(
|
|
1790
|
-
f"Invalid invalidation trigger '{trigger}': only '>=' and '>' operators are allowed. "
|
|
1791
|
-
f"Triggers should specify when a package upgrade invalidates the constraint."
|
|
1792
|
-
)
|
|
1793
|
-
|
|
1794
|
-
# Reconstruct the trigger to normalize format
|
|
1795
|
-
normalized_trigger = f"{parsed['name']}{parsed['constraint']}"
|
|
1796
|
-
valid_triggers.append(normalized_trigger)
|
|
1797
|
-
|
|
1798
|
-
return valid_triggers
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
def discover_auto_constraints(exclude_triggers_for_packages: Optional[List[str]] = None) -> List[Tuple[str, str]]:
|
|
1802
|
-
"""
|
|
1803
|
-
Discover automatic constraints by analyzing installed packages and their requirements.
|
|
1804
|
-
|
|
1805
|
-
Finds packages that have version constraints (==, ~=, <=, <) on their dependencies,
|
|
1806
|
-
and generates constraint specifications with invalidation triggers.
|
|
1807
|
-
|
|
1808
|
-
Excludes packages that are in the ignore list to prevent cluttering the pip config.
|
|
1809
|
-
Optionally excludes constraints where trigger packages are in the exclude list.
|
|
1810
|
-
|
|
1811
|
-
:param exclude_triggers_for_packages: Optional list of package names that should not be
|
|
1812
|
-
used as invalidation triggers (e.g., packages about to be updated)
|
|
1813
|
-
:returns: List of tuples (constraint_spec, invalidation_trigger) where:
|
|
1814
|
-
constraint_spec is like "package==1.0.0" or "package<2.0.0"
|
|
1815
|
-
invalidation_trigger is like "dependent_package>current_version" - when the dependent
|
|
1816
|
-
package upgrades beyond its current version, the constraint becomes invalid
|
|
1817
|
-
"""
|
|
1818
|
-
import importlib.metadata
|
|
1819
|
-
from packaging.requirements import Requirement
|
|
1820
|
-
|
|
1821
|
-
# Get current ignored packages to exclude from auto constraints
|
|
1822
|
-
ignored_packages = read_ignores()
|
|
1823
|
-
|
|
1824
|
-
# Normalize exclude list to canonical names
|
|
1825
|
-
exclude_triggers = set()
|
|
1826
|
-
if exclude_triggers_for_packages:
|
|
1827
|
-
from packaging.utils import canonicalize_name
|
|
1828
|
-
exclude_triggers = {canonicalize_name(pkg) for pkg in exclude_triggers_for_packages}
|
|
1829
|
-
|
|
1830
|
-
auto_constraints = []
|
|
1831
|
-
|
|
1832
|
-
try:
|
|
1833
|
-
distributions = list(importlib.metadata.distributions())
|
|
1834
|
-
except Exception:
|
|
1835
|
-
# Fallback if importlib.metadata fails
|
|
1836
|
-
return []
|
|
1837
|
-
|
|
1838
|
-
# Get installed packages once for validation (use same logic as cleanup validation)
|
|
1839
|
-
installed_packages = _get_installed_packages()
|
|
1840
|
-
|
|
1841
|
-
for dist in distributions:
|
|
1842
|
-
if not hasattr(dist, 'requires') or not dist.requires:
|
|
1843
|
-
continue
|
|
1844
|
-
|
|
1845
|
-
try:
|
|
1846
|
-
from packaging.utils import canonicalize_name
|
|
1847
|
-
pkg_name = canonicalize_name(dist.metadata['Name']) # Canonically normalize package name
|
|
1848
|
-
pkg_version = dist.version
|
|
1849
|
-
|
|
1850
|
-
# Skip if this package is in the exclude list (don't use it as a trigger)
|
|
1851
|
-
if pkg_name in exclude_triggers:
|
|
1852
|
-
continue
|
|
1853
|
-
|
|
1854
|
-
for req_str in dist.requires:
|
|
1855
|
-
try:
|
|
1856
|
-
# Skip extras requirements and environment markers
|
|
1857
|
-
if '; ' in req_str:
|
|
1858
|
-
# Skip if it has 'extra' anywhere in the markers
|
|
1859
|
-
if 'extra' in req_str:
|
|
1860
|
-
continue
|
|
1861
|
-
# Skip other environment markers for now (like "sys_platform == 'win32'")
|
|
1862
|
-
else:
|
|
1863
|
-
continue
|
|
1864
|
-
|
|
1865
|
-
req = Requirement(req_str)
|
|
1866
|
-
|
|
1867
|
-
# Skip packages that are in the ignore list
|
|
1868
|
-
canonical_req_name = canonicalize_name(req.name)
|
|
1869
|
-
if canonical_req_name in ignored_packages:
|
|
1870
|
-
continue
|
|
1871
|
-
|
|
1872
|
-
# Look for version constraints that we should protect
|
|
1873
|
-
if req.specifier:
|
|
1874
|
-
for spec in req.specifier:
|
|
1875
|
-
# Create constraints for exact versions and upper bounds
|
|
1876
|
-
if spec.operator in ['==', '~=', '<=', '<']:
|
|
1877
|
-
constraint_spec = f"{canonical_req_name}{spec.operator}{spec.version}"
|
|
1878
|
-
|
|
1879
|
-
# Only create triggers for packages that will be found by validation
|
|
1880
|
-
# This prevents creating triggers that will immediately be flagged as invalid
|
|
1881
|
-
if pkg_name in installed_packages:
|
|
1882
|
-
# Use > for invalidation trigger (when the dependent package upgrades beyond current version)
|
|
1883
|
-
invalidation_trigger = f"{pkg_name}>{pkg_version}"
|
|
1884
|
-
auto_constraints.append((constraint_spec, invalidation_trigger))
|
|
1885
|
-
|
|
1886
|
-
except Exception:
|
|
1887
|
-
# Skip malformed requirements
|
|
1888
|
-
continue
|
|
1889
|
-
|
|
1890
|
-
except Exception:
|
|
1891
|
-
# Skip packages with metadata issues
|
|
1892
|
-
continue
|
|
1893
|
-
|
|
1894
|
-
return auto_constraints
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
def apply_auto_constraints(env_name: Optional[str] = None, dry_run: bool = False) -> Tuple[Path, Dict[str, Tuple[str, str]], int, int]:
|
|
1898
|
-
"""
|
|
1899
|
-
Apply automatically discovered constraints from installed packages.
|
|
1900
|
-
|
|
1901
|
-
Discovers exact version constraints from installed packages and applies them
|
|
1902
|
-
with invalidation triggers to protect against breaking changes.
|
|
1903
|
-
|
|
1904
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
1905
|
-
:param dry_run: If True, only return what would be applied without making changes
|
|
1906
|
-
:returns: Tuple of (config_file_path, changes_dict, constraints_added, triggers_added) where:
|
|
1907
|
-
changes_dict maps package names to (action, constraint) tuples,
|
|
1908
|
-
constraints_added is the count of new constraints,
|
|
1909
|
-
triggers_added is the count of new triggers
|
|
1910
|
-
:raises ValueError: If constraint specifications are invalid
|
|
1911
|
-
:raises IOError: If config file cannot be written
|
|
1912
|
-
"""
|
|
1913
|
-
# Discover auto-constraints
|
|
1914
|
-
auto_constraints = discover_auto_constraints()
|
|
1915
|
-
|
|
1916
|
-
if not auto_constraints:
|
|
1917
|
-
# Return empty results if no auto-constraints found
|
|
1918
|
-
config_path = get_recommended_pip_config_path()
|
|
1919
|
-
return config_path, {}, 0, 0
|
|
1920
|
-
|
|
1921
|
-
if dry_run:
|
|
1922
|
-
# For dry run, just return what would be applied
|
|
1923
|
-
config_path = get_recommended_pip_config_path()
|
|
1924
|
-
changes = {}
|
|
1925
|
-
constraints_added = len(auto_constraints)
|
|
1926
|
-
triggers_added = len(auto_constraints) # Each constraint gets one trigger
|
|
1927
|
-
|
|
1928
|
-
# Simulate the changes that would be made
|
|
1929
|
-
for constraint_spec, invalidation_trigger in auto_constraints:
|
|
1930
|
-
parsed = parse_requirement_line(constraint_spec)
|
|
1931
|
-
if parsed:
|
|
1932
|
-
package_name = parsed['name'].lower()
|
|
1933
|
-
constraint = parsed['constraint']
|
|
1934
|
-
changes[package_name] = ('would_add', constraint)
|
|
1935
|
-
|
|
1936
|
-
return config_path, changes, constraints_added, triggers_added
|
|
1937
|
-
|
|
1938
|
-
# Group constraints and their triggers
|
|
1939
|
-
constraint_specs = []
|
|
1940
|
-
constraint_triggers = {}
|
|
1941
|
-
|
|
1942
|
-
for constraint_spec, invalidation_trigger in auto_constraints:
|
|
1943
|
-
constraint_specs.append(constraint_spec)
|
|
1944
|
-
|
|
1945
|
-
# Parse the constraint to get the package name for trigger mapping
|
|
1946
|
-
parsed = parse_requirement_line(constraint_spec)
|
|
1947
|
-
if parsed:
|
|
1948
|
-
package_name = parsed['name'].lower()
|
|
1949
|
-
if package_name not in constraint_triggers:
|
|
1950
|
-
constraint_triggers[package_name] = []
|
|
1951
|
-
constraint_triggers[package_name].append(invalidation_trigger)
|
|
1952
|
-
|
|
1953
|
-
# Apply constraints using existing function (skip validation for auto-discovered constraints)
|
|
1954
|
-
config_path, changes = add_constraints_to_config(constraint_specs, env_name, skip_validation=True)
|
|
1955
|
-
|
|
1956
|
-
# Add invalidation triggers
|
|
1957
|
-
section_name = _get_section_name(env_name)
|
|
1958
|
-
config, _ = _load_config(create_if_missing=False)
|
|
1959
|
-
|
|
1960
|
-
# Get existing triggers
|
|
1961
|
-
existing_triggers_storage = {}
|
|
1962
|
-
existing_value = _get_constraint_invalid_when(config, section_name)
|
|
1963
|
-
if existing_value:
|
|
1964
|
-
existing_triggers_storage = parse_invalidation_triggers_storage(existing_value)
|
|
1965
|
-
|
|
1966
|
-
# Get current constraints for formatting
|
|
1967
|
-
current_constraints = {}
|
|
1968
|
-
if config.has_option(section_name, 'constraints'):
|
|
1969
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
1970
|
-
if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
1971
|
-
current_constraints = parse_inline_constraints(constraints_value)
|
|
1972
|
-
|
|
1973
|
-
# Merge triggers for each constraint
|
|
1974
|
-
updated_triggers_storage = existing_triggers_storage.copy()
|
|
1975
|
-
triggers_added = 0
|
|
1976
|
-
|
|
1977
|
-
for package_name, new_triggers in constraint_triggers.items():
|
|
1978
|
-
if package_name in current_constraints: # Only add triggers for successfully added constraints
|
|
1979
|
-
existing_package_triggers = existing_triggers_storage.get(package_name, [])
|
|
1980
|
-
merged_triggers = merge_invalidation_triggers(existing_package_triggers, new_triggers)
|
|
1981
|
-
|
|
1982
|
-
# Count new triggers added
|
|
1983
|
-
triggers_added += len([t for t in merged_triggers if t not in existing_package_triggers])
|
|
1984
|
-
|
|
1985
|
-
if merged_triggers:
|
|
1986
|
-
updated_triggers_storage[package_name] = merged_triggers
|
|
1987
|
-
|
|
1988
|
-
# Format and store the triggers
|
|
1989
|
-
if updated_triggers_storage:
|
|
1990
|
-
trigger_entries = []
|
|
1991
|
-
for package_name, triggers in updated_triggers_storage.items():
|
|
1992
|
-
if package_name in current_constraints:
|
|
1993
|
-
package_constraint = current_constraints[package_name]
|
|
1994
|
-
formatted_entry = format_invalidation_triggers(f"{package_name}{package_constraint}", triggers)
|
|
1995
|
-
if formatted_entry:
|
|
1996
|
-
trigger_entries.append(formatted_entry)
|
|
1997
|
-
|
|
1998
|
-
triggers_value = ','.join(trigger_entries) if trigger_entries else ''
|
|
1999
|
-
_set_constraint_invalid_when(config, section_name, triggers_value)
|
|
2000
|
-
|
|
2001
|
-
# Write the updated config file
|
|
2002
|
-
_write_config_file(config, config_path)
|
|
2003
|
-
|
|
2004
|
-
constraints_added = len([c for c in changes.values() if c[0] == 'added'])
|
|
2005
|
-
|
|
2006
|
-
return config_path, changes, constraints_added, triggers_added
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
def check_constraint_invalidations(packages_to_install: List[str], env_name: Optional[str] = None) -> Dict[str, List[str]]:
|
|
2010
|
-
"""
|
|
2011
|
-
Check which constraints would be invalidated by installing the given packages.
|
|
2012
|
-
|
|
2013
|
-
Analyzes the invalidation triggers for all current constraints and determines
|
|
2014
|
-
which constraints would be violated if the specified packages are installed.
|
|
2015
|
-
|
|
2016
|
-
:param packages_to_install: List of package names that would be installed
|
|
2017
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
2018
|
-
:returns: Dictionary mapping constraint package names to lists of violating packages
|
|
2019
|
-
"""
|
|
2020
|
-
invalidated_constraints = {}
|
|
2021
|
-
|
|
2022
|
-
try:
|
|
2023
|
-
# Get current constraints and invalidation triggers
|
|
2024
|
-
section_name = _get_section_name(env_name)
|
|
2025
|
-
config, _ = _load_config(create_if_missing=False)
|
|
2026
|
-
|
|
2027
|
-
if not config.has_section(section_name):
|
|
2028
|
-
return invalidated_constraints
|
|
2029
|
-
|
|
2030
|
-
# Get current constraints
|
|
2031
|
-
current_constraints = {}
|
|
2032
|
-
if config.has_option(section_name, 'constraints'):
|
|
2033
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
2034
|
-
if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
2035
|
-
current_constraints = parse_inline_constraints(constraints_value)
|
|
2036
|
-
|
|
2037
|
-
# Get invalidation triggers
|
|
2038
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
2039
|
-
if not triggers_value or not triggers_value.strip():
|
|
2040
|
-
return invalidated_constraints
|
|
2041
|
-
|
|
2042
|
-
# Parse triggers
|
|
2043
|
-
all_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
2044
|
-
|
|
2045
|
-
# Check each constraint for invalidations
|
|
2046
|
-
for constrained_package, triggers in all_triggers.items():
|
|
2047
|
-
if constrained_package not in current_constraints:
|
|
2048
|
-
continue # Skip if constraint no longer exists
|
|
2049
|
-
|
|
2050
|
-
violating_packages = []
|
|
2051
|
-
|
|
2052
|
-
for trigger in triggers:
|
|
2053
|
-
# Parse the trigger to get package name
|
|
2054
|
-
parsed = parse_invalidation_trigger(trigger)
|
|
2055
|
-
if parsed:
|
|
2056
|
-
trigger_package = parsed['name'].lower()
|
|
2057
|
-
# Note: trigger constraint (parsed['constraint']) is not checked here
|
|
2058
|
-
# For now, we assume any installation of the trigger package invalidates
|
|
2059
|
-
|
|
2060
|
-
# Check if this trigger package is being installed
|
|
2061
|
-
if trigger_package in [pkg.lower() for pkg in packages_to_install]:
|
|
2062
|
-
# For now, assume that installing a package in the trigger list
|
|
2063
|
-
# will invalidate the constraint (since we don't know the exact
|
|
2064
|
-
# version that will be installed without more complex analysis)
|
|
2065
|
-
violating_packages.append(trigger_package)
|
|
2066
|
-
|
|
2067
|
-
if violating_packages:
|
|
2068
|
-
invalidated_constraints[constrained_package] = violating_packages
|
|
2069
|
-
|
|
2070
|
-
except Exception:
|
|
2071
|
-
# If we can't read constraints/triggers, assume no invalidations
|
|
2072
|
-
pass
|
|
2073
|
-
|
|
2074
|
-
return invalidated_constraints
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
def validate_package_installation(packages_to_install: List[str], env_name: Optional[str] = None) -> Tuple[List[str], Dict[str, List[str]]]:
|
|
2078
|
-
"""
|
|
2079
|
-
Validate that packages can be safely installed without violating active constraints.
|
|
2080
|
-
|
|
2081
|
-
Checks for constraint invalidations and returns packages that are safe to install
|
|
2082
|
-
along with information about any constraints that would be violated.
|
|
2083
|
-
|
|
2084
|
-
:param packages_to_install: List of package names to validate for installation
|
|
2085
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
2086
|
-
:returns: Tuple of (safe_packages, invalidated_constraints) where:
|
|
2087
|
-
safe_packages is a list of packages that can be safely installed
|
|
2088
|
-
invalidated_constraints maps constrained packages to lists of violating packages
|
|
2089
|
-
"""
|
|
2090
|
-
# Check for constraint invalidations
|
|
2091
|
-
invalidated_constraints = check_constraint_invalidations(packages_to_install, env_name)
|
|
2092
|
-
|
|
2093
|
-
# Determine which packages are safe to install
|
|
2094
|
-
# A package is NOT safe if it would invalidate ANY constraint
|
|
2095
|
-
violating_packages = set()
|
|
2096
|
-
for violators in invalidated_constraints.values():
|
|
2097
|
-
violating_packages.update(pkg.lower() for pkg in violators)
|
|
2098
|
-
|
|
2099
|
-
# Filter out packages that would violate constraints
|
|
2100
|
-
safe_packages = []
|
|
2101
|
-
for package in packages_to_install:
|
|
2102
|
-
if package.lower() not in violating_packages:
|
|
2103
|
-
safe_packages.append(package)
|
|
2104
|
-
|
|
2105
|
-
return safe_packages, invalidated_constraints
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
def get_constraint_violation_summary(invalidated_constraints: Dict[str, List[str]]) -> str:
|
|
2109
|
-
"""
|
|
2110
|
-
Generate a human-readable summary of constraint violations.
|
|
2111
|
-
|
|
2112
|
-
:param invalidated_constraints: Dictionary mapping constrained packages to violating packages
|
|
2113
|
-
:returns: Formatted string describing the violations
|
|
2114
|
-
"""
|
|
2115
|
-
if not invalidated_constraints:
|
|
2116
|
-
return ""
|
|
2117
|
-
|
|
2118
|
-
lines = []
|
|
2119
|
-
lines.append("The following constraints would be violated:")
|
|
2120
|
-
|
|
2121
|
-
for constrained_package, violating_packages in invalidated_constraints.items():
|
|
2122
|
-
violators_str = ", ".join(violating_packages)
|
|
2123
|
-
lines.append(f" - {constrained_package}: invalidated by installing {violators_str}")
|
|
2124
|
-
|
|
2125
|
-
return "\n".join(lines)
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
def evaluate_invalidation_triggers(env_name: Optional[str] = None) -> Tuple[List[str], Dict[str, List[str]]]:
|
|
2129
|
-
"""
|
|
2130
|
-
Evaluate invalidation triggers and identify constraints that should be removed.
|
|
2131
|
-
|
|
2132
|
-
Checks all current constraints and their invalidation triggers against the currently
|
|
2133
|
-
installed package versions. Returns constraints where ALL triggers have been satisfied.
|
|
2134
|
-
|
|
2135
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
2136
|
-
:returns: Tuple of (constraints_to_remove, trigger_details) where:
|
|
2137
|
-
constraints_to_remove is a list of package names whose constraints should be removed
|
|
2138
|
-
trigger_details maps package names to lists of satisfied triggers
|
|
2139
|
-
"""
|
|
2140
|
-
import importlib.metadata
|
|
2141
|
-
try:
|
|
2142
|
-
from .internals import _check_constraint_satisfaction
|
|
2143
|
-
except ImportError:
|
|
2144
|
-
# Fallback for when executed via exec in __init__.py
|
|
2145
|
-
from pipu_cli.internals import _check_constraint_satisfaction
|
|
2146
|
-
|
|
2147
|
-
constraints_to_remove = []
|
|
2148
|
-
trigger_details = {}
|
|
2149
|
-
|
|
2150
|
-
try:
|
|
2151
|
-
# Get current constraints and invalidation triggers
|
|
2152
|
-
section_name = _get_section_name(env_name)
|
|
2153
|
-
config, _ = _load_config(create_if_missing=False)
|
|
2154
|
-
|
|
2155
|
-
if not config.has_section(section_name):
|
|
2156
|
-
return constraints_to_remove, trigger_details
|
|
2157
|
-
|
|
2158
|
-
# Get current constraints
|
|
2159
|
-
current_constraints = {}
|
|
2160
|
-
if config.has_option(section_name, 'constraints'):
|
|
2161
|
-
constraints_value = config.get(section_name, 'constraints')
|
|
2162
|
-
if any(op in constraints_value for op in ['>=', '<=', '==', '!=', '~=', '>', '<']):
|
|
2163
|
-
current_constraints = parse_inline_constraints(constraints_value)
|
|
2164
|
-
|
|
2165
|
-
# Get invalidation triggers
|
|
2166
|
-
triggers_value = _get_constraint_invalid_when(config, section_name)
|
|
2167
|
-
if not triggers_value or not triggers_value.strip():
|
|
2168
|
-
return constraints_to_remove, trigger_details
|
|
2169
|
-
|
|
2170
|
-
# Parse triggers
|
|
2171
|
-
all_triggers = parse_invalidation_triggers_storage(triggers_value)
|
|
2172
|
-
|
|
2173
|
-
# Get currently installed package versions
|
|
2174
|
-
installed_versions = {}
|
|
2175
|
-
try:
|
|
2176
|
-
for dist in importlib.metadata.distributions():
|
|
2177
|
-
pkg_name = dist.metadata['Name'].lower()
|
|
2178
|
-
installed_versions[pkg_name] = dist.version
|
|
2179
|
-
except Exception:
|
|
2180
|
-
# If we can't get installed versions, we can't evaluate triggers
|
|
2181
|
-
return constraints_to_remove, trigger_details
|
|
2182
|
-
|
|
2183
|
-
# Check each constraint for trigger satisfaction
|
|
2184
|
-
for constrained_package, triggers in all_triggers.items():
|
|
2185
|
-
if constrained_package not in current_constraints:
|
|
2186
|
-
continue # Skip if constraint no longer exists
|
|
2187
|
-
|
|
2188
|
-
satisfied_triggers = []
|
|
2189
|
-
all_triggers_satisfied = True
|
|
2190
|
-
|
|
2191
|
-
for trigger in triggers:
|
|
2192
|
-
# Parse the trigger to get package name and version constraint
|
|
2193
|
-
parsed = parse_invalidation_trigger(trigger)
|
|
2194
|
-
if parsed:
|
|
2195
|
-
trigger_package = parsed['name'].lower()
|
|
2196
|
-
trigger_constraint = parsed['constraint']
|
|
2197
|
-
|
|
2198
|
-
# Check if the trigger package is installed and satisfies the trigger
|
|
2199
|
-
if trigger_package in installed_versions:
|
|
2200
|
-
installed_version = installed_versions[trigger_package]
|
|
2201
|
-
|
|
2202
|
-
# Check if installed version satisfies the trigger constraint
|
|
2203
|
-
if _check_constraint_satisfaction(installed_version, trigger_constraint):
|
|
2204
|
-
satisfied_triggers.append(trigger)
|
|
2205
|
-
else:
|
|
2206
|
-
all_triggers_satisfied = False
|
|
2207
|
-
break
|
|
2208
|
-
else:
|
|
2209
|
-
# If trigger package is not installed, trigger is not satisfied
|
|
2210
|
-
all_triggers_satisfied = False
|
|
2211
|
-
break
|
|
2212
|
-
else:
|
|
2213
|
-
# If we can't parse the trigger, assume it's not satisfied
|
|
2214
|
-
all_triggers_satisfied = False
|
|
2215
|
-
break
|
|
2216
|
-
|
|
2217
|
-
if all_triggers_satisfied and satisfied_triggers:
|
|
2218
|
-
constraints_to_remove.append(constrained_package)
|
|
2219
|
-
trigger_details[constrained_package] = satisfied_triggers
|
|
2220
|
-
|
|
2221
|
-
except Exception:
|
|
2222
|
-
# If any error occurs, don't remove any constraints to be safe
|
|
2223
|
-
pass
|
|
2224
|
-
|
|
2225
|
-
return constraints_to_remove, trigger_details
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
def cleanup_invalidated_constraints(env_name: Optional[str] = None) -> Tuple[List[str], Dict[str, List[str]], Optional[str]]:
|
|
2229
|
-
"""
|
|
2230
|
-
Remove constraints whose invalidation triggers have all been satisfied.
|
|
2231
|
-
|
|
2232
|
-
Evaluates all invalidation triggers against currently installed packages and removes
|
|
2233
|
-
constraints where all trigger conditions have been met.
|
|
2234
|
-
|
|
2235
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
2236
|
-
:returns: Tuple of (removed_constraints, trigger_details, summary_message) where:
|
|
2237
|
-
removed_constraints is a list of package names whose constraints were removed
|
|
2238
|
-
trigger_details maps package names to lists of satisfied triggers
|
|
2239
|
-
summary_message is a human-readable summary of what was removed
|
|
2240
|
-
"""
|
|
2241
|
-
# First, evaluate which constraints should be removed
|
|
2242
|
-
constraints_to_remove, trigger_details = evaluate_invalidation_triggers(env_name)
|
|
2243
|
-
|
|
2244
|
-
if not constraints_to_remove:
|
|
2245
|
-
return [], {}, None
|
|
2246
|
-
|
|
2247
|
-
try:
|
|
2248
|
-
# Remove the constraints that have been invalidated
|
|
2249
|
-
_, removed_constraints, removed_triggers = remove_constraints_from_config(constraints_to_remove, env_name)
|
|
2250
|
-
|
|
2251
|
-
# Create summary message
|
|
2252
|
-
removed_count = len(removed_constraints)
|
|
2253
|
-
if removed_count > 0:
|
|
2254
|
-
package_list = ", ".join(removed_constraints.keys())
|
|
2255
|
-
summary_message = f"Automatically removed {removed_count} invalidated constraint(s): {package_list}"
|
|
2256
|
-
else:
|
|
2257
|
-
summary_message = None
|
|
2258
|
-
|
|
2259
|
-
return list(removed_constraints.keys()), trigger_details, summary_message
|
|
2260
|
-
|
|
2261
|
-
except Exception:
|
|
2262
|
-
# If removal fails, return empty results
|
|
2263
|
-
return [], {}, None
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
def post_install_cleanup(console=None, env_name: Optional[str] = None) -> None:
|
|
2267
|
-
"""
|
|
2268
|
-
Perform post-installation constraint cleanup.
|
|
2269
|
-
|
|
2270
|
-
This function should be called after successful package installation to automatically
|
|
2271
|
-
remove constraints whose invalidation triggers have all been satisfied.
|
|
2272
|
-
|
|
2273
|
-
:param console: Rich console for output (optional)
|
|
2274
|
-
:param env_name: Environment name for section, uses current environment or global if None
|
|
2275
|
-
"""
|
|
2276
|
-
if console:
|
|
2277
|
-
console.print("[bold blue]Checking for invalidated constraints...[/bold blue]")
|
|
2278
|
-
|
|
2279
|
-
try:
|
|
2280
|
-
removed_constraints, trigger_details, summary_message = cleanup_invalidated_constraints(env_name)
|
|
2281
|
-
|
|
2282
|
-
if summary_message and console:
|
|
2283
|
-
console.print(f"[bold yellow]🧹 {summary_message}[/bold yellow]")
|
|
2284
|
-
|
|
2285
|
-
# Show details of what triggers were satisfied
|
|
2286
|
-
if trigger_details:
|
|
2287
|
-
console.print("\n[bold]Invalidation details:[/bold]")
|
|
2288
|
-
for constrained_package, satisfied_triggers in trigger_details.items():
|
|
2289
|
-
triggers_str = ", ".join(satisfied_triggers)
|
|
2290
|
-
console.print(f" {constrained_package}: triggers satisfied ({triggers_str})")
|
|
2291
|
-
elif console:
|
|
2292
|
-
console.print("[dim]No constraints need to be cleaned up.[/dim]")
|
|
2293
|
-
|
|
2294
|
-
except Exception as e:
|
|
2295
|
-
if console:
|
|
2296
|
-
console.print(f"[yellow]Warning: Could not clean up invalidated constraints: {e}[/yellow]")
|