diff --git a/src/rtgs_lab_tools/auth/auth_service.py b/src/rtgs_lab_tools/auth/auth_service.py index 62b1b613..50bb8d86 100644 --- a/src/rtgs_lab_tools/auth/auth_service.py +++ b/src/rtgs_lab_tools/auth/auth_service.py @@ -23,8 +23,149 @@ def check_gcloud_installed(self) -> bool: ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): + # On Windows, try common installation paths + if sys.platform.lower() == "win32": + return self._check_windows_gcloud_paths() return False + def _check_windows_gcloud_paths(self) -> bool: + """Check common Windows paths for gcloud installation.""" + import shutil + + # Try using shutil.which which checks PATH and PATHEXT on Windows + if shutil.which("gcloud"): + return True + + # Common Windows installation paths + common_paths = [ + os.path.expanduser( + "~\\AppData\\Local\\Google\\Cloud SDK\\google-cloud-sdk\\bin\\gcloud.cmd" + ), + "C:\\Program Files (x86)\\Google\\Cloud SDK\\google-cloud-sdk\\bin\\gcloud.cmd", + "C:\\Program Files\\Google\\Cloud SDK\\google-cloud-sdk\\bin\\gcloud.cmd", + os.path.expanduser("~\\google-cloud-sdk\\bin\\gcloud.cmd"), + ] + + for path in common_paths: + if os.path.exists(path): + try: + result = subprocess.run( + [path, "--version"], capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + return True + except (subprocess.TimeoutExpired, FileNotFoundError): + continue + + return False + + def _update_env_file_with_project(self, project_id: str) -> None: + """Update .env file with Google Cloud project ID. + + Args: + project_id: The Google Cloud project ID to add + """ + env_file_path = ".env" + + # Check if .env file exists in current directory or parent directories + current_dir = os.getcwd() + while current_dir != os.path.dirname(current_dir): # Stop at root + env_path = os.path.join(current_dir, ".env") + if os.path.exists(env_path): + env_file_path = env_path + break + current_dir = os.path.dirname(current_dir) + + try: + # Read existing .env file if it exists + lines = [] + env_var_exists = False + + if os.path.exists(env_file_path): + with open(env_file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Check if GOOGLE_CLOUD_PROJECT already exists and update it + for i, line in enumerate(lines): + if line.strip().startswith("GOOGLE_CLOUD_PROJECT"): + lines[i] = f"GOOGLE_CLOUD_PROJECT={project_id}\n" + env_var_exists = True + break + + # Add the environment variable if it doesn't exist + if not env_var_exists: + # Add a newline if file doesn't end with one + if lines and not lines[-1].endswith("\n"): + lines.append("\n") + lines.append(f"GOOGLE_CLOUD_PROJECT={project_id}\n") + + # Write back to file + with open(env_file_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + except Exception as e: + # Don't fail the login if we can't update .env file + import logging + + logging.warning(f"Could not update .env file with project ID: {e}") + + def _get_gcloud_command(self) -> str: + """Get the correct gcloud command for the current platform.""" + # On Windows, use shutil.which first as it handles PATH and PATHEXT properly + if sys.platform.lower() == "win32": + import shutil + + # Try shutil.which which handles Windows PATH and file extensions correctly + gcloud_path = shutil.which("gcloud") + if gcloud_path: + try: + result = subprocess.run( + [gcloud_path, "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return gcloud_path + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Try common Windows installation paths with proper path handling + common_paths = [ + os.path.expanduser( + r"~\AppData\Local\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd" + ), + r"C:\Program Files (x86)\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd", + r"C:\Program Files\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd", + os.path.expanduser(r"~\google-cloud-sdk\bin\gcloud.cmd"), + ] + + for path in common_paths: + if os.path.exists(path): + try: + result = subprocess.run( + [path, "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return path + except (subprocess.TimeoutExpired, FileNotFoundError): + continue + + # For non-Windows or as fallback, try the standard command + try: + result = subprocess.run( + ["gcloud", "--version"], capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + return "gcloud" + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + return "gcloud" # Final fallback + def install_gcloud_instructions(self) -> str: """Get platform-specific gcloud installation instructions.""" platform = sys.platform.lower() @@ -34,7 +175,10 @@ def install_gcloud_instructions(self) -> str: To install Google Cloud CLI on Windows: 1. Download the installer: https://cloud.google.com/sdk/docs/install-sdk#windows 2. Or use the Windows installer: https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe -3. Restart Git Bash after installation +3. Make sure to check "Add gcloud to PATH" during installation +4. Restart your terminal/command prompt after installation + +If gcloud is installed but not found, you may need to add it to your PATH manually or restart your terminal. """ elif platform == "darwin": return """ @@ -73,8 +217,11 @@ def login(self, headless: bool = False) -> Dict[str, Any]: } try: + # Get the correct gcloud command + gcloud_cmd = self._get_gcloud_command() + # Prepare command based on environment - cmd = ["gcloud", "auth", "application-default", "login"] + cmd = [gcloud_cmd, "auth", "application-default", "login"] if headless: cmd.append("--no-browser") print("Starting headless Google Cloud authentication...") @@ -93,11 +240,16 @@ def login(self, headless: bool = False) -> Dict[str, Any]: # Test authentication by checking Secret Manager access auth_status = self.get_auth_status() if auth_status["authenticated"]: + # Auto-add project ID to .env file if available + project_id = auth_status.get("project") + if project_id: + self._update_env_file_with_project(project_id) + return { "success": True, "message": "Successfully authenticated with Google Cloud", "user": auth_status.get("user"), - "project": auth_status.get("project"), + "project": project_id, "secret_manager_access": auth_status.get( "secret_manager_access", False ), @@ -133,10 +285,13 @@ def get_auth_status(self) -> Dict[str, Any]: return status try: + # Get the correct gcloud command + gcloud_cmd = self._get_gcloud_command() + # Check if authenticated result = subprocess.run( [ - "gcloud", + gcloud_cmd, "auth", "list", "--filter=status:ACTIVE", @@ -153,7 +308,7 @@ def get_auth_status(self) -> Dict[str, Any]: # Get current project result = subprocess.run( - ["gcloud", "config", "get-value", "project"], + [gcloud_cmd, "config", "get-value", "project"], capture_output=True, text=True, timeout=10, @@ -181,9 +336,12 @@ def logout(self) -> Dict[str, Any]: return {"success": False, "error": "gcloud CLI not found"} try: + # Get the correct gcloud command + gcloud_cmd = self._get_gcloud_command() + # Try gcloud revoke with shorter timeout first result = subprocess.run( - ["gcloud", "auth", "application-default", "revoke"], + [gcloud_cmd, "auth", "application-default", "revoke"], capture_output=True, text=True, timeout=10, diff --git a/src/rtgs_lab_tools/auth/cli.py b/src/rtgs_lab_tools/auth/cli.py index 35453aca..7585fd71 100644 --- a/src/rtgs_lab_tools/auth/cli.py +++ b/src/rtgs_lab_tools/auth/cli.py @@ -1,12 +1,43 @@ """CLI commands for Google Cloud authentication.""" import click +from dotenv import load_dotenv from rich.console import Console from rich.table import Table from .auth_service import AuthService -console = Console() +# Load environment variables from .env file +load_dotenv() + + +# Configure console for Windows Unicode support +def _create_console(): + """Create a console with Windows Unicode support.""" + import os + import sys + + # Set UTF-8 encoding for Windows + if sys.platform.lower() == "win32": + # Try to set UTF-8 encoding + try: + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + except Exception: + pass + + try: + if sys.platform.lower() == "win32": + # Use legacy Windows mode to avoid Unicode issues + console = Console(force_terminal=True, legacy_windows=True) + else: + console = Console(force_terminal=True, legacy_windows=False) + return console + except Exception: + # Final fallback + return Console() + + +console = _create_console() @click.group() @@ -30,7 +61,7 @@ def login(headless): # Check if gcloud is installed first if not auth_service.check_gcloud_installed(): - console.print("❌ [bold red]gcloud CLI not found[/bold red]") + console.print("[X] [bold red]gcloud CLI not found[/bold red]") console.print() console.print(auth_service.install_gcloud_instructions()) return @@ -39,11 +70,11 @@ def login(headless): status = auth_service.get_auth_status() if status["authenticated"] and status["secret_manager_access"]: console.print( - f"✅ Already authenticated as: [bold green]{status['user']}[/bold green]" + f"[OK] Already authenticated as: [bold green]{status['user']}[/bold green]" ) - console.print(f"✅ Secret Manager access: [bold green]Working[/bold green]") + console.print(f"[OK] Secret Manager access: [bold green]Working[/bold green]") if status["project"]: - console.print(f"✅ Project: [bold green]{status['project']}[/bold green]") + console.print(f"[OK] Project: [bold green]{status['project']}[/bold green]") console.print() console.print( "You're already set up! Run [bold blue]rtgs sensing-data list-projects[/bold blue] to get started." @@ -53,7 +84,7 @@ def login(headless): # Show headless mode info if headless: console.print( - "🖥️ [bold yellow]Headless Mode:[/bold yellow] Authentication will not open a browser" + "[PC] [bold yellow]Headless Mode:[/bold yellow] Authentication will not open a browser" ) console.print("You'll need to copy a URL and verification code manually") console.print() @@ -63,19 +94,23 @@ def login(headless): # Don't use spinner for headless mode as user needs to see the output result = auth_service.login(headless=True) else: + # Use a Windows-compatible spinner + import sys + + spinner_style = "line" if sys.platform.lower() == "win32" else "dots" with console.status( - "[bold blue]Authenticating with Google Cloud...", spinner="dots" + "[bold blue]Authenticating with Google Cloud...", spinner=spinner_style ): result = auth_service.login(headless=False) console.print() if result["success"]: - console.print("✅ [bold green]Authentication successful![/bold green]") + console.print("[OK] [bold green]Authentication successful![/bold green]") console.print() console.print(f"User: [bold]{result.get('user', 'Unknown')}[/bold]") if result.get("project"): - console.print(f"📋 Project: [bold]{result['project']}[/bold]") + console.print(f"[Project]: [bold]{result['project']}[/bold]") if result.get("secret_manager_access"): console.print("Secret Manager access: [bold green]Working[/bold green]") @@ -90,7 +125,7 @@ def login(headless): console.print("3. Documentation: [bold]rtgs --help[/bold]") else: - console.print("❌ [bold red]Authentication failed[/bold red]") + console.print("[X] [bold red]Authentication failed[/bold red]") console.print(f"Error: {result['error']}") if "instructions" in result: @@ -116,23 +151,25 @@ def status(): # gcloud CLI if auth_status["gcloud_installed"]: - table.add_row("gcloud CLI", "✅ Installed", "") + table.add_row("gcloud CLI", "[OK] Installed", "") else: table.add_row( "gcloud CLI", - "❌ Missing", + "[X] Missing", "Run 'rtgs auth login' for installation instructions", ) # Authentication if auth_status["authenticated"]: - table.add_row("Authentication", "✅ Active", f"User: {auth_status['user']}") + table.add_row("Authentication", "[OK] Active", f"User: {auth_status['user']}") else: - table.add_row("Authentication", "❌ Not authenticated", "Run 'rtgs auth login'") + table.add_row( + "Authentication", "[X] Not authenticated", "Run 'rtgs auth login'" + ) # Project if auth_status["project"]: - table.add_row("Project", "✅ Set", auth_status["project"]) + table.add_row("Project", "[OK] Set", auth_status["project"]) else: table.add_row( "Project", "Not set", "Run 'gcloud config set project PROJECT_ID'" @@ -140,13 +177,13 @@ def status(): # Secret Manager if auth_status["secret_manager_access"]: - table.add_row("Secret Manager", "✅ Accessible", "Ready to use secrets") + table.add_row("Secret Manager", "[OK] Accessible", "Ready to use secrets") elif auth_status["authenticated"]: table.add_row( - "Secret Manager", "❌ No access", "Contact admin for IAM permissions" + "Secret Manager", "[X] No access", "Contact admin for IAM permissions" ) else: - table.add_row("Secret Manager", "❌ No access", "Authentication required") + table.add_row("Secret Manager", "[X] No access", "Authentication required") console.print(table) console.print() @@ -166,7 +203,7 @@ def status(): ) else: console.print( - "✅ [bold green]All systems ready![/bold green] You can use RTGS Lab Tools with Secret Manager." + "[OK] [bold green]All systems ready![/bold green] You can use RTGS Lab Tools with Secret Manager." ) @@ -176,7 +213,7 @@ def particle_login(): import os import subprocess - console.print("🔌 [bold blue]Particle Cloud Authentication[/bold blue]") + console.print("[Particle] [bold blue]Particle Cloud Authentication[/bold blue]") console.print() console.print( "This will create a temporary 7-day access token using the Particle CLI." @@ -188,7 +225,7 @@ def particle_login(): try: subprocess.run(["particle", "--version"], capture_output=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): - console.print("❌ [bold red]Particle CLI not found[/bold red]") + console.print("[X] [bold red]Particle CLI not found[/bold red]") console.print() console.print("Please install the Particle CLI first:") console.print("npm install -g particle-cli") @@ -247,7 +284,7 @@ def particle_login(): f.writelines(env_lines) console.print( - "✅ [bold green]Particle authentication successful![/bold green]" + "[OK] [bold green]Particle authentication successful![/bold green]" ) console.print() console.print(f"Access token created and saved to .env file") @@ -258,14 +295,14 @@ def particle_login(): console.print("2. Token will automatically expire in 7 days") console.print("3. Run this command again when the token expires") else: - console.print("❌ [bold red]Invalid token format[/bold red]") + console.print("[X] [bold red]Invalid token format[/bold red]") console.print("Particle tokens should be 40 characters long") else: - console.print("❌ [bold red]Token creation failed[/bold red]") + console.print("[X] [bold red]Token creation failed[/bold red]") console.print("Please check your Particle CLI installation and credentials") except Exception as e: - console.print("❌ [bold red]Error creating token[/bold red]") + console.print("[X] [bold red]Error creating token[/bold red]") console.print(f"Error: {str(e)}") @@ -280,24 +317,28 @@ def logout(): # Check current status status = auth_service.get_auth_status() if not status["authenticated"]: - console.print("ℹ[bold yellow]Not currently authenticated[/bold yellow]") + console.print("[i] [bold yellow]Not currently authenticated[/bold yellow]") return console.print(f"Currently authenticated as: [bold]{status['user']}[/bold]") console.print() if click.confirm("Are you sure you want to logout?"): - with console.status("[bold blue]Logging out...", spinner="dots"): + # Use a Windows-compatible spinner + import sys + + spinner_style = "line" if sys.platform.lower() == "win32" else "dots" + with console.status("[bold blue]Logging out...", spinner=spinner_style): result = auth_service.logout() console.print() if result["success"]: - console.print("✅ [bold green]Successfully logged out[/bold green]") + console.print("[OK] [bold green]Successfully logged out[/bold green]") console.print( "Your application will now fall back to .env file credentials" ) else: - console.print("❌ [bold red]Logout failed[/bold red]") + console.print("[X] [bold red]Logout failed[/bold red]") console.print(f"Error: {result['error']}") else: console.print("Logout cancelled") diff --git a/src/rtgs_lab_tools/core/config.py b/src/rtgs_lab_tools/core/config.py index b4f14fbb..18fdeea0 100644 --- a/src/rtgs_lab_tools/core/config.py +++ b/src/rtgs_lab_tools/core/config.py @@ -19,10 +19,7 @@ def __init__(self, env_file: Optional[str] = None): Args: env_file: Path to .env file. If None, looks for .env in current directory. """ - # Initialize Secret Manager client - self._secret_client = get_secret_manager_client() - - # Load .env file as fallback + # Load .env file first so environment variables are available if env_file: env_path = Path(env_file) if env_path.exists(): @@ -33,6 +30,9 @@ def __init__(self, env_file: Optional[str] = None): if env_path.exists(): load_dotenv(env_path) + # Initialize Secret Manager client after .env is loaded + self._secret_client = get_secret_manager_client() + def _get_secret( self, secret_name: str, env_var: str, required: bool = True ) -> Optional[str]: diff --git a/src/rtgs_lab_tools/mcp_server/rtgs_lab_tools_mcp_server.py b/src/rtgs_lab_tools/mcp_server/rtgs_lab_tools_mcp_server.py index f45fb3ce..488825a1 100644 --- a/src/rtgs_lab_tools/mcp_server/rtgs_lab_tools_mcp_server.py +++ b/src/rtgs_lab_tools/mcp_server/rtgs_lab_tools_mcp_server.py @@ -17,7 +17,6 @@ # Get the root directory of the project - this should be where your .env file is PROJECT_ROOT = Path(__file__).parent.parent.parent.parent -print(f"Project root: {PROJECT_ROOT}") # Load environment variables from .env file if it exists @@ -28,10 +27,8 @@ def load_env_file(): from dotenv import load_dotenv load_dotenv(env_file) - print(f"Loaded .env file from: {env_file}") return True else: - print(f"No .env file found at: {env_file}") return False @@ -2579,12 +2576,10 @@ async def run_command_with_env( Returns: Tuple of (stdout, stderr) as strings """ - print(f"Running command with MCP env in {cwd}: {' '.join(cmd)}") - print(f"DB_USER in env: {'DB_USER' in env}") - print(f"MCP_SESSION in env: {env.get('MCP_SESSION', 'not set')}") process = await asyncio.create_subprocess_exec( *cmd, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, @@ -2746,16 +2741,6 @@ async def check_environment() -> Dict[str, Any]: # Start the server when the script is run directly if __name__ == "__main__": - # Print some debug info - print(f"UV command: {UV_COMMAND}") - print(f"Project root: {PROJECT_ROOT}") - print(f"Current working directory: {os.getcwd()}") - print(f"Environment loaded: {env_loaded}") - - # Check if .env file exists - env_file = PROJECT_ROOT / ".env" - print(f".env file exists: {env_file.exists()}") - if env_file.exists(): - print(f".env file path: {env_file}") + # Start the MCP server mcp.run(transport="stdio") diff --git a/src/rtgs_lab_tools/sensing_data/data_extractor.py b/src/rtgs_lab_tools/sensing_data/data_extractor.py index 4fc9ae55..6403fed9 100644 --- a/src/rtgs_lab_tools/sensing_data/data_extractor.py +++ b/src/rtgs_lab_tools/sensing_data/data_extractor.py @@ -1,6 +1,7 @@ """Data extraction functions for GEMS sensing database.""" import logging +import re import time from datetime import datetime from typing import List, Optional, Tuple @@ -14,6 +15,60 @@ logger = logging.getLogger(__name__) +def sanitize_filename(filename: str) -> str: + """Sanitize filename to be Windows-compatible. + + Args: + filename: Original filename string + + Returns: + Sanitized filename safe for Windows filesystems + """ + # Replace invalid Windows filename characters with underscores + # Invalid characters: < > : " | ? * \ / + filename = re.sub(r'[<>:"|?*\\/]', "_", filename) + + # Replace spaces and hyphens with underscores for consistency + filename = re.sub(r"[ -]+", "_", filename) + + # Remove multiple consecutive underscores + filename = re.sub(r"_+", "_", filename) + + # Remove leading/trailing underscores + filename = filename.strip("_") + + # Ensure filename is not empty and not a reserved name + reserved_names = { + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", + } + + if not filename or filename.upper() in reserved_names: + filename = "data_export" + + return filename + + def list_projects( database_manager: DatabaseManager, max_retries: int = 3 ) -> List[Tuple[str, int]]: @@ -214,9 +269,12 @@ def extract_data( if project.lower() == "all": filename = f"all_projects_{start_date}_to_{end_date}_{timestamp}" else: - filename = ( - f"{project.replace(' ', '_')}_{start_date}_to_{end_date}_{timestamp}" - ) + # Sanitize project name for Windows compatibility + sanitized_project = sanitize_filename(project) + filename = f"{sanitized_project}_{start_date}_to_{end_date}_{timestamp}" + + # Sanitize the entire filename to ensure Windows compatibility + filename = sanitize_filename(filename) # Save data file_path = save_data(df, output_directory, filename, output_format) diff --git a/uv.lock b/uv.lock index fc0fa540..dcd4b030 100644 --- a/uv.lock +++ b/uv.lock @@ -2545,9 +2545,9 @@ wheels = [ name = "pyserial" version = "3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload_time = "2020-11-23T03:59:15.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload_time = "2020-11-23T03:59:13.41Z" }, + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, ] [[package]] @@ -2851,7 +2851,7 @@ wheels = [ [[package]] name = "rtgs-lab-tools" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "click" },