├── .python-version ├── assets └── image.png ├── lint.sh ├── .gitignore ├── pyproject.toml ├── LICENSE ├── test_uvify.py ├── .github └── workflows │ └── python-publish.yml ├── README.md └── src └── uvify.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avilum/uvify/HEAD/assets/image.png -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | uv run --with=ruff ruff check . --fix && uv run --with=black black . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | node_modules/ 12 | uvify_huggingface/ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "uvify" 3 | authors = [ 4 | { name="Avi Lumelsky", email="avilum+gh@protonmail.com" } 5 | ] 6 | version = "0.1.7" 7 | description = "Get one-liner commands for faster python environment uv python manager." 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | license = { file = "LICENSE" } 11 | urls = { homepage = "https://github.com/avilum/uvify" } 12 | keywords = ["uv", "python", "environment", "uvify", "virtualenv", "pip", "poetry", "venv"] 13 | 14 | dependencies = [ 15 | "gitingest>=0.1.5", 16 | "toml>=0.10.2", 17 | "typer>=0.12.3", 18 | "rich>=13.7.1", 19 | ] 20 | 21 | [project.optional-dependencies] 22 | api = [ 23 | "fastapi>=0.111.0", 24 | "uvicorn>=0.29.0", 25 | ] 26 | 27 | [project.scripts] 28 | uvify = "uvify:cli" 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "pytest>=8.4.1", 33 | ] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Avi Lumelsky. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test_uvify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from src.uvify import api 4 | 5 | client = TestClient(api) 6 | 7 | REPOS = [ 8 | ("psf/requests", "https://github.com/psf/requests"), 9 | ("psf/black", "https://github.com/psf/black"), 10 | ("linqit", "https://github.com/avilum/linqit"), 11 | ("yalla", "https://github.com/avilum/yalla"), 12 | ("numpy", "https://github.com/numpy/numpy"), 13 | ("pandas", "https://github.com/pandas-dev/pandas"), 14 | ("matplotlib", "https://github.com/matplotlib/matplotlib"), 15 | ("scipy", "https://github.com/scipy/scipy"), 16 | ("scikit-learn", "https://github.com/scikit-learn/scikit-learn"), 17 | ("scikit-image", "https://github.com/scikit-image/scikit-image"), 18 | ("pytorch", "https://github.com/pytorch/pytorch"), 19 | ("tensorflow", "https://github.com/tensorflow/tensorflow"), 20 | ("keras", "https://github.com/keras-team/keras"), 21 | ("torchvision", "https://github.com/pytorch/vision"), 22 | ] 23 | 24 | 25 | @pytest.mark.parametrize("repo_name,repo_url", REPOS) 26 | def test_analyze_repo_with_avilum_repos(repo_name, repo_url): 27 | print(f"Testing {repo_name} with {repo_url}...") 28 | # input(f"Press Enter to continue...") 29 | response = client.get(f"/{repo_url}") 30 | assert response.status_code == 200 31 | data = response.json() 32 | print("DATA:", data) 33 | 34 | assert "oneLiner" in data[0] 35 | # assert "command" in data 36 | # assert "arguments" in data 37 | # assert data["command"] == "uv" 38 | # assert isinstance(data["arguments"], list) 39 | # assert any("with" in str(arg) for arg in data["arguments"]) 40 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.10" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python3 -m pip install uv && uv build 33 | 34 | - name: Upload distributions 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: release-dists 38 | path: dist/ 39 | 40 | pypi-publish: 41 | runs-on: ubuntu-latest 42 | needs: 43 | - release-build 44 | permissions: 45 | # IMPORTANT: this permission is mandatory for trusted publishing 46 | id-token: write 47 | 48 | # Dedicated environments with protections for publishing are strongly recommended. 49 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 50 | environment: 51 | name: pypi 52 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 53 | # url: https://pypi.org/p/YOURPROJECT 54 | # 55 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 56 | # ALTERNATIVE: exactly, uncomment the following line instead: 57 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 58 | 59 | steps: 60 | - name: Retrieve release distributions 61 | uses: actions/download-artifact@v4 62 | with: 63 | name: release-dists 64 | path: dist/ 65 | 66 | - name: Publish package to PyPI 67 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 68 | with: 69 | user: __token__ 70 | password: ${{ secrets.PYPI_API_TOKEN }} 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uvify 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/uvify.svg)](https://pypi.org/project/uvify/) 4 | 5 | Turn python repositories to `uv` environments and oneliners, without diving into the code.
6 | 7 | - Generates oneliners for quick python environment setup 8 | - Helps with migration to `uv` for faster builds in CI/CD 9 | - It works on existing projects based on: `requirements.txt`, `pyproject.toml` or `setup.py`, recursively. 10 | - Supports local directories. 11 | - Supports GitHub links using Git Ingest. 12 | - It's fast! 13 | 14 | ## Prerequisites 15 | | uv 16 | 17 | ## Demo 18 | https://huggingface.co/spaces/avilum/uvify 19 | 20 | [![Star History Chart](https://api.star-history.com/svg?repos=avilum/uvify&type=Date)](https://www.star-history.com/#avilum/uvify&Date) 21 | 22 | > uv is by far the fastest python and package manager. 23 | 24 | 25 | Source: https://github.com/astral-sh/uv 26 | 27 | You can run uvify with uv.
28 | Let's generate oneliners for a virtual environment that has `requests` installed, using PyPi or from source: 29 | ```shell 30 | # Run on a local directory 31 | uvx uvify . | jq 32 | 33 | # Run on requests 34 | uvx uvify https://github.com/psf/requests | jq 35 | # or: 36 | # uvx uvify psf/requests | jq 37 | 38 | [ 39 | ... 40 | { 41 | "file": "setup.py", 42 | "fileType": "setup.py", 43 | "oneLiner": "uv run --python '>=3.8.10' --with 'certifi>=2017.4.17,charset_normalizer>=2,<4,idna>=2.5,<4,urllib3>=1.21.1,<3,requests' python -c 'import requests; print(requests)'", 44 | "uvInstallFromSource": "uv run --with 'git+https://github.com/psf/requests' --python '>=3.8.10' python", 45 | "dependencies": [ 46 | "certifi>=2017.4.17", 47 | "charset_normalizer>=2,<4", 48 | "idna>=2.5,<4", 49 | "urllib3>=1.21.1,<3" 50 | ], 51 | "packageName": "requests", 52 | "pythonVersion": ">=3.8", 53 | "isLocal": false 54 | } 55 | ] 56 | ``` 57 | 58 | ### Parse all python artifacts in repository: 59 | ``` 60 | uvify psf/requests 61 | uvify https://github.com/psf/requests 62 | ``` 63 | 64 | ### Parse specific fields in the response 65 | ``` 66 | uvify psf/black | jq '.[] | {file: .file, pythonVersion: .pythonVersion, dependencies: .dependencies, packageName: .packageName}' 67 | ``` 68 | 69 | ### Use existing python repos with 'uv': 70 | ``` 71 | uvify psf/requests | jq '.[0].oneLiner' 72 | "uv run --with 'git+https://github.com/psf/requests' --python '3.11' python" 73 | ``` 74 | ### Install a repository with 'uv' from github sources: 75 | ``` 76 | uvify psf/requests | jq '.[0].dependencies' 77 | ``` 78 | 79 | ### List the dependencies. 80 | ``` 81 | uvify psf/requests | jq '.[].dependencies' 82 | [ 83 | "certifi>=2017.4.17", 84 | "charset_normalizer>=2,<4", 85 | "idna>=2.5,<4", 86 | "urllib3>=1.21.1,<3" 87 | ] 88 | ``` 89 | 90 | ## Filtering Options 91 | 92 | Uvify supports filtering which files to analyze using include and exclude patterns with glob syntax. 93 | 94 | ### Exclude directories from analysis 95 | 96 | Skip test directories and any paths matching the pattern: 97 | ```bash 98 | uvify --exclude "tests/*" --exclude "test_*" my-project/ 99 | ``` 100 | 101 | ### Include only specific directories 102 | 103 | Analyze only files in the `src/` directory: 104 | ```bash 105 | uvify --include "src/*" my-project/ 106 | ``` 107 | 108 | Analyze only a specific subdirectory: 109 | ```bash 110 | uvify --include "src/my_app/*" my-project/ 111 | ``` 112 | 113 | ### Combine include and exclude patterns 114 | 115 | Include everything in `src/` but exclude test files: 116 | ```bash 117 | uvify --include "src/*" --exclude "*/test_*" --exclude "*/tests/*" my-project/ 118 | ``` 119 | 120 | ### GitHub repositories with filtering 121 | 122 | The filtering also works with GitHub repositories: 123 | ```bash 124 | # Exclude test directories from a GitHub repo 125 | uvify --exclude "tests/*" psf/requests 126 | 127 | # Only analyze specific subdirectories 128 | uvify --include "src/*" --include "lib/*" myorg/myrepo 129 | ``` 130 | 131 | ### Pattern Examples 132 | 133 | - `tests/*` - Excludes any directory named "tests" and all its contents 134 | - `test_*` - Excludes any file or directory starting with "test_" 135 | - `*/tests/*` - Excludes "tests" directories at any depth 136 | - `src/my_app/*` - Includes only files within the "src/my_app/" directory 137 | - `*.py` - Includes only Python files 138 | 139 | **Note:** By default, uvify scans all directories for `requirements.txt`, `pyproject.toml`, and `setup.py` files. The include/exclude patterns filter which of these files to analyze based on their path. 140 | 141 | ## Uvify HTTP Server: Using uvify with client/server architecture instead of SDK 142 | 143 | First, install uvify with the optional API dependencies: 144 | ```shell 145 | uv add uvify[api] 146 | # or with pip: 147 | # pip install uvify[api] 148 | ``` 149 | 150 | Then run the server: 151 | ```shell 152 | # Run the server using the built-in serve command 153 | uvify serve --host 0.0.0.0 --port 8000 154 | 155 | # Or using uvicorn directly 156 | uv run uvicorn src.uvify:api --host 0.0.0.0 --port 8000 157 | 158 | # Using curl 159 | curl http://0.0.0.0:8000/psf/requests | jq 160 | 161 | # Using wget 162 | wget -O- http://0.0.0.0:8000/psf/requests | jq 163 | ``` 164 | 165 | 166 | ## Developing 167 | ```shell 168 | # Install dependencies (including optional API dependencies) 169 | uv venv 170 | uv sync --dev --extra api 171 | uv run pytest 172 | 173 | # Run linter before PR 174 | ./lint.sh 175 | 176 | # Install editable version locally 177 | uv run pip install --editable . 178 | uv run python -m src.uvify --help 179 | uv run python -m src.uvify psf/requests 180 | 181 | # Run the HTTP API with reload 182 | uv run uvicorn src.uvify:api --host 0.0.0.0 --port 8000 --reload 183 | # Or use the built-in serve command: 184 | # uv run python -m src.uvify serve --host 0.0.0.0 --port 8000 185 | 186 | curl http://0.0.0.0:8000/psf/requests | jq 187 | ``` 188 | 189 | # Special Thanks 190 | Thanks to the UV team and Astral for this amazing tool. 191 | -------------------------------------------------------------------------------- /src/uvify.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from typing_extensions import Annotated 3 | import json 4 | from gitingest import ingest 5 | 6 | 7 | from collections import OrderedDict 8 | import tempfile 9 | import os 10 | import re 11 | import sys 12 | import urllib.parse 13 | import toml 14 | from pathlib import Path as PathLib 15 | import ast 16 | import fnmatch 17 | 18 | # Optional FastAPI imports 19 | try: 20 | from fastapi import FastAPI, Path, HTTPException 21 | 22 | FASTAPI_AVAILABLE = True 23 | except ImportError: 24 | FASTAPI_AVAILABLE = False 25 | # Create dummy classes to avoid NameError 26 | FastAPI = None 27 | Path = None 28 | HTTPException = None 29 | 30 | # ==================== CONSTANTS ==================== 31 | 32 | # File patterns and extensions 33 | RELEVANT_FILES = ("requirements.txt", "pyproject.toml", "setup.py") 34 | REQUIREMENTS_FILE = "requirements.txt" 35 | PYPROJECT_FILE = "pyproject.toml" 36 | SETUP_PY_FILE = "setup.py" 37 | 38 | # GitHub configuration 39 | GITHUB_BASE_URL = "https://github.com/" 40 | GITHUB_GIT_PREFIX = "git+https://github.com/" 41 | 42 | # Version specifiers regex pattern 43 | VERSION_SPECIFIERS_PATTERN = r"==|>=|<=|!=|~=|>|<|===" 44 | 45 | # Requirements.txt parsing 46 | REQUIREMENTS_COMMENT_CHAR = "#" 47 | ENV_MARKER_SEPARATOR = ";" 48 | 49 | # TOML configuration keys 50 | TOML_TOOL_KEY = "tool" 51 | TOML_POETRY_KEY = "poetry" 52 | TOML_PROJECT_KEY = "project" 53 | TOML_BUILD_SYSTEM_KEY = "build-system" 54 | TOML_DEPENDENCIES_KEY = "dependencies" 55 | TOML_REQUIRES_KEY = "requires" 56 | TOML_PYTHON_KEY = "python" 57 | TOML_NAME_KEY = "name" 58 | 59 | # Python version patterns 60 | PYTHON_VERSION_PATTERN = r'python\s*=\s*"([^"]+)"' 61 | PYTHON_REQUIRES_PATTERN = r'python_requires\s*=\s*["\']([^"\']+)["\']' 62 | PYTHON_DEPENDENCY_PATTERN = r"^python([<>=!~].*)?$" 63 | 64 | # Setup.py configuration 65 | SETUP_FUNCTION_NAME = "setup" 66 | SETUP_INSTALL_REQUIRES = "install_requires" 67 | SETUP_PYTHON_REQUIRES = "python_requires" 68 | SETUP_NAME_ATTR = "name" 69 | SETUP_PACKAGES_ATTR = "packages" 70 | 71 | # Setup.py regex patterns 72 | SETUP_INSTALL_REQUIRES_PATTERN = r"install_requires\s*=\s*\[(.*?)\]" 73 | SETUP_NAME_PATTERN = r'name\s*=\s*["\']([^"\']+)["\']' 74 | SETUP_PACKAGES_PATTERN = r"packages\s*=\s*\[(.*?)\]" 75 | SETUP_STRING_LITERAL_PATTERN = r'"([^"]+)"|\'([^\']+)\'' 76 | PYPROJECT_NAME_PATTERN = r'^name\s*=\s*["\']([^"\']+)["\']' 77 | 78 | # Content parsing markers 79 | FILE_MARKER = "FILE:" 80 | SEPARATOR_MARKER = "=" 81 | SYMLINK_MARKER = "SYMLINK:" 82 | 83 | # Command templates 84 | UV_RUN_BASE_TEMPLATE = "uv run --python '{python_version}'" 85 | UV_RUN_WITH_DEPS_TEMPLATE = ( 86 | "uv run --python '{python_version}' --with '{dependencies}' python" 87 | ) 88 | UV_RUN_WITH_PACKAGE_TEMPLATE = "uv run --python '{python_version}' --with '{dependencies}' python -c 'import {import_name}; print({import_name})'" 89 | UV_RUN_IMPORT_PACKAGE_TEMPLATE = "uv run --python '{python_version}' python -c 'import {package_name}; print({package_name})'" 90 | UV_RUN_PLAIN_TEMPLATE = "uv run --python '{python_version}' python" 91 | UV_INSTALL_FROM_GIT_TEMPLATE = ( 92 | "uv run --with 'git+https://github.com/{source}' --python '{python_version}' python" 93 | ) 94 | 95 | # Default values 96 | DEFAULT_PYTHON_MAJOR = sys.version_info.major 97 | DEFAULT_PYTHON_MINOR = sys.version_info.minor 98 | DEFAULT_PYTHON_VERSION = f"{DEFAULT_PYTHON_MAJOR}.{DEFAULT_PYTHON_MINOR}" 99 | 100 | # Error messages 101 | ERROR_DIR_NOT_EXISTS = "Directory does not exist: {path}" 102 | ERROR_NOT_A_DIRECTORY = "Path is not a directory: {path}" 103 | ERROR_INVALID_GITHUB_REPO = "Invalid GitHub repository format" 104 | 105 | # API configuration 106 | API_DEFAULT_PAGE_SIZE = 100 107 | API_DEFAULT_PAGE = 1 108 | 109 | # File processing 110 | MIN_PATH_COMPONENTS = 1 111 | PATH_SEPARATOR = "/" 112 | 113 | # ==================== END CONSTANTS ==================== 114 | 115 | # Create CLI and FastAPI app 116 | cli = typer.Typer() 117 | 118 | # Create FastAPI app only if FastAPI is available 119 | if FASTAPI_AVAILABLE: 120 | api = FastAPI() 121 | else: 122 | api = None 123 | 124 | # Helper to extract dependencies from requirements.txt 125 | 126 | 127 | def parse_requirements_txt(content: str) -> list[str]: 128 | deps = [] 129 | # print(f"Parsing requirements.txt content:\n{content}") 130 | for line in content.splitlines(): 131 | line = line.strip() 132 | # print(f"Processing line: '{line}'") 133 | if not line or line.startswith(REQUIREMENTS_COMMENT_CHAR): 134 | # print(f"Skipping line (empty or comment): '{line}'") 135 | continue 136 | # Preserve lines with environment markers 137 | if ENV_MARKER_SEPARATOR in line: 138 | # print(f"Adding line with environment marker: '{line}'") 139 | deps.append(line) 140 | continue 141 | # Remove version specifiers for other lines 142 | # Split on any of these version specifiers: ==, >=, <=, !=, ~=, >, <, === 143 | dep = re.split(VERSION_SPECIFIERS_PATTERN, line)[0].strip() 144 | if dep: 145 | # print(f"Adding dependency: '{dep}' (from line: '{line}')") 146 | deps.append(dep) 147 | # print(f"Final dependencies list: {deps}") 148 | return deps 149 | 150 | 151 | def clean_dependency_string(spec: str) -> str: 152 | """Normalize a dependency spec from pyproject to a clean package string. 153 | 154 | - Preserves direct URL specs ("name @ git+https://...") 155 | - Preserves extras (e.g., "package[extra]") 156 | - Removes environment markers ("; python_version < '3.11'") 157 | - Removes parenthesized version blocks (" (>=1.0,<2.0)") 158 | - Removes inline version specifiers after the name (">=", "==", etc.) 159 | """ 160 | if spec is None: 161 | return "" 162 | dep = spec.strip() 163 | 164 | # If direct URL dependency (PEP 508), keep as-is (trimmed) 165 | if " @ " in dep: 166 | # Also remove any markers after ';' 167 | dep = dep.split(";", 1)[0].strip() 168 | return dep 169 | 170 | # Strip environment markers 171 | dep = dep.split(";", 1)[0].strip() 172 | 173 | # Remove parenthesized version constraints like " (>=1.2,<2.0)" 174 | dep = re.sub(r"\s*\(.*?\)", "", dep) 175 | 176 | # If any remaining inline version specifiers, strip everything after the name 177 | if re.search(VERSION_SPECIFIERS_PATTERN, dep): 178 | dep = re.split(VERSION_SPECIFIERS_PATTERN, dep)[0] 179 | 180 | # Collapse whitespace 181 | dep = re.sub(r"\s+", " ", dep).strip() 182 | 183 | return dep 184 | 185 | 186 | # Helper to extract dependencies from pyproject.toml 187 | 188 | 189 | def parse_pyproject_toml(content: str) -> list[str]: 190 | deps = [] 191 | # Try to parse as TOML for robust extraction 192 | try: 193 | data = toml.loads(content) 194 | 195 | # Handle poetry dependencies 196 | if ( 197 | TOML_TOOL_KEY in data 198 | and TOML_POETRY_KEY in data[TOML_TOOL_KEY] 199 | and TOML_DEPENDENCIES_KEY in data[TOML_TOOL_KEY][TOML_POETRY_KEY] 200 | ): 201 | poetry_deps = data[TOML_TOOL_KEY][TOML_POETRY_KEY][TOML_DEPENDENCIES_KEY] 202 | deps.extend( 203 | [ 204 | clean_dependency_string(dep) 205 | for dep in poetry_deps.keys() 206 | if dep != TOML_PYTHON_KEY # Skip python version requirement 207 | ] 208 | ) 209 | 210 | # PEP 621: [project] dependencies 211 | if TOML_PROJECT_KEY in data and TOML_DEPENDENCIES_KEY in data[TOML_PROJECT_KEY]: 212 | project_deps = data[TOML_PROJECT_KEY][TOML_DEPENDENCIES_KEY] 213 | # Handle both list and dict formats 214 | if isinstance(project_deps, list): 215 | deps.extend( 216 | [ 217 | clean_dependency_string(dep) 218 | for dep in project_deps 219 | if isinstance(dep, str) 220 | ] 221 | ) 222 | elif isinstance(project_deps, dict): 223 | deps.extend( 224 | [ 225 | clean_dependency_string(dep) 226 | for dep in project_deps.keys() 227 | if dep != TOML_PYTHON_KEY 228 | ] 229 | ) 230 | 231 | # Handle build-system requires 232 | if ( 233 | TOML_BUILD_SYSTEM_KEY in data 234 | and TOML_REQUIRES_KEY in data[TOML_BUILD_SYSTEM_KEY] 235 | ): 236 | build_deps = data[TOML_BUILD_SYSTEM_KEY][TOML_REQUIRES_KEY] 237 | deps.extend( 238 | [ 239 | clean_dependency_string(dep) 240 | for dep in build_deps 241 | if isinstance(dep, str) 242 | ] 243 | ) 244 | 245 | except Exception: 246 | # print(f"Error parsing pyproject.toml: {e}") 247 | return [] 248 | 249 | # Remove duplicates and empties 250 | deps = [d for d in set(deps) if d] 251 | return deps # Remove duplicates 252 | 253 | 254 | # Helper to extract dependencies from setup.py 255 | 256 | 257 | def parse_setup_py(content: str) -> tuple[list[str], str | None]: 258 | """Parse setup.py for install_requires and python_requires, even if defined as variables or passed as variables.""" 259 | deps = [] 260 | py_version = None 261 | var_map = {} 262 | try: 263 | tree = ast.parse(content) 264 | # Track variable assignments 265 | for node in ast.walk(tree): 266 | if isinstance(node, ast.Assign): 267 | for target in node.targets: 268 | if isinstance(target, ast.Name): 269 | # Only store lists/tuples of strings 270 | if isinstance(node.value, (ast.List, ast.Tuple)): 271 | values = [] 272 | for elt in node.value.elts: 273 | if isinstance(elt, ast.Str): 274 | values.append(elt.s) 275 | elif isinstance(elt, ast.Constant) and isinstance( 276 | elt.value, str 277 | ): 278 | values.append(elt.value) 279 | var_map[target.id] = values 280 | # Also store string assignments (for python_requires) 281 | elif isinstance(node.value, ast.Str): 282 | var_map[target.id] = node.value.s 283 | elif isinstance(node.value, ast.Constant) and isinstance( 284 | node.value.value, str 285 | ): 286 | var_map[target.id] = node.value.value 287 | # Find setup() call 288 | for node in ast.walk(tree): 289 | if ( 290 | isinstance(node, ast.Call) 291 | and hasattr(node.func, "id") 292 | and node.func.id == SETUP_FUNCTION_NAME 293 | ): 294 | for kw in node.keywords: 295 | if kw.arg == SETUP_INSTALL_REQUIRES: 296 | # Direct list/tuple 297 | if isinstance(kw.value, (ast.List, ast.Tuple)): 298 | for elt in kw.value.elts: 299 | if isinstance(elt, ast.Str): 300 | deps.append(elt.s) 301 | elif isinstance(elt, ast.Constant) and isinstance( 302 | elt.value, str 303 | ): 304 | deps.append(elt.value) 305 | # Variable reference 306 | elif isinstance(kw.value, ast.Name): 307 | var_name = kw.value.id 308 | if var_name in var_map and isinstance( 309 | var_map[var_name], list 310 | ): 311 | deps.extend(var_map[var_name]) 312 | if kw.arg == SETUP_PYTHON_REQUIRES: 313 | if isinstance(kw.value, ast.Str): 314 | py_version = kw.value.s 315 | elif isinstance(kw.value, ast.Constant) and isinstance( 316 | kw.value.value, str 317 | ): 318 | py_version = kw.value.value 319 | elif isinstance(kw.value, ast.Name): 320 | var_name = kw.value.id 321 | if var_name in var_map and isinstance( 322 | var_map[var_name], str 323 | ): 324 | py_version = var_map[var_name] 325 | except Exception: 326 | # fallback: regex 327 | match = re.search(SETUP_INSTALL_REQUIRES_PATTERN, content, re.DOTALL) 328 | if match: 329 | items = match.group(1) 330 | for dep in re.findall(SETUP_STRING_LITERAL_PATTERN, items): 331 | dep_name = dep[0] or dep[1] 332 | if dep_name: 333 | deps.append(dep_name) 334 | match_py = re.search(PYTHON_REQUIRES_PATTERN, content) 335 | if match_py: 336 | py_version = match_py.group(1) 337 | return deps, py_version 338 | 339 | 340 | def extract_package_name_from_setup_py(content: str) -> str: 341 | # First, try to find name="" 342 | name_match = re.search(SETUP_NAME_PATTERN, content) 343 | if name_match: 344 | return name_match.group(1) 345 | 346 | # If name is not a literal, fall back to packages=[''] 347 | # Find packages=[...] 348 | packages_match = re.search(SETUP_PACKAGES_PATTERN, content, re.DOTALL) 349 | if packages_match: 350 | # Extract the first string literal from the list 351 | package_list_str = packages_match.group(1) 352 | first_package_match = re.search(SETUP_STRING_LITERAL_PATTERN, package_list_str) 353 | if first_package_match: 354 | return first_package_match.group(1) 355 | 356 | return None 357 | 358 | 359 | def extract_package_name_from_pyproject_toml(content: str) -> str: 360 | match = re.search(PYPROJECT_NAME_PATTERN, content, re.MULTILINE) 361 | if match: 362 | return match.group(1) 363 | return None 364 | 365 | 366 | def should_include_path( 367 | path: str, include_patterns: list[str] = None, exclude_patterns: list[str] = None 368 | ) -> bool: 369 | """Check if a path should be included based on include/exclude patterns.""" 370 | # If include patterns are specified, path must match at least one 371 | if include_patterns: 372 | if not any(fnmatch.fnmatch(path, pattern) for pattern in include_patterns): 373 | return False 374 | 375 | # If exclude patterns are specified, path must not match any 376 | if exclude_patterns: 377 | if any(fnmatch.fnmatch(path, pattern) for pattern in exclude_patterns): 378 | return False 379 | 380 | return True 381 | 382 | 383 | def detect_project_source(source: str) -> tuple[str, bool]: 384 | """Detect if the source is a GitHub repo or a local directory. 385 | Returns (normalized_source, is_github) 386 | """ 387 | # GitHub URL 388 | if source.startswith(GITHUB_BASE_URL): 389 | parsed = urllib.parse.urlparse(source) 390 | path = parsed.path.strip(PATH_SEPARATOR) 391 | if path.count(PATH_SEPARATOR) >= MIN_PATH_COMPONENTS: 392 | owner_repo = PATH_SEPARATOR.join(path.split(PATH_SEPARATOR)[:2]) 393 | return owner_repo, True 394 | # owner/repo format 395 | if PATH_SEPARATOR in source and not source.startswith(PATH_SEPARATOR): 396 | owner_repo = PATH_SEPARATOR.join(source.split(PATH_SEPARATOR)[:2]) 397 | return owner_repo, True 398 | # Otherwise, treat as local directory 399 | return source, False 400 | 401 | 402 | def extract_project_files_multi( 403 | source: str, 404 | is_github: bool, 405 | include_patterns: list[str] = None, 406 | exclude_patterns: list[str] = None, 407 | ) -> list[dict]: 408 | """Extract all relevant files and their parsed info from a repo or local dir. 409 | 410 | [ 411 | { 412 | "file": "pyproject.toml", 413 | "fileType": "pyproject.toml", 414 | "oneLiner": "uv run --python '>=3.10' --with 'gitingest,rich,toml,typer,uvify' python -c 'impor 415 | t uvify; print(uvify)'", 416 | "uvInstallFromSource": null, 417 | "dependencies": [ 418 | "gitingest", 419 | "rich", 420 | "toml", 421 | "typer" 422 | ], 423 | "packageName": "uvify", 424 | "pythonVersion": ">=3.10", 425 | "isLocal": true 426 | }, 427 | ... 428 | ] 429 | """ 430 | found_files = [] 431 | files_content = {} 432 | if is_github: 433 | repo_url = f"{GITHUB_BASE_URL}{source}" 434 | with tempfile.TemporaryDirectory() as _: 435 | if ingest: 436 | # print(f"Ingesting from {repo_url}") 437 | summary, tree, content = ingest( 438 | repo_url, 439 | include_patterns=set(RELEVANT_FILES), 440 | ) 441 | # print(f"Content type: {type(content)}") 442 | # print(f"Content preview:\n{content[:500]}") 443 | 444 | if isinstance(content, str): 445 | content_dict = OrderedDict() 446 | 447 | # First, find all FILE: markers and their content 448 | current_file = None 449 | current_content = [] 450 | 451 | for line in content.splitlines(): 452 | if line.startswith(FILE_MARKER): 453 | # If we were collecting content for a previous file, save it 454 | if current_file and current_content: 455 | file_content = "\n".join(current_content).strip() 456 | if any( 457 | current_file.endswith(fname) 458 | for fname in RELEVANT_FILES 459 | ): 460 | # print(f"Saving content for {current_file}") 461 | content_dict[current_file] = file_content 462 | 463 | # Start new file 464 | current_file = line.replace(FILE_MARKER, "").strip() 465 | current_content = [] 466 | # print(f"Found file: {current_file}") 467 | elif line.startswith(SEPARATOR_MARKER): 468 | # Skip separator lines 469 | continue 470 | elif line.startswith(SYMLINK_MARKER): 471 | # Skip symlink blocks 472 | current_file = None 473 | current_content = [] 474 | elif current_file: 475 | # Collect content lines for current file 476 | current_content.append(line) 477 | 478 | # Don't forget to save the last file 479 | if current_file and current_content: 480 | file_content = "\n".join(current_content).strip() 481 | if any( 482 | current_file.endswith(fname) for fname in RELEVANT_FILES 483 | ): 484 | # print(f"Saving content for {current_file}") 485 | content_dict[current_file] = file_content 486 | 487 | # print(f"\nFound {len(content_dict)} relevant files: {list(content_dict.keys())}") 488 | 489 | # Apply include/exclude filtering for GitHub repos 490 | if include_patterns or exclude_patterns: 491 | filtered_content = {} 492 | for file_path, content in content_dict.items(): 493 | if should_include_path( 494 | file_path, include_patterns, exclude_patterns 495 | ): 496 | filtered_content[file_path] = content 497 | files_content = filtered_content 498 | else: 499 | files_content = content_dict 500 | else: 501 | # Handle case where content is already a dict 502 | # print("Content is a dict, processing directly") 503 | for k, v in content.items(): 504 | if any(k.endswith(fname) for fname in RELEVANT_FILES): 505 | if should_include_path( 506 | k, include_patterns, exclude_patterns 507 | ): 508 | # print(f"Found relevant file in dict: {k}") 509 | files_content[k] = v 510 | else: 511 | dir_path = PathLib(source).resolve() 512 | if not dir_path.exists() or not dir_path.is_dir(): 513 | raise ValueError(ERROR_DIR_NOT_EXISTS.format(path=dir_path)) 514 | for root, _, files in os.walk(dir_path): 515 | for fname in files: 516 | if fname in RELEVANT_FILES: 517 | file_path = os.path.join(root, fname) 518 | rel_path = os.path.relpath(file_path, dir_path) 519 | # Apply include/exclude filtering for local directories 520 | if should_include_path( 521 | rel_path, include_patterns, exclude_patterns 522 | ): 523 | with open(file_path, "r") as f: 524 | files_content[rel_path] = f.read() 525 | 526 | # parse each file 527 | for rel_path, file_content in files_content.items(): 528 | # print(f"\nProcessing file: {rel_path}") 529 | # print(f"Content preview: {file_content[:100]}") 530 | file_type = None 531 | dependencies = [] 532 | py_version = None 533 | package_name = None 534 | if rel_path.endswith(REQUIREMENTS_FILE): 535 | file_type = REQUIREMENTS_FILE 536 | dependencies = parse_requirements_txt(file_content) 537 | # print(f"Found dependencies in requirements.txt: {dependencies}") 538 | elif rel_path.endswith(PYPROJECT_FILE): 539 | file_type = PYPROJECT_FILE 540 | dependencies = parse_pyproject_toml(file_content) 541 | m = re.search(PYTHON_VERSION_PATTERN, file_content) 542 | if m: 543 | py_version = m.group(1) 544 | package_name = extract_package_name_from_pyproject_toml(file_content) 545 | # print(f"Found dependencies in pyproject.toml: {dependencies}") 546 | elif rel_path.endswith(SETUP_PY_FILE): 547 | file_type = SETUP_PY_FILE 548 | dependencies, py_version = parse_setup_py(file_content) 549 | package_name = extract_package_name_from_setup_py(file_content) 550 | # print(f"Found dependencies in setup.py: {dependencies}") 551 | 552 | # Remove python version specifiers from dependencies 553 | dep_list = sorted( 554 | set(d for d in dependencies if not re.match(PYTHON_DEPENDENCY_PATTERN, d)) 555 | ) 556 | # print(f"Final dependency list: {dep_list}") 557 | 558 | default_python_version = py_version or DEFAULT_PYTHON_VERSION 559 | if package_name: 560 | import_name = package_name.replace("-", "_") 561 | else: 562 | import_name = None 563 | 564 | # compose the command 565 | if dep_list: 566 | if package_name: 567 | one_liner = UV_RUN_WITH_PACKAGE_TEMPLATE.format( 568 | python_version=default_python_version, 569 | dependencies=",".join(dep_list + [package_name]), 570 | package_name=package_name, 571 | import_name=import_name, 572 | ) 573 | else: 574 | one_liner = UV_RUN_WITH_DEPS_TEMPLATE.format( 575 | python_version=default_python_version, 576 | dependencies=",".join(dep_list), 577 | ) 578 | else: 579 | if package_name: 580 | one_liner = UV_RUN_IMPORT_PACKAGE_TEMPLATE.format( 581 | python_version=default_python_version, 582 | package_name=package_name, 583 | import_name=import_name, 584 | ) 585 | else: 586 | one_liner = UV_RUN_PLAIN_TEMPLATE.format( 587 | python_version=default_python_version 588 | ) 589 | 590 | uv_install_from_git_command = None 591 | if is_github: 592 | uv_install_from_git_command = UV_INSTALL_FROM_GIT_TEMPLATE.format( 593 | source=source, python_version=default_python_version 594 | ) 595 | 596 | found_files.append( 597 | { 598 | "file": rel_path, 599 | "fileType": file_type, 600 | "oneLiner": one_liner, 601 | "uvInstallFromSource": uv_install_from_git_command, 602 | "dependencies": dep_list, 603 | "packageName": package_name, 604 | "pythonVersion": default_python_version, 605 | "isLocal": not is_github, 606 | } 607 | ) 608 | return found_files 609 | 610 | 611 | def _analyze_repo_logic( 612 | source: str, include_patterns: list[str] = None, exclude_patterns: list[str] = None 613 | ) -> list[dict]: 614 | normalized_source, is_github = detect_project_source(source) 615 | return extract_project_files_multi( 616 | normalized_source, is_github, include_patterns, exclude_patterns 617 | ) 618 | 619 | 620 | @cli.callback(invoke_without_command=True) 621 | def main( 622 | ctx: typer.Context, 623 | repo_name: Annotated[ 624 | str, 625 | typer.Argument( 626 | help="GitHub repository (owner/repo or URL) or path to local directory" 627 | ), 628 | ] = None, 629 | include: Annotated[ 630 | list[str], 631 | typer.Option( 632 | "--include", 633 | help="Include only paths matching these patterns (glob syntax). Can be used multiple times.", 634 | ), 635 | ] = None, 636 | exclude: Annotated[ 637 | list[str], 638 | typer.Option( 639 | "--exclude", 640 | help="Exclude paths matching these patterns (glob syntax). Can be used multiple times.", 641 | ), 642 | ] = None, 643 | ): 644 | """Analyze a GitHub repository or local directory to generate uv commands.""" 645 | if ctx.invoked_subcommand is None: 646 | if repo_name is None: 647 | print("Error: Repository name is required", file=sys.stderr) 648 | raise typer.Exit(1) 649 | try: 650 | result = _analyze_repo_logic( 651 | repo_name, include_patterns=include, exclude_patterns=exclude 652 | ) 653 | print(json.dumps(result, indent=4)) 654 | return result 655 | except ValueError as e: 656 | print(f"Error: {str(e)}", file=sys.stderr) 657 | sys.exit(1) 658 | 659 | 660 | @cli.command() 661 | def serve( 662 | host: Annotated[str, typer.Option(help="Host to bind to")] = "127.0.0.1", 663 | port: Annotated[int, typer.Option(help="Port to bind to")] = 8000, 664 | ): 665 | """Start the FastAPI server for the uvify API.""" 666 | if not FASTAPI_AVAILABLE: 667 | print( 668 | "Error: FastAPI is not installed. Install it with 'pip install uvify[api]' or 'uv add uvify[api]'", 669 | file=sys.stderr, 670 | ) 671 | sys.exit(1) 672 | 673 | try: 674 | import uvicorn 675 | 676 | uvicorn.run(api, host=host, port=port) 677 | except ImportError: 678 | print( 679 | "Error: uvicorn is not installed. Install it with 'pip install uvify[api]' or 'uv add uvify[api]'", 680 | file=sys.stderr, 681 | ) 682 | sys.exit(1) 683 | 684 | 685 | # FastAPI route - only define if FastAPI is available 686 | if FASTAPI_AVAILABLE: 687 | 688 | @api.get("/{repo_name:path}") 689 | def analyze_repo_api( 690 | repo_name: str = Path( 691 | ..., 692 | description="GitHub repository (owner/repo or URL) or path to local directory", 693 | ), 694 | include: str = None, 695 | exclude: str = None, 696 | ): 697 | try: 698 | include_patterns = include.split(",") if include else None 699 | exclude_patterns = exclude.split(",") if exclude else None 700 | return _analyze_repo_logic( 701 | repo_name, 702 | include_patterns=include_patterns, 703 | exclude_patterns=exclude_patterns, 704 | ) 705 | except ValueError as e: 706 | raise HTTPException(status_code=400, detail=str(e)) 707 | 708 | 709 | if __name__ == "__main__": 710 | cli() 711 | --------------------------------------------------------------------------------