Skip to content

Commit

Permalink
FEATURE: check for software updates on the github server
Browse files Browse the repository at this point in the history
This is still work-in-progress
  • Loading branch information
amilcarlucas committed Jan 25, 2025
1 parent f9c4f14 commit 352db78
Show file tree
Hide file tree
Showing 12 changed files with 994 additions and 97 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- name: Install dependencies
# these extra packages are required by pylint to validate the python imports
run: |
uv pip install 'pylint>=3.3.2' defusedxml requests pymavlink pillow numpy matplotlib pyserial setuptools pytest
uv pip install 'pylint>=3.3.2' defusedxml requests pymavlink pillow numpy matplotlib pyserial setuptools pytest GitPython
- name: Analysing the code with pylint
run: |
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
| [![md-link-check](https://github.com/ArduPilot/MethodicConfigurator/actions/workflows/markdown-link-check.yml/badge.svg)](https://github.com/ArduPilot/MethodicConfigurator/actions/workflows/markdown-link-check.yml) | | [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/channels/674039678562861068/1308233496535371856) | ![PyPI - Downloads](https://img.shields.io/pypi/dm/ardupilot-methodic-configurator?link=https%3A%2F%2Fpypi.org%2Fproject%2Fardupilot-methodic-configurator%2F)

Check failure on line 15 in README.md

View workflow job for this annotation

GitHub Actions / markdown-lint

Table pipe style

README.md:15:544 MD055/table-pipe-style Table pipe style [Expected: leading_and_trailing; Actual: leading_only; Missing trailing pipe] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md055.md

Check failure on line 15 in README.md

View workflow job for this annotation

GitHub Actions / markdown-lint

Table column count

README.md:15:544 MD056/table-column-count Table column count [Expected: 5; Actual: 4; Too few cells, row will be missing data] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md056.md
| |

Check failure on line 16 in README.md

View workflow job for this annotation

GitHub Actions / markdown-lint

Table column count

README.md:16:4 MD056/table-column-count Table column count [Expected: 5; Actual: 1; Too few cells, row will be missing data] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md056.md


*ArduPilot Methodic Configurator* is a software, developed by ArduPilot developers, that semi-automates a
[clear, proven and safe configuration sequence](https://ardupilot.github.io/MethodicConfigurator/TUNING_GUIDE_ArduCopter) for ArduCopter drones.
We are working on extending it to [ArduPlane](https://ardupilot.github.io/MethodicConfigurator/TUNING_GUIDE_ArduPlane),
Expand Down
2 changes: 1 addition & 1 deletion ardupilot_methodic_configurator/annotate_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ def get_xml_data(base_url: str, directory: str, filename: str, vehicle_type: str
url = base_url + filename
proxies = get_env_proxies()
try:
response = requests_get(url, timeout=5, proxies=proxies)
response = requests_get(url, timeout=5, proxies=proxies) if proxies else requests_get(url, timeout=5)
if response.status_code != 200:
logging.warning("Remote URL: %s", url)
msg = f"HTTP status code {response.status_code}"
Expand Down
37 changes: 22 additions & 15 deletions ardupilot_methodic_configurator/backend_filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

# from sys import exit as sys_exit
import hashlib
from argparse import ArgumentParser
from logging import debug as logging_debug
from logging import error as logging_error
Expand All @@ -18,21 +19,22 @@
from os import listdir as os_listdir
from os import path as os_path
from os import rename as os_rename
from pathlib import Path
from platform import system as platform_system
from re import compile as re_compile
from shutil import copy2 as shutil_copy2
from shutil import copytree as shutil_copytree
from typing import Any, Optional
from zipfile import ZipFile

from requests import get as requests_get
from git import Repo
from git.exc import InvalidGitRepositoryError

from ardupilot_methodic_configurator import _
from ardupilot_methodic_configurator.annotate_params import (
PARAM_DEFINITION_XML_FILE,
Par,
format_columns,
get_env_proxies,
get_xml_dir,
get_xml_url,
load_default_param_file,
Expand Down Expand Up @@ -639,20 +641,25 @@ def get_upload_local_and_remote_filenames(self, selected_file: str) -> tuple[str
return "", ""

@staticmethod
def download_file_from_url(url: str, local_filename: str, timeout: int = 5) -> bool:
if not url or not local_filename:
logging_error(_("URL or local filename not provided."))
return False
logging_info(_("Downloading %s from %s"), local_filename, url)
response = requests_get(url, timeout=timeout, proxies=get_env_proxies())

if response.status_code == 200:
with open(local_filename, "wb") as file:
file.write(response.content)
return True
def get_git_commit_hash() -> str:
try:
repo = Repo(search_parent_directories=True)
return str(repo.head.object.hexsha[:7])
except InvalidGitRepositoryError:
# Fallback to reading the git_hash.txt file
git_hash_file = os_path.join(os_path.dirname(__file__), "git_hash.txt")
if os_path.exists(git_hash_file):
with open(git_hash_file, encoding="utf-8") as file:
return file.read().strip()
return ""

logging_error(_("Failed to download the file"))
return False
@staticmethod
def verify_file_hash(file_path: Path, expected_hash: str) -> bool:
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest() == expected_hash

@staticmethod
def add_argparse_arguments(parser: ArgumentParser) -> ArgumentParser:
Expand Down
195 changes: 195 additions & 0 deletions ardupilot_methodic_configurator/backend_internet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""
Check for software updates and install them if available.
This file is part of Ardupilot methodic configurator. https://github.com/ArduPilot/MethodicConfigurator
SPDX-FileCopyrightText: 2024-2025 Amilcar Lucas
SPDX-License-Identifier: GPL-3.0-or-later
"""

import os
import subprocess
import tempfile
from datetime import datetime, timezone
from logging import error as logging_error
from logging import info as logging_info
from pathlib import Path
from typing import Any, Callable, Optional
from urllib.parse import urljoin

from requests import HTTPError as requests_HTTPError
from requests import RequestException as requests_RequestException
from requests import Timeout as requests_Timeout
from requests import get as requests_get
from requests.exceptions import RequestException

from ardupilot_methodic_configurator import _
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem

# Constants
GITHUB_API_URL_RELEASES = "https://api.github.com/repos/ArduPilot/MethodicConfigurator/releases/"


def download_file_from_url(
url: str, local_filename: str, timeout: int = 30, progress_callback: Optional[Callable[[float, str], None]] = None
) -> bool:
if not url or not local_filename:
logging_error(_("URL or local filename not provided."))
return False

logging_info(_("Downloading %s from %s"), local_filename, url)

try:
proxies_dict = {
"http": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy"),
"https": os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy"),
"no_proxy": os.environ.get("NO_PROXY") or os.environ.get("no_proxy"),
}

# Remove None values
proxies = {k: v for k, v in proxies_dict.items() if v is not None}

# Make request with proxy support
response = requests_get(
url,
stream=True,
timeout=timeout,
proxies=proxies,
verify=True, # SSL verification
)
response.raise_for_status()

total_size = int(response.headers.get("content-length", 0))
block_size = 8192
downloaded = 0

os.makedirs(os.path.dirname(os.path.abspath(local_filename)), exist_ok=True)

with open(local_filename, "wb") as file:
for chunk in response.iter_content(chunk_size=block_size):
if chunk:
file.write(chunk)
downloaded += len(chunk)
if progress_callback and total_size:
progress = (downloaded / total_size) * 100
msg = _("Downloading ... {:.1f}%")
progress_callback(progress, msg.format(progress))

if progress_callback:
progress_callback(100.0, _("Download complete"))
return True

except requests_Timeout:
logging_error(_("Download timed out"))
except requests_RequestException as e:
logging_error(_("Network error during download: {}").format(e))
except OSError as e:
logging_error(_("File system error: {}").format(e))
except ValueError as e:
logging_error(_("Invalid data received from %s: %s"), url, e)

return False


def get_release_info(name: str, should_be_pre_release: bool, timeout: int = 30) -> dict[str, Any]:
"""
Get release information from GitHub API.
Args:
name: Release name/path (e.g. '/latest')
should_be_pre_release: Whether the release should be a pre-release
timeout: Request timeout in seconds
Returns:
Release information dictionary
Raises:
RequestException: If the request fails
"""
if not name:
msg = "Release name cannot be empty"
raise ValueError(msg)

try:
url = urljoin(GITHUB_API_URL_RELEASES, name.lstrip("/"))
response = requests_get(url, timeout=timeout)
response.raise_for_status()

release_info = response.json()

if should_be_pre_release and not release_info["prerelease"]:
logging_error(_("The latest continuous delivery build must be a pre-release"))
if not should_be_pre_release and release_info["prerelease"]:
logging_error(_("The latest stable release must not be a pre-release"))

return release_info # type: ignore[no-any-return]

except requests_HTTPError as e:
if e.response.status_code == 403:
logging_error(_("Failed to fetch release info: {}").format(e))
# Get the rate limit reset time
reset_time = int(e.response.headers.get("X-RateLimit-Reset", 0))
# Create a timezone-aware UTC datetime
reset_datetime = datetime.fromtimestamp(reset_time, timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")
logging_error(_("Rate limit exceeded. Please try again after: %s (UTC)"), reset_datetime)
raise
except RequestException as e:
logging_error(_("Failed to fetch release info: {}").format(e))
raise
except (KeyError, ValueError) as e:
logging_error(_("Invalid release data: {}").format(e))
raise


def download_and_install_on_windows(
download_url: str,
file_name: str,
expected_hash: Optional[str] = None,
progress_callback: Optional[Callable[[float, str], None]] = None,
) -> bool:
logging_info(_("Downloading and installing new version for Windows..."))
try:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = os.path.join(temp_dir, file_name)

# Download with progress updates
if not download_file_from_url(
download_url,
temp_path,
timeout=60, # Increased timeout for large files
progress_callback=progress_callback,
):
return False

if expected_hash and not LocalFilesystem.verify_file_hash(Path(temp_path), expected_hash):
logging_error(_("File hash verification failed"))
return False

if progress_callback:
progress_callback(100.0, _("Starting installation..."))

# Run installer
result = subprocess.run( # noqa: S603
[temp_path],
shell=False,
check=True,
capture_output=True,
text=True,
creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
)

return result.returncode == 0

except subprocess.SubprocessError as e:
logging_error(_("Installation failed: {}").format(e))
return False
except OSError as e:
logging_error(_("File operation failed: {}").format(e))
return False


def download_and_install_pip_release() -> int:
logging_info(_("Updating via pip for Linux and MacOS..."))
return os.system("pip install --upgrade ardupilot_methodic_configurator") # noqa: S605, S607
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem, is_within_tolerance
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
from ardupilot_methodic_configurator.backend_internet import download_file_from_url
from ardupilot_methodic_configurator.common_arguments import add_common_arguments_and_parse
from ardupilot_methodic_configurator.frontend_tkinter_base import (
AutoResizeCombobox,
Expand Down Expand Up @@ -459,9 +460,9 @@ def __should_download_file_from_url(self, selected_file: str) -> None:
if self.local_filesystem.vehicle_configuration_file_exists(local_filename):
return # file already exists in the vehicle directory, no need to download it
msg = _("Should the {local_filename} file be downloaded from the URL\n{url}?")
if messagebox.askyesno(
_("Download file from URL"), msg.format(**locals())
) and not self.local_filesystem.download_file_from_url(url, local_filename):
if messagebox.askyesno(_("Download file from URL"), msg.format(**locals())) and not download_file_from_url(
url, local_filename
):
error_msg = _("Failed to download {local_filename} from {url}, please download it manually")
messagebox.showerror(_("Download failed"), error_msg.format(**locals()))

Expand Down
Loading

0 comments on commit 352db78

Please sign in to comment.