├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── examples └── simple_usage.ipynb ├── images ├── connect.png ├── login.png └── vscode_colab.png ├── pyproject.toml ├── release.sh ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── src └── vscode_colab │ ├── __init__.py │ ├── assets │ ├── gitignore_template.txt │ └── templates │ │ ├── github_auth_link.html.j2 │ │ └── vscode_connection_options.html.j2 │ ├── environment │ ├── __init__.py │ ├── git_handler.py │ ├── project_setup.py │ └── python_env.py │ ├── logger_config.py │ ├── server.py │ ├── system.py │ ├── templating.py │ └── utils.py └── tests ├── environment ├── test_git_handler.py ├── test_project_setup.py └── test_python_env.py ├── test_init.py ├── test_server.py ├── test_system.py ├── test_templating.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | .hypothesis/ 45 | .pytest_cache/ 46 | 47 | # Jupyter Notebook 48 | .ipynb_checkpoints 49 | 50 | # Virtual Environments 51 | .venv/ 52 | venv/ 53 | env/ 54 | ENV/ 55 | env.bak/ 56 | venv.bak/ 57 | 58 | # IDE specific files 59 | .idea/ 60 | .vscode/ 61 | *.swp 62 | *.swo 63 | 64 | # Specific files to the project 65 | examples/code 66 | examples/vscode_cli.tar.gz 67 | 68 | # Local configuration files 69 | .pypirc 70 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Your Name 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 2. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-colab: Connect VS Code to Google Colab and Kaggle Runtimes 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/vscode-colab.svg)](https://pypi.org/project/vscode-colab/) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/vscode-colab.svg)](https://pypi.org/project/vscode-colab/) 6 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/EssenceSentry/vscode-colab/blob/main/examples/simple_usage.ipynb) 7 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/EssenceSentry/vscode-colab) 8 | 9 | ![Logo](images/vscode_colab.png) 10 | 11 | **vscode-colab** is a Python library that seamlessly connects your Google Colab or Kaggle notebooks to Visual Studio Code (VS Code) using [VS Code Remote Tunnels](https://code.visualstudio.com/docs/remote/tunnels). It allows you to leverage VS Code's powerful editor and extensions while using the computational resources of cloud-based notebooks. 12 | 13 | ## 🚀 Key Features 14 | 15 | - **Secure, Official Integration:** Uses official VS Code Remote Tunnels for secure, stable connections. 16 | - **GitHub Integration:** Automatically authenticated via GitHub, enabling seamless cloning and pushing to private repositories. 17 | - **Easy Git Configuration:** Optionally configure global Git identity (`user.name` and `user.email`) directly from the library. 18 | - **Extension Support:** Installs essential Python and Jupyter extensions by default; easily customize by passing additional extensions. 19 | - **Python Environment Management:** Optionally set up a specific Python version for your project using `pyenv`. (Note: Installing a new Python version via pyenv can take approximately 5 minutes). 20 | - **Project Scaffolding:** Optionally create a new project directory with a Python virtual environment. 21 | - **Minimal Setup:** Simple and intuitive `login()` and `connect()` functions. 22 | - **Cross-Platform Compatibility:** Fully supports both Google Colab and Kaggle notebooks. 23 | - **Interactive UI:** Integrated UI within notebooks to manage authentication and tunnel connections easily. 24 | 25 | ## 🧰 Installation 26 | 27 | Install the package using pip: 28 | 29 | ```shell 30 | pip install vscode-colab 31 | ``` 32 | 33 | ## 📖 Usage 34 | 35 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/EssenceSentry/vscode-colab/blob/main/examples/simple_usage.ipynb) 36 | 37 | ### 1. Import the Library 38 | 39 | In your Colab or Kaggle notebook: 40 | 41 | ```python 42 | import vscode_colab 43 | ``` 44 | 45 | ### 2. Authenticate with GitHub 46 | 47 | Authenticate using GitHub credentials: 48 | 49 | ```python 50 | vscode_colab.login() 51 | ``` 52 | 53 | ![Login](images/login.png) 54 | 55 | Follow the displayed instructions to authorize the connection. 56 | 57 | ### 3. Establish the Tunnel and Configure Git (Optional) 58 | 59 | To start the VS Code tunnel, optionally configure Git, set up a Python version, or create a new project: 60 | 61 | ```python 62 | vscode_colab.connect( 63 | name="my-tunnel", 64 | git_user_name="Your Name", 65 | git_user_email="you@example.com", 66 | setup_python_version="3.13", # Optional: Specify Python version to install with pyenv 67 | create_new_project="my_new_project" # Optional: Create a new project directory 68 | ) 69 | ``` 70 | 71 | ![Connect](images/connect.png) 72 | 73 | - By default, VS Code Python and Jupyter extensions are installed. 74 | - You can customize the extensions to be installed: 75 | 76 | ```python 77 | # Add C++ extensions in addition to default ones 78 | vscode_colab.connect(extensions=["ms-vscode.cpptools"]) 79 | 80 | # Completely override extensions (only install C++ support) 81 | vscode_colab.connect(extensions=["ms-vscode.cpptools"], include_default_extensions=False) 82 | 83 | # Setup a specific Python version and create a new project 84 | # Note: Installing Python with pyenv can take ~5 minutes. 85 | vscode_colab.connect( 86 | name="py-project-tunnel", 87 | setup_python_version="3.9", 88 | create_new_project="data_analysis_project", 89 | new_project_base_path="~/projects", # Optional: specify where to create the project 90 | venv_name_for_project=".venv-data" # Optional: specify venv name 91 | ) 92 | ``` 93 | 94 | ### 4. Connect via VS Code 95 | 96 | In your local VS Code: 97 | 98 | 1. Ensure the [Remote Tunnels extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.remote-server) is installed. 99 | 2. Sign in with the same GitHub account used in the notebook. 100 | 3. Open Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`). 101 | 4. Run `Remote Tunnels: Connect to Tunnel...` and select your notebook's tunnel. 102 | 103 | You're now seamlessly connected to Colab/Kaggle through VS Code! 104 | 105 | ## 🧩 Default Extensions Installed 106 | 107 | By default, `vscode-colab` installs the following Visual Studio Code extensions to enhance your Python and Jupyter development experience: 108 | 109 | - **[Python Path](https://marketplace.visualstudio.com/items?itemName=mgesbert.python-path)**: Facilitates generating internal import statements within a Python project. 110 | 111 | - **[Black Formatter](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter)**: Provides code formatting support for Python files using the Black code formatter. 112 | 113 | - **[isort](https://marketplace.visualstudio.com/items?itemName=ms-python.isort)**: Offers import sorting features to improve the readability of your Python code. 114 | 115 | - **[Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python)**: Adds rich support for the Python language, including IntelliSense, linting, debugging, and more. 116 | 117 | - **[Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)**: Enhances Python language support with fast, feature-rich language services powered by Pyright. 118 | 119 | - **[Debugpy](https://marketplace.visualstudio.com/items?itemName=ms-python.debugpy)**: Enables debugging capabilities for Python applications within VS Code. 120 | 121 | - **[Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter)**: Provides support for Jupyter notebooks, including interactive programming and computing features. 122 | 123 | - **[Jupyter Keymap](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter-keymap)**: Aligns notebook keybindings in VS Code with those in Jupyter Notebook for a consistent experience. 124 | 125 | - **[Jupyter Notebook Renderers](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter-renderers)**: Provides renderers for outputs of Jupyter Notebooks, supporting various output formats. 126 | 127 | - **[TensorBoard](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.tensorboard)**: Allows launching and viewing TensorBoards directly within VS Code. 128 | 129 | ## ⚠️ Important Notes 130 | 131 | - **Closing the notebook tab will terminate the connection.** 132 | - **Kaggle Clipboard Limitation:** On Kaggle, the copy-to-clipboard button will display "Copy Failed" in red due to sandbox restrictions. Manually select and copy the displayed code. 133 | 134 | ## 🧪 Testing 135 | 136 | To run tests: 137 | 138 | ```bash 139 | git clone https://github.com/EssenceSentry/vscode-colab.git 140 | cd vscode-colab 141 | pip install -r requirements-dev.txt 142 | pytest 143 | ``` 144 | 145 | ## 🛠️ Development 146 | 147 | - Configuration via `setup.cfg` 148 | - Development dependencies listed in `requirements-dev.txt` 149 | - Contributions welcome—open a GitHub issue or PR! 150 | 151 | ## 📄 License 152 | 153 | MIT License. See [LICENSE](https://github.com/EssenceSentry/vscode-colab/blob/main/LICENSE). 154 | 155 | ## 🙏 Acknowledgments 156 | 157 | Special thanks to the developers behind [VS Code Remote Tunnels](https://code.visualstudio.com/docs/remote/tunnels) for enabling this seamless remote development experience. 158 | -------------------------------------------------------------------------------- /images/connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EssenceSentry/vscode-colab/f2fa19d1308801844e19600c94efdc7baa453642/images/connect.png -------------------------------------------------------------------------------- /images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EssenceSentry/vscode-colab/f2fa19d1308801844e19600c94efdc7baa453642/images/login.png -------------------------------------------------------------------------------- /images/vscode_colab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EssenceSentry/vscode-colab/f2fa19d1308801844e19600c94efdc7baa453642/images/vscode_colab.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "vscode-colab" 3 | version = "0.1.0" 4 | description = "A library to set up a VS Code server in Google Colab." 5 | authors = ["Agustín Sellanes "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.7" 10 | loguru = "^0.7.2" 11 | jinja2 = "^3.1.6" 12 | requests = "^2.26.0" 13 | 14 | [build-system] 15 | requires = ["setuptools>=42", "wheel"] 16 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Ensure clean working directory 5 | if [[ $(git status --porcelain) ]]; then 6 | echo "Working directory not clean. Commit or stash changes first." 7 | exit 1 8 | fi 9 | 10 | # Get current version from setup.cfg 11 | CURRENT_VERSION=$(grep "version =" setup.cfg | sed 's/version = //') 12 | echo "Current version: $CURRENT_VERSION" 13 | 14 | # Ask for new version 15 | read -p "Enter new version: " NEW_VERSION 16 | 17 | # Update version in setup.cfg 18 | sed -i "s/version = $CURRENT_VERSION/version = $NEW_VERSION/" setup.cfg 19 | 20 | # Run tests with coverage 21 | coverage run -m pytest 22 | COVERAGE=$(coverage report | grep TOTAL | awk '{print $4}' | sed 's/%//') 23 | if (( $(echo "$COVERAGE < 90" | bc -l) )); then 24 | echo "Test coverage is below 90% ($COVERAGE%). Aborting release." 25 | exit 1 26 | else 27 | echo -e "\033[0;32mTest coverage: $COVERAGE%\033[0m" 28 | fi 29 | 30 | # Build package 31 | rm -rf dist/ build/ *.egg-info/ 32 | python -m build 33 | 34 | # Commit and tag 35 | git add setup.cfg 36 | git commit -m "Bump version to $NEW_VERSION" 37 | git tag -a "v$NEW_VERSION" -m "Version $NEW_VERSION" 38 | 39 | # Push changes 40 | git push origin main 41 | git push origin "v$NEW_VERSION" 42 | 43 | # Upload to PyPI 44 | python -m twine upload dist/* 45 | 46 | echo "Released version $NEW_VERSION" -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Install the package in development mode 2 | -e . 3 | 4 | # Development tools 5 | build>=0.10.0 6 | twine>=4.0.2 7 | pytest>=7.0.0 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = vscode-colab 3 | version = 0.3.1 4 | description = A library to set up a VS Code server in Google Colab. 5 | author = EssenceSentry 6 | author_email = essence.sentry@gmail.com 7 | url = https://github.com/EssenceSentry/vscode-colab 8 | license = MIT 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown 11 | classifiers = 12 | Programming Language :: Python :: 3 13 | License :: OSI Approved :: MIT License 14 | Intended Audience :: Developers 15 | 16 | [options] 17 | packages = find: 18 | package_dir = 19 | = src 20 | python_requires = >=3.7 21 | install_requires = 22 | requests 23 | ipython 24 | jinja2 25 | loguru 26 | 27 | [options.package_data] 28 | vscode_colab = 29 | assets/templates/*.j2 30 | assets/gitignore_template.txt 31 | 32 | [options.packages.find] 33 | where = src 34 | 35 | [options.extras_require] 36 | testing = 37 | pytest 38 | pytest-cov 39 | 40 | [options.entry_points] 41 | console_scripts = 42 | vscode-colab = vscode_colab.server:setup_vscode_server 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/vscode_colab/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Initialization of the vscode_colab package. 3 | """ 4 | 5 | import subprocess 6 | from typing import List, Optional 7 | 8 | from vscode_colab.server import DEFAULT_EXTENSIONS as server_default_extensions 9 | from vscode_colab.server import connect as server_connect 10 | from vscode_colab.server import login as server_login 11 | from vscode_colab.system import System 12 | from vscode_colab.utils import SystemOperationResult 13 | 14 | _default_system_instance = System() 15 | 16 | 17 | def login(provider: str = "github", system: Optional[System] = None) -> bool: 18 | """ 19 | Attempts to log in to VS Code Tunnel using the specified authentication provider. 20 | On Linux, this involves running the 'code tunnel user login' command. 21 | 22 | Args: 23 | provider: The authentication provider to use. Typically "github". 24 | system: Optional System instance for dependency injection (testing). 25 | 26 | Returns: 27 | bool: True if the login process initiated successfully (auth info displayed), False otherwise. 28 | """ 29 | active_system = system if system is not None else _default_system_instance 30 | # The server_login function handles the logic and returns a simple bool. 31 | return server_login(system=active_system, provider=provider) 32 | 33 | 34 | def connect( 35 | name: str = "colab", 36 | include_default_extensions: bool = True, 37 | extensions: Optional[List[str]] = None, 38 | git_user_name: Optional[str] = None, 39 | git_user_email: Optional[str] = None, 40 | setup_python_version: Optional[str] = None, 41 | force_python_reinstall: bool = False, 42 | attempt_pyenv_dependency_install: bool = True, 43 | create_new_project: Optional[str] = None, 44 | new_project_base_path: str = ".", 45 | venv_name_for_project: str = ".venv", 46 | system: Optional[System] = None, 47 | ) -> Optional[subprocess.Popen]: 48 | """ 49 | Establishes a VS Code tunnel connection on a Linux environment (e.g., Colab, Kaggle). 50 | 51 | Args: 52 | name (str): The name of the connection tunnel. Defaults to "colab". 53 | include_default_extensions (bool): Whether to include default extensions. Defaults to True. 54 | extensions (Optional[List[str]]): A list of additional VS Code extension IDs to install. 55 | git_user_name (Optional[str]): Git user name for global configuration. 56 | git_user_email (Optional[str]): Git user email for global configuration. 57 | setup_python_version (Optional[str]): Python version (e.g., "3.9.18") to set up using pyenv. 58 | force_python_reinstall (bool): If setup_python_version is provided, force reinstall it. 59 | attempt_pyenv_dependency_install (bool): Attempt to install pyenv OS dependencies (e.g. via apt). Requires sudo. 60 | create_new_project (Optional[str]): Name of a new project directory to create. 61 | new_project_base_path (str): Base path for the new project. Defaults to current directory ".". 62 | venv_name_for_project (str): Name of the virtual environment directory within the new project. 63 | system: Optional System instance for dependency injection (testing). 64 | 65 | Returns: 66 | Optional[subprocess.Popen]: A Popen object for the tunnel process if successful, None otherwise. 67 | """ 68 | active_system = system if system is not None else _default_system_instance 69 | return server_connect( 70 | system=active_system, 71 | name=name, 72 | include_default_extensions=include_default_extensions, 73 | extensions=extensions, 74 | git_user_name=git_user_name, 75 | git_user_email=git_user_email, 76 | setup_python_version=setup_python_version, 77 | force_python_reinstall=force_python_reinstall, 78 | attempt_pyenv_dependency_install=attempt_pyenv_dependency_install, 79 | create_new_project=create_new_project, 80 | new_project_base_path=new_project_base_path, 81 | venv_name_for_project=venv_name_for_project, 82 | ) 83 | 84 | 85 | # Expose DEFAULT_EXTENSIONS as a frozenset for immutability if users import it. 86 | DEFAULT_EXTENSIONS: frozenset[str] = frozenset(server_default_extensions) 87 | 88 | __all__ = [ 89 | "login", 90 | "connect", 91 | "DEFAULT_EXTENSIONS", 92 | ] 93 | -------------------------------------------------------------------------------- /src/vscode_colab/assets/gitignore_template.txt: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual Environment 7 | {{ venv_name }}/ 8 | 9 | # IDE / Editor 10 | .vscode/ 11 | .idea/ 12 | *.swp 13 | *~ -------------------------------------------------------------------------------- /src/vscode_colab/assets/templates/github_auth_link.html.j2: -------------------------------------------------------------------------------- 1 |
2 |

GitHub Authentication Required

3 |

Please open the link below in a new tab and enter the following code:

4 |
5 | 7 | Open GitHub Authentication Page 8 | 9 |
10 | {{ code }} 11 |
12 |
13 | 16 | 68 |
-------------------------------------------------------------------------------- /src/vscode_colab/assets/templates/vscode_connection_options.html.j2: -------------------------------------------------------------------------------- 1 | {% set text_color = "#333333" %} 2 |
3 |

✅ VS Code Tunnel Ready!

4 |

You can connect in two ways:

5 |
6 | 1. Open in Browser:
7 | 9 | Open VS Code Web 10 | 11 |
12 |
13 |
14 | 2. Connect from Desktop VS Code: 15 |
    16 |
  1. Make sure you have the Remote Tunnels extension installed.
  2. 17 |
  3. Ensure you are signed in to VS Code with the same GitHub account used for authentication.
  4. 18 |
  5. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P).
  6. 19 |
  7. Run the command: Remote Tunnels: Connect to Tunnel
  8. 20 |
  9. Select the tunnel named "{{ tunnel_name }}" from the list.
  10. 21 |
