|
@@ -0,0 +1,453 @@
|
|
|
+'''
|
|
|
+This script is a helper on the dependencies management of the project.
|
|
|
+It can be used:
|
|
|
+- To check that the same version of a package is set across files.
|
|
|
+- To generate a Pipfile and requirements files with the latest version installables.
|
|
|
+- To display a summary of the dependencies to update.
|
|
|
+'''
|
|
|
+import glob
|
|
|
+import itertools
|
|
|
+import sys
|
|
|
+from dataclasses import dataclass, field
|
|
|
+from datetime import datetime
|
|
|
+from pathlib import Path
|
|
|
+from typing import Dict, List
|
|
|
+
|
|
|
+import tabulate
|
|
|
+import toml
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class Release:
|
|
|
+ """
|
|
|
+ Information about a release of a package.
|
|
|
+ """
|
|
|
+ version: str
|
|
|
+ upload_date: datetime.date
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class Package:
|
|
|
+ """
|
|
|
+ Information about a package.
|
|
|
+ """
|
|
|
+ # Package name
|
|
|
+ name: str
|
|
|
+ # Min version of the package set as requirements.
|
|
|
+ min_version: str
|
|
|
+ # Max version of the package set as requirements.
|
|
|
+ max_version: str
|
|
|
+ # Setup installation markers of the package.
|
|
|
+ # ex: ;python_version>="3.6"
|
|
|
+ installation_markers: str
|
|
|
+ # Taipy dependencies are ignored.
|
|
|
+ is_taipy: bool
|
|
|
+ # Optional dependencies
|
|
|
+ extras_dependencies: List[str]
|
|
|
+ # Files where the package is set as requirement.
|
|
|
+ files: List[str]
|
|
|
+ # List of releases of the package.
|
|
|
+ releases: List[Release] = field(default_factory=list)
|
|
|
+ # Min release of the package.
|
|
|
+ # Also present in the releases list.
|
|
|
+ min_release: Release = None
|
|
|
+ # Max release of the package.
|
|
|
+ # Also present in the releases list.
|
|
|
+ max_release: Release = None
|
|
|
+ # Latest version available on PyPI.
|
|
|
+ latest_release: Release = None
|
|
|
+
|
|
|
+ def __eq__(self, other):
|
|
|
+ return self.name == other.name
|
|
|
+
|
|
|
+ def __hash__(self):
|
|
|
+ return hash(self.name)
|
|
|
+
|
|
|
+ def load_releases(self):
|
|
|
+ """
|
|
|
+ Retrieve all releases of the package from PyPI.
|
|
|
+ """
|
|
|
+ import requests # pylint: disable=import-outside-toplevel
|
|
|
+
|
|
|
+ releases = requests.get(
|
|
|
+ f"https://pypi.org/pypi/{self.name}/json",
|
|
|
+ timeout=5
|
|
|
+ ).json().get('releases', {})
|
|
|
+
|
|
|
+ for version, info in releases.items():
|
|
|
+ # Ignore old releases without upload time.
|
|
|
+ if not info:
|
|
|
+ continue
|
|
|
+ # Ignore pre and post releases.
|
|
|
+ if any(str.isalpha(c) for c in version):
|
|
|
+ continue
|
|
|
+ date = datetime.strptime(info[0]['upload_time'], "%Y-%m-%dT%H:%M:%S").date()
|
|
|
+ release = Release(version, date)
|
|
|
+ self.releases.append(release)
|
|
|
+ if self.min_version == version:
|
|
|
+ self.min_release = release
|
|
|
+ # Min and max version can be the same.
|
|
|
+ if self.max_version == version:
|
|
|
+ self.max_release = release
|
|
|
+
|
|
|
+ self.releases.sort(key=lambda x: x.upload_date, reverse=True)
|
|
|
+ self.latest_release = self.releases[0]
|
|
|
+
|
|
|
+ def as_requirements_line(self, with_version: bool = True) -> str:
|
|
|
+ """
|
|
|
+ Return the package as a requirements line.
|
|
|
+ """
|
|
|
+ if self.is_taipy:
|
|
|
+ return self.name
|
|
|
+
|
|
|
+ name = self.name
|
|
|
+ if self.extras_dependencies:
|
|
|
+ name += f'[{",".join(self.extras_dependencies)}]'
|
|
|
+
|
|
|
+ if with_version:
|
|
|
+ if self.installation_markers:
|
|
|
+ return f'{name}>={self.min_version},<={self.max_version};{self.installation_markers}'
|
|
|
+ return f'{name}>={self.min_version},<={self.max_version}'
|
|
|
+
|
|
|
+ if self.installation_markers:
|
|
|
+ return f'{name};{self.installation_markers}'
|
|
|
+ return name
|
|
|
+
|
|
|
+ def as_pipfile_line(self) -> str:
|
|
|
+ """
|
|
|
+ Return the package as a pipfile line.
|
|
|
+ If min_version is True, the min version is used.
|
|
|
+ """
|
|
|
+ line = f'"{self.name}" = {{version="=={self.max_version}"'
|
|
|
+
|
|
|
+ if self.installation_markers:
|
|
|
+ line += f', markers="{self.installation_markers}"'
|
|
|
+
|
|
|
+ if self.extras_dependencies:
|
|
|
+ dep = ','.join(f'"{p}"' for p in self.extras_dependencies)
|
|
|
+ line += f', extras=[{dep}]'
|
|
|
+
|
|
|
+ line += '}'
|
|
|
+ return line
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def check_format(cls, package: str):
|
|
|
+ """
|
|
|
+ Check if a package definition is correctly formatted.
|
|
|
+ """
|
|
|
+ if '>=' not in package or '<' not in package:
|
|
|
+ # Only Taipy dependencies can be without version.
|
|
|
+ if 'taipy' not in package:
|
|
|
+ raise Exception(f"Invalid package: {package}")
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_requirements(cls, package: str, filename: str):
|
|
|
+ """
|
|
|
+ Create a package from a requirements line.
|
|
|
+ ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'"
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # Lower the name to avoid case issues.
|
|
|
+ name = extract_name(package).lower()
|
|
|
+ is_taipy = 'taipy' in name
|
|
|
+ return cls(
|
|
|
+ name,
|
|
|
+ extract_min_version(package) if not is_taipy else '',
|
|
|
+ extract_max_version(package) if not is_taipy else '',
|
|
|
+ extract_installation_markers(package) if not is_taipy else '',
|
|
|
+ is_taipy,
|
|
|
+ extract_extras_dependencies(package),
|
|
|
+ [filename]
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ print(f"Error while parsing package {package}: {e}") # noqa: T201
|
|
|
+ raise
|
|
|
+
|
|
|
+
|
|
|
+def extract_installation_markers(package: str) -> str:
|
|
|
+ """
|
|
|
+ Extract the installation markers of a package from a requirements line.
|
|
|
+ ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'" -> "python_version<'3.9'"
|
|
|
+ """
|
|
|
+ if ';' not in package:
|
|
|
+ return ''
|
|
|
+ return package.split(';')[1]
|
|
|
+
|
|
|
+
|
|
|
+def extract_min_version(package: str) -> str:
|
|
|
+ """
|
|
|
+ Extract the min version of a package from a requirements line.
|
|
|
+ ex: "pandas>=1.0.0,<2.0.0;python_version<'3.9'" -> "1.0.0"
|
|
|
+ """
|
|
|
+ # The max version is the defined version if it is a fixed version.
|
|
|
+ if '==' in package:
|
|
|
+ version = package.split('==')[1]
|
|
|
+ if ';' in version:
|
|
|
+ # Remove installation markers.
|
|
|
+ version = version.split(';')[0]
|
|
|
+ return version
|
|
|
+
|
|
|
+ return package.split('>=')[1].split(',')[0]
|
|
|
+
|
|
|
+
|
|
|
+def extract_max_version(package: str) -> str:
|
|
|
+ """
|
|
|
+ Extract the max version of a package from a requirements line.
|
|
|
+ Ex:
|
|
|
+ - pandas==1.0.0 -> 1.0.0
|
|
|
+ - pandas>=1.0.0,<=2.0.0 -> 2.0.0
|
|
|
+ - pandas==1.0.0;python_version<'3.9' -> 1.0.0
|
|
|
+ - pandas>=1.0.0,<2.0.0;python_version<'3.9' -> 2.0.0
|
|
|
+ """
|
|
|
+ # The max version is the defined version if it is a fixed version.
|
|
|
+ if '==' in package:
|
|
|
+ version = package.split('==')[1]
|
|
|
+ if ';' in version:
|
|
|
+ # Remove installation markers.
|
|
|
+ version = version.split(';')[0]
|
|
|
+ return version
|
|
|
+
|
|
|
+ version = None
|
|
|
+
|
|
|
+ if ',<=' in package:
|
|
|
+ version = package.split(',<=')[1]
|
|
|
+ else:
|
|
|
+ version = package.split(',<')[1]
|
|
|
+
|
|
|
+ if ';' in version:
|
|
|
+ # Remove installation markers.
|
|
|
+ version = version.split(';')[0]
|
|
|
+
|
|
|
+ return version
|
|
|
+
|
|
|
+
|
|
|
+def extract_name(package: str) -> str:
|
|
|
+ """
|
|
|
+ Extract the name of a package from a requirements line.
|
|
|
+ Ex:
|
|
|
+ - pandas==1.0.0 -> pandas
|
|
|
+ - pandas>=1.0.0,<2.0.0 -> pandas
|
|
|
+ - pandas==1.0.0;python_version<'3.9' -> pandas
|
|
|
+ - pandas>=1.0.0,<2.0.0;python_version<'3.9' -> pandas
|
|
|
+ """
|
|
|
+ if '==' in package:
|
|
|
+ return package.split('==')[0]
|
|
|
+
|
|
|
+ name = package.split('>=')[0]
|
|
|
+
|
|
|
+ # Remove optional dependencies.
|
|
|
+ # Ex: "pandas[sql]" -> "pandas"
|
|
|
+ if '[' in name:
|
|
|
+ name = name.split('[')[0]
|
|
|
+ return name
|
|
|
+
|
|
|
+
|
|
|
+def extract_extras_dependencies(package: str) -> List[str]:
|
|
|
+ """
|
|
|
+ Extract the extras dependencies of a package from a requirements line.
|
|
|
+ Ex:
|
|
|
+ - pymongo[srv]>=4.2.0,<=4.6.1 -> ["srv"]
|
|
|
+ """
|
|
|
+ if '[' not in package:
|
|
|
+ return []
|
|
|
+
|
|
|
+ return package.split('[')[1].split(']')[0].split(',')
|
|
|
+
|
|
|
+
|
|
|
+def load_dependencies(requirements_filenames: List[str], enforce_format: bool) -> Dict[str, Package]:
|
|
|
+ """
|
|
|
+ Load and concat dependencies from requirements files.
|
|
|
+ """
|
|
|
+ # Extracted dependencies from requirements files.
|
|
|
+ dependencies = {}
|
|
|
+
|
|
|
+ for filename in requirements_filenames:
|
|
|
+ file_dependencies = Path(filename).read_text("UTF-8").split("\n")
|
|
|
+
|
|
|
+ for package_requirements in file_dependencies:
|
|
|
+ # Ignore empty lines.
|
|
|
+ if not package_requirements:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Ensure the package is correctly formatted with born min and max.
|
|
|
+ if enforce_format:
|
|
|
+ Package.check_format(package_requirements)
|
|
|
+
|
|
|
+ package = Package.from_requirements(package_requirements, filename)
|
|
|
+
|
|
|
+ # dependencies may be present multiple times in different files.
|
|
|
+ # In that case, do not load the releases again but ensure versions are the same.
|
|
|
+ if package.name in dependencies:
|
|
|
+ existing_package = dependencies[package.name]
|
|
|
+ if not existing_package.min_version == package.min_version or \
|
|
|
+ not existing_package.max_version == package.max_version:
|
|
|
+ raise Exception(
|
|
|
+ f"Inconsistent version of '{package.name}' between '{filename}' and {','.join(package.files)}."
|
|
|
+ )
|
|
|
+
|
|
|
+ # Add the file as dependency of the package.
|
|
|
+ existing_package.files.append(filename)
|
|
|
+ # Stop processing, package is already extracted.
|
|
|
+ continue
|
|
|
+
|
|
|
+ dependencies[package.name] = package
|
|
|
+
|
|
|
+ return dependencies
|
|
|
+
|
|
|
+
|
|
|
+def display_dependencies_versions(dependencies: Dict[str, Package]):
|
|
|
+ """
|
|
|
+ Display dependencies information.
|
|
|
+ """
|
|
|
+ to_print = []
|
|
|
+
|
|
|
+ for package_name, package in dependencies.items():
|
|
|
+ if package.is_taipy:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Load the latest releases of the package.
|
|
|
+ package.load_releases()
|
|
|
+
|
|
|
+ to_print.append((
|
|
|
+ package_name,
|
|
|
+ f'{package.min_version} ({package.min_release.upload_date if package.min_release else "N.A."})',
|
|
|
+ f'{package.max_version} ({package.max_release.upload_date if package.max_release else "N.C."})',
|
|
|
+ f'{package.releases[0].version} ({package.releases[0].upload_date})',
|
|
|
+ len(list(itertools.takewhile(lambda x: x.version != package.max_version, package.releases))), # noqa: B023
|
|
|
+ ))
|
|
|
+
|
|
|
+ to_print.sort(key=lambda x: x[0])
|
|
|
+ h = ['name', 'version-min', 'version-max', 'current-version', 'nb-releases-behind']
|
|
|
+ print(tabulate.tabulate(to_print, headers=h, tablefmt='pretty')) # noqa: T201
|
|
|
+
|
|
|
+
|
|
|
+def update_dependencies(
|
|
|
+ # Dependencies installed in the environment.
|
|
|
+ dependencies_installed: Dict[str, Package],
|
|
|
+ # Dependencies set in requirements files.
|
|
|
+ dependencies_set: Dict[str, Package],
|
|
|
+ # Requirements files to update.
|
|
|
+ requirements_filenames: List[str]
|
|
|
+ ):
|
|
|
+ """
|
|
|
+ Display and updates dependencies.
|
|
|
+ """
|
|
|
+ to_print = []
|
|
|
+
|
|
|
+ for name, ds in dependencies_set.items():
|
|
|
+ if ds.is_taipy:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Find the package in use.
|
|
|
+ di = dependencies_installed.get(name)
|
|
|
+ # Some package as 'gitignore-parser' becomes 'gitignore_parser' during the installation.
|
|
|
+ if not di:
|
|
|
+ di = dependencies_installed.get(name.replace('-', '_'))
|
|
|
+
|
|
|
+ if di:
|
|
|
+ if di.max_version != ds.max_version:
|
|
|
+ to_print.append((
|
|
|
+ name,
|
|
|
+ di.max_version,
|
|
|
+ ','.join(f.split('/')[0] for f in ds.files)
|
|
|
+ ))
|
|
|
+ # Save the new dependency version.
|
|
|
+ ds.max_version = di.max_version
|
|
|
+
|
|
|
+ # Print the dependencies to update.
|
|
|
+ to_print.sort(key=lambda x: x[0])
|
|
|
+ print(tabulate.tabulate(to_print, headers=['name', 'version', 'files'], tablefmt='pretty')) # noqa: T201
|
|
|
+
|
|
|
+ # Update requirements files.
|
|
|
+ for fd in requirements_filenames:
|
|
|
+ requirements = '\n'.join(
|
|
|
+ d.as_requirements_line()
|
|
|
+ for d in sorted(dependencies_set.values(), key=lambda d: d.name)
|
|
|
+ if fd in d.files
|
|
|
+ )
|
|
|
+ # Add a new line at the end of the file.
|
|
|
+ requirements += '\n'
|
|
|
+ Path(fd).write_text(requirements, 'UTF-8')
|
|
|
+
|
|
|
+
|
|
|
+def generate_raw_requirements_txt(dependencies: Dict[str, Package]):
|
|
|
+ """
|
|
|
+ Print the dependencies as requirements lines without version.
|
|
|
+ """
|
|
|
+ for package in dependencies.values():
|
|
|
+ if not package.is_taipy:
|
|
|
+ print(package.as_requirements_line(with_version=False)) # noqa: T201
|
|
|
+
|
|
|
+
|
|
|
+def update_pipfile(pipfile: str, dependencies_version: Dict[str, Package]):
|
|
|
+ """
|
|
|
+ Update in place dependencies version of a Pipfile.
|
|
|
+ Warning:
|
|
|
+ Dependencies are loaded from requirements files without extras or markers.
|
|
|
+ The Pipfile contains extras and markers information.
|
|
|
+ """
|
|
|
+ dependencies_str = ""
|
|
|
+ pipfile_obj = toml.load(pipfile)
|
|
|
+
|
|
|
+ packages = pipfile_obj.pop('packages')
|
|
|
+ for name, dep in packages.items():
|
|
|
+ # Find the package in use.
|
|
|
+ rp = dependencies_version.get(name)
|
|
|
+ # Some package as 'gitignore-parser' becomes 'gitignore_parser' during the installation.
|
|
|
+ if not rp:
|
|
|
+ rp = dependencies_version.get(name.replace('-', '_'))
|
|
|
+ if rp:
|
|
|
+ # Change for the real name of the package.
|
|
|
+ rp.name = name
|
|
|
+
|
|
|
+ if not rp:
|
|
|
+ # Package not found. Can be due to python version.
|
|
|
+ # Ex: backports.zoneinfo
|
|
|
+ if isinstance(dep, dict):
|
|
|
+ new_dep = ''
|
|
|
+ # Format as a Pipfile line.
|
|
|
+ new_dep = f'version="{dep["version"]}"'
|
|
|
+ if dep.get('markers'):
|
|
|
+ new_dep += f', markers="{dep["markers"]}"'
|
|
|
+ if dep.get('extras'):
|
|
|
+ new_dep += f', extras={dep["extras"]}'
|
|
|
+ dep = f"{{{new_dep}}}"
|
|
|
+ dependencies_str += f'"{name}" = {dep}\n'
|
|
|
+ else:
|
|
|
+ if isinstance(dep, dict):
|
|
|
+ # Requirements does not have installation markers and extras.
|
|
|
+ rp.installation_markers = dep.get('markers', '')
|
|
|
+ rp.extras_dependencies = [dep.get('extras')[0]] if dep.get('extras') else []
|
|
|
+ dependencies_str += f'{rp.as_pipfile_line()}\n'
|
|
|
+
|
|
|
+ toml_str = toml.dumps(pipfile_obj)
|
|
|
+ Path(pipfile).write_text(f'{toml_str}\n\n[packages]\n{dependencies_str}', 'UTF-8')
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ if sys.argv[1] == 'ensure-same-version':
|
|
|
+ # Load dependencies from requirements files.
|
|
|
+ # Verify that the same version is set for the same package across files.
|
|
|
+ _requirements_filenames = glob.glob('taipy*/*requirements.txt')
|
|
|
+ _dependencies = load_dependencies(_requirements_filenames, True)
|
|
|
+ display_dependencies_versions(_dependencies)
|
|
|
+ if sys.argv[1] == 'dependencies-summary':
|
|
|
+ # Load and compare dependencies from requirements files.
|
|
|
+ # The first file is the reference to the other.
|
|
|
+ # Display the differences including new version available on Pypi.
|
|
|
+ _requirements_filenames = glob.glob('taipy*/*requirements.txt')
|
|
|
+ _dependencies_installed = load_dependencies([sys.argv[2]], False)
|
|
|
+ _dependencies_set = load_dependencies(_requirements_filenames, False)
|
|
|
+ update_dependencies(_dependencies_installed, _dependencies_set, _requirements_filenames)
|
|
|
+ if sys.argv[1] == 'generate-raw-requirements':
|
|
|
+ # Load dependencies from requirements files.
|
|
|
+ # Print the dependencies as requirements lines without born.
|
|
|
+ _requirements_filenames = glob.glob('taipy*/*requirements.txt')
|
|
|
+ _dependencies = load_dependencies(_requirements_filenames, False)
|
|
|
+ generate_raw_requirements_txt(_dependencies)
|
|
|
+ if sys.argv[1] == 'generate-pipfile':
|
|
|
+ # Generate a new Pipfile from requirements files using dependencies versions
|
|
|
+ # set in the requirement file.
|
|
|
+ _pipfile_path = sys.argv[2]
|
|
|
+ _dependencies_version = load_dependencies([sys.argv[3]], False)
|
|
|
+ update_pipfile(_pipfile_path, _dependencies_version)
|