2222# * *
2323# ***************************************************************************
2424
25- """ Utilities to work across different platforms, providers and python versions """
25+ """Utilities to work across different platforms, providers and python versions"""
2626
27+ # pylint: disable=deprecated-module, ungrouped-imports
28+
29+ from datetime import datetime
30+ from typing import Optional , Any , List
2731import os
2832import platform
2933import shutil
3034import stat
3135import subprocess
36+ import time
3237import re
3338import ctypes
34- from typing import Optional , Any
3539
3640from urllib .parse import urlparse
3741
3842try :
3943 from PySide import QtCore , QtGui , QtWidgets
4044except ImportError :
41- QtCore = None
42- QtWidgets = None
43- QtGui = None
45+ try :
46+ from PySide6 import QtCore , QtGui , QtWidgets
47+ except ImportError :
48+ from PySide2 import QtCore , QtGui , QtWidgets
4449
4550import addonmanager_freecad_interface as fci
4651
52+ try :
53+ from freecad .utils import get_python_exe
54+ except ImportError :
55+
56+ def get_python_exe ():
57+ """Use shutil.which to find python executable"""
58+ return shutil .which ("python" )
59+
60+
4761if fci .FreeCADGui :
4862
4963 # If the GUI is up, we can use the NetworkManager to handle our downloads. If there is no event
5064 # loop running this is not possible, so fall back to requests (if available), or the native
5165 # Python urllib.request (if requests is not available).
5266 import NetworkManager # Requires an event loop, so is only available with the GUI
67+
68+ requests = None
69+ ssl = None
70+ urllib = None
5371else :
72+ NetworkManager = None
5473 try :
5574 import requests
75+
76+ ssl = None
77+ urllib = None
5678 except ImportError :
5779 requests = None
5880 import urllib .request
5981 import ssl
6082
83+ if fci .FreeCADGui :
84+ loadUi = fci .loadUi
85+ else :
86+ has_loader = False
87+ try :
88+ from PySide6 .QtUiTools import QUiLoader
89+
90+ has_loader = True
91+ except ImportError :
92+ try :
93+ from PySide2 .QtUiTools import QUiLoader
94+
95+ has_loader = True
96+ except ImportError :
97+
98+ def loadUi (ui_file : str ):
99+ """If there are no available versions of QtUiTools, then raise an error if this
100+ method is used."""
101+ raise RuntimeError ("Cannot use QUiLoader without PySide or FreeCAD" )
102+
103+ if has_loader :
104+
105+ def loadUi (ui_file : str ) -> QtWidgets .QWidget :
106+ """Load a Qt UI from an on-disk file."""
107+ q_ui_file = QtCore .QFile (ui_file )
108+ q_ui_file .open (QtCore .QFile .OpenModeFlag .ReadOnly )
109+ loader = QUiLoader ()
110+ return loader .load (ui_file )
111+
112+
61113# @package AddonManager_utilities
62114# \ingroup ADDONMANAGER
63115# \brief Utilities to work across different platforms, providers and python versions
@@ -97,10 +149,13 @@ def symlink(source, link_name):
97149
98150
99151def rmdir (path : str ) -> bool :
152+ """Remove a directory or symlink, even if it is read-only."""
100153 try :
101154 if os .path .islink (path ):
102155 os .unlink (path ) # Remove symlink
103156 else :
157+ # NOTE: the onerror argument was deprecated in Python 3.12, replaced by onexc -- replace
158+ # when earlier versions are no longer supported.
104159 shutil .rmtree (path , onerror = remove_readonly )
105160 except (WindowsError , PermissionError , OSError ):
106161 return False
@@ -175,7 +230,7 @@ def get_zip_url(repo):
175230
176231
177232def recognized_git_location (repo ) -> bool :
178- """Returns whether this repo is based at a known git repo location: works with github , gitlab,
233+ """Returns whether this repo is based at a known git repo location: works with GitHub , gitlab,
179234 framagit, and salsa.debian.org"""
180235
181236 parsed_url = urlparse (repo .url )
@@ -357,7 +412,7 @@ def is_float(element: Any) -> bool:
357412
358413
359414def get_pip_target_directory ():
360- # Get the default location to install new pip packages
415+ """ Get the default location to install new pip packages"""
361416 major , minor , _ = platform .python_version_tuple ()
362417 vendor_path = os .path .join (
363418 fci .DataPaths ().mod_dir , ".." , "AdditionalPythonPackages" , f"py{ major } { minor } "
@@ -379,7 +434,12 @@ def blocking_get(url: str, method=None) -> bytes:
379434 succeeded, or an empty string if it failed, or returned no data. The method argument is
380435 provided mainly for testing purposes."""
381436 p = b""
382- if fci .FreeCADGui and method is None or method == "networkmanager" :
437+ if (
438+ fci .FreeCADGui
439+ and method is None
440+ or method == "networkmanager"
441+ and NetworkManager is not None
442+ ):
383443 NetworkManager .InitializeNetworkManager ()
384444 p = NetworkManager .AM_NETWORK_MANAGER .blocking_get (url , 10000 ) # 10 second timeout
385445 if p :
@@ -398,7 +458,7 @@ def blocking_get(url: str, method=None) -> bytes:
398458 return p
399459
400460
401- def run_interruptable_subprocess (args ) -> subprocess .CompletedProcess :
461+ def run_interruptable_subprocess (args , timeout_secs : int = 10 ) -> subprocess .CompletedProcess :
402462 """Wrap subprocess call so it can be interrupted gracefully."""
403463 creation_flags = 0
404464 if hasattr (subprocess , "CREATE_NO_WINDOW" ):
@@ -418,22 +478,63 @@ def run_interruptable_subprocess(args) -> subprocess.CompletedProcess:
418478 stdout = ""
419479 stderr = ""
420480 return_code = None
481+ start_time = time .time ()
421482 while return_code is None :
422483 try :
423- stdout , stderr = p .communicate (timeout = 10 )
484+ # one second timeout allows interrupting the run once per second
485+ stdout , stderr = p .communicate (timeout = 1 )
424486 return_code = p .returncode
425- except subprocess .TimeoutExpired :
426- if QtCore .QThread .currentThread ().isInterruptionRequested ():
487+ except subprocess .TimeoutExpired as timeout_exception :
488+ if (
489+ hasattr (QtCore , "QThread" )
490+ and QtCore .QThread .currentThread ().isInterruptionRequested ()
491+ ):
492+ p .kill ()
493+ raise ProcessInterrupted () from timeout_exception
494+ if time .time () - start_time >= timeout_secs : # The real timeout
427495 p .kill ()
428- raise ProcessInterrupted ()
496+ stdout , stderr = p .communicate ()
497+ return_code = - 1
429498 if return_code is None or return_code != 0 :
430499 raise subprocess .CalledProcessError (
431500 return_code if return_code is not None else - 1 , args , stdout , stderr
432501 )
433502 return subprocess .CompletedProcess (args , return_code , stdout , stderr )
434503
435504
505+ def process_date_string_to_python_datetime (date_string : str ) -> datetime :
506+ """For modern macros the expected date format is ISO 8601, YYYY-MM-DD. For older macros this
507+ standard was not always used, and various orderings and separators were used. This function
508+ tries to match the majority of those older macros. Commonly-used separators are periods,
509+ slashes, and dashes."""
510+
511+ def raise_error (bad_string : str , root_cause : Exception = None ):
512+ raise ValueError (
513+ f"Unrecognized date string '{ bad_string } ' (expected YYYY-MM-DD)"
514+ ) from root_cause
515+
516+ split_result = re .split (r"[ ./-]+" , date_string .strip ())
517+ if len (split_result ) != 3 :
518+ raise_error (date_string )
519+
520+ try :
521+ split_result = [int (x ) for x in split_result ]
522+ # The earliest possible year an addon can be created or edited is 2001:
523+ if split_result [0 ] > 2000 :
524+ return datetime (split_result [0 ], split_result [1 ], split_result [2 ])
525+ if split_result [2 ] > 2000 :
526+ # Generally speaking it's not possible to distinguish between DD-MM and MM-DD, so try
527+ # the first, and only if that fails try the second
528+ if split_result [1 ] <= 12 :
529+ return datetime (split_result [2 ], split_result [1 ], split_result [0 ])
530+ return datetime (split_result [2 ], split_result [0 ], split_result [1 ])
531+ raise ValueError (f"Invalid year in date string '{ date_string } '" )
532+ except ValueError as exception :
533+ raise_error (date_string , exception )
534+
535+
436536def get_main_am_window ():
537+ """Find the Addon Manager's main window in the Qt widget hierarchy."""
437538 windows = QtWidgets .QApplication .topLevelWidgets ()
438539 for widget in windows :
439540 if widget .objectName () == "AddonManager_Main_Window" :
@@ -449,3 +550,37 @@ def get_main_am_window():
449550 return widget .centralWidget ()
450551 # Why is this code even getting called?
451552 return None
553+
554+
555+ def remove_target_option (args : List [str ]) -> List [str ]:
556+ # The Snap pip automatically adds the --user option, which is not compatible with the
557+ # --target option, so we have to remove --target and its argument, if present
558+ try :
559+ index = args .index ("--target" )
560+ del args [index : index + 2 ] # The --target option and its argument
561+ except ValueError :
562+ pass
563+ return args
564+
565+
566+ def create_pip_call (args : List [str ]) -> List [str ]:
567+ """Choose the correct mechanism for calling pip on each platform. It currently supports
568+ either `python -m pip` (most environments) or `pip` (Snap packages). Returns a list
569+ of arguments suitable for passing directly to subprocess.Popen and related functions."""
570+ snap_package = os .getenv ("SNAP_REVISION" )
571+ appimage = os .getenv ("APPIMAGE" )
572+ if snap_package :
573+ args = remove_target_option (args )
574+ call_args = ["pip" , "--disable-pip-version-check" ]
575+ call_args .extend (args )
576+ elif appimage :
577+ python_exe = fci .DataPaths .home_dir + "bin/python"
578+ call_args = [python_exe , "-m" , "pip" , "--disable-pip-version-check" ]
579+ call_args .extend (args )
580+ else :
581+ python_exe = get_python_exe ()
582+ if not python_exe :
583+ raise RuntimeError ("Could not locate Python executable on this system" )
584+ call_args = [python_exe , "-m" , "pip" , "--disable-pip-version-check" ]
585+ call_args .extend (args )
586+ return call_args
0 commit comments