22 |
23 |
-------------------------------------------------------------------------------- /src/vscode_colab/environment/__init__.py: -------------------------------------------------------------------------------- 1 | from vscode_colab.environment.git_handler import configure_git 2 | from vscode_colab.environment.project_setup import setup_project_directory 3 | from vscode_colab.environment.python_env import PythonEnvManager 4 | 5 | __all__ = [ 6 | "configure_git", 7 | "setup_project_directory", 8 | "PythonEnvManager", 9 | ] 10 | -------------------------------------------------------------------------------- /src/vscode_colab/environment/git_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from vscode_colab.logger_config import log as logger 4 | from vscode_colab.system import System 5 | from vscode_colab.utils import SystemOperationResult 6 | 7 | 8 | def configure_git( 9 | system: System, 10 | git_user_name: Optional[str] = None, 11 | git_user_email: Optional[str] = None, 12 | ) -> SystemOperationResult[None, Exception]: 13 | """ 14 | Configures global Git user name and email using the provided values. 15 | """ 16 | if not git_user_name and not git_user_email: 17 | logger.debug( 18 | "Both git_user_name and git_user_email are not provided. Skipping git configuration." 19 | ) 20 | return SystemOperationResult.Ok() 21 | 22 | if not git_user_name or not git_user_email: 23 | msg = "Both git_user_name and git_user_email must be provided together. Skipping git configuration." 24 | logger.warning(msg) 25 | return SystemOperationResult.Ok() 26 | 27 | logger.info( 28 | f"Attempting to set git global user.name='{git_user_name}' and user.email='{git_user_email}'..." 29 | ) 30 | 31 | git_exe = system.which("git") 32 | if not git_exe: 33 | err_msg = "'git' command not found. Cannot configure git." 34 | logger.error(err_msg) 35 | return SystemOperationResult.Err(FileNotFoundError("git"), message=err_msg) 36 | 37 | all_successful = True 38 | errors_encountered: list[str] = [] 39 | 40 | # Configure user name 41 | name_cmd = [git_exe, "config", "--global", "user.name", git_user_name] 42 | try: 43 | result_name_proc = system.run_command( 44 | name_cmd, capture_output=True, text=True, check=False 45 | ) 46 | except Exception as e_run_name: 47 | msg = f"Failed to execute git config user.name: {e_run_name}" 48 | logger.error(msg) 49 | errors_encountered.append(msg) 50 | all_successful = False 51 | else: 52 | if result_name_proc.returncode == 0: 53 | logger.info(f"Successfully set git global user.name='{git_user_name}'.") 54 | else: 55 | err_output_name = ( 56 | result_name_proc.stderr.strip() or result_name_proc.stdout.strip() 57 | ) 58 | msg = f"Failed to set git global user.name. RC: {result_name_proc.returncode}. Error: {err_output_name}" 59 | logger.error(msg) 60 | errors_encountered.append(msg) 61 | all_successful = False 62 | 63 | # Configure user email 64 | if not all_successful: 65 | logger.info( 66 | "Skipping git email configuration due to previous error in name configuration." 67 | ) 68 | # If name configuration failed, assemble the error message and return 69 | final_err_msg = "Git user.name configuration failed. " + " | ".join( 70 | errors_encountered 71 | ) 72 | return SystemOperationResult.Err( 73 | Exception("Git configuration failed"), message=final_err_msg 74 | ) 75 | 76 | email_cmd = [git_exe, "config", "--global", "user.email", git_user_email] 77 | try: 78 | result_email_proc = system.run_command( 79 | email_cmd, capture_output=True, text=True, check=False 80 | ) 81 | except Exception as e_run_email: 82 | msg = f"Failed to execute git config user.email: {e_run_email}" 83 | logger.error(msg) 84 | errors_encountered.append(msg) 85 | all_successful = False 86 | else: 87 | if result_email_proc.returncode == 0: 88 | logger.info(f"Successfully set git global user.email='{git_user_email}'.") 89 | else: 90 | err_output_email = ( 91 | result_email_proc.stderr.strip() or result_email_proc.stdout.strip() 92 | ) 93 | msg = f"Failed to set git global user.email. RC: {result_email_proc.returncode}. Error: {err_output_email}" 94 | logger.error(msg) 95 | errors_encountered.append(msg) 96 | all_successful = False 97 | 98 | if all_successful: 99 | return SystemOperationResult.Ok() 100 | else: 101 | final_err_msg = "One or more git configuration steps failed. " + " | ".join( 102 | errors_encountered 103 | ) 104 | return SystemOperationResult.Err( 105 | Exception("Git configuration failed"), message=final_err_msg 106 | ) 107 | -------------------------------------------------------------------------------- /src/vscode_colab/environment/project_setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Optional 3 | 4 | from vscode_colab.logger_config import log as logger 5 | from vscode_colab.system import System 6 | from vscode_colab.utils import SystemOperationResult 7 | 8 | GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" 9 | GET_PIP_SCRIPT_NAME = "get-pip.py" 10 | 11 | 12 | def _determine_venv_python_executable( 13 | system: System, 14 | venv_path: str, # Absolute path to venv directory 15 | base_python_executable_name: str, # e.g., "python3.9" or "python" 16 | ) -> Optional[str]: 17 | """ 18 | Attempts to determine the path to the Python executable within a created 19 | Linux virtual environment. 20 | """ 21 | venv_bin_dir = system.get_absolute_path(os.path.join(venv_path, "bin")) 22 | 23 | potential_exe_names: List[str] = [] 24 | # Start with the most specific names derived from the base Python executable 25 | if base_python_executable_name: 26 | potential_exe_names.append(base_python_executable_name) # e.g., python3.9 27 | # Attempt to add variants like "pythonX.Y" if base is "pythonX.Y.Z" 28 | # or if base is "pythonX" then "pythonX" is already there. 29 | if base_python_executable_name.startswith("python"): 30 | version_part = base_python_executable_name[ 31 | len("python") : 32 | ] # e.g., "3.9" or "3.9.12" 33 | if version_part.count(".") > 1: # like 3.9.12 34 | short_version = ".".join(version_part.split(".")[:2]) # 3.9 35 | potential_exe_names.append(f"python{short_version}") 36 | 37 | # Add common fallbacks 38 | potential_exe_names.extend(["python3", "python"]) 39 | 40 | # Remove duplicates while preserving order (important for preference) 41 | unique_potential_exe_names: List[str] = list(dict.fromkeys(potential_exe_names)) 42 | 43 | logger.debug( 44 | f"Looking for venv python in {venv_bin_dir} with names: {unique_potential_exe_names}" 45 | ) 46 | for exe_name in unique_potential_exe_names: 47 | potential_path = system.get_absolute_path(os.path.join(venv_bin_dir, exe_name)) 48 | if system.is_executable(potential_path): 49 | logger.info(f"Found venv python executable: {potential_path}") 50 | return potential_path 51 | 52 | logger.warning( 53 | f"Could not reliably determine python executable in {venv_bin_dir}. Venv structure might be incomplete or Python executable has an unexpected name." 54 | ) 55 | return None 56 | 57 | 58 | def _download_get_pip_script( 59 | project_path: str, # Should be absolute for clarity 60 | system: System, 61 | ) -> SystemOperationResult[str, Exception]: 62 | get_pip_script_path = system.get_absolute_path( 63 | os.path.join(project_path, GET_PIP_SCRIPT_NAME) 64 | ) 65 | download_res = system.download_file(GET_PIP_URL, get_pip_script_path) 66 | if not download_res: 67 | return SystemOperationResult.Err( 68 | download_res.error or Exception("Failed to download get-pip.py"), 69 | message=f"Failed to download {GET_PIP_URL}: {download_res.message}", 70 | ) 71 | logger.info( 72 | f"Successfully downloaded {GET_PIP_SCRIPT_NAME} to {get_pip_script_path}." 73 | ) 74 | return SystemOperationResult.Ok(get_pip_script_path) 75 | 76 | 77 | def _install_pip_with_script( 78 | system: System, 79 | venv_python_executable: str, # Absolute path to venv python 80 | get_pip_script_path: str, # Absolute path to get-pip.py script 81 | project_path: str, # Absolute path to project directory (CWD for command) 82 | pip_check_cmd: List[str], 83 | ) -> SystemOperationResult[None, Exception]: 84 | try: 85 | logger.info(f"Running {get_pip_script_path} using {venv_python_executable}...") 86 | pip_install_cmd = [venv_python_executable, get_pip_script_path] 87 | 88 | try: 89 | pip_install_result = system.run_command( 90 | pip_install_cmd, 91 | cwd=project_path, 92 | capture_output=True, 93 | text=True, 94 | check=False, 95 | ) 96 | except Exception as e_run: 97 | logger.error(f"Failed to execute get-pip.py script: {e_run}") 98 | return SystemOperationResult.Err( 99 | e_run, message="Execution of get-pip.py failed" 100 | ) 101 | 102 | if pip_install_result.returncode != 0: 103 | err_msg_install = ( 104 | pip_install_result.stderr.strip() or pip_install_result.stdout.strip() 105 | ) 106 | full_err_msg_install = f"Failed to install pip using get-pip.py. RC: {pip_install_result.returncode}. Error: {err_msg_install}" 107 | logger.error(full_err_msg_install) 108 | return SystemOperationResult.Err( 109 | Exception("get-pip.py execution failed"), message=full_err_msg_install 110 | ) 111 | 112 | logger.info( 113 | f"get-pip.py script executed successfully. Verifying pip installation..." 114 | ) 115 | try: 116 | pip_verify_result = system.run_command( 117 | pip_check_cmd, 118 | cwd=project_path, 119 | capture_output=True, 120 | text=True, 121 | check=False, 122 | ) 123 | except Exception as e_verify_run: 124 | logger.error(f"Failed to execute pip verification command: {e_verify_run}") 125 | return SystemOperationResult.Err( 126 | e_verify_run, message="Pip verification command failed" 127 | ) 128 | 129 | if pip_verify_result.returncode != 0: 130 | err_msg_verify = ( 131 | pip_verify_result.stderr.strip() or pip_verify_result.stdout.strip() 132 | ) 133 | full_err_msg_verify = f"pip installed via get-pip.py but subsequent verification failed. RC: {pip_verify_result.returncode}. Error: {err_msg_verify}" 134 | logger.error(full_err_msg_verify) 135 | return SystemOperationResult.Err( 136 | Exception("Pip verification failed"), message=full_err_msg_verify 137 | ) 138 | 139 | logger.info( 140 | f"pip verified successfully in the virtual environment: {pip_verify_result.stdout.strip()}" 141 | ) 142 | return SystemOperationResult.Ok() 143 | 144 | finally: 145 | # Clean up get-pip.py script 146 | rm_res = system.remove_file( 147 | get_pip_script_path, 148 | missing_ok=True, 149 | log_success=False, 150 | ) 151 | if not rm_res: # Log if removal failed 152 | logger.warning(f"Could not remove {get_pip_script_path}: {rm_res.message}") 153 | 154 | 155 | def _ensure_pip_in_venv( 156 | system: System, 157 | project_path: str, # CWD for commands, should be absolute 158 | venv_python_executable: str, # Absolute path to venv python 159 | ) -> SystemOperationResult[None, Exception]: 160 | """ 161 | Checks for pip in the venv and attempts to install it via get-pip.py if not found or not working. 162 | """ 163 | logger.info(f"Checking for pip using venv Python: {venv_python_executable}") 164 | pip_check_cmd = [venv_python_executable, "-m", "pip", "--version"] 165 | 166 | try: 167 | pip_check_result_proc = system.run_command( 168 | pip_check_cmd, cwd=project_path, capture_output=True, text=True, check=False 169 | ) 170 | # Catch if run_command itself fails (e.g. venv_python_executable missing) 171 | except Exception as e_check_run: 172 | logger.error(f"Failed to run pip check command: {e_check_run}") 173 | return SystemOperationResult.Err( 174 | e_check_run, message="Execution of pip check command failed." 175 | ) 176 | 177 | if pip_check_result_proc.returncode == 0: 178 | logger.info( 179 | f"pip is available and working in the virtual environment. Version: {pip_check_result_proc.stdout.strip()}" 180 | ) 181 | return SystemOperationResult.Ok() 182 | 183 | logger.warning( 184 | f"pip check failed (RC: {pip_check_result_proc.returncode}) or pip not found. Stderr: {pip_check_result_proc.stderr.strip()}. Stdout: {pip_check_result_proc.stdout.strip()}. Attempting manual installation using get-pip.py." 185 | ) 186 | 187 | download_script_res = _download_get_pip_script(project_path, system) 188 | if not download_script_res: 189 | return SystemOperationResult.Err( 190 | download_script_res.error or Exception("Download of get-pip.py failed"), 191 | message=f"Failed to download get-pip.py. Cannot proceed. {download_script_res.message}", 192 | ) 193 | 194 | return _install_pip_with_script( 195 | system, 196 | venv_python_executable, 197 | download_script_res.value, # Path to script 198 | project_path, 199 | pip_check_cmd, 200 | ) 201 | 202 | 203 | def _initialize_git_repo( 204 | system: System, project_path: str, venv_name: str 205 | ) -> SystemOperationResult[None, Exception]: 206 | logger.info("Initializing Git repository...") 207 | git_init_cmd = ["git", "init"] 208 | original_cwd = system.get_cwd() 209 | 210 | change_cwd_res = system.change_cwd(project_path) 211 | if not change_cwd_res: 212 | return SystemOperationResult.Err( 213 | change_cwd_res.error or Exception("CWD change failed"), 214 | message=f"Failed to change CWD to {project_path} for git init: {change_cwd_res.message}", 215 | ) 216 | 217 | try: 218 | git_init_proc = system.run_command( 219 | git_init_cmd, capture_output=True, text=True, check=False 220 | ) 221 | except Exception as e_git_run: 222 | system.change_cwd(original_cwd) # Attempt to restore CWD 223 | logger.error(f"Failed to execute 'git init': {e_git_run}") 224 | return SystemOperationResult.Err( 225 | e_git_run, message="Execution of 'git init' failed." 226 | ) 227 | 228 | if git_init_proc.returncode != 0: 229 | err_msg = git_init_proc.stderr.strip() or git_init_proc.stdout.strip() 230 | system.change_cwd(original_cwd) # Restore CWD 231 | logger.warning(f"Failed to initialize Git repository: {err_msg}") 232 | return SystemOperationResult.Err( 233 | Exception("git init command failed"), message=f"git init error: {err_msg}" 234 | ) 235 | 236 | logger.info("Git repository initialized successfully.") 237 | gitignore_template_res = system.read_package_asset("gitignore_template.txt") 238 | if not gitignore_template_res.is_ok or not gitignore_template_res.value: 239 | logger.warning( 240 | f"Could not read .gitignore template: {gitignore_template_res.message}" 241 | ) 242 | 243 | gitignore_content = gitignore_template_res.value.replace( # type: ignore 244 | "{{ venv_name }}", venv_name 245 | ) 246 | write_res = system.write_file( 247 | os.path.join(project_path, ".gitignore"), gitignore_content 248 | ) # Ensure writing to correct path 249 | if not write_res: 250 | logger.warning(f"Could not create .gitignore file: {write_res.message}") 251 | 252 | system.change_cwd(original_cwd) # Restore CWD 253 | return SystemOperationResult.Ok() 254 | 255 | 256 | def _create_virtual_environment( 257 | system: System, 258 | project_path: str, # Absolute path 259 | python_executable: str, # Name or path of python to use for venv creation 260 | venv_name: str, 261 | ) -> SystemOperationResult[str, Exception]: # Returns path to venv python on success 262 | """ 263 | Creates a Python virtual environment in the specified project directory. 264 | """ 265 | logger.info( 266 | f"Attempting to create virtual environment '{venv_name}' in '{project_path}' using Python: {python_executable}" 267 | ) 268 | 269 | base_python_path = system.which(python_executable) 270 | if not base_python_path: 271 | return SystemOperationResult.Err( 272 | FileNotFoundError( 273 | f"Base Python executable '{python_executable}' not found in PATH." 274 | ), 275 | message=f"Base Python executable '{python_executable}' not found in PATH. Cannot create virtual environment.", 276 | ) 277 | 278 | logger.info( 279 | f"Creating virtual environment '{venv_name}' in '{project_path}' using '{base_python_path}'." 280 | ) 281 | # Use base_python_path directly, as it's the absolute path from which() 282 | venv_create_cmd = [base_python_path, "-m", "venv", venv_name] 283 | try: 284 | venv_create_result = system.run_command( 285 | venv_create_cmd, 286 | capture_output=True, 287 | text=True, 288 | cwd=project_path, 289 | check=False, 290 | ) 291 | except Exception as e_venv_run: 292 | logger.error(f"Failed to execute venv creation command: {e_venv_run}") 293 | return SystemOperationResult.Err( 294 | e_venv_run, message="Execution of venv command failed." 295 | ) 296 | 297 | if venv_create_result.returncode != 0: 298 | err_msg_venv = ( 299 | venv_create_result.stderr.strip() or venv_create_result.stdout.strip() 300 | ) 301 | full_err_msg_venv = f"Failed to create venv '{venv_name}'. RC: {venv_create_result.returncode}. Error: {err_msg_venv}" 302 | logger.error(full_err_msg_venv) 303 | return SystemOperationResult.Err( 304 | Exception("Venv creation command failed"), message=full_err_msg_venv 305 | ) 306 | 307 | logger.info(f"Virtual environment '{venv_name}' creation command reported success.") 308 | 309 | abs_venv_path = system.get_absolute_path(os.path.join(project_path, venv_name)) 310 | base_python_exe_name = os.path.basename(python_executable) 311 | 312 | venv_python_exe_path = _determine_venv_python_executable( 313 | system, abs_venv_path, base_python_exe_name 314 | ) 315 | if not venv_python_exe_path: 316 | err_msg_pip = f"Venv '{venv_name}' at {abs_venv_path} created, but its Python exe not found. Pip setup skipped." 317 | logger.error(err_msg_pip) 318 | return SystemOperationResult.Err( 319 | FileNotFoundError("Venv Python executable"), message=err_msg_pip 320 | ) 321 | 322 | ensure_pip_res = _ensure_pip_in_venv(system, project_path, venv_python_exe_path) 323 | if not ensure_pip_res: 324 | logger.warning( 325 | f"WARNING: Failed to ensure pip in '{venv_name}'. Venv may not be fully usable. Error: {ensure_pip_res.message}" 326 | ) 327 | return SystemOperationResult.Err( 328 | ensure_pip_res.error or Exception("Pip setup failed"), 329 | message=f"Pip setup in venv failed: {ensure_pip_res.message}", 330 | ) 331 | 332 | logger.info( 333 | f"SUCCESS: Virtual environment '{venv_name}' with pip is ready at {abs_venv_path}. Venv Python: {venv_python_exe_path}" 334 | ) 335 | return SystemOperationResult.Ok(venv_python_exe_path) 336 | 337 | 338 | def setup_project_directory( 339 | system: System, 340 | project_name: str, 341 | base_path: str = ".", # Relative to CWD at time of call 342 | python_executable: str = "python3", # Python for creating the venv 343 | venv_name: str = ".venv", 344 | ) -> SystemOperationResult[str, Exception]: # Returns absolute project path on success 345 | """ 346 | Creates a project directory, initializes Git, and creates a Python virtual environment. 347 | Operations are performed relative to the project_path once created. 348 | """ 349 | abs_base_path = system.get_absolute_path(base_path) 350 | project_path = system.get_absolute_path(os.path.join(abs_base_path, project_name)) 351 | 352 | logger.debug(f"Attempting to set up project directory: {project_path}") 353 | 354 | if system.path_exists(project_path): 355 | logger.info( 356 | f"Project directory {project_path} already exists. Skipping creation and setup within it." 357 | ) 358 | return SystemOperationResult.Ok(project_path) 359 | 360 | logger.info(f"Creating project directory at: {project_path}") 361 | mk_dir_res = system.make_dirs(project_path) 362 | if not mk_dir_res: 363 | return SystemOperationResult.Err( 364 | mk_dir_res.error or OSError("Failed to create project directory"), 365 | message=f"Failed to create project directory {project_path}: {mk_dir_res.message}", 366 | ) 367 | 368 | # Initialize Git repository 369 | git_init_res = _initialize_git_repo(system, project_path, venv_name) 370 | if not git_init_res: 371 | logger.error( 372 | f"Failed to initialize Git repository in {project_path}. Project setup may be incomplete. Error: {git_init_res.message}" 373 | ) 374 | # For now, let's consider it non-fatal for directory setup itself. 375 | 376 | # Create virtual environment 377 | # Note: _create_virtual_environment will use project_path as CWD for its commands 378 | venv_res = _create_virtual_environment( 379 | system, 380 | project_path, 381 | python_executable, 382 | venv_name, 383 | ) 384 | if not venv_res: 385 | logger.error( 386 | f"Failed to create virtual environment '{venv_name}' in {project_path}. Project setup may be incomplete. Error: {venv_res.message}" 387 | ) 388 | # This is more critical than git init failure. 389 | return SystemOperationResult.Err( 390 | venv_res.error or Exception("Venv creation failed"), 391 | message=f"Virtual environment setup failed: {venv_res.message}", 392 | ) 393 | 394 | logger.info(f"Project '{project_name}' successfully set up at {project_path}") 395 | return SystemOperationResult.Ok(project_path) 396 | -------------------------------------------------------------------------------- /src/vscode_colab/environment/python_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from typing import Dict, List, Optional, Set 4 | 5 | from vscode_colab.logger_config import log as logger 6 | from vscode_colab.system import System 7 | from vscode_colab.utils import SystemOperationResult 8 | 9 | PYENV_INSTALLER_URL = "https://pyenv.run" 10 | INSTALLER_SCRIPT_NAME = "pyenv-installer.sh" 11 | 12 | # Common pyenv build dependencies for Debian/Ubuntu-based systems 13 | # Source: https://github.com/pyenv/pyenv/wiki#suggested-build-environment 14 | PYENV_BUILD_DEPENDENCIES: Set[str] = { 15 | "build-essential", 16 | "curl", 17 | "libbz2-dev", 18 | "libffi-dev", 19 | "liblzma-dev", 20 | "libncurses5-dev", 21 | "libncursesw5-dev", 22 | "libreadline-dev", 23 | "libsqlite3-dev", 24 | "libssl-dev", 25 | "libxml2-dev", 26 | "libxmlsec1-dev", 27 | "llvm", 28 | "make", 29 | "tk-dev", 30 | "wget", 31 | "xz-utils", 32 | "zlib1g-dev", 33 | } 34 | 35 | 36 | class PythonEnvManager: 37 | """ 38 | Manages pyenv installation and Python version installations via pyenv on Linux. 39 | """ 40 | 41 | def __init__(self, system: System) -> None: 42 | """ 43 | Initializes the PythonEnvManager with a System instance. 44 | """ 45 | self.system = system 46 | self.pyenv_root = self.system.expand_user_path("~/.pyenv") 47 | self.pyenv_executable_path = self.system.get_absolute_path( 48 | os.path.join(self.pyenv_root, "bin", "pyenv") 49 | ) 50 | 51 | def _get_pyenv_env_vars(self) -> Dict[str, str]: 52 | """ 53 | Constructs environment variables needed for pyenv commands. 54 | """ 55 | current_env = os.environ.copy() 56 | current_env["PYENV_ROOT"] = self.pyenv_root 57 | 58 | # pyenv's own init script would typically add these to the shell's PATH 59 | # For direct command execution, ensure pyenv's bin and shims are in PATH 60 | pyenv_bin_path = self.system.get_absolute_path( 61 | os.path.join(self.pyenv_root, "bin") 62 | ) 63 | pyenv_shims_path = self.system.get_absolute_path( 64 | os.path.join(self.pyenv_root, "shims") 65 | ) 66 | 67 | new_path_parts: List[str] = [pyenv_bin_path, pyenv_shims_path] 68 | existing_path = current_env.get("PATH", "") 69 | if existing_path: 70 | new_path_parts.append(existing_path) 71 | current_env["PATH"] = os.pathsep.join(new_path_parts) 72 | 73 | return current_env 74 | 75 | def install_pyenv_dependencies(self) -> SystemOperationResult[None, Exception]: 76 | """ 77 | Installs pyenv build dependencies using apt. Assumes a Debian/Ubuntu-based system. 78 | Requires sudo privileges. 79 | """ 80 | logger.info("Attempting to install pyenv build dependencies...") 81 | 82 | # Check for sudo 83 | sudo_path = self.system.which("sudo") 84 | if not sudo_path: 85 | msg = "sudo command not found. Cannot install dependencies." 86 | logger.warning(msg) 87 | return SystemOperationResult.Err(FileNotFoundError("sudo"), message=msg) 88 | 89 | apt_path = self.system.which("apt") 90 | if not apt_path: 91 | msg = "apt command not found. Cannot install dependencies." 92 | logger.warning(msg) 93 | return SystemOperationResult.Err(FileNotFoundError("apt"), message=msg) 94 | 95 | # Step 1: apt update 96 | update_cmd = [sudo_path, apt_path, "update", "-y"] 97 | 98 | logger.info(f"Running: {' '.join(update_cmd)}") 99 | try: 100 | update_proc = self.system.run_command( 101 | update_cmd, capture_output=True, text=True, check=False 102 | ) 103 | except Exception as e_update_run: 104 | logger.error(f"Failed to execute apt-get update: {e_update_run}") 105 | return SystemOperationResult.Err( 106 | e_update_run, message="apt-get update execution failed" 107 | ) 108 | 109 | if update_proc.returncode != 0: 110 | err_msg = f"apt-get update failed (RC: {update_proc.returncode}). Stdout: {update_proc.stdout.strip()} Stderr: {update_proc.stderr.strip()}" 111 | logger.error(err_msg) 112 | return SystemOperationResult.Err( 113 | Exception("apt update failed"), message=err_msg 114 | ) 115 | logger.info("apt update completed successfully.") 116 | 117 | # Step 2: apt install dependencies 118 | install_cmd = [sudo_path, apt_path, "install", "-y"] 119 | install_cmd.extend(sorted(list(PYENV_BUILD_DEPENDENCIES))) 120 | 121 | logger.info(f"Running: {' '.join(install_cmd)} (this might take a moment)") 122 | try: 123 | install_proc = self.system.run_command( 124 | install_cmd, capture_output=True, text=True, check=False 125 | ) 126 | except Exception as e_install_run: 127 | logger.error( 128 | f"Failed to execute apt install for pyenv dependencies: {e_install_run}" 129 | ) 130 | return SystemOperationResult.Err( 131 | e_install_run, message="apt install dependencies execution failed" 132 | ) 133 | 134 | if install_proc.returncode != 0: 135 | err_msg_install = f"apt install pyenv dependencies failed (RC: {install_proc.returncode}). Stdout: {install_proc.stdout.strip()} Stderr: {install_proc.stderr.strip()}" 136 | logger.error(err_msg_install) 137 | logger.warning( 138 | "One or more dependencies might have failed to install. Check apt output above." 139 | ) 140 | return SystemOperationResult.Err( 141 | Exception("apt install dependencies failed"), 142 | message=err_msg_install, 143 | ) 144 | 145 | logger.info("Successfully installed pyenv build dependencies.") 146 | return SystemOperationResult.Ok() 147 | 148 | def install_pyenv( 149 | self, attempt_to_install_deps: bool = True 150 | ) -> SystemOperationResult[str, Exception]: 151 | """ 152 | Installs the pyenv executable. 153 | Returns SystemOperationResult with pyenv executable path in `value` on success. 154 | """ 155 | logger.info(f"Attempting to install pyenv into {self.pyenv_root}...") 156 | 157 | if attempt_to_install_deps: 158 | logger.info("Checking and installing pyenv build dependencies first...") 159 | deps_res = self.install_pyenv_dependencies() 160 | if not deps_res: 161 | logger.warning( 162 | f"Failed to install pyenv dependencies: {deps_res.message}. Pyenv installation might fail." 163 | ) 164 | 165 | # The pyenv installer script expects to create PYENV_ROOT or find it as a valid git repo. 166 | 167 | temp_installer_script_path: Optional[str] = None 168 | try: 169 | # Create a temporary file to download the installer script to. 170 | # We use delete=False because we need to pass the name to an external command (bash). 171 | # We will manually delete it in the finally block. 172 | with tempfile.NamedTemporaryFile( 173 | delete=False, suffix=".sh", prefix="pyenv-installer-" 174 | ) as tf: 175 | temp_installer_script_path = tf.name 176 | 177 | logger.debug( 178 | f"Downloading pyenv installer to temporary path: {temp_installer_script_path}" 179 | ) 180 | download_res = self.system.download_file( 181 | PYENV_INSTALLER_URL, temp_installer_script_path 182 | ) 183 | if not download_res: 184 | return SystemOperationResult.Err( 185 | download_res.error or Exception("Download failed"), 186 | message=f"Failed to download pyenv installer from {PYENV_INSTALLER_URL} to {temp_installer_script_path}: {download_res.message}", 187 | ) 188 | 189 | bash_exe = self.system.which("bash") 190 | if not bash_exe: 191 | err = FileNotFoundError( 192 | "bash not found, cannot execute pyenv installer script." 193 | ) 194 | logger.error(err.args[0]) 195 | return SystemOperationResult.Err(err, message=err.args[0]) 196 | 197 | installer_cmd = [bash_exe, temp_installer_script_path] 198 | # PYENV_ROOT is often set as an env var for the installer script itself 199 | pyenv_installer_env = os.environ.copy() 200 | pyenv_installer_env["PYENV_ROOT"] = self.pyenv_root 201 | 202 | logger.info( 203 | f"Executing pyenv installer script: {' '.join(installer_cmd)} with PYENV_ROOT={self.pyenv_root}" 204 | ) 205 | 206 | installer_proc_result = self.system.run_command( 207 | installer_cmd, 208 | env=pyenv_installer_env, 209 | capture_output=True, 210 | text=True, 211 | check=False, # We check returncode manually 212 | ) 213 | 214 | if installer_proc_result.returncode != 0: 215 | err_msg = [ 216 | f"Pyenv installer script failed (RC: {installer_proc_result.returncode})." 217 | ] 218 | if installer_proc_result.stdout: 219 | err_msg.append( 220 | f"Pyenv installer script stdout: {installer_proc_result.stdout.strip()}" 221 | ) 222 | if installer_proc_result.stderr: 223 | err_msg.append( 224 | f"Pyenv installer script stderr: {installer_proc_result.stderr.strip()}" 225 | ) 226 | err_msg = "\n".join(err_msg) 227 | 228 | logger.error(err_msg) 229 | return SystemOperationResult.Err( 230 | Exception("Pyenv installer script failed"), message=err_msg 231 | ) 232 | 233 | if installer_proc_result.stdout: 234 | logger.info( 235 | f"pyenv installer stdout: {installer_proc_result.stdout.strip()}" 236 | ) 237 | if installer_proc_result.stderr: 238 | logger.error( 239 | f"pyenv installer stderr: {installer_proc_result.stderr.strip()}" 240 | ) 241 | 242 | if not self.system.is_executable(self.pyenv_executable_path): 243 | err = Exception( 244 | f"Pyenv installer script ran, but pyenv executable not found at {self.pyenv_executable_path}." 245 | ) 246 | logger.error(err.args[0]) 247 | return SystemOperationResult.Err(err, message=err.args[0]) 248 | 249 | logger.info( 250 | f"pyenv installed successfully into {self.pyenv_root}. Executable: {self.pyenv_executable_path}" 251 | ) 252 | return SystemOperationResult.Ok(value=self.pyenv_executable_path) 253 | 254 | except ( 255 | Exception 256 | ) as e_install_run: # Catch exceptions from run_command or other operations 257 | logger.error( 258 | f"An exception occurred during pyenv installation process: {e_install_run}" 259 | ) 260 | return SystemOperationResult.Err( 261 | e_install_run, 262 | message=f"Pyenv installation process failed: {e_install_run}", 263 | ) 264 | finally: 265 | if temp_installer_script_path: 266 | logger.debug( 267 | f"Removing temporary installer script: {temp_installer_script_path}" 268 | ) 269 | self.system.remove_file( 270 | temp_installer_script_path, missing_ok=True, log_success=False 271 | ) 272 | 273 | @property 274 | def is_pyenv_installed(self) -> bool: 275 | """Checks if the pyenv executable is present and executable.""" 276 | is_present = self.system.is_executable(self.pyenv_executable_path) 277 | logger.debug( 278 | f"pyenv executable {'found' if is_present else 'not found'} at {self.pyenv_executable_path}" 279 | ) 280 | return is_present 281 | 282 | def is_python_version_installed( 283 | self, python_version: str 284 | ) -> SystemOperationResult[bool, Exception]: 285 | """Checks if a specific Python version is installed by pyenv.""" 286 | if not self.is_pyenv_installed: 287 | return SystemOperationResult.Err( 288 | Exception("Pyenv is not installed, cannot check Python versions.") 289 | ) 290 | 291 | pyenv_env = self._get_pyenv_env_vars() 292 | logger.debug( 293 | f"Checking if Python version {python_version} is installed by pyenv..." 294 | ) 295 | versions_cmd = [self.pyenv_executable_path, "versions", "--bare"] 296 | 297 | try: 298 | versions_proc_result = self.system.run_command( 299 | versions_cmd, env=pyenv_env, capture_output=True, text=True, check=False 300 | ) 301 | except Exception as e_run: 302 | return SystemOperationResult.Err( 303 | e_run, message=f"Failed to run 'pyenv versions': {e_run}" 304 | ) 305 | 306 | if versions_proc_result.returncode == 0: 307 | installed_versions = versions_proc_result.stdout.strip().splitlines() 308 | is_present = python_version in installed_versions 309 | logger.debug( 310 | f"Python version '{python_version}' present in pyenv: {is_present}. Installed: {installed_versions}" 311 | ) 312 | return SystemOperationResult.Ok(is_present) 313 | else: 314 | err_msg = ( 315 | f"Could not list pyenv versions (RC: {versions_proc_result.returncode}). " 316 | f"Stdout: {versions_proc_result.stdout.strip()} Stderr: {versions_proc_result.stderr.strip()}" 317 | ) 318 | logger.warning(err_msg) 319 | return SystemOperationResult.Err( 320 | Exception("Failed to list pyenv versions"), message=err_msg 321 | ) 322 | 323 | def install_python_version( 324 | self, python_version: str, force_reinstall: bool = False 325 | ) -> SystemOperationResult[None, Exception]: 326 | """Installs a specific Python version using pyenv. Assumes pyenv is installed.""" 327 | if not self.is_pyenv_installed: 328 | return SystemOperationResult.Err( 329 | Exception("Pyenv is not installed, cannot install Python version.") 330 | ) 331 | 332 | pyenv_env = self._get_pyenv_env_vars() 333 | action = "Force reinstalling" if force_reinstall else "Installing" 334 | logger.info( 335 | f"{action} Python {python_version} with pyenv. This may take around 5-10 minutes..." 336 | ) 337 | 338 | install_cmd_list = [self.pyenv_executable_path, "install"] 339 | if force_reinstall: 340 | install_cmd_list.append("--force") 341 | install_cmd_list.append(python_version) 342 | 343 | # Add PYTHON_CONFIGURE_OPTS for shared library, crucial for some tools like venv/virtualenv with pyenv python 344 | python_build_env = pyenv_env.copy() 345 | python_build_env["PYTHON_CONFIGURE_OPTS"] = "--enable-shared" 346 | # Can also add CFLAGS for optimizations if needed, e.g. 347 | # python_build_env["CFLAGS"] = "-O2 -march=native" (be careful with march=native in shared envs) 348 | logger.info( 349 | f"Using PYTHON_CONFIGURE_OPTS: {python_build_env['PYTHON_CONFIGURE_OPTS']}" 350 | ) 351 | 352 | try: 353 | install_proc_result = self.system.run_command( 354 | install_cmd_list, 355 | env=python_build_env, 356 | capture_output=True, 357 | text=True, 358 | check=False, 359 | ) 360 | except Exception as e_run: 361 | return SystemOperationResult.Err( 362 | e_run, 363 | message=f"Failed to run 'pyenv install {python_version}': {e_run}", 364 | ) 365 | 366 | if install_proc_result.returncode == 0: 367 | logger.info(f"Python {python_version} installed successfully via pyenv.") 368 | return SystemOperationResult.Ok() 369 | 370 | err_msg = ( 371 | f"Failed to install Python {python_version} using pyenv (RC: {install_proc_result.returncode}). " 372 | f"Stdout: {install_proc_result.stdout.strip()} Stderr: {install_proc_result.stderr.strip()}" 373 | ) 374 | logger.error(err_msg) 375 | logger.error( 376 | "Ensure build dependencies are installed (see pyenv docs/wiki or try `install_pyenv_dependencies()`)." 377 | ) 378 | return SystemOperationResult.Err( 379 | Exception(f"Pyenv install {python_version} failed"), message=err_msg 380 | ) 381 | 382 | def set_global_python_version( 383 | self, python_version: str 384 | ) -> SystemOperationResult[None, Exception]: 385 | """Sets the global Python version using pyenv. Assumes pyenv is installed.""" 386 | if not self.is_pyenv_installed: 387 | return SystemOperationResult.Err( 388 | Exception("Pyenv is not installed, cannot set global Python version.") 389 | ) 390 | 391 | pyenv_env = self._get_pyenv_env_vars() 392 | logger.info(f"Setting global Python version to {python_version} using pyenv...") 393 | global_cmd = [self.pyenv_executable_path, "global", python_version] 394 | 395 | try: 396 | global_proc_result = self.system.run_command( 397 | global_cmd, env=pyenv_env, capture_output=True, text=True, check=False 398 | ) 399 | except Exception as e_run: 400 | return SystemOperationResult.Err( 401 | e_run, message=f"Failed to run 'pyenv global {python_version}': {e_run}" 402 | ) 403 | 404 | if global_proc_result.returncode == 0: 405 | logger.info(f"Global Python version successfully set to {python_version}.") 406 | return SystemOperationResult.Ok() 407 | 408 | err_msg = ( 409 | f"Failed to set global Python version to {python_version} (RC: {global_proc_result.returncode}). " 410 | f"Stdout: {global_proc_result.stdout.strip()} Stderr: {global_proc_result.stderr.strip()}" 411 | ) 412 | logger.error(err_msg) 413 | return SystemOperationResult.Err( 414 | Exception(f"pyenv global {python_version} failed"), message=err_msg 415 | ) 416 | 417 | def get_python_executable_path( 418 | self, python_version: str 419 | ) -> SystemOperationResult[str, Exception]: 420 | """ 421 | Gets the path to the Python executable managed by pyenv for the given version. 422 | Assumes pyenv is installed and the version is set globally or is otherwise findable by `pyenv which`. 423 | """ 424 | if not self.is_pyenv_installed: 425 | return SystemOperationResult.Err( 426 | Exception("Pyenv is not installed, cannot get Python path.") 427 | ) 428 | 429 | pyenv_env = self._get_pyenv_env_vars() 430 | logger.debug( 431 | f"Verifying Python executable for version {python_version} via 'pyenv which python'..." 432 | ) 433 | # 'pyenv which python' should give the path for the currently active python version (e.g., global) 434 | which_cmd = [self.pyenv_executable_path, "which", "python"] 435 | 436 | try: 437 | which_proc_result = self.system.run_command( 438 | which_cmd, env=pyenv_env, capture_output=True, text=True, check=False 439 | ) 440 | except Exception as e_run: 441 | return SystemOperationResult.Err( 442 | e_run, message=f"Failed to run 'pyenv which python': {e_run}" 443 | ) 444 | 445 | found_path_via_which: Optional[str] = None 446 | if which_proc_result.returncode != 0 or not which_proc_result.stdout.strip(): 447 | message = ( 448 | f"pyenv which python failed (RC: {which_proc_result.returncode}) or returned empty. " 449 | f"Stdout: {which_proc_result.stdout.strip()} Stderr: {which_proc_result.stderr.strip()}" 450 | ) 451 | logger.warning(message) 452 | 453 | candidate_path = self.system.get_absolute_path(which_proc_result.stdout.strip()) 454 | 455 | if not self.system.is_executable(candidate_path): 456 | logger.warning( 457 | f"'pyenv which python' provided a non-executable path: {candidate_path}" 458 | ) 459 | 460 | try: 461 | # Resolve symlinks to ensure we're checking the actual binary's location 462 | resolved_path = self.system.get_absolute_path( 463 | os.path.realpath(candidate_path) 464 | ) 465 | expected_version_dir_prefix = self.system.get_absolute_path( 466 | os.path.join(self.pyenv_root, "versions", python_version) 467 | ) 468 | # Check if the resolved path is within the expected version's directory 469 | if resolved_path.startswith(expected_version_dir_prefix): 470 | logger.debug( 471 | f"Python executable for '{python_version}' (via 'pyenv which') found at: {resolved_path}" 472 | ) 473 | found_path_via_which = resolved_path 474 | else: 475 | message = ( 476 | f"'pyenv which python' resolved to '{resolved_path}', which is not in the expected directory " 477 | f"for version '{python_version}' ({expected_version_dir_prefix}). This might indicate the " 478 | f"global pyenv version is not '{python_version}' or pyenv state is unexpected." 479 | ) 480 | logger.warning(message) 481 | except Exception as e_real: # os.path.realpath can fail 482 | logger.warning( 483 | f"Could not resolve real path for {candidate_path}: {e_real}. Using direct path check." 484 | ) 485 | 486 | if found_path_via_which: 487 | return SystemOperationResult.Ok(found_path_via_which) 488 | 489 | # Fallback: Construct the path directly. This is less robust if shims/global aren't set. 490 | # However, if 'pyenv global ' was successful, this should be correct. 491 | expected_python_path_direct = self.system.get_absolute_path( 492 | os.path.join(self.pyenv_root, "versions", python_version, "bin", "python") 493 | ) 494 | logger.debug( 495 | f"Checking direct path for Python {python_version}: {expected_python_path_direct}" 496 | ) 497 | if self.system.is_executable(expected_python_path_direct): 498 | logger.info( 499 | f"Python executable for version {python_version} found at direct path: {expected_python_path_direct}" 500 | ) 501 | return SystemOperationResult.Ok(expected_python_path_direct) 502 | 503 | err_msg_final = f"Python executable for version {python_version} could not be reliably located via 'pyenv which' or direct path." 504 | logger.error(err_msg_final) 505 | return SystemOperationResult.Err( 506 | FileNotFoundError(err_msg_final), message=err_msg_final 507 | ) 508 | 509 | def setup_and_get_python_executable( 510 | self, 511 | python_version: str, 512 | force_reinstall_python: bool = False, 513 | attempt_pyenv_dependency_install: bool = True, # New parameter 514 | ) -> SystemOperationResult[str, Exception]: 515 | """ 516 | Ensures pyenv is installed (optionally installing its deps), installs the specified Python version 517 | (if needed or forced), sets it as global, and returns the path to its executable. 518 | """ 519 | if not self.is_pyenv_installed: 520 | logger.info("Pyenv is not installed. Attempting to install pyenv.") 521 | # Pass the dependency install flag to install_pyenv 522 | install_pyenv_res = self.install_pyenv( 523 | attempt_to_install_deps=attempt_pyenv_dependency_install 524 | ) 525 | if not install_pyenv_res: 526 | return SystemOperationResult.Err( 527 | install_pyenv_res.error 528 | or Exception("Pyenv installation failed during setup."), 529 | message=f"Pyenv installation failed: {install_pyenv_res.message}", 530 | ) 531 | 532 | version_installed_check_res = self.is_python_version_installed(python_version) 533 | if not version_installed_check_res: 534 | return SystemOperationResult.Err( 535 | version_installed_check_res.error 536 | or Exception("Failed to check installed Python versions"), 537 | message=f"Failed to check installed Python versions: {version_installed_check_res.message}", 538 | ) 539 | 540 | is_python_already_installed = version_installed_check_res.value 541 | 542 | if not is_python_already_installed or force_reinstall_python: 543 | install_op_res = self.install_python_version( 544 | python_version, force_reinstall_python 545 | ) 546 | if not install_op_res: 547 | return SystemOperationResult.Err( 548 | install_op_res.error 549 | or Exception(f"Failed to install Python {python_version}"), 550 | message=f"Failed to install Python {python_version}: {install_op_res.message}", 551 | ) 552 | else: 553 | logger.info( 554 | f"Python version {python_version} is already installed. Skipping installation." 555 | ) 556 | 557 | set_global_op_res = self.set_global_python_version(python_version) 558 | if not set_global_op_res: 559 | return SystemOperationResult.Err( 560 | set_global_op_res.error 561 | or Exception(f"Failed to set Python {python_version} as global"), 562 | message=f"Failed to set Python {python_version} as global: {set_global_op_res.message}", 563 | ) 564 | 565 | return self.get_python_executable_path(python_version) 566 | -------------------------------------------------------------------------------- /src/vscode_colab/logger_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | 5 | from loguru import logger 6 | 7 | # --- Default Configuration --- 8 | DEFAULT_CONSOLE_LOG_LEVEL = "INFO" 9 | DEFAULT_CONSOLE_LOG_FORMAT = ( 10 | "{time:YYYY-MM-DD HH:mm:ss} | " 11 | "{level: <8} | " 12 | "{name}:{function}:{line} - {message}" 13 | ) 14 | 15 | ENABLE_FILE_LOGGING_ENV_VAR = "VSCODE_COLAB_ENABLE_FILE_LOGGING" 16 | DEFAULT_FILE_LOG_PATH = "vscode_colab_activity.log" 17 | DEFAULT_FILE_LOG_LEVEL = "DEBUG" 18 | DEFAULT_FILE_LOG_FORMAT = ( 19 | "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " 20 | "{name}:{module}:{function}:{line} - {message}" 21 | ) 22 | 23 | # --- Apply Configuration --- 24 | logger.remove() 25 | 26 | console_log_level = os.getenv( 27 | "VSCODE_COLAB_CONSOLE_LOG_LEVEL", DEFAULT_CONSOLE_LOG_LEVEL 28 | ).upper() 29 | logger.add( 30 | sys.stderr, 31 | level=console_log_level, 32 | format=DEFAULT_CONSOLE_LOG_FORMAT, 33 | colorize=True, 34 | ) 35 | 36 | if os.getenv(ENABLE_FILE_LOGGING_ENV_VAR, "false").lower() in ["true", "1", "yes"]: 37 | file_log_path = os.getenv("VSCODE_COLAB_LOG_FILE_PATH", DEFAULT_FILE_LOG_PATH) 38 | file_log_level = os.getenv( 39 | "VSCODE_COLAB_FILE_LOG_LEVEL", DEFAULT_FILE_LOG_LEVEL 40 | ).upper() 41 | 42 | logger.info(f"File logging enabled. Level: {file_log_level}, Path: {file_log_path}") 43 | try: 44 | logger.add( 45 | file_log_path, 46 | level=file_log_level, 47 | format=DEFAULT_FILE_LOG_FORMAT, 48 | mode="a", 49 | encoding="utf-8", 50 | rotation="10 MB", 51 | retention="3 days", 52 | ) 53 | except Exception as e: 54 | logger.warning( 55 | f"Could not configure file logger at '{file_log_path}': {e}. File logging disabled." 56 | ) 57 | 58 | 59 | class PropagateHandler(logging.Handler): 60 | def emit(self, record: logging.LogRecord) -> None: 61 | logging.getLogger(record.name).handle(record) 62 | 63 | 64 | if ( 65 | "pytest" in sys.modules 66 | or os.getenv("VSCODE_COLAB_PROPAGATE_LOGS", "false").lower() == "true" 67 | ): 68 | logger.add(PropagateHandler(), format="{message}", level="DEBUG") 69 | if "pytest" in sys.modules: # Only log this info if pytest is detected 70 | logger.debug("Loguru to standard logging propagation enabled for pytest.") 71 | 72 | 73 | log = logger 74 | -------------------------------------------------------------------------------- /src/vscode_colab/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import time 5 | from typing import List, Optional, Set, Tuple 6 | 7 | from IPython.display import HTML, display 8 | 9 | from vscode_colab.environment import ( 10 | PythonEnvManager, 11 | configure_git, 12 | setup_project_directory, 13 | ) 14 | from vscode_colab.logger_config import log as logger 15 | from vscode_colab.system import System 16 | from vscode_colab.templating import ( 17 | render_github_auth_template, 18 | render_vscode_connection_template, 19 | ) 20 | from vscode_colab.utils import SystemOperationResult 21 | 22 | DEFAULT_EXTENSIONS: Set[str] = { 23 | "mgesbert.python-path", 24 | "ms-python.black-formatter", 25 | "ms-python.isort", 26 | "ms-python.python", 27 | "ms-python.vscode-pylance", 28 | "ms-python.debugpy", 29 | "ms-toolsai.jupyter", 30 | "ms-toolsai.jupyter-keymap", 31 | "ms-toolsai.jupyter-renderers", 32 | "ms-toolsai.tensorboard", 33 | } 34 | 35 | VSCODE_COLAB_LOGIN_ENV_VAR = "VSCODE_COLAB_LOGGED_IN" 36 | 37 | 38 | def download_vscode_cli( 39 | system: System, force_download: bool = False 40 | ) -> SystemOperationResult[str, Exception]: 41 | """ 42 | Downloads and extracts the Visual Studio Code CLI for Linux. 43 | Returns SystemOperationResult with the absolute path to the CLI executable directory on success. 44 | """ 45 | # On Linux, the CLI is typically extracted into a directory, and the executable is within it. 46 | cli_dir_name = "code" # The directory created by extracting the tarball 47 | cli_executable_name_in_dir = "code" 48 | 49 | cli_tarball_name = "vscode_cli_alpine_x64.tar.gz" # More specific name 50 | 51 | # Always use the true current working directory for extraction and lookup 52 | cwd = system.get_cwd() 53 | abs_cli_dir_path = os.path.join(cwd, cli_dir_name) 54 | abs_cli_executable_path = os.path.join(cwd, cli_executable_name_in_dir) 55 | abs_cli_tarball_path = os.path.join(cwd, cli_tarball_name) 56 | 57 | if system.is_executable(abs_cli_executable_path) and not force_download: 58 | logger.info( 59 | f"VS Code CLI already exists and is executable at {abs_cli_executable_path}. Skipping download." 60 | ) 61 | return SystemOperationResult.Ok(abs_cli_executable_path) 62 | 63 | # If directory exists but not executable, or force_download, remove existing first 64 | if system.path_exists(abs_cli_dir_path) and force_download: 65 | logger.info( 66 | f"Force download: Removing existing VS Code CLI directory at {abs_cli_dir_path}" 67 | ) 68 | rm_dir_res = system.remove_dir(abs_cli_dir_path, recursive=True) 69 | if not rm_dir_res: 70 | logger.warning( 71 | f"Failed to remove existing CLI directory {abs_cli_dir_path}: {rm_dir_res.message}" 72 | ) 73 | 74 | logger.info( 75 | f"Downloading VS Code CLI (cli-alpine-x64) to {abs_cli_tarball_path}..." 76 | ) 77 | download_res = system.download_file( 78 | "https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64", # Hardcoded for Linux 79 | abs_cli_tarball_path, 80 | ) 81 | if not download_res: 82 | return SystemOperationResult.Err( 83 | download_res.error or Exception("Download failed"), 84 | message=f"Failed to download VS Code CLI: {download_res.message}", 85 | ) 86 | 87 | logger.info("VS Code CLI tarball downloaded. Extracting...") 88 | tar_exe = system.which("tar") 89 | if not tar_exe: 90 | msg = "'tar' command not found. Cannot extract VS Code CLI." 91 | logger.error(msg) 92 | system.remove_file(abs_cli_tarball_path, missing_ok=True, log_success=False) 93 | return SystemOperationResult.Err(FileNotFoundError("tar"), message=msg) 94 | 95 | # Tar typically extracts into the current working directory. 96 | # The archive is expected to contain a top-level directory (e.g., "code") 97 | extract_cmd = [ 98 | tar_exe, 99 | "-xzf", 100 | abs_cli_tarball_path, 101 | ] # -x: extract, -z: gzip, -f: file 102 | 103 | try: 104 | # Ensure extraction happens in the correct directory 105 | extract_proc = system.run_command( 106 | extract_cmd, capture_output=True, text=True, check=False, cwd=cwd 107 | ) 108 | except Exception as e_extract_run: 109 | system.remove_file(abs_cli_tarball_path, missing_ok=True, log_success=False) 110 | return SystemOperationResult.Err( 111 | e_extract_run, 112 | message=f"VS Code CLI extraction command failed: {e_extract_run}", 113 | ) 114 | finally: 115 | system.remove_file( 116 | abs_cli_tarball_path, missing_ok=True, log_success=False 117 | ) # Cleanup tarball 118 | 119 | if extract_proc.returncode != 0: 120 | err_msg = extract_proc.stderr.strip() or extract_proc.stdout.strip() 121 | full_err_msg = f"Failed to extract VS Code CLI. RC: {extract_proc.returncode}. Error: {err_msg}" 122 | logger.error(full_err_msg) 123 | return SystemOperationResult.Err( 124 | Exception("tar extraction failed"), message=full_err_msg 125 | ) 126 | 127 | # After extraction, the executable should be at abs_cli_executable_path 128 | if not system.file_exists(abs_cli_executable_path): 129 | msg = f"VS Code CLI executable '{abs_cli_executable_path}' not found after extraction." 130 | logger.error(msg) 131 | # Check if the directory itself was created, maybe the exe name inside is different? 132 | if system.dir_exists(abs_cli_dir_path): 133 | logger.debug( 134 | f"Directory {abs_cli_dir_path} exists, but executable {cli_executable_name_in_dir} not found within." 135 | ) 136 | return SystemOperationResult.Err( 137 | FileNotFoundError(abs_cli_executable_path), message=msg 138 | ) 139 | 140 | # Ensure the extracted CLI binary is executable 141 | if not system.is_executable(abs_cli_executable_path): 142 | logger.info( 143 | f"VS Code CLI at {abs_cli_executable_path} is not executable. Attempting to set permissions." 144 | ) 145 | # Get current permissions first, then add execute bits 146 | perm_res = system.get_permissions(abs_cli_executable_path) 147 | if perm_res.is_ok and perm_res.value is not None: 148 | chmod_res = system.change_permissions( 149 | abs_cli_executable_path, perm_res.value | 0o111 150 | ) # Add u+x, g+x, o+x 151 | if not chmod_res: 152 | msg = ( 153 | f"Could not set execute permission for {abs_cli_executable_path}: {chmod_res.message}. " 154 | "Tunnel connection might fail." 155 | ) 156 | logger.warning(msg) 157 | # Not returning Err here, as Popen might still work if OS allows execution. 158 | else: 159 | logger.info(f"Set execute permission for {abs_cli_executable_path}") 160 | elif perm_res.is_err: 161 | logger.warning( 162 | f"Could not get permissions for {abs_cli_executable_path} to make it executable: {perm_res.message}" 163 | ) 164 | 165 | # Re-check after attempting to set permissions 166 | if not system.is_executable(abs_cli_executable_path): 167 | msg = f"VS Code CLI at {abs_cli_executable_path} is still not executable after attempting chmod." 168 | logger.error(msg) 169 | return SystemOperationResult.Err(PermissionError(msg), message=msg) 170 | 171 | logger.info( 172 | f"VS Code CLI setup successful. Executable at: '{abs_cli_executable_path}'." 173 | ) 174 | return SystemOperationResult.Ok(abs_cli_executable_path) 175 | 176 | 177 | def display_github_auth_link(url: str, code: str) -> None: 178 | html_content = render_github_auth_template(url=url, code=code) 179 | display(HTML(html_content)) 180 | 181 | 182 | def display_vscode_connection_options(tunnel_url: str, tunnel_name: str) -> None: 183 | html_content = render_vscode_connection_template( 184 | tunnel_url=tunnel_url, tunnel_name=tunnel_name 185 | ) 186 | display(HTML(html_content)) 187 | 188 | 189 | def login(system: System, provider: str = "github") -> bool: 190 | """ 191 | Handles the login process for VS Code Tunnel. 192 | Returns True on success (auth info displayed), False otherwise. 193 | """ 194 | # Clear any previous login flag at the start of a new login attempt 195 | if os.environ.get(VSCODE_COLAB_LOGIN_ENV_VAR): 196 | del os.environ[VSCODE_COLAB_LOGIN_ENV_VAR] 197 | if _login(system, provider): 198 | # Set environment variable on successful detection of auth info 199 | os.environ[VSCODE_COLAB_LOGIN_ENV_VAR] = "true" 200 | logger.info( 201 | f"Login successful: Set environment variable {VSCODE_COLAB_LOGIN_ENV_VAR}=true" 202 | ) 203 | return True 204 | return False 205 | 206 | 207 | def _login(system: System, provider: str = "github") -> bool: 208 | cli_download_res = download_vscode_cli(system=system) # Downloads to CWD by default 209 | if not cli_download_res.is_ok or not cli_download_res.value: 210 | logger.error( 211 | f"VS Code CLI download/setup failed. Cannot perform login. Error: {cli_download_res.message}" 212 | ) 213 | return False 214 | 215 | cli_exe_abs_path = cli_download_res.value 216 | 217 | # Command list for Popen (avoids shell=True) 218 | cmd_list = [cli_exe_abs_path, "tunnel", "user", "login", "--provider", provider] 219 | cmd_str_for_log = " ".join(cmd_list) # For logging 220 | logger.info(f"Initiating VS Code Tunnel login with command: {cmd_str_for_log}") 221 | 222 | proc = None 223 | try: 224 | # Use CWD of the script/library for Popen, as CLI was downloaded there. 225 | proc = subprocess.Popen( 226 | cmd_list, 227 | stdout=subprocess.PIPE, 228 | stderr=subprocess.STDOUT, # Combine stderr with stdout 229 | text=True, 230 | bufsize=1, # Line buffered 231 | universal_newlines=True, # For text=True 232 | cwd=system.get_cwd(), # Explicitly use current CWD 233 | ) 234 | 235 | if proc.stdout is None: 236 | logger.error("Failed to get login process stdout (proc.stdout is None).") 237 | if proc: 238 | proc.terminate() 239 | proc.wait() 240 | return False 241 | 242 | start_time = time.time() 243 | timeout_seconds = 60 # Increased from 60 for robustness 244 | 245 | # Regex for GitHub device flow URL and code 246 | url_re = re.compile(r"(https?://github\.com/login/device)") 247 | code_re = re.compile( 248 | r"\s+([A-Z0-9]{4,}-[A-Z0-9]{4,})" 249 | ) # Capture group for the code 250 | 251 | auth_url_found: Optional[str] = None 252 | auth_code_found: Optional[str] = None 253 | 254 | logger.info( 255 | "Monitoring login process output for GitHub authentication URL and code..." 256 | ) 257 | for line in iter(proc.stdout.readline, ""): 258 | if time.time() - start_time > timeout_seconds: 259 | logger.warning( 260 | f"Login process timed out after {timeout_seconds} seconds." 261 | ) 262 | proc.terminate() 263 | proc.wait() 264 | return False 265 | 266 | logger.debug(f"Login STDOUT: {line.strip()}") 267 | 268 | if not auth_url_found: 269 | url_match = url_re.search(line) 270 | if url_match: 271 | auth_url_found = url_match.group(1) 272 | logger.info(f"Detected authentication URL: {auth_url_found}") 273 | 274 | if not auth_code_found: 275 | code_match = code_re.search(line) 276 | if code_match: 277 | auth_code_found = code_match.group(1) 278 | logger.info(f"Detected authentication code: {auth_code_found}") 279 | 280 | if auth_url_found and auth_code_found: 281 | logger.info("Authentication URL and code detected. Displaying to user.") 282 | display_github_auth_link(auth_url_found, auth_code_found) 283 | # The process should continue running in the background until login is complete or it times out/fails. 284 | return True 285 | 286 | if proc.poll() is not None: # Process ended 287 | logger.info( 288 | "Login process ended before URL and code were found or fully processed." 289 | ) 290 | break 291 | 292 | # Loop ended (either EOF or process terminated) 293 | if not (auth_url_found and auth_code_found): 294 | logger.error( 295 | "Failed to detect GitHub authentication URL and code from CLI output before process ended." 296 | ) 297 | if proc and proc.poll() is None: # If somehow still running 298 | proc.terminate() 299 | proc.wait() 300 | return False 301 | 302 | # Should have returned True inside the loop if both found 303 | return False # Fallback 304 | 305 | except FileNotFoundError: # For cli_exe_abs_path 306 | logger.error( 307 | f"VS Code CLI ('{cli_exe_abs_path}') not found by Popen. Ensure it's downloaded and executable." 308 | ) 309 | return False 310 | except Exception as e: 311 | logger.exception( 312 | f"An unexpected error occurred during VS Code Tunnel login: {e}" 313 | ) 314 | if proc and proc.poll() is None: 315 | proc.terminate() 316 | proc.wait() 317 | return False 318 | 319 | 320 | def _configure_environment_for_tunnel( 321 | system: System, 322 | git_user_name: Optional[str], 323 | git_user_email: Optional[str], 324 | setup_python_version: Optional[str], 325 | force_python_reinstall: bool, 326 | attempt_pyenv_dependency_install: bool, 327 | create_new_project: Optional[str], 328 | new_project_base_path: str, 329 | venv_name_for_project: str, 330 | ) -> Tuple[SystemOperationResult[str, Exception], str]: 331 | """ 332 | Handles Git configuration, pyenv setup, and project creation. 333 | Returns a tuple: (SOR for python_executable_for_venv, project_path_for_tunnel_cwd). 334 | The SOR's value for python_executable_for_venv will be the path if pyenv setup was successful, 335 | otherwise it's an error. The project_path_for_tunnel_cwd is the CWD to use for the tunnel. 336 | """ 337 | # Default Python executable for creating venvs if pyenv setup is skipped or fails 338 | default_python_for_venv = "python3" 339 | python_executable_for_venv_res: SystemOperationResult[str, Exception] = ( 340 | SystemOperationResult.Ok(default_python_for_venv) 341 | ) 342 | 343 | # Determine CWD for the tunnel. Default to current dir. 344 | project_path_for_tunnel_cwd = system.get_cwd() 345 | 346 | if git_user_name and git_user_email: 347 | git_config_res = configure_git(system, git_user_name, git_user_email) 348 | if not git_config_res: 349 | logger.warning( 350 | f"Git configuration failed: {git_config_res.message}. Continuing..." 351 | ) 352 | 353 | if setup_python_version: 354 | logger.info( 355 | f"Attempting to set up Python version: {setup_python_version} using pyenv." 356 | ) 357 | pyenv_manager = PythonEnvManager(system=system) 358 | pyenv_python_exe_res = pyenv_manager.setup_and_get_python_executable( 359 | python_version=setup_python_version, 360 | force_reinstall_python=force_python_reinstall, 361 | attempt_pyenv_dependency_install=attempt_pyenv_dependency_install, 362 | ) 363 | 364 | if pyenv_python_exe_res.is_ok and pyenv_python_exe_res.value: 365 | logger.info( 366 | f"Using pyenv Python '{pyenv_python_exe_res.value}' for subsequent venv creation." 367 | ) 368 | python_executable_for_venv_res = SystemOperationResult.Ok( 369 | pyenv_python_exe_res.value 370 | ) 371 | else: 372 | msg = ( 373 | f"Failed to set up pyenv Python {setup_python_version}: {pyenv_python_exe_res.message}. " 374 | f"Will use default '{default_python_for_venv}' for venv creation if applicable." 375 | ) 376 | logger.warning(msg) 377 | python_executable_for_venv_res = SystemOperationResult.Err( 378 | pyenv_python_exe_res.error or Exception("Pyenv setup failed"), 379 | message=pyenv_python_exe_res.message, 380 | ) 381 | # Even if pyenv fails, we still use the default python for project venv creation. 382 | 383 | current_python_for_venv = ( 384 | python_executable_for_venv_res.value 385 | if python_executable_for_venv_res.is_ok 386 | else default_python_for_venv 387 | ) 388 | 389 | if create_new_project: 390 | logger.info( 391 | f"Attempting to create project: '{create_new_project}' at '{new_project_base_path}'." 392 | ) 393 | # setup_project_directory expects an absolute base_path if provided, or uses CWD. 394 | # Here, new_project_base_path is relative to the CWD when `connect` was called. 395 | abs_new_project_base_path = system.get_absolute_path(new_project_base_path) 396 | 397 | project_setup_res = setup_project_directory( 398 | system, 399 | project_name=create_new_project, 400 | base_path=abs_new_project_base_path, # Pass absolute path 401 | python_executable=current_python_for_venv, # Use result from pyenv setup or default 402 | venv_name=venv_name_for_project, 403 | ) 404 | if project_setup_res.is_ok and project_setup_res.value: 405 | logger.info( 406 | f"Successfully created project at '{project_setup_res.value}'. Tunnel CWD set." 407 | ) 408 | project_path_for_tunnel_cwd = project_setup_res.value 409 | else: 410 | msg = ( 411 | f"Failed to create project '{create_new_project}': {project_setup_res.message}. " 412 | f"Tunnel will use CWD: {project_path_for_tunnel_cwd}." 413 | ) 414 | logger.warning(msg) 415 | # Project creation failure is not necessarily fatal for the tunnel itself, it will just run in original CWD. 416 | 417 | return python_executable_for_venv_res, project_path_for_tunnel_cwd 418 | 419 | 420 | def _prepare_vscode_tunnel_command( 421 | cli_executable_path: str, # Absolute path to 'code' executable 422 | tunnel_name: str, 423 | include_default_extensions: bool, 424 | custom_extensions: Optional[List[str]], 425 | ) -> List[str]: 426 | """Prepares the command list for launching the VS Code tunnel.""" 427 | final_extensions: Set[str] = set() 428 | if include_default_extensions: 429 | final_extensions.update(DEFAULT_EXTENSIONS) 430 | if custom_extensions: 431 | final_extensions.update(custom_extensions) 432 | 433 | cmd_list = [ 434 | cli_executable_path, 435 | "tunnel", 436 | "--accept-server-license-terms", # Required for unattended execution 437 | "--name", 438 | tunnel_name, 439 | ] 440 | if final_extensions: 441 | for ext_id in sorted(list(final_extensions)): 442 | cmd_list.extend(["--install-extension", ext_id]) 443 | 444 | return cmd_list 445 | 446 | 447 | def _launch_and_monitor_tunnel( 448 | command_list: List[str], 449 | tunnel_cwd: str, # Absolute path for CWD 450 | tunnel_name: str, # For display purposes 451 | timeout_seconds: int = 60, # Timeout for detecting URL 452 | ) -> Optional[subprocess.Popen]: 453 | """ 454 | Launches the VS Code tunnel command and monitors its output for the connection URL. 455 | Returns the Popen object if URL is detected, None otherwise. 456 | """ 457 | logger.info(f"Starting VS Code tunnel with command: {' '.join(command_list)}") 458 | logger.info(f"Tunnel will run with CWD: {tunnel_cwd}") 459 | 460 | proc: Optional[subprocess.Popen] = None 461 | try: 462 | proc = subprocess.Popen( 463 | command_list, 464 | stdout=subprocess.PIPE, 465 | stderr=subprocess.STDOUT, # Merge stderr to stdout 466 | text=True, 467 | bufsize=1, # Line buffered 468 | universal_newlines=True, 469 | cwd=tunnel_cwd, # Set the CWD for the tunnel process 470 | ) 471 | 472 | if proc.stdout is None: # Should not happen with PIPE 473 | logger.error("Failed to get tunnel process stdout (proc.stdout is None).") 474 | if proc: 475 | proc.terminate() 476 | proc.wait() 477 | return None 478 | 479 | start_time = time.time() 480 | # Regex for vscode.dev tunnel URL 481 | url_re = re.compile(r"(https://vscode\.dev/tunnel/[^\s/]+(?:/[^\s/]+)?)") 482 | 483 | logger.info( 484 | f"Monitoring tunnel '{tunnel_name}' process output for connection URL..." 485 | ) 486 | for line in iter(proc.stdout.readline, ""): 487 | if time.time() - start_time > timeout_seconds: 488 | logger.error( 489 | f"Tunnel URL for '{tunnel_name}' not detected within {timeout_seconds}s. Timing out." 490 | ) 491 | if proc: # Check proc again as it might be None if Popen failed 492 | proc.terminate() 493 | proc.wait() 494 | return None 495 | 496 | logger.debug(f"Tunnel '{tunnel_name}' STDOUT: {line.strip()}") 497 | match = url_re.search(line) 498 | if match: 499 | tunnel_url = match.group(1) 500 | # Ensure it's not the generic access grant URL if we expect a named tunnel URL 501 | if ( 502 | tunnel_name.lower() in tunnel_url.lower() 503 | or "tunnel" not in tunnel_url.lower() 504 | ): 505 | logger.info( 506 | f"VS Code Tunnel URL for '{tunnel_name}' detected: {tunnel_url}" 507 | ) 508 | display_vscode_connection_options(tunnel_url, tunnel_name) 509 | return proc # Return the running process 510 | else: 511 | logger.debug( 512 | f"Detected a vscode.dev URL but it might be for access grant: {tunnel_url}" 513 | ) 514 | 515 | if proc.poll() is not None: # Process ended 516 | logger.error( 517 | f"Tunnel process '{tunnel_name}' exited prematurely (RC: {proc.returncode}) before URL was detected." 518 | ) 519 | # Log remaining output if any 520 | if proc.stdout: # Check if stdout is still available 521 | remaining_output = proc.stdout.read() 522 | if remaining_output: 523 | logger.debug( 524 | f"Remaining output from tunnel '{tunnel_name}':\n{remaining_output.strip()}" 525 | ) 526 | return None 527 | 528 | # Loop ended (EOF on stdout) 529 | logger.error( 530 | f"Tunnel process '{tunnel_name}' stdout stream ended before URL was detected." 531 | ) 532 | if proc and proc.poll() is None: # If somehow still running 533 | proc.terminate() 534 | proc.wait() 535 | return None 536 | 537 | except FileNotFoundError: # For the CLI command itself 538 | logger.error( 539 | f"VS Code CLI ('{command_list[0]}') not found by Popen. Tunnel CWD: {tunnel_cwd}." 540 | ) 541 | return None 542 | except Exception as e: 543 | logger.exception( 544 | f"An unexpected error occurred while starting or monitoring tunnel '{tunnel_name}': {e}" 545 | ) 546 | if proc and proc.poll() is None: 547 | proc.terminate() 548 | proc.wait() 549 | return None 550 | 551 | 552 | def connect( 553 | system: System, 554 | name: str = "colab", # Name of the tunnel 555 | include_default_extensions: bool = True, 556 | extensions: Optional[List[str]] = None, # Custom extensions 557 | git_user_name: Optional[str] = None, 558 | git_user_email: Optional[str] = None, 559 | setup_python_version: Optional[str] = None, # e.g., "3.9" 560 | force_python_reinstall: bool = False, 561 | attempt_pyenv_dependency_install: bool = True, # Attempt to install pyenv OS deps 562 | create_new_project: Optional[str] = None, # Name of project dir to create 563 | new_project_base_path: str = ".", # Base path for new project (relative to initial CWD) 564 | venv_name_for_project: str = ".venv", # Name of venv dir inside project 565 | ) -> Optional[subprocess.Popen]: 566 | """ 567 | Establishes a VS Code tunnel connection with optional environment setup. 568 | """ 569 | # Check for login status 570 | if os.environ.get(VSCODE_COLAB_LOGIN_ENV_VAR) != "true": 571 | logger.error( 572 | "Login required: Please run the login() function before attempting to connect." 573 | ) 574 | return None 575 | 576 | # Ensure VS Code CLI is available. Download/setup happens in CWD of this script. 577 | # This CWD needs to be stable for the CLI to be found later by Popen. 578 | initial_script_cwd = system.get_cwd() 579 | logger.info(f"Initial CWD for connect operation: {initial_script_cwd}") 580 | 581 | cli_download_res = download_vscode_cli(system, force_download=False) 582 | if not cli_download_res.is_ok or not cli_download_res.value: 583 | logger.error( 584 | f"VS Code CLI is not available, cannot start tunnel. Error: {cli_download_res.message}" 585 | ) 586 | return None 587 | 588 | # cli_download_res.value is the absolute path to the 'code' executable 589 | cli_executable_abs_path = cli_download_res.value 590 | 591 | # Step 1: Configure environment (Git, Python via pyenv, Project directory) 592 | # This helper returns the Python executable to use for venv and the CWD for the tunnel. 593 | _py_exec_res, tunnel_run_cwd = _configure_environment_for_tunnel( 594 | system, 595 | git_user_name, 596 | git_user_email, 597 | setup_python_version, 598 | force_python_reinstall, 599 | attempt_pyenv_dependency_install, 600 | create_new_project, 601 | new_project_base_path, # This is relative to initial_script_cwd 602 | venv_name_for_project, 603 | ) 604 | 605 | # Step 2: Prepare the VS Code tunnel command 606 | # The CLI executable path is absolute, found/downloaded relative to initial_script_cwd. 607 | command_list = _prepare_vscode_tunnel_command( 608 | cli_executable_path=cli_executable_abs_path, 609 | tunnel_name=name, 610 | include_default_extensions=include_default_extensions, 611 | custom_extensions=extensions, 612 | ) 613 | 614 | # Step 3: Launch and monitor the tunnel 615 | # The tunnel_run_cwd is where the 'code tunnel' command will execute. 616 | # This is important if the tunnel creates files or expects to be in a project dir. 617 | tunnel_proc = _launch_and_monitor_tunnel( 618 | command_list, 619 | tunnel_cwd=tunnel_run_cwd, # Use the determined CWD 620 | tunnel_name=name, 621 | ) 622 | 623 | if not tunnel_proc: 624 | logger.error(f"Failed to establish VS Code tunnel '{name}'.") 625 | return None 626 | 627 | logger.info(f"VS Code tunnel '{name}' process started successfully.") 628 | return tunnel_proc 629 | -------------------------------------------------------------------------------- /src/vscode_colab/system.py: -------------------------------------------------------------------------------- 1 | import importlib.resources 2 | import os 3 | import shutil 4 | import subprocess 5 | from typing import Any, Dict, List, Optional, Union 6 | 7 | import requests 8 | 9 | from vscode_colab.logger_config import log as logger 10 | from vscode_colab.utils import ( # E is the TypeVar for Exception 11 | E, 12 | SystemOperationResult, 13 | ) 14 | 15 | 16 | class System: 17 | """ 18 | A facade for interacting with the Linux operating system. 19 | This class centralizes OS-level operations to improve testability and 20 | isolate dependencies on modules like `os`, `shutil`, `subprocess`, etc. 21 | It is tailored for Linux environments as expected in Colab/Kaggle. 22 | """ 23 | 24 | def run_command( 25 | self, 26 | command: List[str], 27 | cwd: Optional[str] = None, 28 | env: Optional[Dict[str, str]] = None, 29 | capture_output: bool = True, 30 | text: bool = True, 31 | check: bool = False, # If True, will raise CalledProcessError on non-zero exit 32 | stderr_to_stdout: bool = True, 33 | ) -> subprocess.CompletedProcess: 34 | """ 35 | Executes a system command using `subprocess.run` with configurable options. 36 | This method directly returns subprocess.CompletedProcess. Other System methods might wrap this into a SystemOperationResult. 37 | """ 38 | stdout_pipe = subprocess.PIPE if capture_output else None 39 | stderr_pipe = None 40 | if capture_output: 41 | stderr_pipe = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE 42 | 43 | # Log the command being run for easier debugging 44 | cmd_str_for_log = " ".join(command) 45 | logger.debug( 46 | f"Running command: '{cmd_str_for_log}' with CWD: {cwd or self.get_cwd()}" 47 | ) 48 | 49 | try: 50 | result = subprocess.run( 51 | command, 52 | cwd=cwd, 53 | env=env, 54 | text=text, 55 | check=check, # Let subprocess.run handle raising CalledProcessError if check is True 56 | stdout=stdout_pipe, 57 | stderr=stderr_pipe, 58 | ) 59 | if result.stdout and capture_output: 60 | logger.debug( 61 | f"Command '{cmd_str_for_log}' STDOUT: {result.stdout.strip()}" 62 | ) 63 | if ( 64 | result.stderr and capture_output and not stderr_to_stdout 65 | ): # only log if stderr wasn't redirected 66 | logger.debug( 67 | f"Command '{cmd_str_for_log}' STDERR: {result.stderr.strip()}" 68 | ) 69 | return result 70 | except FileNotFoundError as e_fnf: 71 | logger.error(f"Command not found: {command[0]}. Error: {e_fnf}") 72 | # Re-raise if check=True semantics are desired, or handle as SOR.Err in calling methods 73 | if check: 74 | raise 75 | raise # Propagate FileNotFoundError to be handled by the caller or global exception handler 76 | 77 | def file_exists(self, path: str) -> bool: 78 | """Checks if a file exists at the given path.""" 79 | return os.path.exists(path) and os.path.isfile(path) 80 | 81 | def dir_exists(self, path: str) -> bool: 82 | """Checks if a directory exists at the given path.""" 83 | return os.path.exists(path) and os.path.isdir(path) 84 | 85 | def path_exists(self, path: str) -> bool: 86 | """Checks if a path (file or directory) exists.""" 87 | return os.path.exists(path) 88 | 89 | def make_dirs( 90 | self, 91 | path: str, 92 | exist_ok: bool = True, 93 | ) -> SystemOperationResult[None, OSError]: 94 | """ 95 | Ensures that the specified directory exists, creating it if necessary. 96 | Args: 97 | path (str): The path of the directory to create. 98 | exist_ok (bool, optional): If True, no exception is raised if the 99 | directory already exists. Defaults to True. 100 | Returns: 101 | SystemOperationResult: Ok if directory created/exists, Err otherwise. 102 | """ 103 | try: 104 | os.makedirs(path, exist_ok=exist_ok) 105 | logger.debug(f"Ensured directory exists: {path} (exist_ok={exist_ok})") 106 | return SystemOperationResult.Ok() 107 | except OSError as e: 108 | logger.warning(f"Could not create directory {path}: {e}") 109 | return SystemOperationResult.Err(e) 110 | 111 | def get_absolute_path(self, path: str) -> str: 112 | """Returns the absolute version of a path.""" 113 | return os.path.abspath(path) 114 | 115 | def which(self, command: str) -> Optional[str]: 116 | """Locates an executable, similar to the `which` shell command.""" 117 | return shutil.which(command) 118 | 119 | def remove_file( 120 | self, 121 | path: str, 122 | missing_ok: bool = True, 123 | log_success: bool = True, 124 | ) -> SystemOperationResult[None, Union[OSError, FileNotFoundError]]: 125 | """ 126 | Removes a file at the specified path. 127 | """ 128 | if self.file_exists(path): 129 | try: 130 | os.remove(path) 131 | if log_success: 132 | logger.debug(f"Successfully removed file: {path}") 133 | return SystemOperationResult.Ok() 134 | except OSError as e: 135 | logger.warning(f"Could not remove file {path}: {e}") 136 | return SystemOperationResult.Err(e) 137 | elif missing_ok: 138 | if log_success: 139 | logger.debug( 140 | f"File not found (missing_ok=True), skipping removal: {path}" 141 | ) 142 | return SystemOperationResult.Ok() 143 | else: 144 | err = FileNotFoundError(f"File not found, cannot remove: {path}") 145 | logger.warning(str(err)) 146 | return SystemOperationResult.Err(err) 147 | 148 | def remove_dir( 149 | self, 150 | path: str, 151 | recursive: bool = True, 152 | missing_ok: bool = True, 153 | log_success: bool = True, 154 | ) -> SystemOperationResult[None, Union[OSError, FileNotFoundError]]: 155 | """ 156 | Removes a directory at the specified path. 157 | """ 158 | if self.dir_exists(path): 159 | try: 160 | if recursive: 161 | shutil.rmtree(path) 162 | else: 163 | os.rmdir(path) # Note: os.rmdir only works on empty directories 164 | if log_success: 165 | logger.debug( 166 | f"Successfully removed directory: {path} (recursive={recursive})" 167 | ) 168 | return SystemOperationResult.Ok() 169 | except OSError as e: 170 | logger.warning(f"Could not remove directory {path}: {e}") 171 | return SystemOperationResult.Err(e) 172 | elif missing_ok: 173 | if log_success: 174 | logger.debug( 175 | f"Directory not found (missing_ok=True), skipping removal: {path}" 176 | ) 177 | return SystemOperationResult.Ok() 178 | else: 179 | err = FileNotFoundError(f"Directory not found, cannot remove: {path}") 180 | logger.warning(str(err)) 181 | return SystemOperationResult.Err(err) 182 | 183 | def read_package_asset( 184 | self, 185 | asset_path: str, 186 | encoding: str = "utf-8", 187 | ) -> SystemOperationResult[str, Exception]: 188 | """ 189 | Reads the specified asset file from package resources. 190 | """ 191 | full_asset_path_for_log = f"vscode_colab:assets/{asset_path}" 192 | try: 193 | # For Python 3.9+ 194 | content = ( 195 | importlib.resources.files("vscode_colab") 196 | .joinpath("assets", asset_path) 197 | .read_text(encoding=encoding) 198 | ) 199 | logger.debug(f"Successfully read package asset: {full_asset_path_for_log}") 200 | return SystemOperationResult.Ok(content) 201 | except AttributeError: # Fallback for Python < 3.9 (e.g., 3.7, 3.8) 202 | try: 203 | with importlib.resources.path("vscode_colab.assets", asset_path) as p: 204 | content = p.read_text(encoding=encoding) 205 | logger.debug( 206 | f"Successfully read package asset (legacy path): {full_asset_path_for_log}" 207 | ) 208 | return SystemOperationResult.Ok(content) 209 | except Exception as e_legacy: 210 | logger.warning( 211 | f"Could not read package asset {full_asset_path_for_log} (legacy path): {e_legacy}" 212 | ) 213 | return SystemOperationResult.Err(e_legacy) 214 | except Exception as e_modern: 215 | logger.warning( 216 | f"Could not read package asset {full_asset_path_for_log}: {e_modern}" 217 | ) 218 | return SystemOperationResult.Err(e_modern) 219 | 220 | def write_file( 221 | self, 222 | path: str, 223 | content: Union[str, bytes], 224 | mode: str = "w", # Default to text write 225 | encoding: Optional[str] = "utf-8", 226 | ) -> SystemOperationResult[None, Union[IOError, Exception]]: 227 | """ 228 | Writes content to a file at the specified path. 229 | """ 230 | open_kwargs: Dict[str, Any] = {"mode": mode} 231 | if "b" not in mode and encoding: 232 | open_kwargs["encoding"] = encoding 233 | elif "b" in mode and encoding: 234 | logger.debug( 235 | f"Encoding '{encoding}' provided but opening file in binary mode '{mode}'. Encoding will be ignored." 236 | ) 237 | 238 | try: 239 | with open(path, **open_kwargs) as f: 240 | f.write(content) # type: ignore 241 | logger.debug(f"Successfully wrote to file: {path}") 242 | return SystemOperationResult.Ok() 243 | except IOError as e_io: 244 | logger.warning(f"Could not write to file {path}: {e_io}") 245 | return SystemOperationResult.Err(e_io) 246 | except Exception as e: # Catch other potential errors 247 | logger.warning(f"Unexpected error writing to file {path}: {e}") 248 | return SystemOperationResult.Err(e) 249 | 250 | def read_file( 251 | self, 252 | path: str, 253 | mode: str = "r", # Default to text read 254 | encoding: Optional[str] = "utf-8", 255 | ) -> SystemOperationResult[ 256 | Union[str, bytes], Union[FileNotFoundError, IOError, Exception] 257 | ]: 258 | """ 259 | Reads the content of a file at the specified path. 260 | """ 261 | open_kwargs: Dict[str, Any] = {"mode": mode} 262 | if "b" not in mode and encoding: 263 | open_kwargs["encoding"] = encoding 264 | elif ( 265 | "b" in mode and encoding 266 | ): # For binary mode, encoding should be None for open() 267 | open_kwargs["encoding"] = None 268 | 269 | try: 270 | with open(path, **open_kwargs) as f: # type: ignore 271 | content_read = f.read() 272 | logger.debug(f"Successfully read file: {path}") 273 | return SystemOperationResult.Ok(content_read) 274 | except FileNotFoundError as e_fnf: 275 | logger.warning(f"Cannot read file {path}: File not found.") 276 | return SystemOperationResult.Err(e_fnf) 277 | except IOError as e_io: 278 | logger.warning(f"Could not read file {path}: {e_io}") 279 | return SystemOperationResult.Err(e_io) 280 | except Exception as e: # Catch other potential errors 281 | logger.warning(f"Unexpected error reading file {path}: {e}") 282 | return SystemOperationResult.Err(e) 283 | 284 | def get_cwd(self) -> str: 285 | """Gets the current working directory.""" 286 | return os.getcwd() 287 | 288 | def change_cwd(self, path: str) -> SystemOperationResult[ 289 | None, 290 | Union[ 291 | FileNotFoundError, 292 | NotADirectoryError, 293 | PermissionError, 294 | OSError, 295 | Exception, 296 | ], 297 | ]: 298 | """ 299 | Changes the current working directory to the specified path. 300 | """ 301 | try: 302 | os.chdir(path) 303 | logger.debug(f"Changed current working directory to: {path}") 304 | return SystemOperationResult.Ok() 305 | except FileNotFoundError as e_fnf: 306 | logger.warning(f"Cannot change CWD to {path}: Directory not found.") 307 | return SystemOperationResult.Err(e_fnf) 308 | except NotADirectoryError as e_nad: 309 | logger.warning(f"Cannot change CWD to {path}: Not a directory.") 310 | return SystemOperationResult.Err(e_nad) 311 | except PermissionError as e_perm: 312 | logger.warning(f"Cannot change CWD to {path}: Permission denied.") 313 | return SystemOperationResult.Err(e_perm) 314 | except OSError as e_os: # Other OS-related errors 315 | logger.warning(f"Cannot change CWD to {path}: {e_os}") 316 | return SystemOperationResult.Err(e_os) 317 | except Exception as e: # Catch any other unexpected exceptions 318 | logger.warning(f"Unexpected error changing CWD to {path}: {e}") 319 | return SystemOperationResult.Err(e) 320 | 321 | def download_file( 322 | self, 323 | url: str, 324 | destination_path: str, 325 | timeout: int = 30, 326 | ) -> SystemOperationResult[ 327 | None, 328 | Union[ 329 | requests.exceptions.RequestException, 330 | IOError, 331 | Exception, 332 | ], 333 | ]: 334 | """ 335 | Downloads a file from a given URL and saves it to the specified destination path. 336 | """ 337 | logger.debug(f"Attempting to download file from {url} to {destination_path}") 338 | try: 339 | response = requests.get( 340 | url, stream=True, allow_redirects=True, timeout=timeout 341 | ) 342 | response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) 343 | with open(destination_path, "wb") as f: 344 | for chunk in response.iter_content(chunk_size=8192): 345 | f.write(chunk) 346 | logger.debug( 347 | f"Successfully downloaded file from {url} to {destination_path}" 348 | ) 349 | return SystemOperationResult.Ok() 350 | except ( 351 | requests.exceptions.RequestException 352 | ) as e_req: # Includes HTTPError, ConnectionError, Timeout, etc. 353 | logger.warning(f"Failed to download file from {url}: {e_req}") 354 | return SystemOperationResult.Err(e_req) 355 | except IOError as e_io: 356 | logger.warning( 357 | f"Failed to write downloaded file to {destination_path}: {e_io}" 358 | ) 359 | return SystemOperationResult.Err(e_io) 360 | except Exception as e: # Catch any other unexpected exceptions 361 | logger.warning( 362 | f"An unexpected error occurred during download from {url}: {e}" 363 | ) 364 | return SystemOperationResult.Err(e) 365 | 366 | def expand_user_path(self, path: str) -> str: 367 | """Expands '~' and '~user' path components.""" 368 | return os.path.expanduser(path) 369 | 370 | def get_env_var( 371 | self, 372 | name: str, 373 | default: Optional[str] = None, 374 | ) -> Optional[str]: 375 | """Gets an environment variable.""" 376 | return os.environ.get(name, default) 377 | 378 | def is_executable(self, path: str) -> bool: 379 | """Checks if a path is an executable file.""" 380 | return self.file_exists(path) and os.access(path, os.X_OK) 381 | 382 | def change_permissions( 383 | self, path: str, mode: int = 0o755 384 | ) -> SystemOperationResult[None, OSError]: 385 | """Changes the mode of a file or directory.""" 386 | try: 387 | os.chmod(path, mode) 388 | logger.debug(f"Changed permissions of {path} to {oct(mode)}") 389 | return SystemOperationResult.Ok() 390 | except OSError as e: 391 | logger.warning(f"Could not change permissions of {path}: {e}") 392 | return SystemOperationResult.Err(e) 393 | 394 | def get_permissions( 395 | self, 396 | path: str, 397 | ) -> SystemOperationResult[int, OSError]: 398 | """Gets the permissions of a file or directory as an integer.""" 399 | try: 400 | mode = os.stat(path).st_mode 401 | return SystemOperationResult.Ok(mode) 402 | except OSError as e: 403 | logger.warning(f"Could not get permissions of {path}: {e}") 404 | return SystemOperationResult.Err(e, message=f"Failed to stat {path}") 405 | 406 | def get_user_home_dir(self) -> str: 407 | """Returns the user's home directory.""" 408 | return self.expand_user_path("~") 409 | -------------------------------------------------------------------------------- /src/vscode_colab/templating.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Environment, PackageLoader, select_autoescape 2 | 3 | # Initialize Jinja2 environment 4 | jinja_env = Environment( 5 | loader=PackageLoader("vscode_colab", "assets/templates"), 6 | autoescape=select_autoescape(["html", "xml"]), 7 | ) 8 | 9 | 10 | def render_github_auth_template(url: str, code: str) -> str: 11 | """Renders the GitHub authentication link HTML.""" 12 | template = jinja_env.get_template("github_auth_link.html.j2") 13 | return template.render(url=url, code=code) 14 | 15 | 16 | def render_vscode_connection_template(tunnel_url: str, tunnel_name: str) -> str: 17 | """Renders the VS Code connection options HTML.""" 18 | template = jinja_env.get_template("vscode_connection_options.html.j2") 19 | return template.render(tunnel_url=tunnel_url, tunnel_name=tunnel_name) 20 | -------------------------------------------------------------------------------- /src/vscode_colab/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Optional, TypeVar 2 | 3 | T = TypeVar("T") 4 | E = TypeVar("E", bound=Exception) 5 | 6 | 7 | class SystemOperationResult(Generic[T, E]): 8 | """ 9 | Represents the result of a system operation, which can either be a success (Ok) or a failure (Err). 10 | """ 11 | 12 | def __init__( 13 | self, 14 | is_success: bool, 15 | value: Optional[T] = None, 16 | error: Optional[E] = None, 17 | message: Optional[str] = None, 18 | ): 19 | self._is_success: bool = is_success 20 | self._value: Optional[T] = value 21 | self._error: Optional[E] = error 22 | self._message: Optional[str] = message # Additional context for the error 23 | 24 | if is_success and error is not None: 25 | raise ValueError("Successful result cannot have an error.") 26 | if not is_success and error is None: 27 | raise ValueError("Failed result must have an error.") 28 | if is_success and value is None: 29 | # Allow successful operations to have no specific return value (like a void function) 30 | # but ensure _value is explicitly set to something (even if None) to type hint correctly 31 | pass 32 | 33 | @property 34 | def is_ok(self) -> bool: 35 | return self._is_success 36 | 37 | @property 38 | def is_err(self) -> bool: 39 | return not self._is_success 40 | 41 | @property 42 | def value(self) -> Optional[T]: 43 | if not self._is_success: 44 | return None 45 | return self._value 46 | 47 | @property 48 | def error(self) -> Optional[E]: 49 | return self._error 50 | 51 | @property 52 | def message(self) -> Optional[str]: 53 | """ 54 | Returns an optional descriptive message, typically used for errors. 55 | """ 56 | return self._message 57 | 58 | @staticmethod 59 | def Ok( 60 | value: Optional[T] = None, 61 | ) -> "SystemOperationResult[T, Any]": # Using Any for E in Ok case 62 | # If value is None, it's like a void success. 63 | return SystemOperationResult(is_success=True, value=value, error=None) 64 | 65 | @staticmethod 66 | def Err( 67 | error: E, message: Optional[str] = None 68 | ) -> "SystemOperationResult[Any, E]": # Using Any for T in Err case 69 | return SystemOperationResult( 70 | is_success=False, value=None, error=error, message=message or str(error) 71 | ) 72 | 73 | def __bool__(self) -> bool: 74 | return self._is_success 75 | 76 | def __str__(self) -> str: 77 | if self._is_success: 78 | return f"Ok(value={self._value})" 79 | return f"Err(error={self._error}, message={self._message})" 80 | -------------------------------------------------------------------------------- /tests/environment/test_git_handler.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | from vscode_colab.environment.git_handler import configure_git 7 | from vscode_colab.system import System 8 | from vscode_colab.utils import SystemOperationResult 9 | 10 | 11 | @pytest.fixture 12 | def mock_system_git(): 13 | mock_system = MagicMock(spec=System) 14 | mock_system.which.return_value = "/usr/bin/git" # Assume git is found 15 | return mock_system 16 | 17 | 18 | def test_configure_git_success_both_provided(mock_system_git): 19 | mock_system_git.run_command.side_effect = [ 20 | MagicMock( 21 | returncode=0, stdout="name configured", stderr="" 22 | ), # Name config success 23 | MagicMock( 24 | returncode=0, stdout="email configured", stderr="" 25 | ), # Email config success 26 | ] 27 | 28 | result = configure_git(mock_system_git, "Test User", "test@example.com") 29 | 30 | assert result.is_ok 31 | assert mock_system_git.run_command.call_count == 2 32 | mock_system_git.run_command.assert_any_call( 33 | ["/usr/bin/git", "config", "--global", "user.name", "Test User"], 34 | capture_output=True, 35 | text=True, 36 | check=False, 37 | ) 38 | mock_system_git.run_command.assert_any_call( 39 | ["/usr/bin/git", "config", "--global", "user.email", "test@example.com"], 40 | capture_output=True, 41 | text=True, 42 | check=False, 43 | ) 44 | 45 | 46 | def test_configure_git_skipped_no_params(mock_system_git): 47 | result = configure_git(mock_system_git) 48 | assert result.is_ok # Skipped is considered OK 49 | mock_system_git.run_command.assert_not_called() 50 | 51 | 52 | def test_configure_git_skipped_only_name(mock_system_git): 53 | result = configure_git(mock_system_git, git_user_name="Test User") 54 | assert result.is_ok # Skipped is considered OK 55 | mock_system_git.run_command.assert_not_called() 56 | 57 | 58 | def test_configure_git_skipped_only_email(mock_system_git): 59 | result = configure_git(mock_system_git, git_user_email="test@example.com") 60 | assert result.is_ok # Skipped is considered OK 61 | mock_system_git.run_command.assert_not_called() 62 | 63 | 64 | def test_configure_git_name_config_fails(mock_system_git): 65 | mock_system_git.run_command.return_value = MagicMock( 66 | returncode=1, stdout="", stderr="git name error" 67 | ) 68 | 69 | result = configure_git(mock_system_git, "Test User", "test@example.com") 70 | 71 | assert result.is_err 72 | assert "git name error" in result.message 73 | mock_system_git.run_command.assert_called_once_with( 74 | ["/usr/bin/git", "config", "--global", "user.name", "Test User"], 75 | capture_output=True, 76 | text=True, 77 | check=False, 78 | ) 79 | 80 | 81 | def test_configure_git_email_config_fails(mock_system_git): 82 | mock_system_git.run_command.side_effect = [ 83 | MagicMock(returncode=0, stdout="name configured", stderr=""), # Name success 84 | MagicMock(returncode=1, stdout="", stderr="git email error"), # Email fails 85 | ] 86 | 87 | result = configure_git(mock_system_git, "Test User", "test@example.com") 88 | 89 | assert result.is_err 90 | assert "git email error" in result.message 91 | assert mock_system_git.run_command.call_count == 2 92 | 93 | 94 | def test_configure_git_command_not_found(mock_system_git): 95 | mock_system_git.which.return_value = None # git not found 96 | 97 | result = configure_git(mock_system_git, "Test User", "test@example.com") 98 | 99 | assert result.is_err 100 | assert isinstance(result.error, FileNotFoundError) 101 | assert "'git' command not found" in result.message 102 | mock_system_git.run_command.assert_not_called() 103 | 104 | 105 | def test_configure_git_run_command_raises_exception_for_name(mock_system_git): 106 | mock_system_git.run_command.side_effect = subprocess.SubprocessError( 107 | "Execution failed for name" 108 | ) 109 | 110 | result = configure_git(mock_system_git, "Test User", "test@example.com") 111 | assert result.is_err 112 | assert "Execution failed for name" in result.message 113 | assert isinstance(result.error, Exception) # Wraps the original error 114 | 115 | 116 | def test_configure_git_run_command_raises_exception_for_email(mock_system_git): 117 | mock_system_git.run_command.side_effect = [ 118 | MagicMock(returncode=0, stdout="name configured", stderr=""), 119 | subprocess.SubprocessError("Execution failed for email"), 120 | ] 121 | result = configure_git(mock_system_git, "Test User", "test@example.com") 122 | assert result.is_err 123 | assert "Execution failed for email" in result.message 124 | assert isinstance(result.error, Exception) 125 | -------------------------------------------------------------------------------- /tests/environment/test_project_setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock, call, patch 3 | 4 | import pytest 5 | 6 | from vscode_colab.environment.project_setup import ( 7 | GET_PIP_SCRIPT_NAME, 8 | GET_PIP_URL, 9 | _create_virtual_environment, 10 | _determine_venv_python_executable, 11 | _download_get_pip_script, 12 | _ensure_pip_in_venv, 13 | _initialize_git_repo, 14 | _install_pip_with_script, 15 | setup_project_directory, 16 | ) 17 | from vscode_colab.system import System 18 | from vscode_colab.utils import SystemOperationResult 19 | 20 | 21 | @pytest.fixture 22 | def mock_system_project(): 23 | mock_system = MagicMock(spec=System) 24 | mock_system.get_absolute_path = lambda x: ( 25 | f"/abs/{x}" if not x.startswith("/") else x 26 | ) 27 | mock_system.get_cwd.return_value = "/current/workdir" 28 | mock_system.which.return_value = "/usr/bin/python3" # Default python3 found 29 | return mock_system 30 | 31 | 32 | # Tests for _determine_venv_python_executable 33 | @pytest.mark.parametrize( 34 | "base_exe_name, expected_searches, found_exe", 35 | [ 36 | ("python3.9", ["python3.9", "python3", "python"], "python3.9"), 37 | ("python3", ["python3", "python"], "python3"), 38 | ("mycustompython", ["mycustompython", "python3", "python"], "mycustompython"), 39 | ( 40 | "python3.9.12", 41 | ["python3.9.12", "python3.9", "python3", "python"], 42 | "python3.9", 43 | ), 44 | ("", ["python3", "python"], "python"), # Empty base name 45 | ], 46 | ) 47 | def test_determine_venv_python_executable_found( 48 | mock_system_project, base_exe_name, expected_searches, found_exe 49 | ): 50 | venv_path = "/abs/myproject/.venv" 51 | 52 | # Simulate which executable is found 53 | def is_executable_side_effect(path): 54 | exe_name_from_path = os.path.basename(path) 55 | return exe_name_from_path == found_exe 56 | 57 | mock_system_project.is_executable.side_effect = is_executable_side_effect 58 | 59 | result = _determine_venv_python_executable( 60 | mock_system_project, venv_path, base_exe_name 61 | ) 62 | 63 | assert result == f"/abs/myproject/.venv/bin/{found_exe}" 64 | 65 | # Check that it tried the expected paths in order until found 66 | calls_made = [] 67 | for search_name in expected_searches: 68 | calls_made.append(call(f"/abs/myproject/.venv/bin/{search_name}")) 69 | if search_name == found_exe: 70 | break 71 | mock_system_project.is_executable.assert_has_calls(calls_made) 72 | 73 | 74 | def test_determine_venv_python_executable_not_found(mock_system_project): 75 | mock_system_project.is_executable.return_value = ( 76 | False # None of them are executable 77 | ) 78 | result = _determine_venv_python_executable( 79 | mock_system_project, "/abs/myproject/.venv", "python3.9" 80 | ) 81 | assert result is None 82 | 83 | 84 | # Tests for _download_get_pip_script 85 | def test_download_get_pip_script_success(mock_system_project): 86 | project_path = "/abs/myproject" 87 | expected_script_path = "/abs/myproject/get-pip.py" 88 | mock_system_project.download_file.return_value = SystemOperationResult.Ok() 89 | 90 | result = _download_get_pip_script(project_path, mock_system_project) 91 | 92 | assert result.is_ok 93 | assert result.value == expected_script_path 94 | mock_system_project.download_file.assert_called_once_with( 95 | GET_PIP_URL, expected_script_path 96 | ) 97 | 98 | 99 | def test_download_get_pip_script_failure(mock_system_project): 100 | project_path = "/abs/myproject" 101 | mock_system_project.download_file.return_value = SystemOperationResult.Err( 102 | Exception("Network Error") 103 | ) 104 | result = _download_get_pip_script(project_path, mock_system_project) 105 | assert result.is_err 106 | 107 | 108 | # Tests for _install_pip_with_script 109 | def test_install_pip_with_script_success(mock_system_project): 110 | venv_python = "/abs/myproject/.venv/bin/python" 111 | script_path = "/abs/myproject/get-pip.py" 112 | project_path = "/abs/myproject" 113 | pip_check_cmd = [venv_python, "-m", "pip", "--version"] 114 | 115 | mock_system_project.run_command.side_effect = [ 116 | MagicMock( 117 | returncode=0, stdout="pip installed", stderr="" 118 | ), # get-pip.py success 119 | MagicMock(returncode=0, stdout="pip 20.0", stderr=""), # pip verify success 120 | ] 121 | mock_system_project.remove_file.return_value = SystemOperationResult.Ok() 122 | 123 | result = _install_pip_with_script( 124 | mock_system_project, venv_python, script_path, project_path, pip_check_cmd 125 | ) 126 | 127 | assert result.is_ok 128 | mock_system_project.run_command.assert_any_call( 129 | [venv_python, script_path], 130 | cwd=project_path, 131 | capture_output=True, 132 | text=True, 133 | check=False, 134 | ) 135 | mock_system_project.run_command.assert_any_call( 136 | pip_check_cmd, cwd=project_path, capture_output=True, text=True, check=False 137 | ) 138 | mock_system_project.remove_file.assert_called_once_with( 139 | script_path, missing_ok=True, log_success=False 140 | ) 141 | 142 | 143 | def test_install_pip_with_script_get_pip_fails(mock_system_project): 144 | # ... (similar setup) 145 | mock_system_project.run_command.return_value = MagicMock( 146 | returncode=1, stdout="", stderr="get-pip error" 147 | ) 148 | # ... 149 | result = _install_pip_with_script(mock_system_project, "", "", "", []) 150 | assert result.is_err 151 | assert "Failed to install pip using get-pip.py" in result.message 152 | 153 | 154 | def test_install_pip_with_script_verify_fails(mock_system_project): 155 | mock_system_project.run_command.side_effect = [ 156 | MagicMock(returncode=0), # get-pip.py success 157 | MagicMock(returncode=1, stderr="pip verify error"), # pip verify fails 158 | ] 159 | result = _install_pip_with_script(mock_system_project, "", "", "", []) 160 | assert result.is_err 161 | assert "subsequent verification failed" in result.message 162 | 163 | 164 | # Tests for _ensure_pip_in_venv 165 | def test_ensure_pip_in_venv_already_present(mock_system_project): 166 | venv_python = "/abs/myproject/.venv/bin/python" 167 | project_path = "/abs/myproject" 168 | mock_system_project.run_command.return_value = MagicMock( 169 | returncode=0, stdout="pip 20.0" 170 | ) # pip check success 171 | 172 | result = _ensure_pip_in_venv(mock_system_project, project_path, venv_python) 173 | assert result.is_ok 174 | mock_system_project.download_file.assert_not_called() # Should not download get-pip 175 | 176 | 177 | def test_ensure_pip_in_venv_install_success(mock_system_project): 178 | venv_python = "/abs/myproject/.venv/bin/python" 179 | project_path = "/abs/myproject" 180 | 181 | # Simulate pip check fails, then download and install pip succeed 182 | mock_system_project.run_command.side_effect = [ 183 | MagicMock(returncode=1, stderr="pip not found"), # pip check fails 184 | MagicMock(returncode=0, stdout="pip installed by script"), # get-pip.py success 185 | MagicMock(returncode=0, stdout="pip 20.0 verified"), # pip verify success 186 | ] 187 | mock_system_project.download_file.return_value = SystemOperationResult.Ok( 188 | "/abs/myproject/get-pip.py" 189 | ) 190 | mock_system_project.remove_file.return_value = SystemOperationResult.Ok() 191 | 192 | result = _ensure_pip_in_venv(mock_system_project, project_path, venv_python) 193 | assert result.is_ok 194 | mock_system_project.download_file.assert_called_once() 195 | 196 | 197 | # Tests for _initialize_git_repo 198 | def test_initialize_git_repo_success(mock_system_project): 199 | project_path = "/abs/myproject" 200 | venv_name = ".venv" 201 | mock_system_project.change_cwd.return_value = SystemOperationResult.Ok() 202 | mock_system_project.run_command.return_value = MagicMock( 203 | returncode=0, stdout="git init success" 204 | ) # git init 205 | mock_system_project.read_package_asset.return_value = SystemOperationResult.Ok( 206 | "venv_name: {{ venv_name }}" 207 | ) 208 | mock_system_project.write_file.return_value = SystemOperationResult.Ok() 209 | 210 | result = _initialize_git_repo(mock_system_project, project_path, venv_name) 211 | 212 | assert result.is_ok 213 | mock_system_project.change_cwd.assert_any_call(project_path) # Change to project 214 | mock_system_project.change_cwd.assert_any_call("/current/workdir") # Change back 215 | mock_system_project.run_command.assert_called_once_with( 216 | ["git", "init"], capture_output=True, text=True, check=False 217 | ) 218 | mock_system_project.write_file.assert_called_once_with( 219 | os.path.join(project_path, ".gitignore"), "venv_name: .venv" 220 | ) 221 | 222 | 223 | def test_initialize_git_repo_git_init_fails(mock_system_project): 224 | mock_system_project.change_cwd.return_value = SystemOperationResult.Ok() 225 | mock_system_project.run_command.return_value = MagicMock( 226 | returncode=1, stderr="git init error" 227 | ) 228 | 229 | result = _initialize_git_repo(mock_system_project, "/abs/myproject", ".venv") 230 | assert result.is_err 231 | assert "git init error" in result.message 232 | 233 | 234 | # Tests for _create_virtual_environment 235 | @patch("vscode_colab.environment.project_setup._determine_venv_python_executable") 236 | @patch("vscode_colab.environment.project_setup._ensure_pip_in_venv") 237 | def test_create_virtual_environment_success( 238 | mock_ensure_pip, mock_determine_exe, mock_system_project 239 | ): 240 | project_path = "/abs/myproject" 241 | python_exe = "python3.9" # Base python for venv creation 242 | venv_name = ".myenv" 243 | 244 | mock_system_project.which.return_value = ( 245 | f"/usr/bin/{python_exe}" # Base python exists 246 | ) 247 | mock_system_project.run_command.return_value = MagicMock( 248 | returncode=0, stdout="venv created" 249 | ) # venv creation 250 | mock_determine_exe.return_value = f"{project_path}/{venv_name}/bin/python" 251 | mock_ensure_pip.return_value = SystemOperationResult.Ok() 252 | 253 | result = _create_virtual_environment( 254 | mock_system_project, project_path, python_exe, venv_name 255 | ) 256 | 257 | assert result.is_ok 258 | assert result.value == f"{project_path}/{venv_name}/bin/python" 259 | mock_system_project.run_command.assert_called_once_with( 260 | [f"/usr/bin/{python_exe}", "-m", "venv", venv_name], 261 | capture_output=True, 262 | text=True, 263 | cwd=project_path, 264 | check=False, 265 | ) 266 | mock_determine_exe.assert_called_once() 267 | mock_ensure_pip.assert_called_once() 268 | 269 | 270 | def test_create_virtual_environment_base_python_not_found(mock_system_project): 271 | mock_system_project.which.return_value = None # Base python not found 272 | result = _create_virtual_environment(mock_system_project, "", "nonexistentpy", "") 273 | assert result.is_err 274 | assert "Base Python executable 'nonexistentpy' not found" in result.message 275 | 276 | 277 | # Tests for setup_project_directory (main function) 278 | @patch("vscode_colab.environment.project_setup._initialize_git_repo") 279 | @patch("vscode_colab.environment.project_setup._create_virtual_environment") 280 | def test_setup_project_directory_new_project_success( 281 | mock_create_venv, mock_init_git, mock_system_project 282 | ): 283 | project_name = "new_proj" 284 | base_path = "/tmp" # So abs_base_path = /tmp 285 | abs_project_path = ( 286 | f"{base_path}/{project_name}" # mock_system_project.get_absolute_path behavior 287 | ) 288 | 289 | mock_system_project.path_exists.return_value = False # Project does not exist 290 | mock_system_project.make_dirs.return_value = SystemOperationResult.Ok() 291 | mock_init_git.return_value = SystemOperationResult.Ok() 292 | mock_create_venv.return_value = SystemOperationResult.Ok( 293 | f"{abs_project_path}/.venv/bin/python" 294 | ) 295 | 296 | # Mock get_absolute_path specifically for this test case flow 297 | def get_abs_path_side_effect(p): 298 | if p == base_path: 299 | return base_path # Already absolute 300 | if p == os.path.join(base_path, project_name): 301 | return abs_project_path 302 | return f"/abs/{p}" # Default mock 303 | 304 | mock_system_project.get_absolute_path.side_effect = get_abs_path_side_effect 305 | 306 | result = setup_project_directory( 307 | mock_system_project, project_name, base_path=base_path 308 | ) 309 | 310 | assert result.is_ok 311 | assert result.value == abs_project_path 312 | mock_system_project.make_dirs.assert_called_once_with(abs_project_path) 313 | mock_init_git.assert_called_once_with( 314 | mock_system_project, abs_project_path, ".venv" 315 | ) 316 | mock_create_venv.assert_called_once_with( 317 | mock_system_project, abs_project_path, "python3", ".venv" 318 | ) 319 | 320 | 321 | def test_setup_project_directory_already_exists(mock_system_project): 322 | project_name = "existing_proj" 323 | abs_project_path = f"/tmp/{project_name}" 324 | mock_system_project.get_absolute_path.return_value = abs_project_path 325 | mock_system_project.path_exists.return_value = True # Project exists 326 | 327 | result = setup_project_directory( 328 | mock_system_project, project_name, base_path="/tmp" 329 | ) 330 | 331 | assert result.is_ok 332 | assert result.value == abs_project_path 333 | mock_system_project.make_dirs.assert_not_called() 334 | 335 | 336 | def test_setup_project_directory_creation_fails(mock_system_project): 337 | mock_system_project.path_exists.return_value = False 338 | mock_system_project.make_dirs.return_value = SystemOperationResult.Err( 339 | OSError("Cannot create") 340 | ) 341 | 342 | result = setup_project_directory(mock_system_project, "fail_proj") 343 | assert result.is_err 344 | assert "Failed to create project directory" in result.message 345 | 346 | 347 | @patch("vscode_colab.environment.project_setup._initialize_git_repo") 348 | @patch("vscode_colab.environment.project_setup._create_virtual_environment") 349 | def test_setup_project_directory_venv_fails( 350 | mock_create_venv, mock_init_git, mock_system_project 351 | ): 352 | mock_system_project.path_exists.return_value = False 353 | mock_system_project.make_dirs.return_value = SystemOperationResult.Ok() 354 | mock_init_git.return_value = SystemOperationResult.Ok() 355 | mock_create_venv.return_value = SystemOperationResult.Err(Exception("Venv boom")) 356 | 357 | result = setup_project_directory(mock_system_project, "proj_no_venv") 358 | assert result.is_err # Venv failure is critical 359 | assert "Virtual environment setup failed" in result.message 360 | -------------------------------------------------------------------------------- /tests/environment/test_python_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from unittest import mock 4 | from unittest.mock import MagicMock, call, patch 5 | 6 | import pytest 7 | 8 | from vscode_colab.environment.python_env import ( 9 | INSTALLER_SCRIPT_NAME, 10 | PYENV_BUILD_DEPENDENCIES, 11 | PYENV_INSTALLER_URL, 12 | PythonEnvManager, 13 | ) 14 | from vscode_colab.system import System 15 | from vscode_colab.utils import SystemOperationResult 16 | 17 | 18 | @pytest.fixture 19 | def mock_system_pyenv(): 20 | mock_system = MagicMock(spec=System) 21 | mock_system.expand_user_path.side_effect = lambda x: x.replace("~", "/home/user") 22 | mock_system.get_absolute_path.side_effect = lambda x: ( 23 | x if x.startswith("/") else f"/abs/{x}" 24 | ) 25 | mock_system.is_executable.return_value = False # Default to not executable 26 | mock_system.which.return_value = "/usr/bin/bash" # Default bash found 27 | return mock_system 28 | 29 | 30 | @pytest.fixture 31 | def pyenv_manager(mock_system_pyenv): 32 | return PythonEnvManager(mock_system_pyenv) 33 | 34 | 35 | # Expected paths based on fixtures 36 | PYENV_ROOT = "/home/user/.pyenv" 37 | PYENV_EXECUTABLE = f"{PYENV_ROOT}/bin/pyenv" 38 | INSTALLER_SCRIPT_AT_ROOT = f"{PYENV_ROOT}/{INSTALLER_SCRIPT_NAME}" 39 | 40 | 41 | class TestPythonEnvManagerPropertiesPaths: 42 | def test_paths_initialized_correctly(self, pyenv_manager): 43 | assert pyenv_manager.pyenv_root == PYENV_ROOT 44 | assert pyenv_manager.pyenv_executable_path == PYENV_EXECUTABLE 45 | 46 | def test_get_pyenv_env_vars(self, pyenv_manager): 47 | with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True): 48 | env_vars = pyenv_manager._get_pyenv_env_vars() 49 | 50 | assert env_vars["PYENV_ROOT"] == PYENV_ROOT 51 | expected_path = f"{PYENV_ROOT}/bin:{PYENV_ROOT}/shims:/usr/bin" 52 | assert env_vars["PATH"] == expected_path 53 | 54 | def test_is_pyenv_installed_true(self, pyenv_manager, mock_system_pyenv): 55 | mock_system_pyenv.is_executable.return_value = True 56 | assert pyenv_manager.is_pyenv_installed is True 57 | mock_system_pyenv.is_executable.assert_called_with(PYENV_EXECUTABLE) 58 | 59 | def test_is_pyenv_installed_false(self, pyenv_manager, mock_system_pyenv): 60 | mock_system_pyenv.is_executable.return_value = False 61 | assert pyenv_manager.is_pyenv_installed is False 62 | 63 | 64 | class TestPyenvDependencyInstallation: 65 | def test_install_pyenv_dependencies_success(self, pyenv_manager, mock_system_pyenv): 66 | mock_system_pyenv.which.side_effect = ( 67 | lambda cmd: f"/usr/bin/{cmd}" 68 | ) # sudo, apt found 69 | mock_system_pyenv.run_command.side_effect = [ 70 | MagicMock(returncode=0, stdout="update done"), # apt update 71 | MagicMock(returncode=0, stdout="install done"), # apt install 72 | ] 73 | result = pyenv_manager.install_pyenv_dependencies() 74 | assert result.is_ok 75 | expected_install_cmd_start = [ 76 | "/usr/bin/sudo", 77 | "/usr/bin/apt", 78 | "install", 79 | "-y", 80 | ] 81 | 82 | update_call = call( 83 | ["/usr/bin/sudo", "/usr/bin/apt", "update", "-y"], 84 | capture_output=True, 85 | text=True, 86 | check=False, 87 | ) 88 | 89 | # Check that the install command contains all dependencies 90 | install_cmd_call = mock_system_pyenv.run_command.call_args_list[1] 91 | actual_install_cmd_list = install_cmd_call[0][ 92 | 0 93 | ] # First arg of the call tuple is the cmd list 94 | 95 | assert ( 96 | actual_install_cmd_list[: len(expected_install_cmd_start)] 97 | == expected_install_cmd_start 98 | ) 99 | assert ( 100 | set(actual_install_cmd_list[len(expected_install_cmd_start) :]) 101 | == PYENV_BUILD_DEPENDENCIES 102 | ) 103 | assert mock_system_pyenv.run_command.call_args_list[0] == update_call 104 | 105 | def test_install_pyenv_dependencies_sudo_not_found( 106 | self, pyenv_manager, mock_system_pyenv 107 | ): 108 | mock_system_pyenv.which.side_effect = lambda cmd: ( 109 | None if cmd == "sudo" else f"/usr/bin/{cmd}" 110 | ) 111 | result = pyenv_manager.install_pyenv_dependencies() 112 | assert result.is_err 113 | assert "sudo command not found" in result.message 114 | 115 | def test_install_pyenv_dependencies_apt_update_fails( 116 | self, pyenv_manager, mock_system_pyenv 117 | ): 118 | mock_system_pyenv.which.side_effect = lambda cmd: f"/usr/bin/{cmd}" 119 | mock_system_pyenv.run_command.return_value = MagicMock( 120 | returncode=1, stderr="update error" 121 | ) 122 | result = pyenv_manager.install_pyenv_dependencies() 123 | assert result.is_err 124 | assert "apt-get update failed" in result.message # Changed to apt-get 125 | 126 | def test_install_pyenv_dependencies_apt_install_fails( 127 | self, pyenv_manager, mock_system_pyenv 128 | ): 129 | mock_system_pyenv.which.side_effect = lambda cmd: f"/usr/bin/{cmd}" 130 | mock_system_pyenv.run_command.side_effect = [ 131 | MagicMock(returncode=0), # update success 132 | MagicMock(returncode=1, stderr="install error"), # install fails 133 | ] 134 | result = pyenv_manager.install_pyenv_dependencies() 135 | assert result.is_err 136 | assert "apt install pyenv dependencies failed" in result.message 137 | 138 | 139 | class TestPyenvInstallation: 140 | def test_install_pyenv_success(self, pyenv_manager, mock_system_pyenv): 141 | # Mock sequence: make_dirs -> download_file -> run_command (installer) -> is_executable (final check) 142 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 143 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Ok() 144 | mock_system_pyenv.run_command.return_value = MagicMock( 145 | returncode=0, stdout="pyenv installed" 146 | ) 147 | # For the final check mock_system_pyenv.is_executable should return true 148 | mock_system_pyenv.is_executable.return_value = True # After successful install 149 | 150 | # Mock dependency install to succeed without actually running its commands for this unit test 151 | with patch.object( 152 | pyenv_manager, 153 | "install_pyenv_dependencies", 154 | return_value=SystemOperationResult.Ok(), 155 | ) as mock_install_deps: 156 | result = pyenv_manager.install_pyenv(attempt_to_install_deps=True) 157 | 158 | assert result.is_ok 159 | assert result.value == PYENV_EXECUTABLE 160 | mock_install_deps.assert_called_once() 161 | # Accept any path for installer script (tempfile) 162 | mock_system_pyenv.download_file.assert_called_once() 163 | mock_system_pyenv.run_command.assert_called_once() # For installer 164 | # Accept any script path for run_command 165 | assert mock_system_pyenv.run_command.call_args[0][0][0] == "/usr/bin/bash" 166 | # Remove file should be called with a .sh file 167 | remove_file_path = mock_system_pyenv.remove_file.call_args[0][0] 168 | assert remove_file_path.endswith(".sh") 169 | # Also check kwargs 170 | kwargs = mock_system_pyenv.remove_file.call_args[1] 171 | assert kwargs.get("missing_ok") is True 172 | assert kwargs.get("log_success") is False 173 | 174 | def test_install_pyenv_deps_fail_continues_and_succeeds( 175 | self, pyenv_manager, mock_system_pyenv 176 | ): 177 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 178 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Ok() 179 | mock_system_pyenv.run_command.return_value = MagicMock(returncode=0) 180 | mock_system_pyenv.is_executable.return_value = True 181 | 182 | with patch.object( 183 | pyenv_manager, 184 | "install_pyenv_dependencies", 185 | return_value=SystemOperationResult.Err(Exception("deps fail")), 186 | ) as mock_install_deps: 187 | result = pyenv_manager.install_pyenv(attempt_to_install_deps=True) 188 | 189 | assert result.is_ok # pyenv install itself succeeded 190 | mock_install_deps.assert_called_once() 191 | 192 | def test_install_pyenv_download_fails(self, pyenv_manager, mock_system_pyenv): 193 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 194 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Err( 195 | Exception("Network Error") 196 | ) 197 | 198 | with patch.object( 199 | pyenv_manager, 200 | "install_pyenv_dependencies", 201 | return_value=SystemOperationResult.Ok(), 202 | ): 203 | result = pyenv_manager.install_pyenv() 204 | 205 | assert result.is_err 206 | assert ( 207 | "Failed to download pyenv installer" in result.message 208 | ) # Made message more specific 209 | # Accept any path for installer script (tempfile) 210 | mock_system_pyenv.remove_file.assert_called() 211 | remove_file_path = mock_system_pyenv.remove_file.call_args[0][0] 212 | assert remove_file_path.endswith(".sh") 213 | kwargs = mock_system_pyenv.remove_file.call_args[1] 214 | assert kwargs.get("missing_ok") is True 215 | assert kwargs.get("log_success") is False 216 | 217 | def test_install_pyenv_installer_script_fails( 218 | self, pyenv_manager, mock_system_pyenv 219 | ): 220 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 221 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Ok() 222 | mock_system_pyenv.run_command.return_value = MagicMock( 223 | returncode=1, stderr="Installer error" 224 | ) 225 | 226 | with patch.object( 227 | pyenv_manager, 228 | "install_pyenv_dependencies", 229 | return_value=SystemOperationResult.Ok(), 230 | ): 231 | result = pyenv_manager.install_pyenv() 232 | 233 | assert result.is_err 234 | assert "Pyenv installer script failed" in result.message 235 | 236 | def test_install_pyenv_not_executable_after_install( 237 | self, pyenv_manager, mock_system_pyenv 238 | ): 239 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 240 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Ok() 241 | mock_system_pyenv.run_command.return_value = MagicMock(returncode=0) 242 | mock_system_pyenv.is_executable.return_value = ( 243 | False # This is the key for this test 244 | ) 245 | 246 | with patch.object( 247 | pyenv_manager, 248 | "install_pyenv_dependencies", 249 | return_value=SystemOperationResult.Ok(), 250 | ): 251 | result = pyenv_manager.install_pyenv() 252 | 253 | assert result.is_err 254 | assert "executable not found at" in str(result.error) 255 | 256 | 257 | class TestPythonVersionManagement: 258 | PYTHON_VERSION = "3.9.5" 259 | 260 | @pytest.fixture(autouse=True) 261 | def _setup_pyenv_installed(self, pyenv_manager, mock_system_pyenv): 262 | # For these tests, assume pyenv itself is installed and executable 263 | mock_system_pyenv.is_executable.return_value = ( 264 | True # For pyenv_manager.is_pyenv_installed 265 | ) 266 | 267 | def test_is_python_version_installed_true(self, pyenv_manager, mock_system_pyenv): 268 | mock_system_pyenv.run_command.return_value = MagicMock( 269 | returncode=0, stdout=f"3.8.0\n{self.PYTHON_VERSION}\n3.10.0" 270 | ) 271 | result = pyenv_manager.is_python_version_installed(self.PYTHON_VERSION) 272 | assert result.is_ok 273 | assert result.value is True 274 | mock_system_pyenv.run_command.assert_called_once_with( 275 | [PYENV_EXECUTABLE, "versions", "--bare"], 276 | env=mock.ANY, 277 | capture_output=True, 278 | text=True, 279 | check=False, 280 | ) 281 | 282 | def test_is_python_version_installed_false(self, pyenv_manager, mock_system_pyenv): 283 | mock_system_pyenv.run_command.return_value = MagicMock( 284 | returncode=0, stdout="3.8.0\n3.10.0" 285 | ) 286 | result = pyenv_manager.is_python_version_installed(self.PYTHON_VERSION) 287 | assert result.is_ok 288 | assert result.value is False 289 | 290 | def test_is_python_version_installed_pyenv_command_fails( 291 | self, pyenv_manager, mock_system_pyenv 292 | ): 293 | mock_system_pyenv.run_command.return_value = MagicMock( 294 | returncode=1, stderr="pyenv versions error" 295 | ) 296 | result = pyenv_manager.is_python_version_installed(self.PYTHON_VERSION) 297 | assert result.is_err 298 | assert ( 299 | "Could not list pyenv versions" in result.message 300 | ) # Made message more specific 301 | 302 | def test_install_python_version_success(self, pyenv_manager, mock_system_pyenv): 303 | mock_system_pyenv.run_command.return_value = MagicMock( 304 | returncode=0, stdout="python installed" 305 | ) 306 | result = pyenv_manager.install_python_version(self.PYTHON_VERSION) 307 | assert result.is_ok 308 | mock_system_pyenv.run_command.assert_called_once_with( 309 | [PYENV_EXECUTABLE, "install", self.PYTHON_VERSION], 310 | env=mock.ANY, 311 | capture_output=True, 312 | text=True, 313 | check=False, 314 | ) 315 | # Check that PYTHON_CONFIGURE_OPTS was in env 316 | env_arg = mock_system_pyenv.run_command.call_args[1]["env"] 317 | assert "PYTHON_CONFIGURE_OPTS" in env_arg 318 | assert env_arg["PYTHON_CONFIGURE_OPTS"] == "--enable-shared" 319 | 320 | def test_install_python_version_fails(self, pyenv_manager, mock_system_pyenv): 321 | mock_system_pyenv.run_command.return_value = MagicMock( 322 | returncode=1, stderr="install error" 323 | ) 324 | result = pyenv_manager.install_python_version(self.PYTHON_VERSION) 325 | assert result.is_err 326 | assert ( 327 | f"Failed to install Python {self.PYTHON_VERSION} using pyenv" 328 | in result.message 329 | ) # Made message more specific 330 | 331 | def test_set_global_python_version_success(self, pyenv_manager, mock_system_pyenv): 332 | mock_system_pyenv.run_command.return_value = MagicMock(returncode=0) 333 | result = pyenv_manager.set_global_python_version(self.PYTHON_VERSION) 334 | assert result.is_ok 335 | mock_system_pyenv.run_command.assert_called_once_with( 336 | [PYENV_EXECUTABLE, "global", self.PYTHON_VERSION], 337 | env=mock.ANY, 338 | capture_output=True, 339 | text=True, 340 | check=False, 341 | ) 342 | 343 | def test_get_python_executable_path_via_which_success( 344 | self, pyenv_manager, mock_system_pyenv 345 | ): 346 | expected_path_via_which = ( 347 | f"{PYENV_ROOT}/versions/{self.PYTHON_VERSION}/bin/python-via-which" 348 | ) 349 | # Mock 'pyenv which python' 350 | mock_system_pyenv.run_command.return_value = MagicMock( 351 | returncode=0, stdout=expected_path_via_which 352 | ) 353 | # Mock is_executable for the path returned by 'which' and for pyenv itself 354 | mock_system_pyenv.is_executable.side_effect = ( 355 | lambda p: p == expected_path_via_which or p == PYENV_EXECUTABLE 356 | ) 357 | # Mock realpath 358 | mock_system_pyenv.get_absolute_path.side_effect = lambda p: ( 359 | os.path.realpath(p) if "realpath" in str(p) else p 360 | ) # for os.path.realpath 361 | 362 | # Patch os.path.realpath as it's called directly 363 | with patch("os.path.realpath", return_value=expected_path_via_which): 364 | result = pyenv_manager.get_python_executable_path(self.PYTHON_VERSION) 365 | 366 | assert result.is_ok 367 | assert result.value == expected_path_via_which 368 | # is_executable called for PYENV_EXECUTABLE (by is_pyenv_installed) and expected_path_via_which 369 | assert mock_system_pyenv.is_executable.call_count >= 1 370 | 371 | def test_get_python_executable_path_direct_fallback_success( 372 | self, pyenv_manager, mock_system_pyenv 373 | ): 374 | direct_path = f"{PYENV_ROOT}/versions/{self.PYTHON_VERSION}/bin/python" 375 | # 'pyenv which python' fails or returns non-executable 376 | mock_system_pyenv.run_command.return_value = MagicMock( 377 | returncode=1, stderr="which error" 378 | ) 379 | # is_executable for direct_path is True, others False (except pyenv bin itself) 380 | mock_system_pyenv.is_executable.side_effect = ( 381 | lambda p: p == PYENV_EXECUTABLE or p == direct_path 382 | ) 383 | 384 | result = pyenv_manager.get_python_executable_path(self.PYTHON_VERSION) 385 | assert result.is_ok 386 | assert result.value == direct_path 387 | 388 | def test_get_python_executable_path_not_found( 389 | self, pyenv_manager, mock_system_pyenv 390 | ): 391 | # 'pyenv which python' fails 392 | mock_system_pyenv.run_command.return_value = MagicMock( 393 | returncode=1, stderr="which error" 394 | ) 395 | # Direct path is also not executable (is_executable default is False, only True for PYENV_EXECUTABLE) 396 | mock_system_pyenv.is_executable.side_effect = lambda p: p == PYENV_EXECUTABLE 397 | 398 | result = pyenv_manager.get_python_executable_path(self.PYTHON_VERSION) 399 | assert result.is_err 400 | assert "could not be reliably located" in result.message 401 | 402 | 403 | class TestSetupAndGetPythonExecutable: 404 | PYTHON_VERSION = "3.10.1" 405 | 406 | def test_setup_full_success_new_pyenv_new_python( 407 | self, pyenv_manager, mock_system_pyenv 408 | ): 409 | # Pyenv not installed initially 410 | # is_executable calls: pyenv_manager.is_pyenv_installed (False), pyenv after install (True), 411 | # python after install (True), python via which (True) 412 | pyenv_exe_path_after_install = PYENV_EXECUTABLE 413 | python_exe_path_after_install = ( 414 | f"{PYENV_ROOT}/versions/{self.PYTHON_VERSION}/bin/python" 415 | ) 416 | 417 | is_executable_call_count = 0 418 | 419 | def is_executable_side_effect(path_checked): 420 | nonlocal is_executable_call_count 421 | is_executable_call_count += 1 422 | if is_executable_call_count == 1: # First is_pyenv_installed check 423 | return False 424 | if ( 425 | path_checked == pyenv_exe_path_after_install 426 | ): # pyenv after its install, or subsequent is_pyenv_installed 427 | return True 428 | if ( 429 | path_checked == python_exe_path_after_install 430 | ): # python version direct path check or from 'which' 431 | return True 432 | return False 433 | 434 | mock_system_pyenv.is_executable.side_effect = is_executable_side_effect 435 | 436 | # Mock calls for install_pyenv() 437 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Ok() 438 | mock_system_pyenv.download_file.return_value = SystemOperationResult.Ok() 439 | # run_command calls: pyenv_installer, pyenv_versions, pyenv_install, pyenv_global, pyenv_which 440 | mock_system_pyenv.run_command.side_effect = [ 441 | MagicMock(returncode=0, stdout="pyenv installer ok"), # pyenv installer 442 | MagicMock(returncode=0, stdout=""), # pyenv versions (empty) 443 | MagicMock( 444 | returncode=0, stdout="python install ok" 445 | ), # pyenv install 446 | MagicMock(returncode=0, stdout="global set ok"), # pyenv global 447 | MagicMock( 448 | returncode=0, stdout=python_exe_path_after_install 449 | ), # pyenv which python 450 | ] 451 | 452 | # Mock dependency install to succeed 453 | with patch.object( 454 | pyenv_manager, 455 | "install_pyenv_dependencies", 456 | return_value=SystemOperationResult.Ok(), 457 | ) as mock_deps: 458 | result = pyenv_manager.setup_and_get_python_executable( 459 | self.PYTHON_VERSION, attempt_pyenv_dependency_install=True 460 | ) 461 | 462 | assert result.is_ok 463 | assert result.value == python_exe_path_after_install 464 | mock_deps.assert_called_once() 465 | assert mock_system_pyenv.run_command.call_count == 5 466 | 467 | def test_setup_pyenv_install_fails(self, pyenv_manager, mock_system_pyenv): 468 | mock_system_pyenv.is_executable.return_value = False # pyenv not installed 469 | mock_system_pyenv.make_dirs.return_value = SystemOperationResult.Err( 470 | OSError("cannot make dir") 471 | ) # pyenv install fails at mkdir 472 | 473 | with patch.object( 474 | pyenv_manager, 475 | "install_pyenv_dependencies", 476 | return_value=SystemOperationResult.Ok(), 477 | ): 478 | result = pyenv_manager.setup_and_get_python_executable(self.PYTHON_VERSION) 479 | 480 | assert result.is_err 481 | assert "Pyenv installation failed" in result.message 482 | 483 | def test_setup_python_already_installed_skip_install( 484 | self, pyenv_manager, mock_system_pyenv 485 | ): 486 | python_exe_path = f"{PYENV_ROOT}/versions/{self.PYTHON_VERSION}/bin/python" 487 | # Pyenv is installed, Python version is installed 488 | mock_system_pyenv.is_executable.side_effect = ( 489 | lambda p: p == PYENV_EXECUTABLE or p == python_exe_path 490 | ) 491 | mock_system_pyenv.run_command.side_effect = [ 492 | MagicMock( 493 | returncode=0, stdout=self.PYTHON_VERSION 494 | ), # pyenv versions (version present) 495 | MagicMock(returncode=0, stdout="global set ok"), # pyenv global 496 | MagicMock(returncode=0, stdout=python_exe_path), # pyenv which 497 | ] 498 | 499 | result = pyenv_manager.setup_and_get_python_executable( 500 | self.PYTHON_VERSION, force_reinstall_python=False 501 | ) 502 | assert result.is_ok 503 | assert result.value == python_exe_path 504 | # Check that 'pyenv install ' was NOT in the calls 505 | pyenv_install_cmd_part = [PYENV_EXECUTABLE, "install", self.PYTHON_VERSION] 506 | for call_obj in mock_system_pyenv.run_command.call_args_list: 507 | assert ( 508 | call_obj[0][0][:3] != pyenv_install_cmd_part 509 | ) # Check first 3 elements for safety 510 | assert mock_system_pyenv.run_command.call_count == 3 # versions, global, which 511 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | # Functions and classes to test from __init__.py 7 | from vscode_colab import ( 8 | DEFAULT_EXTENSIONS, 9 | System, 10 | SystemOperationResult, 11 | connect, 12 | login, 13 | ) 14 | 15 | # Also from vscode_colab.server import server_login, server_connect (if needed for patching) 16 | 17 | 18 | @pytest.fixture 19 | def mock_default_system_instance(): 20 | # This fixture will patch the _default_system_instance in vscode_colab.__init__ 21 | with patch("vscode_colab._default_system_instance", spec=System) as mock_system: 22 | yield mock_system 23 | 24 | 25 | class TestInitLogin: 26 | @patch("vscode_colab.server_login") # Patch the aliased server_login 27 | def test_login_uses_default_system_instance( 28 | self, mock_server_login_impl, mock_default_system_instance 29 | ): 30 | mock_server_login_impl.return_value = True # Simulate successful login 31 | 32 | result = login(provider="test_provider") 33 | 34 | assert result is True 35 | # Check that server_login was called with the _default_system_instance 36 | mock_server_login_impl.assert_called_once_with( 37 | system=mock_default_system_instance, provider="test_provider" 38 | ) 39 | 40 | @patch("vscode_colab.server_login") 41 | def test_login_uses_provided_system_instance(self, mock_server_login_impl): 42 | custom_mock_system = MagicMock(spec=System) 43 | mock_server_login_impl.return_value = False # Simulate failed login 44 | 45 | result = login(provider="github", system=custom_mock_system) 46 | 47 | assert result is False 48 | # Check that server_login was called with the custom_mock_system 49 | mock_server_login_impl.assert_called_once_with( 50 | system=custom_mock_system, provider="github" 51 | ) 52 | 53 | 54 | class TestInitConnect: 55 | @patch("vscode_colab.server_connect") # Patch the aliased server_connect 56 | def test_connect_uses_default_system_instance_with_defaults( 57 | self, mock_server_connect_impl, mock_default_system_instance 58 | ): 59 | mock_proc = MagicMock(spec=subprocess.Popen) 60 | mock_server_connect_impl.return_value = mock_proc 61 | 62 | result = connect() # Call with all defaults 63 | 64 | assert result == mock_proc 65 | mock_server_connect_impl.assert_called_once_with( 66 | system=mock_default_system_instance, 67 | name="colab", 68 | include_default_extensions=True, 69 | extensions=None, 70 | git_user_name=None, 71 | git_user_email=None, 72 | setup_python_version=None, 73 | force_python_reinstall=False, 74 | attempt_pyenv_dependency_install=True, # Default from __init__ 75 | create_new_project=None, 76 | new_project_base_path=".", 77 | venv_name_for_project=".venv", 78 | ) 79 | 80 | @patch("vscode_colab.server_connect") 81 | def test_connect_uses_provided_system_and_custom_args( 82 | self, mock_server_connect_impl 83 | ): 84 | custom_mock_system = MagicMock(spec=System) 85 | mock_server_connect_impl.return_value = None # Simulate connection failure 86 | 87 | result = connect( 88 | name="custom_tunnel", 89 | include_default_extensions=False, 90 | extensions=["ext1"], 91 | git_user_name="Git User", 92 | git_user_email="git@email.com", 93 | setup_python_version="3.10", 94 | force_python_reinstall=True, 95 | attempt_pyenv_dependency_install=False, 96 | create_new_project="MyProj", 97 | new_project_base_path="/tmp/projects", 98 | venv_name_for_project=".custom_env", 99 | system=custom_mock_system, 100 | ) 101 | 102 | assert result is None 103 | mock_server_connect_impl.assert_called_once_with( 104 | system=custom_mock_system, 105 | name="custom_tunnel", 106 | include_default_extensions=False, 107 | extensions=["ext1"], 108 | git_user_name="Git User", 109 | git_user_email="git@email.com", 110 | setup_python_version="3.10", 111 | force_python_reinstall=True, 112 | attempt_pyenv_dependency_install=False, 113 | create_new_project="MyProj", 114 | new_project_base_path="/tmp/projects", 115 | venv_name_for_project=".custom_env", 116 | ) 117 | 118 | 119 | class TestInitExports: 120 | def test_default_extensions_is_frozenset(self): 121 | assert isinstance(DEFAULT_EXTENSIONS, frozenset) 122 | # Check for a known default extension 123 | assert "ms-python.python" in DEFAULT_EXTENSIONS 124 | 125 | def test_system_class_is_exported(self): 126 | # Simple check that it's the class we expect 127 | assert System.__name__ == "System" 128 | # Further check: an instance of System from init should be an instance of the class type 129 | s = System() 130 | assert isinstance(s, System) 131 | 132 | def test_system_operation_result_is_exported(self): 133 | assert SystemOperationResult.__name__ == "SystemOperationResult" 134 | ok_res = SystemOperationResult.Ok("test") 135 | assert ok_res.is_ok 136 | err_res = SystemOperationResult.Err(ValueError("test")) 137 | assert err_res.is_err 138 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import subprocess 4 | import time 5 | from unittest import mock 6 | from unittest.mock import ANY, MagicMock, call, patch 7 | 8 | import pytest 9 | from IPython.display import HTML 10 | 11 | from vscode_colab.environment import PythonEnvManager # For type hinting if needed 12 | from vscode_colab.server import ( 13 | DEFAULT_EXTENSIONS, 14 | VSCODE_COLAB_LOGIN_ENV_VAR, 15 | _configure_environment_for_tunnel, 16 | _launch_and_monitor_tunnel, 17 | _prepare_vscode_tunnel_command, 18 | connect, 19 | display_github_auth_link, 20 | display_vscode_connection_options, 21 | download_vscode_cli, 22 | login, 23 | ) 24 | from vscode_colab.system import System 25 | from vscode_colab.utils import SystemOperationResult 26 | 27 | 28 | # --- Fixtures --- 29 | @pytest.fixture 30 | def mock_system_server(): 31 | mock_sys = MagicMock(spec=System) 32 | mock_sys.get_absolute_path.side_effect = lambda x: ( 33 | f"/abs/{x}" if not x.startswith("/") else x 34 | ) 35 | mock_sys.is_executable.return_value = False # Default: CLI not executable 36 | mock_sys.file_exists.return_value = False # Default: File does not exist 37 | mock_sys.path_exists.return_value = False # Default: Path does not exist 38 | mock_sys.which.return_value = "/usr/bin/tar" # Default tar found 39 | mock_sys.get_cwd.return_value = "/current/test/dir" 40 | return mock_sys 41 | 42 | 43 | @pytest.fixture 44 | def mock_display_server(monkeypatch): 45 | mock_disp = MagicMock() 46 | monkeypatch.setattr("vscode_colab.server.display", mock_disp) 47 | # Also mock HTML if it's directly instantiated and then passed to display 48 | mock_html_constructor = MagicMock(return_value=MagicMock(spec=HTML)) 49 | monkeypatch.setattr("vscode_colab.server.HTML", mock_html_constructor) 50 | return {"display": mock_disp, "HTML": mock_html_constructor} 51 | 52 | 53 | # --- Tests for download_vscode_cli --- 54 | CLI_DIR_NAME = "code" 55 | CLI_EXE_NAME_IN_DIR = "code" 56 | MOCK_CWD = "/current/test/dir" 57 | ABS_CLI_DIR_PATH = f"{MOCK_CWD}/{CLI_DIR_NAME}" 58 | ABS_CLI_EXE_PATH = f"{MOCK_CWD}/{CLI_EXE_NAME_IN_DIR}" 59 | 60 | 61 | def test_download_vscode_cli_already_exists_executable(mock_system_server): 62 | mock_system_server.is_executable.return_value = ( 63 | True # CLI is already there and executable 64 | ) 65 | 66 | result = download_vscode_cli(mock_system_server) 67 | 68 | assert result.is_ok 69 | assert result.value == ABS_CLI_EXE_PATH 70 | mock_system_server.download_file.assert_not_called() 71 | 72 | 73 | def test_download_vscode_cli_success_full_download_and_setup(mock_system_server): 74 | # Simulate: not executable initially, download succeeds, tar extracts, chmod succeeds 75 | mock_system_server.is_executable.side_effect = [ 76 | False, # Initial check (download_vscode_cli L50) 77 | False, # Before chmod (download_vscode_cli L140) 78 | True, # After chmod (download_vscode_cli L165) 79 | ] 80 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 81 | mock_system_server.run_command.return_value = MagicMock( 82 | returncode=0, stdout="tar extracted" 83 | ) # tar command 84 | # After tar, the CLI executable file should exist 85 | mock_system_server.file_exists.return_value = True # for abs_cli_executable_path 86 | # Mock permissions part 87 | mock_system_server.get_permissions.return_value = SystemOperationResult.Ok(0o644) 88 | mock_system_server.change_permissions.return_value = SystemOperationResult.Ok() 89 | 90 | result = download_vscode_cli(mock_system_server, force_download=False) 91 | 92 | assert result.is_ok 93 | assert result.value == ABS_CLI_EXE_PATH 94 | mock_system_server.download_file.assert_called_once() 95 | mock_system_server.run_command.assert_called_once() # tar command 96 | mock_system_server.change_permissions.assert_called_once_with( 97 | ABS_CLI_EXE_PATH, 0o644 | 0o111 98 | ) 99 | 100 | 101 | def test_download_vscode_cli_force_download_removes_existing(mock_system_server): 102 | mock_system_server.path_exists.return_value = True # CLI dir path exists 103 | mock_system_server.remove_dir.return_value = SystemOperationResult.Ok() 104 | # Continue with successful download and setup as above 105 | mock_system_server.is_executable.side_effect = [False, True] 106 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 107 | mock_system_server.run_command.return_value = MagicMock(returncode=0) 108 | mock_system_server.file_exists.return_value = True 109 | mock_system_server.get_permissions.return_value = SystemOperationResult.Ok(0o644) 110 | mock_system_server.change_permissions.return_value = SystemOperationResult.Ok() 111 | 112 | result = download_vscode_cli(mock_system_server, force_download=True) 113 | 114 | assert result.is_ok 115 | mock_system_server.remove_dir.assert_called_once_with( 116 | ABS_CLI_DIR_PATH, recursive=True 117 | ) 118 | mock_system_server.download_file.assert_called_once() 119 | 120 | 121 | def test_download_vscode_cli_download_fails(mock_system_server): 122 | mock_system_server.download_file.return_value = SystemOperationResult.Err( 123 | Exception("Network Error") 124 | ) 125 | result = download_vscode_cli(mock_system_server) 126 | assert result.is_err 127 | assert "Network Error" in str(result.error) 128 | 129 | 130 | def test_download_vscode_cli_tar_not_found(mock_system_server): 131 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 132 | mock_system_server.which.return_value = None # tar not found 133 | result = download_vscode_cli(mock_system_server) 134 | assert result.is_err 135 | assert "'tar' command not found" in result.message 136 | 137 | 138 | def test_download_vscode_cli_tar_extraction_fails(mock_system_server): 139 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 140 | # tar is found (default mock_system_server.which) 141 | mock_system_server.run_command.return_value = MagicMock( 142 | returncode=1, stderr="tar error" 143 | ) # tar fails 144 | result = download_vscode_cli(mock_system_server) 145 | assert result.is_err 146 | assert "Failed to extract VS Code CLI" in result.message 147 | assert "tar error" in result.message 148 | 149 | 150 | def test_download_vscode_cli_executable_not_found_after_extraction(mock_system_server): 151 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 152 | mock_system_server.run_command.return_value = MagicMock(returncode=0) # tar success 153 | mock_system_server.file_exists.return_value = ( 154 | False # But the executable is not there 155 | ) 156 | 157 | result = download_vscode_cli(mock_system_server) 158 | assert result.is_err 159 | assert ( 160 | f"VS Code CLI executable '{ABS_CLI_EXE_PATH}' not found after extraction" 161 | in result.message 162 | ) 163 | 164 | 165 | def test_download_vscode_cli_chmod_fails_but_still_not_executable(mock_system_server): 166 | mock_system_server.download_file.return_value = SystemOperationResult.Ok() 167 | mock_system_server.run_command.return_value = MagicMock(returncode=0) # tar success 168 | mock_system_server.file_exists.return_value = True # Exe exists 169 | # is_executable: False (initial), False (before chmod), False (after chmod fails) 170 | mock_system_server.is_executable.side_effect = [False, False, False] 171 | mock_system_server.get_permissions.return_value = SystemOperationResult.Ok(0o644) 172 | mock_system_server.change_permissions.return_value = SystemOperationResult.Err( 173 | OSError("chmod fail") 174 | ) 175 | 176 | result = download_vscode_cli(mock_system_server) 177 | assert result.is_err # Should fail if still not executable after trying 178 | assert "still not executable after attempting chmod" in result.message 179 | 180 | 181 | # --- Tests for login --- 182 | @patch("vscode_colab.server.download_vscode_cli") 183 | @patch("subprocess.Popen", autospec=True) 184 | def test_login_success( 185 | mock_popen, mock_download_cli, mock_system_server, mock_display_server 186 | ): 187 | mock_download_cli.return_value = SystemOperationResult.Ok(ABS_CLI_EXE_PATH) 188 | 189 | mock_proc = mock_popen.return_value 190 | # Explicitly create and spec stdout, as autospec might not handle it for Popen 191 | # The Popen call in login() uses text=True (universal_newlines=True), 192 | # so stdout will be a text stream. 193 | mock_proc.stdout = MagicMock(spec=io.TextIOWrapper) 194 | 195 | # Simulate process output with URL and code 196 | lines = [ 197 | "Some output before\n", 198 | "To sign in, use a web browser to open the page https://github.com/login/device and enter the code ABCD-1234.\n", 199 | "Some output after\n", 200 | "", # EOF 201 | ] 202 | mock_proc.stdout.readline.side_effect = lines 203 | # Poll: None (running) until after URL/code found, then maybe 0 or we don't care as func returns 204 | mock_proc.poll.return_value = None 205 | 206 | result = login(mock_system_server) 207 | 208 | assert result is True 209 | mock_download_cli.assert_called_once_with(system=mock_system_server) 210 | mock_popen.assert_called_once_with( 211 | [ABS_CLI_EXE_PATH, "tunnel", "user", "login", "--provider", "github"], 212 | stdout=subprocess.PIPE, 213 | stderr=subprocess.STDOUT, 214 | text=True, 215 | bufsize=1, 216 | universal_newlines=True, 217 | cwd=mock_system_server.get_cwd(), 218 | ) 219 | mock_display_server["HTML"].assert_called_once() 220 | 221 | # Check if URL and code were correctly embedded in the HTML content 222 | # mock_display_server["HTML"] is the mock for the HTML constructor. 223 | # It's called as HTML(rendered_html_string) 224 | html_constructor_call_args = mock_display_server["HTML"].call_args 225 | 226 | # The first positional argument to the HTML constructor is the rendered HTML string 227 | assert html_constructor_call_args is not None, "HTML constructor was not called" 228 | rendered_html_string = html_constructor_call_args.args[0] 229 | 230 | assert "https://github.com/login/device" in rendered_html_string 231 | assert "ABCD-1234" in rendered_html_string 232 | 233 | mock_display_server["display"].assert_called_once() 234 | 235 | 236 | @patch("vscode_colab.server.download_vscode_cli") 237 | def test_login_cli_download_fails(mock_download_cli, mock_system_server): 238 | mock_download_cli.return_value = SystemOperationResult.Err( 239 | Exception("CLI download failed") 240 | ) 241 | result = login(mock_system_server) 242 | assert result is False 243 | 244 | 245 | @patch("vscode_colab.server.download_vscode_cli") 246 | @patch("subprocess.Popen") 247 | def test_login_timeout(mock_popen, mock_download_cli, mock_system_server): 248 | mock_download_cli.return_value = SystemOperationResult.Ok(ABS_CLI_EXE_PATH) 249 | mock_proc = mock_popen.return_value 250 | mock_proc.stdout.readline.return_value = ( 251 | "no url or code here\n" # Keeps outputting non-matching lines 252 | ) 253 | mock_proc.poll.return_value = None # Always running 254 | mock_popen.return_value = mock_proc 255 | 256 | # Patch time.time to simulate timeout 257 | original_time = time.time 258 | time_calls = [ 259 | original_time(), 260 | original_time() + 70, 261 | ] # Start, then time after timeout 262 | 263 | def time_side_effect(): 264 | if time_calls: 265 | return time_calls.pop(0) 266 | return original_time() + 100 # Keep returning time well past timeout 267 | 268 | with patch("time.time", side_effect=time_side_effect): 269 | result = login(mock_system_server) 270 | 271 | assert result is False 272 | mock_proc.terminate.assert_called_once() 273 | 274 | 275 | @patch("vscode_colab.server.download_vscode_cli") 276 | @patch("subprocess.Popen") 277 | def test_login_process_ends_before_auth_info( 278 | mock_popen, mock_download_cli, mock_system_server 279 | ): 280 | mock_download_cli.return_value = SystemOperationResult.Ok(ABS_CLI_EXE_PATH) 281 | mock_proc = mock_popen.return_value 282 | mock_proc.stdout.readline.side_effect = ["output\n", ""] # EOF 283 | mock_proc.poll.return_value = 0 # Process ended 284 | mock_popen.return_value = mock_proc 285 | 286 | result = login(mock_system_server) 287 | assert result is False 288 | 289 | 290 | # --- Tests for connect and its helpers --- 291 | 292 | 293 | # _configure_environment_for_tunnel is complex, test its main branches 294 | @patch("vscode_colab.server.configure_git") 295 | @patch("vscode_colab.server.PythonEnvManager") 296 | @patch("vscode_colab.server.setup_project_directory") 297 | def test_configure_environment_defaults( 298 | mock_setup_proj, mock_pyenv_mgr_constructor, mock_conf_git, mock_system_server 299 | ): 300 | # No git, no pyenv, no project creation 301 | 302 | pyenv_res, tunnel_cwd = _configure_environment_for_tunnel( 303 | mock_system_server, None, None, None, False, False, None, ".", ".venv" 304 | ) 305 | 306 | assert pyenv_res.is_ok # Default python3 307 | assert pyenv_res.value == "python3" 308 | assert tunnel_cwd == mock_system_server.get_cwd() # Should be initial CWD 309 | mock_conf_git.assert_not_called() 310 | mock_pyenv_mgr_constructor.assert_not_called() 311 | mock_setup_proj.assert_not_called() 312 | 313 | 314 | @patch("vscode_colab.server.configure_git") 315 | @patch("vscode_colab.server.PythonEnvManager") 316 | @patch("vscode_colab.server.setup_project_directory") 317 | def test_configure_environment_with_git_pyenv_project_success( 318 | mock_setup_proj, mock_pyenv_mgr_constructor, mock_conf_git, mock_system_server 319 | ): 320 | mock_conf_git.return_value = SystemOperationResult.Ok() 321 | 322 | mock_pyenv_mgr_instance = MagicMock() 323 | mock_pyenv_mgr_instance.setup_and_get_python_executable.return_value = ( 324 | SystemOperationResult.Ok("/pyenv/python") 325 | ) 326 | mock_pyenv_mgr_constructor.return_value = mock_pyenv_mgr_instance 327 | 328 | mock_setup_proj.return_value = SystemOperationResult.Ok("/abs/my_new_project_dir") 329 | 330 | pyenv_res, tunnel_cwd = _configure_environment_for_tunnel( 331 | mock_system_server, 332 | "Test User", 333 | "test@example.com", 334 | "3.9", 335 | False, 336 | True, 337 | "my_new_project", 338 | "/base", 339 | ".special_venv", 340 | ) 341 | 342 | assert pyenv_res.is_ok 343 | assert pyenv_res.value == "/pyenv/python" 344 | assert tunnel_cwd == "/abs/my_new_project_dir" 345 | 346 | mock_conf_git.assert_called_once_with( 347 | mock_system_server, "Test User", "test@example.com" 348 | ) 349 | mock_pyenv_mgr_constructor.assert_called_once_with(system=mock_system_server) 350 | mock_pyenv_mgr_instance.setup_and_get_python_executable.assert_called_once_with( 351 | python_version="3.9", 352 | force_reinstall_python=False, 353 | attempt_pyenv_dependency_install=True, 354 | ) 355 | mock_setup_proj.assert_called_once_with( 356 | mock_system_server, 357 | project_name="my_new_project", 358 | base_path="/base", # Corrected: mock_system_server.get_absolute_path("/base") returns "/base" 359 | python_executable="/pyenv/python", 360 | venv_name=".special_venv", 361 | ) 362 | 363 | 364 | def test_prepare_vscode_tunnel_command_defaults(): 365 | cmd_list = _prepare_vscode_tunnel_command( 366 | ABS_CLI_EXE_PATH, "colab-tunnel", True, None 367 | ) 368 | assert cmd_list[:5] == [ 369 | ABS_CLI_EXE_PATH, 370 | "tunnel", 371 | "--accept-server-license-terms", 372 | "--name", 373 | "colab-tunnel", 374 | ] 375 | assert "--install-extension" in cmd_list 376 | # Check if a few default extensions are there 377 | assert "ms-python.python" in cmd_list 378 | assert "ms-toolsai.jupyter" in cmd_list 379 | 380 | 381 | def test_prepare_vscode_tunnel_command_custom_extensions_no_defaults(): 382 | custom_ext = ["ext1.foo", "ext2.bar"] 383 | cmd_list = _prepare_vscode_tunnel_command( 384 | ABS_CLI_EXE_PATH, "custom-tunnel", False, custom_ext 385 | ) 386 | assert cmd_list[:5] == [ 387 | ABS_CLI_EXE_PATH, 388 | "tunnel", 389 | "--accept-server-license-terms", 390 | "--name", 391 | "custom-tunnel", 392 | ] 393 | assert "--install-extension" in cmd_list 394 | assert "ext1.foo" in cmd_list 395 | assert "ext2.bar" in cmd_list 396 | assert "ms-python.python" not in cmd_list # Default not included 397 | 398 | 399 | @patch("subprocess.Popen") 400 | def test_launch_and_monitor_tunnel_success( 401 | mock_popen, mock_system_server, mock_display_server 402 | ): 403 | mock_proc = mock_popen.return_value 404 | lines = [ 405 | "Tunnel logs...\n", 406 | "Ready to connect to VS Code Tunnel: https://vscode.dev/tunnel/colab-tunnel/folder\n", 407 | "", 408 | ] 409 | mock_proc.stdout.readline.side_effect = lines 410 | mock_proc.poll.return_value = None # Running 411 | mock_popen.return_value = mock_proc 412 | 413 | cmd_list = [ABS_CLI_EXE_PATH, "tunnel", "--name", "colab-tunnel"] 414 | tunnel_cwd = "/project/dir" 415 | 416 | result_proc = _launch_and_monitor_tunnel(cmd_list, tunnel_cwd, "colab-tunnel") 417 | 418 | assert result_proc == mock_proc 419 | mock_popen.assert_called_once_with( 420 | cmd_list, 421 | stdout=subprocess.PIPE, 422 | stderr=subprocess.STDOUT, 423 | text=True, 424 | bufsize=1, 425 | universal_newlines=True, 426 | cwd=tunnel_cwd, 427 | ) 428 | mock_display_server["HTML"].assert_called_once() 429 | html_args = mock_display_server["HTML"].call_args[0][ 430 | 0 431 | ] # First arg is the rendered HTML string 432 | assert "https://vscode.dev/tunnel/colab-tunnel/folder" in html_args 433 | assert "colab-tunnel" in html_args 434 | 435 | 436 | @patch("subprocess.Popen") 437 | def test_launch_and_monitor_tunnel_timeout(mock_popen, mock_system_server): 438 | mock_proc = mock_popen.return_value 439 | mock_proc.stdout.readline.return_value = "Still waiting...\n" 440 | mock_proc.poll.return_value = None 441 | mock_popen.return_value = mock_proc 442 | 443 | original_time = time.time 444 | time_calls = [original_time(), original_time() + 70] # Timeout is 60s 445 | 446 | with patch( 447 | "time.time", 448 | side_effect=lambda: time_calls.pop(0) if time_calls else original_time() + 100, 449 | ): 450 | result_proc = _launch_and_monitor_tunnel([], "/cwd", "test-tunnel") 451 | 452 | assert result_proc is None 453 | mock_proc.terminate.assert_called_once() 454 | 455 | 456 | # Integration test for the main `connect` function, mocking its direct dependencies 457 | @patch("vscode_colab.server.download_vscode_cli") 458 | @patch("vscode_colab.server._configure_environment_for_tunnel") 459 | @patch("vscode_colab.server._prepare_vscode_tunnel_command") 460 | @patch("vscode_colab.server._launch_and_monitor_tunnel") 461 | def test_connect_main_flow_success( 462 | mock_launch_monitor, 463 | mock_prepare_cmd, 464 | mock_config_env, 465 | mock_download_cli, 466 | mock_system_server, 467 | ): 468 | mock_download_cli.return_value = SystemOperationResult.Ok(ABS_CLI_EXE_PATH) 469 | # _configure_environment_for_tunnel returns (SOR_python_exe, tunnel_CWD) 470 | mock_config_env.return_value = ( 471 | SystemOperationResult.Ok("python3"), 472 | "/configured/cwd", 473 | ) 474 | 475 | expected_cmd_list = [ABS_CLI_EXE_PATH, "tunnel", "--name", "my-test-tunnel"] 476 | mock_prepare_cmd.return_value = expected_cmd_list 477 | 478 | mock_tunnel_proc = MagicMock(spec=subprocess.Popen) 479 | mock_launch_monitor.return_value = mock_tunnel_proc 480 | 481 | # Patch environment variable for login 482 | os.environ[VSCODE_COLAB_LOGIN_ENV_VAR] = "true" 483 | result = connect(mock_system_server, name="my-test-tunnel") 484 | 485 | assert result == mock_tunnel_proc 486 | mock_download_cli.assert_called_once_with(mock_system_server, force_download=False) 487 | mock_config_env.assert_called_once_with( 488 | mock_system_server, 489 | None, 490 | None, 491 | None, 492 | False, 493 | True, 494 | None, 495 | ".", 496 | ".venv", # Defaults 497 | ) 498 | mock_prepare_cmd.assert_called_once_with( 499 | cli_executable_path=ABS_CLI_EXE_PATH, 500 | tunnel_name="my-test-tunnel", 501 | include_default_extensions=True, 502 | custom_extensions=None, # Defaults 503 | ) 504 | mock_launch_monitor.assert_called_once_with( 505 | expected_cmd_list, # Removed mock_system_server 506 | tunnel_cwd="/configured/cwd", 507 | tunnel_name="my-test-tunnel", 508 | ) 509 | 510 | 511 | @patch("vscode_colab.server.download_vscode_cli") 512 | def test_connect_cli_download_fails_overall_connect_fails( 513 | mock_download_cli, mock_system_server 514 | ): 515 | mock_download_cli.return_value = SystemOperationResult.Err( 516 | Exception("Download Boom") 517 | ) 518 | result = connect(mock_system_server) 519 | assert result is None 520 | -------------------------------------------------------------------------------- /tests/test_system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from unittest import mock 5 | from unittest.mock import MagicMock, mock_open, patch 6 | 7 | import pytest 8 | import requests 9 | 10 | from vscode_colab.system import System 11 | 12 | 13 | @pytest.fixture 14 | def mock_system_dependencies(monkeypatch): 15 | """Mocks external dependencies for the System class.""" 16 | mock_os = MagicMock(spec=os) 17 | mock_shutil = MagicMock(spec=shutil) 18 | mock_subprocess = MagicMock(spec=subprocess) 19 | mock_requests = MagicMock(spec=requests) 20 | # FIX: Ensure real requests.exceptions are used for try-except blocks in system.py 21 | mock_requests.exceptions = requests.exceptions 22 | mock_importlib_resources = MagicMock() 23 | 24 | monkeypatch.setattr("vscode_colab.system.os", mock_os) 25 | monkeypatch.setattr("vscode_colab.system.shutil", mock_shutil) 26 | monkeypatch.setattr("vscode_colab.system.subprocess", mock_subprocess) 27 | monkeypatch.setattr("vscode_colab.system.requests", mock_requests) 28 | monkeypatch.setattr( 29 | "vscode_colab.system.importlib.resources", mock_importlib_resources 30 | ) 31 | 32 | # Specific os attributes that might be called directly 33 | mock_os.path = MagicMock(spec=os.path) 34 | mock_os.path.exists.return_value = False 35 | mock_os.path.isfile.return_value = False 36 | mock_os.path.isdir.return_value = False 37 | # FIX: mock_os.path.abspath should be a MagicMock for assertion capabilities 38 | mock_os.path.abspath = MagicMock(side_effect=lambda x: "/abs/" + x) 39 | 40 | # Mock logger used within System class 41 | mock_logger_system = MagicMock() 42 | monkeypatch.setattr("vscode_colab.system.logger", mock_logger_system) 43 | 44 | mock_subprocess.PIPE = subprocess.PIPE 45 | mock_subprocess.STDOUT = subprocess.STDOUT 46 | mock_os.X_OK = os.X_OK 47 | # FIX: Ensure os.getcwd() returns None for the logger assertion in test_run_command_success 48 | # when the run_command's cwd parameter is None. 49 | mock_os.getcwd.return_value = None 50 | 51 | return { 52 | "os": mock_os, 53 | "shutil": mock_shutil, 54 | "subprocess": mock_subprocess, 55 | "requests": mock_requests, 56 | "importlib_resources": mock_importlib_resources, 57 | "logger": mock_logger_system, 58 | } 59 | 60 | 61 | @pytest.fixture 62 | def system_instance(): 63 | """Returns an instance of the System class.""" 64 | return System() 65 | 66 | 67 | class TestSystemRunCommand: 68 | 69 | def test_run_command_success(self, system_instance, mock_system_dependencies): 70 | mock_proc = MagicMock(spec=subprocess.CompletedProcess) 71 | mock_proc.returncode = 0 72 | mock_proc.stdout = "output" 73 | mock_proc.stderr = "" 74 | mock_system_dependencies["subprocess"].run.return_value = mock_proc 75 | 76 | cmd = ["echo", "hello"] 77 | result = system_instance.run_command(cmd) 78 | 79 | mock_system_dependencies["subprocess"].run.assert_called_once_with( 80 | cmd, 81 | cwd=None, 82 | env=None, 83 | text=True, 84 | check=False, 85 | stdout=subprocess.PIPE, 86 | stderr=subprocess.STDOUT, # Default stderr_to_stdout is True 87 | ) 88 | assert result == mock_proc 89 | mock_system_dependencies["logger"].debug.assert_any_call( 90 | "Running command: 'echo hello' with CWD: None" 91 | ) 92 | 93 | def test_run_command_failure_no_check( 94 | self, system_instance, mock_system_dependencies 95 | ): 96 | mock_proc = MagicMock(spec=subprocess.CompletedProcess) 97 | mock_proc.returncode = 1 98 | mock_proc.stdout = "output" 99 | mock_proc.stderr = ( 100 | "error" # This won't be in mock_proc.stderr if stderr_to_stdout=True 101 | ) 102 | mock_system_dependencies["subprocess"].run.return_value = mock_proc 103 | 104 | cmd = ["false_command"] 105 | result = system_instance.run_command( 106 | cmd, stderr_to_stdout=False 107 | ) # Test with separate stderr 108 | 109 | mock_system_dependencies["subprocess"].run.assert_called_once_with( 110 | cmd, 111 | cwd=None, 112 | env=None, 113 | text=True, 114 | check=False, 115 | stdout=subprocess.PIPE, 116 | stderr=subprocess.PIPE, # Because stderr_to_stdout=False 117 | ) 118 | assert result.returncode == 1 119 | mock_system_dependencies["logger"].debug.assert_any_call( 120 | "Command 'false_command' STDERR: error" 121 | ) 122 | 123 | def test_run_command_with_check_raises( 124 | self, system_instance, mock_system_dependencies 125 | ): 126 | mock_system_dependencies["subprocess"].run.side_effect = ( 127 | subprocess.CalledProcessError(1, "cmd", "output", "error") 128 | ) 129 | cmd = ["failing_cmd"] 130 | with pytest.raises(subprocess.CalledProcessError): 131 | system_instance.run_command(cmd, check=True) 132 | 133 | def test_run_command_file_not_found_raises( 134 | self, system_instance, mock_system_dependencies 135 | ): 136 | mock_system_dependencies["subprocess"].run.side_effect = FileNotFoundError( 137 | "No such file" 138 | ) 139 | cmd = ["non_existent_cmd"] 140 | with pytest.raises(FileNotFoundError): 141 | system_instance.run_command(cmd) 142 | mock_system_dependencies["logger"].error.assert_called_once_with( 143 | "Command not found: non_existent_cmd. Error: No such file" 144 | ) 145 | 146 | 147 | class TestSystemFileDirOperations: 148 | 149 | def test_file_exists_true(self, system_instance, mock_system_dependencies): 150 | mock_system_dependencies["os"].path.exists.return_value = True 151 | mock_system_dependencies["os"].path.isfile.return_value = True 152 | assert system_instance.file_exists("file.txt") is True 153 | mock_system_dependencies["os"].path.exists.assert_called_with("file.txt") 154 | mock_system_dependencies["os"].path.isfile.assert_called_with("file.txt") 155 | 156 | def test_file_exists_false_not_a_file( 157 | self, system_instance, mock_system_dependencies 158 | ): 159 | mock_system_dependencies["os"].path.exists.return_value = True 160 | mock_system_dependencies["os"].path.isfile.return_value = False 161 | assert system_instance.file_exists("dir/") is False 162 | 163 | def test_dir_exists_true(self, system_instance, mock_system_dependencies): 164 | mock_system_dependencies["os"].path.exists.return_value = True 165 | mock_system_dependencies["os"].path.isdir.return_value = True 166 | assert system_instance.dir_exists("folder") is True 167 | 168 | def test_make_dirs_success(self, system_instance, mock_system_dependencies): 169 | result = system_instance.make_dirs("/new/dir") 170 | mock_system_dependencies["os"].makedirs.assert_called_once_with( 171 | "/new/dir", exist_ok=True 172 | ) 173 | assert result.is_ok 174 | assert result.error is None 175 | 176 | def test_make_dirs_failure(self, system_instance, mock_system_dependencies): 177 | mock_system_dependencies["os"].makedirs.side_effect = OSError( 178 | "Permission denied" 179 | ) 180 | result = system_instance.make_dirs("/restricted/dir") 181 | assert result.is_err 182 | assert isinstance(result.error, OSError) 183 | assert "Permission denied" in str(result.message) 184 | mock_system_dependencies["logger"].warning.assert_called_once_with( 185 | "Could not create directory /restricted/dir: Permission denied" 186 | ) 187 | 188 | def test_remove_file_success(self, system_instance, mock_system_dependencies): 189 | mock_system_dependencies["os"].path.exists.return_value = ( 190 | True # for file_exists 191 | ) 192 | mock_system_dependencies["os"].path.isfile.return_value = ( 193 | True # for file_exists 194 | ) 195 | result = system_instance.remove_file("file.txt") 196 | mock_system_dependencies["os"].remove.assert_called_once_with("file.txt") 197 | assert result.is_ok 198 | 199 | def test_remove_file_missing_ok_true( 200 | self, system_instance, mock_system_dependencies 201 | ): 202 | mock_system_dependencies["os"].path.exists.return_value = ( 203 | False # for file_exists 204 | ) 205 | result = system_instance.remove_file("missing.txt", missing_ok=True) 206 | assert result.is_ok 207 | mock_system_dependencies["os"].remove.assert_not_called() 208 | 209 | def test_remove_file_missing_ok_false( 210 | self, system_instance, mock_system_dependencies 211 | ): 212 | mock_system_dependencies["os"].path.exists.return_value = ( 213 | False # for file_exists 214 | ) 215 | result = system_instance.remove_file("missing.txt", missing_ok=False) 216 | assert result.is_err 217 | assert isinstance(result.error, FileNotFoundError) 218 | mock_system_dependencies["logger"].warning.assert_called_once_with( 219 | "File not found, cannot remove: missing.txt" 220 | ) 221 | 222 | def test_remove_dir_recursive_success( 223 | self, system_instance, mock_system_dependencies 224 | ): 225 | mock_system_dependencies["os"].path.exists.return_value = True # for dir_exists 226 | mock_system_dependencies["os"].path.isdir.return_value = True # for dir_exists 227 | result = system_instance.remove_dir("/my/dir") 228 | mock_system_dependencies["shutil"].rmtree.assert_called_once_with("/my/dir") 229 | assert result.is_ok 230 | 231 | def test_remove_dir_non_recursive_success( 232 | self, system_instance, mock_system_dependencies 233 | ): 234 | mock_system_dependencies["os"].path.exists.return_value = True 235 | mock_system_dependencies["os"].path.isdir.return_value = True 236 | result = system_instance.remove_dir("/empty/dir", recursive=False) 237 | mock_system_dependencies["os"].rmdir.assert_called_once_with("/empty/dir") 238 | assert result.is_ok 239 | 240 | 241 | class TestSystemPathUtils: 242 | def test_get_absolute_path(self, system_instance, mock_system_dependencies): 243 | # The mock for os.path.abspath is simple: lambda x: "/abs/" + x 244 | assert ( 245 | system_instance.get_absolute_path("relative/path") == "/abs/relative/path" 246 | ) 247 | mock_system_dependencies["os"].path.abspath.assert_called_with("relative/path") 248 | 249 | def test_which_found(self, system_instance, mock_system_dependencies): 250 | mock_system_dependencies["shutil"].which.return_value = "/usr/bin/git" 251 | assert system_instance.which("git") == "/usr/bin/git" 252 | 253 | def test_which_not_found(self, system_instance, mock_system_dependencies): 254 | mock_system_dependencies["shutil"].which.return_value = None 255 | assert system_instance.which("nonexistentcmd") is None 256 | 257 | def test_expand_user_path(self, system_instance, mock_system_dependencies): 258 | mock_system_dependencies["os"].path.expanduser.return_value = ( 259 | "/home/user/somepath" 260 | ) 261 | assert system_instance.expand_user_path("~/somepath") == "/home/user/somepath" 262 | mock_system_dependencies["os"].path.expanduser.assert_called_with("~/somepath") 263 | 264 | def test_get_cwd(self, system_instance, mock_system_dependencies): 265 | mock_system_dependencies["os"].getcwd.return_value = "/current/working/dir" 266 | assert system_instance.get_cwd() == "/current/working/dir" 267 | 268 | def test_change_cwd_success(self, system_instance, mock_system_dependencies): 269 | result = system_instance.change_cwd("/new/path") 270 | mock_system_dependencies["os"].chdir.assert_called_once_with("/new/path") 271 | assert result.is_ok 272 | 273 | def test_change_cwd_failure_filenotfound( 274 | self, system_instance, mock_system_dependencies 275 | ): 276 | mock_system_dependencies["os"].chdir.side_effect = FileNotFoundError( 277 | "No such directory" 278 | ) 279 | result = system_instance.change_cwd("/invalid/path") 280 | assert result.is_err 281 | assert isinstance(result.error, FileNotFoundError) 282 | 283 | 284 | class TestSystemReadWriteAssets: 285 | 286 | def test_read_package_asset_success_py39plus( 287 | self, system_instance, mock_system_dependencies 288 | ): 289 | mock_files_obj = MagicMock() 290 | mock_path_obj = MagicMock() 291 | mock_path_obj.read_text.return_value = "asset content" 292 | mock_files_obj.joinpath.return_value = mock_path_obj 293 | mock_system_dependencies["importlib_resources"].files.return_value = ( 294 | mock_files_obj 295 | ) 296 | # Ensure AttributeError is NOT raised to simulate Python 3.9+ 297 | # This is tricky as importlib.resources.files exists or not. 298 | # We can control by NOT setting side_effect=AttributeError for .files 299 | 300 | result = system_instance.read_package_asset("my_asset.txt") 301 | 302 | assert result.is_ok 303 | assert result.value == "asset content" 304 | mock_system_dependencies["importlib_resources"].files.assert_called_with( 305 | "vscode_colab" 306 | ) 307 | mock_files_obj.joinpath.assert_called_with("assets", "my_asset.txt") 308 | mock_path_obj.read_text.assert_called_with(encoding="utf-8") 309 | 310 | def test_read_package_asset_success_legacy( 311 | self, system_instance, mock_system_dependencies 312 | ): 313 | # Simulate Python < 3.9 by making importlib.resources.files raise AttributeError 314 | mock_system_dependencies["importlib_resources"].files.side_effect = ( 315 | AttributeError 316 | ) 317 | 318 | # Mock the legacy importlib.resources.path context manager 319 | mock_legacy_path_obj = MagicMock() 320 | mock_legacy_path_obj.read_text.return_value = "legacy asset content" 321 | 322 | # The __enter__ method of the context manager should return the path object 323 | mock_cm = MagicMock() 324 | mock_cm.__enter__.return_value = mock_legacy_path_obj 325 | mock_cm.__exit__.return_value = None 326 | mock_system_dependencies["importlib_resources"].path.return_value = mock_cm 327 | 328 | result = system_instance.read_package_asset("legacy_asset.txt") 329 | 330 | assert result.is_ok 331 | assert result.value == "legacy asset content" 332 | mock_system_dependencies["importlib_resources"].path.assert_called_with( 333 | "vscode_colab.assets", "legacy_asset.txt" 334 | ) 335 | mock_legacy_path_obj.read_text.assert_called_with(encoding="utf-8") 336 | 337 | def test_read_package_asset_failure( 338 | self, system_instance, mock_system_dependencies 339 | ): 340 | mock_system_dependencies["importlib_resources"].files.side_effect = Exception( 341 | "Read error" 342 | ) 343 | result = system_instance.read_package_asset("bad_asset.txt") 344 | assert result.is_err 345 | assert "Read error" in str(result.error) 346 | 347 | def test_write_file_text_success(self, system_instance, mock_system_dependencies): 348 | m = mock_open() 349 | with patch("builtins.open", m): 350 | result = system_instance.write_file("out.txt", "content") 351 | 352 | m.assert_called_once_with("out.txt", mode="w", encoding="utf-8") 353 | m().write.assert_called_once_with("content") 354 | assert result.is_ok 355 | 356 | def test_write_file_binary_success(self, system_instance, mock_system_dependencies): 357 | m = mock_open() 358 | with patch("builtins.open", m): 359 | result = system_instance.write_file("out.bin", b"binary", mode="wb") 360 | 361 | m.assert_called_once_with( 362 | "out.bin", mode="wb" 363 | ) # Encoding not passed for binary 364 | m().write.assert_called_once_with(b"binary") 365 | assert result.is_ok 366 | 367 | def test_write_file_failure(self, system_instance, mock_system_dependencies): 368 | m = mock_open() 369 | m.side_effect = IOError("Disk full") 370 | with patch("builtins.open", m): 371 | result = system_instance.write_file("out.txt", "content") 372 | assert result.is_err 373 | assert isinstance(result.error, IOError) 374 | 375 | def test_read_file_success(self, system_instance, mock_system_dependencies): 376 | m = mock_open(read_data="file content") 377 | with patch("builtins.open", m): 378 | result = system_instance.read_file("in.txt") 379 | 380 | m.assert_called_once_with("in.txt", mode="r", encoding="utf-8") 381 | assert result.is_ok 382 | assert result.value == "file content" 383 | 384 | def test_read_file_not_found(self, system_instance, mock_system_dependencies): 385 | m = mock_open() 386 | m.side_effect = FileNotFoundError("No such file") 387 | with patch("builtins.open", m): 388 | result = system_instance.read_file("missing.txt") 389 | assert result.is_err 390 | assert isinstance(result.error, FileNotFoundError) 391 | 392 | 393 | class TestSystemNetworkOperations: 394 | 395 | def test_download_file_success(self, system_instance, mock_system_dependencies): 396 | mock_response = MagicMock(spec=requests.Response) 397 | mock_response.raise_for_status.return_value = None # No exception 398 | mock_response.iter_content.return_value = [b"chunk1", b"chunk2"] 399 | mock_system_dependencies["requests"].get.return_value = mock_response 400 | 401 | m_open = mock_open() 402 | with patch("builtins.open", m_open): 403 | result = system_instance.download_file( 404 | "http://example.com/file", "local_file" 405 | ) 406 | 407 | mock_system_dependencies["requests"].get.assert_called_once_with( 408 | "http://example.com/file", stream=True, allow_redirects=True, timeout=30 409 | ) 410 | mock_response.raise_for_status.assert_called_once() 411 | m_open.assert_called_once_with("local_file", "wb") 412 | m_open().write.assert_any_call(b"chunk1") 413 | m_open().write.assert_any_call(b"chunk2") 414 | assert result.is_ok 415 | 416 | def test_download_file_request_exception( 417 | self, system_instance, mock_system_dependencies 418 | ): 419 | mock_system_dependencies["requests"].get.side_effect = ( 420 | requests.exceptions.RequestException("Network error") 421 | ) 422 | result = system_instance.download_file("http://example.com/file", "local_file") 423 | assert result.is_err 424 | assert isinstance(result.error, requests.exceptions.RequestException) 425 | 426 | def test_download_file_http_error(self, system_instance, mock_system_dependencies): 427 | mock_response = MagicMock(spec=requests.Response) 428 | mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( 429 | "404 Not Found" 430 | ) 431 | mock_system_dependencies["requests"].get.return_value = mock_response 432 | 433 | result = system_instance.download_file( 434 | "http://example.com/notfound", "local_file" 435 | ) 436 | assert result.is_err 437 | assert isinstance(result.error, requests.exceptions.HTTPError) 438 | 439 | 440 | class TestSystemPermissionsAndMisc: 441 | def test_get_env_var_found(self, system_instance, mock_system_dependencies): 442 | mock_system_dependencies["os"].environ.get.return_value = "my_value" 443 | assert system_instance.get_env_var("MY_VAR") == "my_value" 444 | mock_system_dependencies["os"].environ.get.assert_called_once_with( 445 | "MY_VAR", None 446 | ) 447 | 448 | def test_get_env_var_not_found_with_default( 449 | self, system_instance, mock_system_dependencies 450 | ): 451 | mock_system_dependencies["os"].environ.get.return_value = ( 452 | "default_val" # Mocking .get behavior 453 | ) 454 | assert ( 455 | system_instance.get_env_var("NON_EXISTENT", "default_val") == "default_val" 456 | ) 457 | mock_system_dependencies["os"].environ.get.assert_called_once_with( 458 | "NON_EXISTENT", "default_val" 459 | ) 460 | 461 | def test_is_executable_true(self, system_instance, mock_system_dependencies): 462 | # Simulate file_exists returning true 463 | mock_system_dependencies["os"].path.exists.return_value = True 464 | mock_system_dependencies["os"].path.isfile.return_value = True 465 | # Simulate os.access returning true for X_OK 466 | mock_system_dependencies["os"].access.return_value = True 467 | 468 | assert system_instance.is_executable("script.sh") is True 469 | mock_system_dependencies["os"].access.assert_called_once_with( 470 | "script.sh", os.X_OK 471 | ) 472 | 473 | def test_is_executable_false_not_a_file( 474 | self, system_instance, mock_system_dependencies 475 | ): 476 | mock_system_dependencies["os"].path.exists.return_value = True 477 | mock_system_dependencies["os"].path.isfile.return_value = ( 478 | False # It's a directory 479 | ) 480 | assert system_instance.is_executable("folder/") is False 481 | mock_system_dependencies[ 482 | "os" 483 | ].access.assert_not_called() # Should not be called if not a file 484 | 485 | def test_is_executable_false_no_x_permission( 486 | self, system_instance, mock_system_dependencies 487 | ): 488 | mock_system_dependencies["os"].path.exists.return_value = True 489 | mock_system_dependencies["os"].path.isfile.return_value = True 490 | mock_system_dependencies["os"].access.return_value = ( 491 | False # No execute permission 492 | ) 493 | assert system_instance.is_executable("non_exec_file.txt") is False 494 | 495 | def test_change_permissions_success( 496 | self, system_instance, mock_system_dependencies 497 | ): 498 | result = system_instance.change_permissions("file.txt", 0o777) 499 | mock_system_dependencies["os"].chmod.assert_called_once_with("file.txt", 0o777) 500 | assert result.is_ok 501 | 502 | def test_change_permissions_failure( 503 | self, system_instance, mock_system_dependencies 504 | ): 505 | mock_system_dependencies["os"].chmod.side_effect = OSError( 506 | "Operation not permitted" 507 | ) 508 | result = system_instance.change_permissions("file.txt", 0o777) 509 | assert result.is_err 510 | assert isinstance(result.error, OSError) 511 | 512 | def test_get_permissions_success(self, system_instance, mock_system_dependencies): 513 | mock_stat_result = MagicMock() 514 | mock_stat_result.st_mode = 0o644 515 | mock_system_dependencies["os"].stat.return_value = mock_stat_result 516 | 517 | result = system_instance.get_permissions("file.txt") 518 | mock_system_dependencies["os"].stat.assert_called_once_with("file.txt") 519 | assert result.is_ok 520 | assert result.value == 0o644 521 | 522 | def test_get_permissions_failure(self, system_instance, mock_system_dependencies): 523 | mock_system_dependencies["os"].stat.side_effect = OSError("File not found") 524 | result = system_instance.get_permissions("missing.txt") 525 | assert result.is_err 526 | assert isinstance(result.error, OSError) 527 | 528 | def test_get_user_home_dir(self, system_instance, mock_system_dependencies): 529 | # Relies on expand_user_path, which is already tested. 530 | # Just ensure it calls expand_user_path with "~" 531 | mock_system_dependencies["os"].path.expanduser.return_value = ( 532 | "/home/testuser" # Mock its behavior 533 | ) 534 | assert system_instance.get_user_home_dir() == "/home/testuser" 535 | mock_system_dependencies["os"].path.expanduser.assert_called_once_with("~") 536 | -------------------------------------------------------------------------------- /tests/test_templating.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | 5 | # Assuming templating.py uses Jinja2 6 | from jinja2 import Environment, TemplateNotFound 7 | 8 | from vscode_colab.templating import ( # Assuming jinja_env is an Environment instance in templating.py; from vscode_colab.templating import jinja_env # If accessible for more direct mocking 9 | render_github_auth_template, 10 | render_vscode_connection_template, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def mock_jinja_env(): 16 | # Create a mock Jinja2 Environment 17 | # This allows us to control what get_template returns 18 | mock_env = MagicMock(spec=Environment) 19 | return mock_env 20 | 21 | 22 | @patch("vscode_colab.templating.jinja_env") # Patch the global jinja_env instance 23 | def test_render_github_auth_template_success(mock_jinja_env_global, mock_jinja_env): 24 | # Use the locally created mock_jinja_env for setup, 25 | # but the patch targets the one imported by the templating module. 26 | # So, make the global patched one behave like our local mock. 27 | mock_jinja_env_global.get_template.return_value = ( 28 | mock_jinja_env.get_template.return_value 29 | ) 30 | 31 | mock_template = MagicMock() 32 | mock_template.render.return_value = "GitHub Auth: test_url, test_code" 33 | mock_jinja_env.get_template.return_value = mock_template # Configure local mock 34 | mock_jinja_env_global.get_template.return_value = ( 35 | mock_template # Configure global patched mock 36 | ) 37 | 38 | url = "test_url" 39 | code = "test_code" 40 | result = render_github_auth_template(url, code) 41 | 42 | assert result == "GitHub Auth: test_url, test_code" 43 | mock_jinja_env_global.get_template.assert_called_once_with( 44 | "github_auth_link.html.j2" 45 | ) 46 | mock_template.render.assert_called_once_with(url=url, code=code) 47 | 48 | 49 | @patch("vscode_colab.templating.jinja_env") 50 | def test_render_vscode_connection_template_success( 51 | mock_jinja_env_global, mock_jinja_env 52 | ): 53 | mock_jinja_env_global.get_template.return_value = ( 54 | mock_jinja_env.get_template.return_value 55 | ) 56 | 57 | mock_template = MagicMock() 58 | mock_template.render.return_value = ( 59 | "VSCode Connect: test_tunnel_url, test_tunnel_name" 60 | ) 61 | mock_jinja_env.get_template.return_value = mock_template 62 | mock_jinja_env_global.get_template.return_value = mock_template 63 | 64 | tunnel_url = "test_tunnel_url" 65 | tunnel_name = "test_tunnel_name" 66 | result = render_vscode_connection_template(tunnel_url, tunnel_name) 67 | 68 | assert result == "VSCode Connect: test_tunnel_url, test_tunnel_name" 69 | mock_jinja_env_global.get_template.assert_called_once_with( 70 | "vscode_connection_options.html.j2" 71 | ) 72 | mock_template.render.assert_called_once_with( 73 | tunnel_url=tunnel_url, tunnel_name=tunnel_name 74 | ) 75 | 76 | 77 | @patch("vscode_colab.templating.jinja_env") 78 | def test_render_template_not_found(mock_jinja_env_global, mock_jinja_env): 79 | mock_jinja_env_global.get_template.return_value = ( 80 | mock_jinja_env.get_template.return_value 81 | ) 82 | mock_jinja_env.get_template.side_effect = TemplateNotFound("missing.html.j2") 83 | mock_jinja_env_global.get_template.side_effect = TemplateNotFound("missing.html.j2") 84 | 85 | with pytest.raises(TemplateNotFound): 86 | render_github_auth_template("url", "code") # Will fail at get_template 87 | 88 | 89 | @patch("vscode_colab.templating.jinja_env") 90 | def test_render_template_render_error(mock_jinja_env_global, mock_jinja_env): 91 | # Test if template.render() itself raises an error 92 | mock_jinja_env_global.get_template.return_value = ( 93 | mock_jinja_env.get_template.return_value 94 | ) 95 | mock_template = MagicMock() 96 | mock_template.render.side_effect = Exception("Jinja Render Error") 97 | mock_jinja_env.get_template.return_value = mock_template 98 | mock_jinja_env_global.get_template.return_value = mock_template 99 | 100 | with pytest.raises(Exception, match="Jinja Render Error"): 101 | render_vscode_connection_template("url", "name") 102 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vscode_colab.utils import SystemOperationResult 4 | 5 | 6 | class TestSystemOperationResult: 7 | 8 | def test_ok_with_value(self): 9 | result = SystemOperationResult.Ok("success_value") 10 | assert result.is_ok is True 11 | assert result.is_err is False 12 | assert result.value == "success_value" 13 | assert result.error is None 14 | assert result.message is None 15 | assert bool(result) is True 16 | assert str(result) == "Ok(value=success_value)" 17 | 18 | def test_ok_without_value(self): 19 | result = SystemOperationResult.Ok() # Represents a void success 20 | assert result.is_ok is True 21 | assert result.is_err is False 22 | assert result.value is None 23 | assert result.error is None 24 | assert result.message is None 25 | assert bool(result) is True 26 | assert str(result) == "Ok(value=None)" 27 | 28 | def test_err_with_exception_and_message(self): 29 | custom_error = ValueError("A custom error occurred") 30 | result = SystemOperationResult.Err( 31 | custom_error, message="Detailed error description" 32 | ) 33 | assert result.is_ok is False 34 | assert result.is_err is True 35 | assert result.value is None 36 | assert result.error == custom_error 37 | assert result.message == "Detailed error description" 38 | assert bool(result) is False 39 | assert ( 40 | str(result) 41 | == "Err(error=A custom error occurred, message=Detailed error description)" 42 | ) 43 | 44 | def test_err_with_exception_no_custom_message(self): 45 | custom_error = TypeError("Type mismatch") 46 | result = SystemOperationResult.Err(custom_error) 47 | assert result.is_ok is False 48 | assert result.is_err is True 49 | assert result.value is None 50 | assert result.error == custom_error 51 | assert result.message == "Type mismatch" # Default message is str(error) 52 | assert bool(result) is False 53 | assert str(result) == "Err(error=Type mismatch, message=Type mismatch)" 54 | 55 | def test_err_accessing_value_returns_none(self): 56 | result = SystemOperationResult.Err(Exception("test error")) 57 | assert result.value is None # As per current implementation 58 | 59 | def test_constructor_integrity_ok_with_error(self): 60 | with pytest.raises(ValueError, match="Successful result cannot have an error."): 61 | SystemOperationResult(is_success=True, value="val", error=Exception("err")) 62 | 63 | def test_constructor_integrity_err_without_error(self): 64 | with pytest.raises(ValueError, match="Failed result must have an error."): 65 | SystemOperationResult(is_success=False, value="val", error=None) 66 | 67 | def test_generic_type_ok(self): 68 | result_int: SystemOperationResult[int, Exception] = SystemOperationResult.Ok( 69 | 123 70 | ) 71 | assert result_int.value == 123 72 | 73 | result_none: SystemOperationResult[None, Exception] = SystemOperationResult.Ok() 74 | assert result_none.value is None 75 | 76 | def test_generic_type_err(self): 77 | error = ValueError("Bad value") 78 | result_str_err: SystemOperationResult[str, ValueError] = ( 79 | SystemOperationResult.Err(error) 80 | ) 81 | assert result_str_err.error == error 82 | assert isinstance(result_str_err.error, ValueError) 83 | --------------------------------------------------------------------------------