diff --git a/.github/workflows/autoupdate_python_versions.yml b/.github/workflows/autoupdate_python_versions.yml new file mode 100644 index 00000000..de4d337a --- /dev/null +++ b/.github/workflows/autoupdate_python_versions.yml @@ -0,0 +1,68 @@ +name: Autoupdate Python Versions +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + # Or if the "scripts" directory is modified + push: + paths: + - 'scripts/**' + + +env: + MAIN_PYTHON_VERSION: '3.12' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + autoupdate-python-versions: + name: Autoupdate Python Versions + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.PYANSYS_CI_BOT_TOKEN }} + + - name: Set up Python ${{ env.MAIN_PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + + - name: Run the autoupdate script + run: | + python -m pip install --upgrade pip + pip install -r scripts/requirements.txt + python scripts/update_python_versions.py + + - name: Commit changes (if any) + run: | + # Configure git username & email + git config user.name 'pyansys-ci-bot' + git config user.email 'pyansys.github.bot@ansys.com' + + # Check if there are any changes + diff_code=$(git diff --exit-code) + + # If there are changes, verify whether the branch 'feat/update-python-version' exists + # If it does, switch to it, rebase it on top of the main branch, and push the changes + # If it doesn't, create the branch, commit the changes, and push the branch + if [ -n "$diff_code" ]; then + if git show-ref --verify --quiet refs/heads/feat/update-python-version; then + git checkout feat/update-python-version + git rebase main + else + git checkout -b feat/update-python-version + fi + + git add . + git commit -m "chore: update Python versions" + git push origin feat/update-python-version + fi + + # If there are changes, create a pull request + if [ -n "$diff_code" ]; then + gh pr create --title "chore: update Python versions" --body "This PR updates the Python versions in the CI configuration files." --base main --head feat/update-python-version + fi \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index abfdadab..73cfe1f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - --line-length=88 - repo: https://github.com/psf/black - rev: 24.3.0 + rev: 24.4.0 hooks: - id: black args: @@ -54,7 +54,7 @@ repos: # this validates our github workflow files - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.1 + rev: 0.28.2 hooks: - id: check-github-workflows diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..df5facde --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +requests==2.31.0 +packaging==23.2 diff --git a/scripts/update_python_versions.py b/scripts/update_python_versions.py new file mode 100644 index 00000000..17ddf52a --- /dev/null +++ b/scripts/update_python_versions.py @@ -0,0 +1,224 @@ +"""Script that updates the Python versions used in the project.""" + +import os +import re + +from packaging.version import Version +import requests + + +def is_version_string(s: str) -> bool: + """Check if the string is in accepted version format. + + Parameters + ---------- + s : str + String to check. + + Returns + ------- + bool + True if the string is in the accepted version format, False otherwise. + """ + pattern = r"^\d+\.\d+\.\d+$" + return bool(re.match(pattern, s)) + + +def get_latest_github_release(user, repo) -> dict: + """Get the latest release of a GitHub repository. + + Parameters + ---------- + user : str + GitHub username. + repo : str + Repository name. + + Returns + ------- + dict + JSON response of the latest release. + """ + url = f"https://api.github.com/repos/{user}/{repo}/releases/latest" + response = requests.get(url) + if response.status_code == 200: + return response.json() + else: + print(f"Failed to get releases: {response.content}") + return None + + +def get_minor_version_sublist_with_greater_patch(list: list[str], current_version: str): + """Get the sublist of versions with greater patch than the current version.""" + major, minor, patch = current_version.split(".") + major, minor, patch = int(major), int(minor), int(patch) + sublist = [version for version in list if version.startswith(f"{major}.{minor}.")] + sublist = [version for version in sublist if int(version.split(".")[2]) > patch] + sublist = sorted(sublist, key=Version, reverse=True) + + return sublist + + +# Get path to the root of the project +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Path to the constants file +CONSTANTS_FILE = os.path.join( + ROOT_DIR, "src", "ansys", "tools", "installer", "constants.py" +) + +# Parse the constants file to find the current Python versions +# used in the project. The versions are stored in a tuple +with open(CONSTANTS_FILE, "r") as f: + lines = f.readlines() + +# Import the following constants inside the constants file +# +# Example: +# +# VANILLA_PYTHON_VERSIONS = { +# "Python 3.8": "3.8.10", +# "Python 3.9": "3.9.13", +# "Python 3.10": "3.10.11", +# "Python 3.11": "3.11.6", +# "Python 3.12": "3.12.0", +# } +# +# CONDA_PYTHON_VERSION = "23.1.0-4" +# + +vanilla_python_versions: dict[str, str] = {} +conda_python_version: str = "" + +for line in lines: + if "VANILLA_PYTHON_VERSIONS" in line: + # Store the index of the line where the dictionary starts + start_index = lines.index(line) + break + +# Get the dictionary that contains the Python versions +for line in lines[start_index:]: + if "}" in line: + # Store the index of the line where the dictionary ends + end_index = lines.index(line, start_index) + break + +# Parse the dictionary to get the Python versions +for line in lines[start_index : end_index + 1]: + if "Python" in line: + # Extract the Python version and the version number + python_version = line.split(":")[0].strip().replace('"', "") + version_number = line.split(":")[1].strip().replace('"', "").replace(",", "") + + # Store the Python version and the version number + vanilla_python_versions[python_version] = version_number + +# Get the Conda Python version +for line in lines: + if "CONDA_PYTHON_VERSION" in line: + conda_python_version = line.split("=")[1].strip().replace('"', "") + +# LOG - Print the current Python versions +print("Current Vanilla Python versions:") +for version in vanilla_python_versions.values(): + print(f">>> '{version}'") + +print("Current Conda Python version") +print(f">>> '{conda_python_version}'") + +# -------------------------------------------------------------------------------------------- + +print("--- \nUpdating Python versions...\n") + +# Check remote Python versions available +PYTHON_FTP = "https://www.python.org/ftp/python" + +# List all folders in the Python FTP +response = requests.get(PYTHON_FTP) +text = response.text.split("\n") +ftp_versions = [] +for line in text: + tmp = line.strip('')[0] + # Check if the folder is a Python version + if is_version_string(tmp): + ftp_versions.append(tmp) + +# For each minor version, get the patch versions available +# greter than the current patch version +for python_version_key, python_version_value in vanilla_python_versions.items(): + # Get the minor version of the current Python version + minor_version = ".".join(python_version_value.split(".")[:2]) + + # Get the patch versions available + patch_versions = get_minor_version_sublist_with_greater_patch( + ftp_versions, python_version_value + ) + + # Check if the patch versions contain the executable + new_patch_version = None + for patch_version in patch_versions: + # Check if the executable exists + response_1 = requests.get( + f"{PYTHON_FTP}/{patch_version}/Python-{patch_version}.tar.xz" + ) + response_2 = requests.get( + f"{PYTHON_FTP}/{patch_version}/python-{patch_version}-amd64.exe" + ) + if response_1.status_code == 200 and response_2.status_code == 200: + print(f"Python {patch_version} is available for download") + new_patch_version = patch_version + break + + # Update the Python version + if new_patch_version: + vanilla_python_versions[python_version_key] = new_patch_version + else: + print(f"Python {python_version_value} is already the latest version available") + +# Get the latest Conda Python version +latest_conda_release = get_latest_github_release("conda-forge", "miniforge") + +# Verify that the assets are available +assets = latest_conda_release["assets"] +new_conda_version = None +count = 0 +for asset in assets: + if f"Miniforge3-{latest_conda_release['name']}-Linux-x86_64.sh" in asset["name"]: + count += 1 + if f"Miniforge3-{latest_conda_release['name']}-Windows-x86_64.exe" in asset["name"]: + count += 1 + if count == 2: + new_conda_version = latest_conda_release["name"] + break + +# Update the Conda Python version +if new_conda_version: + conda_python_version = new_conda_version + print(f"Conda Python version updated to {conda_python_version}") +else: + print(f"Conda Python version is already the latest version available") + +print("\nPython versions updated successfully\n ---") + +# -------------------------------------------------------------------------------------------- + +# LOG - Print the new Python versions +print("New Vanilla Python versions:") +for version in vanilla_python_versions.values(): + print(f">>> '{version}'") + +print("New Conda Python version:") +print(f">>> '{conda_python_version}'") + +# Update the constants file with the new Python versions +# Write the new Python versions to the constants file +with open(CONSTANTS_FILE, "w") as f: + for line in lines[:start_index]: + f.write(line) + + f.write("VANILLA_PYTHON_VERSIONS = {\n") + for python_version, version_number in vanilla_python_versions.items(): + f.write(f' "{python_version}": "{version_number}",\n') + f.write("}\n\n") + + f.write(f'CONDA_PYTHON_VERSION = "{conda_python_version}"\n') diff --git a/src/ansys/tools/installer/constants.py b/src/ansys/tools/installer/constants.py index be5565fe..a8a75aac 100644 --- a/src/ansys/tools/installer/constants.py +++ b/src/ansys/tools/installer/constants.py @@ -180,3 +180,21 @@ VENV_DEFAULT_PATH = "venv_default_path" VENV_SEARCH_PATH = "venv_search_path" + + +############################################################################### +# Python versions +############################################################################### +# +# Do not modify below this section +# + +VANILLA_PYTHON_VERSIONS = { + "Python 3.8": "3.8.10", + "Python 3.9": "3.9.13", + "Python 3.10": "3.10.11", + "Python 3.11": "3.11.9", + "Python 3.12": "3.12.3", +} + +CONDA_PYTHON_VERSION = "24.1.2-0" diff --git a/src/ansys/tools/installer/main.py b/src/ansys/tools/installer/main.py index ce5b9ff4..f7b6ea9c 100644 --- a/src/ansys/tools/installer/main.py +++ b/src/ansys/tools/installer/main.py @@ -42,11 +42,13 @@ ABOUT_TEXT, ANSYS_FAVICON, ASSETS_PATH, + CONDA_PYTHON_VERSION, INSTALL_TEXT, LOG, PRE_COMPILED_PYTHON_WARNING, PYTHON_VERSION_TEXT, UNABLE_TO_RETRIEVE_LATEST_VERSION_TEXT, + VANILLA_PYTHON_VERSIONS, ) from ansys.tools.installer.create_virtual_environment import CreateVenvTab from ansys.tools.installer.installed_table import InstalledTab @@ -224,14 +226,14 @@ def __init__(self, show=True): python_version.setLayout(python_version_layout) self.python_version_select = QtWidgets.QComboBox() - self.python_version_select.addItem("Python 3.8", "3.8.10") - self.python_version_select.addItem("Python 3.9", "3.9.13") - self.python_version_select.addItem("Python 3.10", "3.10.11") - self.python_version_select.addItem("Python 3.11", "3.11.6") - self.python_version_select.addItem("Python 3.12", "3.12.0") - - # Set the default selection to "Python 3.11" - default_index = self.python_version_select.findText("Python 3.11") + for elem_key, elem_value in VANILLA_PYTHON_VERSIONS.items(): + self.python_version_select.addItem(elem_key, elem_value) + + # Set the default selection to the last Python version + VANILLA_PYTHON_VERSIONS + default_index = self.python_version_select.findText( + list(VANILLA_PYTHON_VERSIONS.keys())[-1] + ) self.python_version_select.setCurrentIndex(default_index) python_version_layout.addWidget(self.python_version_select) @@ -561,14 +563,13 @@ def download_and_install(self): filename = f"python-{selected_version}-amd64.exe" LOG.info("Installing vanilla Python %s", selected_version) else: - conda_version = "23.1.0-4" # OS based file download if is_linux_os(): LOG.info("Linux") - url, filename = get_conda_url_and_filename(conda_version) + url, filename = get_conda_url_and_filename(CONDA_PYTHON_VERSION) else: - url = f"https://github.com/conda-forge/miniforge/releases/download/{conda_version}/Miniforge3-{conda_version}-Windows-x86_64.exe" - filename = f"Miniforge3-{conda_version}-Windows-x86_64.exe" + url = f"https://github.com/conda-forge/miniforge/releases/download/{CONDA_PYTHON_VERSION}/Miniforge3-{CONDA_PYTHON_VERSION}-Windows-x86_64.exe" + filename = f"Miniforge3-{CONDA_PYTHON_VERSION}-Windows-x86_64.exe" LOG.info("Installing miniconda from %s", url) try: self._download(url, filename, when_finished=self._run_install_python) diff --git a/src/ansys/tools/installer/vscode.py b/src/ansys/tools/installer/vscode.py index 91693ea3..f0be5888 100644 --- a/src/ansys/tools/installer/vscode.py +++ b/src/ansys/tools/installer/vscode.py @@ -115,9 +115,9 @@ def _open_vscode(self): """Open VS code from path.""" # handle errors path = self.vscode_window_path_config_edit.text().strip() - if os.path.exists(path): + if os.path.exists(rf"{path}"): error_msg = "echo Failed to launch vscode. Try reinstalling code by following this link https://code.visualstudio.com/download" - self._parent.launch_cmd(f"code {path} && exit 0 || {error_msg}") + self._parent.launch_cmd(f'code "{path}" && exit 0 || {error_msg}') self.user_confirmation_form.close() self._parent.vscode_window.close()