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.
@@ -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]")