# -*- coding: utf-8 -*- """ /*************************************************************************** GeoPublicHealth A QGIS plugin ------------------- begin : 2026-01-24 copyright : (C) 2026 by Manuel Vidaurre email : manuel.vidaurre@gmail.com ***************************************************************************/ /*************************************************************************** * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * ***************************************************************************/ GeoPublicHealth Dependency Installer for QGIS Python Console RECOMMENDED INSTALLATION METHOD - Run this from QGIS Python Console. This script is designed to be run directly in the QGIS Python Console, which automatically ensures dependencies are installed to the correct Python environment. USAGE: 1. Open QGIS 2. Go to Plugins → Python Console 3. Click "Show Editor" button (icon in toolbar) 4. Click "Open Script" and select this file 5. Click "Run Script" button 6. Wait for installation to complete 7. Restart QGIS Or paste the code directly into the console (copy everything below the instructions). Why this method is best: - Automatically uses QGIS's Python (no environment confusion) - No need to find or type QGIS Python path - Works regardless of other Python installations on your Mac - No Terminal knowledge required - Can't accidentally install to wrong Python """ import datetime import importlib import os import subprocess import sys import tempfile import time import sysconfig from collections import deque from pathlib import Path # Create log file in a writable location timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") log_filename = f"geopublichealth_install_{timestamp}.log" default_log_dir = Path.home() / "GeoPublicHealth" try: default_log_dir.mkdir(parents=True, exist_ok=True) log_path = default_log_dir / log_filename except OSError: log_path = Path(tempfile.gettempdir()) / log_filename print("=" * 70) print("GeoPublicHealth Dependency Installer") print("=" * 70) print() print("Installing dependencies for QGIS Python environment...") print(f"Log file: {log_path}") print() def resolve_python_executable(): python_exe = Path(sys.executable) candidates = [] def add_versioned_candidates(base_dir): if not base_dir: return try: for candidate in base_dir.glob("python3*"): if candidate.is_file() and candidate.name.startswith("python3"): candidates.append(candidate) except OSError: pass bin_dir = base_dir / "bin" try: for candidate in bin_dir.glob("python3*"): if candidate.is_file() and candidate.name.startswith("python3"): candidates.append(candidate) except OSError: pass if python_exe.name.lower().startswith("python"): candidates.append(python_exe) try: qgis_core = importlib.import_module("qgis.core") qgis_prefix = Path(qgis_core.QgsApplication.prefixPath()) candidates.append(qgis_prefix / "bin" / "python3") candidates.append(qgis_prefix / "bin" / "python") add_versioned_candidates(qgis_prefix) add_versioned_candidates(qgis_prefix / "Contents" / "MacOS") except Exception: pass for prefix in { sys.exec_prefix, sys.prefix, getattr(sys, "base_prefix", None), }: if prefix: candidates.append(Path(prefix) / "bin" / "python3") candidates.append(Path(prefix) / "bin" / "python") add_versioned_candidates(Path(prefix)) add_versioned_candidates(Path(prefix) / "bin") candidates.append(python_exe.parent / "bin" / "python3") candidates.append(python_exe.parent / "bin" / "python") add_versioned_candidates(python_exe.parent) add_versioned_candidates(python_exe.parent / "bin") add_versioned_candidates(python_exe.parent.parent) for candidate in candidates: if candidate and candidate.exists() and candidate.name.startswith("python"): return candidate, candidates return python_exe, candidates def resolve_python_home(python_version, qgis_root): version_str = f"{python_version.major}.{python_version.minor}" candidates = [] if qgis_root: candidates.append(qgis_root / "Contents" / "Frameworks") candidates.append(qgis_root / "Contents" / "Resources") for prefix in { sys.exec_prefix, sys.prefix, getattr(sys, "base_prefix", None), }: if prefix: candidates.append(Path(prefix)) candidates.append(python_executable.parent) candidates.append(python_executable.parent.parent) for base in candidates: if not base: continue lib_path = base / "lib" / f"python{version_str}" / "encodings" / "__init__.py" if lib_path.exists(): return base, candidates direct_path = base / f"python{version_str}" / "encodings" / "__init__.py" if direct_path.exists(): return base / f"python{version_str}", candidates return None, candidates python_executable, python_candidates = resolve_python_executable() # Check Python environment print(f"Console Python: {sys.executable}") print(f"Pip target Python: {python_executable}") qgis_prefix = None try: qgis_core = importlib.import_module("qgis.core") qgis_prefix = Path(qgis_core.QgsApplication.prefixPath()) except Exception: qgis_prefix = None def resolve_qgis_app_root(prefix_path): if not prefix_path: return None current = Path(prefix_path) while current != current.parent: if current.name == "QGIS.app": return current current = current.parent return None def resolve_qgis_profile_python_dir(): for path_entry in sys.path: try: path = Path(path_entry) except TypeError: continue if not path.parts: continue if path.name == "python" and "profiles" in path.parts: return path return None def resolve_scripts_dir(): candidates = [] try: scripts_path = sysconfig.get_path("scripts") if scripts_path: candidates.append(Path(scripts_path)) except Exception: pass cmd = [ str(python_executable), "-c", "import sysconfig; print(sysconfig.get_path('scripts'))", ] result = subprocess.run(cmd, capture_output=True, text=True, env=python_env) if result.returncode == 0: scripts_path = result.stdout.strip() if scripts_path: candidates.append(Path(scripts_path)) if qgis_root: candidates.append(qgis_root / "Contents" / "Frameworks" / "bin") candidates.append(qgis_root / "Contents" / "MacOS" / "bin") for candidate in candidates: if candidate: return candidate return None python_ok = python_executable.exists() and python_executable.name.startswith("python") qgis_root = resolve_qgis_app_root(qgis_prefix) if not qgis_root and "QGIS.app" in str(sys.executable): qgis_root = resolve_qgis_app_root(Path(sys.executable)) python_home, python_home_candidates = resolve_python_home(sys.version_info, qgis_root) python_env = os.environ.copy() if python_home: python_env["PYTHONHOME"] = str(python_home) qgis_profile_python_dir = resolve_qgis_profile_python_dir() if qgis_profile_python_dir: python_env["PYTHONPATH"] = os.pathsep.join( [str(qgis_profile_python_dir)] + [p for p in sys.path if p] ) else: python_env["PYTHONPATH"] = os.pathsep.join(sys.path) if python_ok and qgis_root: try: python_executable.relative_to(qgis_root) except ValueError: python_ok = False if python_ok and not python_home: python_ok = False if python_ok: print("✓ Running in QGIS Python environment (correct!)") if qgis_profile_python_dir: print(f"Profile Python path: {qgis_profile_python_dir}") scripts_dir = resolve_scripts_dir() if scripts_dir: try: scripts_dir.mkdir(parents=True, exist_ok=True) print(f"Scripts directory: {scripts_dir}") except OSError: print("⚠️ Warning: Could not create scripts directory") if qgis_root: bin_dirs = [ qgis_root / "Contents" / "bin", qgis_root / "Contents" / "Frameworks" / "bin", qgis_root / "Contents" / "MacOS" / "bin", ] for bin_dir in bin_dirs: try: bin_dir.mkdir(parents=True, exist_ok=True) except OSError: print(f"⚠️ Warning: Could not create {bin_dir}") else: print("⚠️ Warning: Could not locate QGIS Python interpreter") print(" The script will not run pip to avoid QGIS errors.") if python_candidates: print(" Candidates checked:") for candidate in python_candidates: print(f" - {candidate}") if python_home_candidates: print(" Python home candidates checked:") for candidate in python_home_candidates: print(f" - {candidate}") print(" Recommended: Run this from QGIS Python Console instead") print() def run_pip_install(packages, timeout=None): """ Run pip install using subprocess for stability. Note: We use subprocess with a resolved QGIS Python instead of pip.main() because pip.main() is not a stable public API and can break with pip upgrades. """ cmd = [ str(python_executable), "-m", "pip", "install", "--no-input", "--disable-pip-version-check", ] + packages last_lines = deque(maxlen=6) def should_print_line(line): prefixes = ( "Collecting ", "Using cached", "Downloading", "Building wheel for", "Created wheel for", "Installing collected packages", "Requirement already satisfied", "Successfully installed", "ERROR:", "WARNING:", ) return line.startswith(prefixes) with open(log_path, "a", encoding="utf-8") as log: log.write(f"\n{'=' * 70}\n") log.write(f">>> {' '.join(cmd)}\n") log.write(f"{'=' * 70}\n") try: start_time = time.monotonic() proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=python_env, ) if proc.stdout is None: error_msg = "Error: failed to capture pip output\n" log.write(error_msg) return 1, "", error_msg while True: if timeout and (time.monotonic() - start_time) > timeout: proc.terminate() error_msg = f"Timeout after {timeout} seconds\n" log.write(error_msg) return 1, "", error_msg line = proc.stdout.readline() if not line: if proc.poll() is not None: break time.sleep(0.1) continue log.write(line) last_lines.append(line) if should_print_line(line.lstrip()): print(f" {line.rstrip()}") return proc.returncode, "".join(last_lines), "" except Exception as e: error_msg = f"Error: {str(e)}\n" log.write(error_msg) return 1, "", error_msg # Define packages to install # Format: (name, pip_args, description, required, min_version, check_modules) # IMPORTANT: Order matters! # - fiona must be installed before geopandas (dependency) # - geopandas should be installed before libpysal (used by plugin for GeoPackage support) # - llvmlite must be installed before numba (dependency) # - numba must be installed before libpysal/esda (may use during build) # - shapely, llvmlite, and numba are installed to profile dir with --no-deps to avoid NumPy 2.x packages = [ ("pip", ["pip", "--upgrade"], "Package installer", True, None, None), ("numpy", ["numpy"], "Numerical computing", True, None, None), ("scipy", ["scipy"], "Scientific computing", True, None, None), ("pandas", ["pandas"], "Data analysis", True, None, None), ( "shapely", ["shapely==2.1.2", "--upgrade", "--force-reinstall", "--no-deps"], "Geometry engine", True, "2.1.2", ["shapely"], ), ( "fiona", ["fiona"], "Geospatial file I/O", True, None, ["fiona"], ), ( "geopandas", ["geopandas"], "Geospatial data handling (GeoPackage support)", True, None, ["geopandas"], ), ( "llvmlite", ["llvmlite"], "LLVM compiler for numba (numba dependency)", True, None, ["llvmlite"], ), ( "numba", ["numba", "--no-warn-script-location"], "Performance optimization", True, None, ["numba"], ), ( "libpysal & esda", ["libpysal", "esda", "--no-build-isolation"], "Spatial analysis", True, None, ["libpysal", "esda"], ), ( "matplotlib", ["matplotlib"], "Plotting and visualization", False, None, ["matplotlib"], ), ] print("The following packages will be checked/installed:") for name, _, desc, required, _, _ in packages: status = "Required" if required else "Optional" print(f" • {name}: {desc} ({status})") print() if not python_ok: print("=" * 70) print("Installation Aborted") print("=" * 70) print() print("Could not locate the QGIS Python interpreter.") print("No packages were installed to avoid QGIS errors.") print() print("Troubleshooting:") print("1. Run this script from the QGIS Python Console") print("2. Ensure QGIS is fully installed") print("3. See MAC_INSTALL_TECHNICAL.md for advanced troubleshooting") print() print(f"Log file: {log_path}") print("=" * 70) else: print("=" * 70) print("Starting installation...") print("=" * 70) print() def check_package_version(module_name, min_version=None): """Check if a package is already installed with the required version.""" cmd = [ str(python_executable), "-c", ( "import importlib; " f"module = importlib.import_module('{module_name}'); " "print(getattr(module, '__version__', 'unknown'))" ), ] result = subprocess.run(cmd, capture_output=True, text=True, env=python_env) if result.returncode != 0: return False, None version = result.stdout.strip() if min_version and version != "unknown": # Simple string comparison for exact version match return version == min_version, version return True, version return True, version installed = [] failed = [] skipped = [] for name, pip_args, desc, required, min_version, check_modules in packages: # Check if already installed with correct version skip_install = False if check_modules: all_present = True versions = [] for module in check_modules: present, version = check_package_version( module, min_version if module == check_modules[0] else None ) if not present: all_present = False break versions.append(f"{module} {version}") if all_present: skip_install = True print( f"Checking {name}... ✓ (already installed: {', '.join(versions)})" ) skipped.append(name) if skip_install: continue print(f"Installing {name}...", end=" ", flush=True) # Install shapely, numba, and llvmlite to profile directory with --no-deps # to avoid NumPy 2.x upgrade (these packages list numpy as dependency) if name in ("shapely", "numba", "llvmlite") and qgis_profile_python_dir: target_dir = str(qgis_profile_python_dir) try: qgis_profile_python_dir.mkdir(parents=True, exist_ok=True) except OSError: print() print("⚠️ Warning: Could not create profile Python directory") pip_args = pip_args + ["--target", target_dir, "--upgrade", "--no-deps"] rc, stdout, stderr = run_pip_install(pip_args, timeout=600) if rc == 0: print("✓") installed.append(name) else: print("✗") failed.append(name) if required: print(f" Warning: {name} is required for the plugin to work") if stdout: print(" Output snippet:") for line in stdout.splitlines(): if line.strip(): print(f" {line}") print(f" See log for details: {log_path}") print() print("=" * 70) print("Installation Summary") print("=" * 70) print() if skipped: print(f"○ Already installed ({len(skipped)}):") for name in skipped: print(f" • {name}") print() if installed: print(f"✓ Successfully installed ({len(installed)}):") for name in installed: print(f" • {name}") print() if failed: print(f"✗ Failed to install ({len(failed)}):") for name in failed: print(f" • {name}") print() print(f"Full installation log: {log_path}") print() # Verify critical dependencies print("=" * 70) print("Verifying Required Dependencies") print("=" * 70) print() critical_packages = { "libpysal": "Spatial analysis library", "esda": "Exploratory spatial data analysis", "numba": "Performance optimization", } def check_module(module_name): if not python_ok: return False, "" cmd = [ str(python_executable), "-c", ( "import importlib; " f"module = importlib.import_module('{module_name}'); " "print(getattr(module, '__version__', 'unknown'))" ), ] result = subprocess.run(cmd, capture_output=True, text=True, env=python_env) if result.returncode == 0: return True, result.stdout.strip() return False, result.stderr.strip() all_critical_ok = True for module, description in critical_packages.items(): ok, version = check_module(module) if ok: print(f"✓ {module} {version} - {description}") else: print(f"✗ {module} NOT FOUND - {description}") all_critical_ok = False print() # Check optional ok, version = check_module("matplotlib") if ok: print(f"✓ matplotlib {version} - Plotting (optional)") else: print("matplotlib not installed - plotting disabled (optional)") print() print("=" * 70) # Configure QGIS plugin repository (do this regardless of verification status) # Verification may fail because QGIS needs restart, but we still want to configure the repo print() print("Configuring QGIS plugin repository settings...") repo_url = "https://raw.githubusercontent.com/ePublicHealth/GeoPublicHealth/main/docs/plugins.xml" repo_name = "GeoPublicHealth" try: from qgis.core import QgsSettings, QgsSettingsTree settings = QgsSettings() # Debug: Show current settings location print(f"Settings file: {settings.fileName()}") allow_experimental_setting = QgsSettingsTree.node( "plugin-manager" ).childSetting("allow-experimental") python_allow_experimental = settings.value( "PythonPlugins/allowExperimental", False, type=bool ) ui_experimental_setting = settings.value( "QgsCollapsibleGroupBox/QgsPluginManagerBase/ckbExperimental/checked", False, type=bool, ) ui_experimental_setting_alt = settings.value( "QgsPluginManagerBase/ckbExperimental/checked", False, type=bool, ) experimental_changed = False if not allow_experimental_setting.value(): allow_experimental_setting.setValue(True) experimental_changed = True if not python_allow_experimental: settings.setValue("PythonPlugins/allowExperimental", True) experimental_changed = True if not ui_experimental_setting: settings.beginGroup("QgsCollapsibleGroupBox") settings.setValue( "QgsPluginManagerBase/ckbExperimental/checked", True, ) settings.setValue( "QgsPluginManagerBase/ckbExperimental/collapsed", False, ) settings.endGroup() experimental_changed = True if not ui_experimental_setting_alt: settings.beginGroup("QgsPluginManagerBase") settings.setValue("ckbExperimental/checked", True) settings.endGroup() experimental_changed = True if experimental_changed: print("✓ Enabled experimental plugins") else: print("○ Experimental plugins already enabled") try: from pyplugin_installer import installer_data repos_group = installer_data.reposGroup except Exception: repos_group = "app/plugin_repositories" print(f"Using repository settings group: {repos_group}") settings.beginGroup(repos_group) existing_repos = settings.childGroups() repo_exists = False existing_repo_key = None for repo in existing_repos: url = settings.value(f"{repo}/url", "", type=str) if url == repo_url: repo_exists = True existing_repo_key = repo break if not repo_exists: settings.setValue(f"{repo_name}/url", repo_url) settings.setValue(f"{repo_name}/enabled", True) settings.setValue(f"{repo_name}/valid", True) print("✓ Added GeoPublicHealth plugin repository to settings") else: enabled = settings.value(f"{existing_repo_key}/enabled", True, type=bool) if not enabled: settings.setValue(f"{existing_repo_key}/enabled", True) print("✓ Re-enabled GeoPublicHealth plugin repository") else: print("○ Repository already configured in settings") settings.endGroup() # Final sync to ensure all changes are written settings.sync() try: from pyplugin_installer import installer, installer_data from pyplugin_installer.qgsplugininstallerfetchingdialog import ( QgsPluginInstallerFetchingDialog, ) from qgis.utils import iface repositories = installer_data.repositories plugins = installer_data.plugins repositories.load() plugins.getAllInstalled() print("Reloading plugin repositories...") for repo_key in repositories.allEnabled(): repositories.requestFetching(repo_key, force_reload=True) if repositories.fetchingInProgress() and iface: print("Fetching repository metadata...") fetch_dialog = QgsPluginInstallerFetchingDialog(iface.mainWindow()) fetch_dialog.exec() del fetch_dialog for repo_key in repositories.all(): repositories.killConnection(repo_key) print("Rebuilding plugin cache...") plugins.rebuild() print("✓ Repositories reloaded from network") plugin_key = "geopublichealth" if plugin_key in plugins.all(): print("Installing GeoPublicHealth plugin...") plugin_data = plugins.all()[plugin_key] is_experimental = plugin_data.get("experimental", False) print("Preparing plugin installer...") installer.initPluginInstaller() if installer.pluginInstaller: installer.pluginInstaller.installPlugin( plugin_key, quiet=True, stable=not is_experimental ) print("✓ GeoPublicHealth plugin installed") else: print("○ Plugin installer not available") else: print("○ GeoPublicHealth plugin not found in repositories") except Exception as refresh_error: print(f"○ Repository cache refresh failed: {refresh_error}") print(" You may need to restart QGIS to see the repository") # Note: Plugin Manager UI refresh can still require a restart print(" Note: Restart QGIS if GeoPublicHealth is not visible") print(" Installed plugins may require a restart before loading") except Exception as e: print(f"⚠️ Warning: Could not configure plugin settings automatically: {e}") print(" You can configure manually in QGIS:") print(" 1. Enable experimental plugins:") print(" Settings → Options → Plugins → Check 'Show experimental plugins'") print(" 2. Add plugin repository:") print(" Settings → Options → Plugins → Plugin Repositories → Add") print(f" URL: {repo_url}") print(f" Name: {repo_name}") print() print("=" * 70) if all_critical_ok: print("SUCCESS!") print() print("All required dependencies are installed.") print() print("Next steps:") print("1. Restart QGIS completely (close and reopen)") print("2. Verify GeoPublicHealth is enabled in Plugins menu") print("3. Start using GeoPublicHealth!") print() print(f"Installation log saved to: {log_path}") print() try: response = ( input("Restart QGIS now to reload updated libraries? [Y/n]: ") .strip() .lower() ) except EOFError: response = "" if response in {"", "y", "yes"}: try: qgis_core = importlib.import_module("qgis.core") project = qgis_core.QgsProject.instance() if project and project.isDirty(): try: confirm = ( input("Unsaved changes detected. Quit anyway? [y/N]: ") .strip() .lower() ) except EOFError: confirm = "" if confirm not in {"y", "yes"}: print("Restart cancelled. Please save your project.") else: qgis_core.QgsApplication.exitQgis() else: qgis_core.QgsApplication.exitQgis() except Exception: print("Unable to restart QGIS automatically.") else: print("INSTALLATION INCOMPLETE") print() print("Some required dependencies could not be verified.") print() print("NOTE: If packages were installed successfully above, the") print("verification may fail because QGIS needs to be restarted") print("to reload the updated libraries.") print() print(f"Full installation log: {log_path}") print() print("Troubleshooting:") print("1. Close and restart QGIS, then test imports manually.") print(" To test after restart, open QGIS Python Console:") print(" - Click 'Show Editor' button") print(" - Copy and paste these lines into the editor:") print() print("import libpysal") print("import esda") print("import numba") print("print('All imports successful!')") print() print(" - Click 'Run Script' button") print("2. If imports still fail, run this script again") print("3. See MAC_INSTALL_TECHNICAL.md for advanced troubleshooting") print("4. Report the issue with the log file:") print(" https://github.com/ePublicHealth/GeoPublicHealth/issues") # Note: We don't call sys.exit() here because when run in QGIS Python # Console it would raise SystemExit and show a stack trace that confuses # users. # The script simply finishes with the error message above. print("=" * 70)