├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------