requirements.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. # Licensed under the Apache License, Version 2.0 (the "License");
  2. # http://www.apache.org/licenses/LICENSE-2.0
  3. #
  4. """Utilities to parse and adjust Python requirements files.
  5. This module parses requirement lines while preserving inline comments and pip arguments and
  6. supports relaxing version pins based on a chosen unfreeze strategy: "none", "major", or "all".
  7. """
  8. import re
  9. from collections.abc import Iterable, Iterator
  10. from pathlib import Path
  11. from typing import Any, Optional, Union
  12. from packaging.requirements import Requirement
  13. from packaging.version import Version
  14. def _yield_lines(strs: Union[str, Iterable[str]]) -> Iterator[str]:
  15. """Yield non-empty, non-comment lines from a string or iterable of strings.
  16. Adapted from pkg_resources.yield_lines.
  17. """
  18. if isinstance(strs, str):
  19. strs = strs.splitlines()
  20. for line in strs:
  21. line = line.strip()
  22. if line and not line.startswith("#"):
  23. yield line
  24. class _RequirementWithComment(Requirement):
  25. """Requirement subclass that preserves an inline comment and optional pip argument.
  26. Attributes:
  27. comment: The trailing comment captured from the requirement line (including the leading '# ...').
  28. pip_argument: A preceding pip argument line (e.g., ``"--extra-index-url ..."``) associated
  29. with this requirement, or ``None`` if not provided.
  30. strict: Whether the special marker ``"# strict"`` appears in ``comment`` (case-insensitive), in which case
  31. upper bound adjustments are disabled.
  32. """
  33. strict_string = "# strict"
  34. def __init__(self, *args: Any, comment: str = "", pip_argument: Optional[str] = None, **kwargs: Any) -> None:
  35. super().__init__(*args, **kwargs)
  36. self.comment = comment
  37. if not (pip_argument is None or pip_argument): # sanity check that it's not an empty str
  38. raise RuntimeError(f"wrong pip argument: {pip_argument}")
  39. self.pip_argument = pip_argument
  40. self.strict = self.strict_string in comment.lower()
  41. def adjust(self, unfreeze: str) -> str:
  42. """Adjust version specifiers according to the selected unfreeze strategy.
  43. The special marker ``"# strict"`` in the captured comment disables any relaxation of upper bounds.
  44. >>> _RequirementWithComment("arrow<=1.2.2,>=1.2.0", comment="# anything").adjust("none")
  45. 'arrow<=1.2.2,>=1.2.0'
  46. >>> _RequirementWithComment("arrow<=1.2.2,>=1.2.0", comment="# strict").adjust("none")
  47. 'arrow<=1.2.2,>=1.2.0 # strict'
  48. >>> _RequirementWithComment("arrow<=1.2.2,>=1.2.0", comment="# my name").adjust("all")
  49. 'arrow>=1.2.0'
  50. >>> _RequirementWithComment("arrow>=1.2.0, <=1.2.2", comment="# strict").adjust("all")
  51. 'arrow<=1.2.2,>=1.2.0 # strict'
  52. >>> _RequirementWithComment("arrow").adjust("all")
  53. 'arrow'
  54. >>> _RequirementWithComment("arrow>=1.2.0, <=1.2.2", comment="# cool").adjust("major")
  55. 'arrow<2.0,>=1.2.0'
  56. >>> _RequirementWithComment("arrow>=1.2.0, <=1.2.2", comment="# strict").adjust("major")
  57. 'arrow<=1.2.2,>=1.2.0 # strict'
  58. >>> _RequirementWithComment("arrow>=1.2.0").adjust("major")
  59. 'arrow>=1.2.0'
  60. >>> _RequirementWithComment("arrow").adjust("major")
  61. 'arrow'
  62. Args:
  63. unfreeze: One of:
  64. - ``"none"``: Keep all version specifiers unchanged.
  65. - ``"major"``: Relax the upper bound to the next major version (e.g., ``<2.0``).
  66. - ``"all"``: Drop any upper bound constraint entirely.
  67. Returns:
  68. The adjusted requirement string. If strict, the original string is returned with the strict marker appended.
  69. """
  70. out = str(self)
  71. if self.strict:
  72. return f"{out} {self.strict_string}"
  73. if unfreeze == "major":
  74. for spec in self.specifier:
  75. if spec.operator in ("<", "<="):
  76. major = Version(spec.version).major
  77. # replace upper bound with major version increased by one
  78. return out.replace(f"{spec.operator}{spec.version}", f"<{int(major) + 1}.0")
  79. elif unfreeze == "all":
  80. for spec in self.specifier:
  81. if spec.operator in ("<", "<="):
  82. # drop upper bound (with or without trailing/leading comma)
  83. upper = f"{spec.operator}{spec.version}"
  84. result = out.replace(f"{upper},", "").replace(f",{upper}", "")
  85. if upper in result:
  86. result = result.replace(upper, "")
  87. return result.strip()
  88. elif unfreeze != "none":
  89. raise ValueError(f"Unexpected unfreeze: {unfreeze!r} value.")
  90. return out
  91. def _parse_requirements(strs: Union[str, Iterable[str]]) -> Iterator[_RequirementWithComment]:
  92. r"""Adapted from ``pkg_resources.parse_requirements`` to include comments and pip arguments.
  93. Parses a sequence or string of requirement lines, preserving trailing comments and associating any
  94. preceding pip arguments (``--...``) with the subsequent requirement. Lines starting with ``-r`` or
  95. containing direct URLs are ignored.
  96. >>> txt = ['# ignored', '', 'this # is an', '--piparg', 'example', 'foo # strict', 'thing', '-r different/file.txt']
  97. >>> [r.adjust('none') for r in _parse_requirements(txt)]
  98. ['this', 'example', 'foo # strict', 'thing']
  99. >>> txt = '\\n'.join(txt)
  100. >>> [r.adjust('none') for r in _parse_requirements(txt)]
  101. ['this', 'example', 'foo # strict', 'thing']
  102. Args:
  103. strs: Either an iterable of requirement lines or a single multi-line string.
  104. Yields:
  105. _RequirementWithComment: Parsed requirement objects with preserved comment and pip argument.
  106. """
  107. lines = _yield_lines(strs)
  108. pip_argument = None
  109. for line in lines:
  110. # Drop comments -- a hash without a space may be in a URL.
  111. if " #" in line:
  112. comment_pos = line.find(" #")
  113. line, comment = line[:comment_pos], line[comment_pos:]
  114. else:
  115. comment = ""
  116. # If there is a line continuation, drop it, and append the next line.
  117. if line.endswith("\\"):
  118. line = line[:-1].strip()
  119. try:
  120. line += next(lines)
  121. except StopIteration:
  122. return
  123. # If there's a pip argument, save it
  124. if line.startswith("--"):
  125. pip_argument = line
  126. continue
  127. if line.startswith("-r "):
  128. # linked requirement files are unsupported
  129. continue
  130. if "@" in line or re.search("https?://", line):
  131. # skip lines with links like `pesq @ git+https://github.com/ludlows/python-pesq`
  132. continue
  133. yield _RequirementWithComment(line, comment=comment, pip_argument=pip_argument)
  134. pip_argument = None
  135. def load_requirements(path_dir: str, file_name: str = "base.txt", unfreeze: str = "all") -> list[str]:
  136. """Load, parse, and optionally relax requirement specifiers from a file.
  137. >>> import os
  138. >>> from lightning_utilities import _PROJECT_ROOT
  139. >>> path_req = os.path.join(_PROJECT_ROOT, "requirements")
  140. >>> load_requirements(path_req, "docs.txt", unfreeze="major") # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
  141. ['sphinx<6.0,>=4.0', ...]
  142. Args:
  143. path_dir: Directory containing the requirements file.
  144. file_name: The requirements filename inside ``path_dir``.
  145. unfreeze: Unfreeze strategy: ``"none"``, ``"major"``, or ``"all"`` (see ``_RequirementWithComment.adjust``).
  146. Returns:
  147. A list of requirement strings adjusted according to ``unfreeze``.
  148. Raises:
  149. ValueError: If ``unfreeze`` is not one of the supported options.
  150. FileNotFoundError: If the composed path does not exist.
  151. """
  152. if unfreeze not in {"none", "major", "all"}:
  153. raise ValueError(f'unsupported option of "{unfreeze}"')
  154. path = Path(path_dir) / file_name
  155. if not path.exists():
  156. raise FileNotFoundError(f"missing file for {(path_dir, file_name, path)}")
  157. text = path.read_text()
  158. return [req.adjust(unfreeze) for req in _parse_requirements(text)]