pypm-cli 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pypm_cli-0.0.1/.github/workflows/ci.yml +32 -0
- pypm_cli-0.0.1/.gitignore +16 -0
- pypm_cli-0.0.1/CHANGELOG.md +12 -0
- pypm_cli-0.0.1/LICENSE +21 -0
- pypm_cli-0.0.1/PKG-INFO +64 -0
- pypm_cli-0.0.1/README.md +41 -0
- pypm_cli-0.0.1/docs/api.md +37 -0
- pypm_cli-0.0.1/docs/architecture.md +20 -0
- pypm_cli-0.0.1/docs/index.md +26 -0
- pypm_cli-0.0.1/docs/usage.md +43 -0
- pypm_cli-0.0.1/pyproject.toml +49 -0
- pypm_cli-0.0.1/src/pypm/__init__.py +1 -0
- pypm_cli-0.0.1/src/pypm/cli.py +264 -0
- pypm_cli-0.0.1/src/pypm/installer.py +32 -0
- pypm_cli-0.0.1/src/pypm/parser.py +58 -0
- pypm_cli-0.0.1/src/pypm/pypi.py +105 -0
- pypm_cli-0.0.1/src/pypm/resolver.py +309 -0
- pypm_cli-0.0.1/src/pypm/scanner.py +41 -0
- pypm_cli-0.0.1/src/pypm/utils.py +85 -0
- pypm_cli-0.0.1/tests/test_resolver.py +14 -0
- pypm_cli-0.0.1/tests/test_scanner.py +29 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install build pytest
|
|
28
|
+
pip install -e .
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: |
|
|
32
|
+
pytest
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.0.1] - 2026-02-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial release
|
|
9
|
+
- AST-based import parsing
|
|
10
|
+
- PyPI resolution logic
|
|
11
|
+
- CLI with `infer` and `install` commands
|
|
12
|
+
- GitHub Actions CI workflow
|
pypm_cli-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 D. Suriya Kumar
|
|
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.
|
pypm_cli-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypm-cli
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Automatic, AST-based dependency inference from Python imports. Stop writing dependencies twice.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Suriyakumardurai/pypm
|
|
6
|
+
Project-URL: Repository, https://github.com/Suriyakumardurai/pypm
|
|
7
|
+
Project-URL: Issues, https://github.com/Suriyakumardurai/pypm/issues
|
|
8
|
+
Author-email: "D. Suriya Kumar" <suriyakumardurai.sk.in@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,dependency,package-manager,pyproject,python
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: requests>=2.31
|
|
21
|
+
Requires-Dist: uv>=0.1.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pypm - Python Project Manager
|
|
25
|
+
|
|
26
|
+
**pypm** is a smart, zero-config CLI tool that automatically infers dependencies from your Python source code.
|
|
27
|
+
|
|
28
|
+
> **Stop writing dependencies twice.** Let your imports define your project.
|
|
29
|
+
|
|
30
|
+
`pypm` uses AST-based parsing to detect imports, resolves them to PyPI packages (handling distinct names like `PIL` -> `Pillow`), and generates a `pyproject.toml` for you.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Smart Inference**:Scans your project for `.py` files and extracts all imports.
|
|
35
|
+
- **Auto-Resolution**: Maps imports (e.g., `import cv2`) to their actual PyPI packages (e.g., `opencv-python`).
|
|
36
|
+
- **Standard Library Detection**: Automatically ignores Python standard library modules.
|
|
37
|
+
- **Zero Config**: No need to manually list dependencies in `requirements.txt` or `pyproject.toml`.
|
|
38
|
+
- **Modern Standards**: Generates PEP 621 compliant `pyproject.toml` files.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
(Coming soon - for now, clone and run locally)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/Suriyakumardurai/pypm.git
|
|
46
|
+
cd pypm
|
|
47
|
+
pip install .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
1. **Infer Dependencies**: Scan your current directory and update `pyproject.toml`.
|
|
53
|
+
```bash
|
|
54
|
+
pypm infer
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. **Install Dependencies**: Install the inferred packages (uses `uv` if available, else `pip`).
|
|
58
|
+
```bash
|
|
59
|
+
pypm install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
For full documentation, see [docs/index.md](docs/index.md).
|
pypm_cli-0.0.1/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# pypm - Python Project Manager
|
|
2
|
+
|
|
3
|
+
**pypm** is a smart, zero-config CLI tool that automatically infers dependencies from your Python source code.
|
|
4
|
+
|
|
5
|
+
> **Stop writing dependencies twice.** Let your imports define your project.
|
|
6
|
+
|
|
7
|
+
`pypm` uses AST-based parsing to detect imports, resolves them to PyPI packages (handling distinct names like `PIL` -> `Pillow`), and generates a `pyproject.toml` for you.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Smart Inference**:Scans your project for `.py` files and extracts all imports.
|
|
12
|
+
- **Auto-Resolution**: Maps imports (e.g., `import cv2`) to their actual PyPI packages (e.g., `opencv-python`).
|
|
13
|
+
- **Standard Library Detection**: Automatically ignores Python standard library modules.
|
|
14
|
+
- **Zero Config**: No need to manually list dependencies in `requirements.txt` or `pyproject.toml`.
|
|
15
|
+
- **Modern Standards**: Generates PEP 621 compliant `pyproject.toml` files.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
(Coming soon - for now, clone and run locally)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone https://github.com/Suriyakumardurai/pypm.git
|
|
23
|
+
cd pypm
|
|
24
|
+
pip install .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
1. **Infer Dependencies**: Scan your current directory and update `pyproject.toml`.
|
|
30
|
+
```bash
|
|
31
|
+
pypm infer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Install Dependencies**: Install the inferred packages (uses `uv` if available, else `pip`).
|
|
35
|
+
```bash
|
|
36
|
+
pypm install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
For full documentation, see [docs/index.md](docs/index.md).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
This section documents the internal modules of `pypm`.
|
|
4
|
+
|
|
5
|
+
## `pypm.scanner`
|
|
6
|
+
|
|
7
|
+
Handles file system traversal and virtual environment detection.
|
|
8
|
+
|
|
9
|
+
- `scan_directory(root_path: Path) -> List[Path]`: Recursively finds `.py` files.
|
|
10
|
+
- `is_virtual_env(path: Path) -> bool`: Checks if a directory is likely a virtualenv.
|
|
11
|
+
|
|
12
|
+
## `pypm.parser`
|
|
13
|
+
|
|
14
|
+
Uses Python's AST to extract imports.
|
|
15
|
+
|
|
16
|
+
- `get_imports_from_file(filepath: Path) -> Set[str]`: returns a set of module names imported in a file.
|
|
17
|
+
|
|
18
|
+
## `pypm.resolver`
|
|
19
|
+
|
|
20
|
+
The core logic for mapping imports to PyPI packages.
|
|
21
|
+
|
|
22
|
+
- `resolve_dependencies(imports: Set[str], project_root: str) -> List[str]`:
|
|
23
|
+
1. Filters standard library modules.
|
|
24
|
+
2. Filters local modules.
|
|
25
|
+
3. Checks online PyPI availability.
|
|
26
|
+
4. Resolves package names (e.g., `PIL` -> `Pillow`).
|
|
27
|
+
|
|
28
|
+
## `pypm.pypi`
|
|
29
|
+
|
|
30
|
+
Utilities for interacting with the Python Package Index.
|
|
31
|
+
|
|
32
|
+
- `check_package_exists(name: str) -> bool`: Checks if a project exists on PyPI.
|
|
33
|
+
- `get_latest_version(name: str) -> str`: Fetches the latest version string.
|
|
34
|
+
|
|
35
|
+
## `pypm.cli`
|
|
36
|
+
|
|
37
|
+
Entry point for the command-line interface.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`pypm` follows a modular pipeline approach to dependency management.
|
|
4
|
+
|
|
5
|
+
## Pipeline Steps
|
|
6
|
+
|
|
7
|
+
1. **Scanning**: The `scanner` module traverses the directory tree, respecting `.gitignore` and skipping virtual environments (`venv`, `.env`, etc.) to find Python source files.
|
|
8
|
+
2. **Parsing**: The `parser` module reads each file and uses `ast.parse()` to generate an Abstract Syntax Tree. It visits `Import` and `ImportFrom` nodes to collect module names.
|
|
9
|
+
3. **Resolution**: The `resolver` is the brain of the operation.
|
|
10
|
+
- It filters out Python standard library modules (using `sys.stdlib_module_names`).
|
|
11
|
+
- It detects local modules to avoid installing your own files from PyPI.
|
|
12
|
+
- It uses a mapped lookup for common specific cases (e.g., `cv2` -> `opencv-python`).
|
|
13
|
+
- For unknown modules, it queries PyPI ensuring the package exists.
|
|
14
|
+
4. **Generation**: The CLI takes the resolved list and updates `pyproject.toml` using a merging strategy that preserves existing configuration.
|
|
15
|
+
|
|
16
|
+
## Design Decisions
|
|
17
|
+
|
|
18
|
+
- **AST over Regex**: We use AST to avoid false positives in comments or strings.
|
|
19
|
+
- **Zero-Dependency Core**: The core logic relies mainly on the standard library, with `requests` for PyPI checks and `uv` for fast installation.
|
|
20
|
+
- **Determinism**: Dependencies are always sorted to ensure reproducible builds.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# pypm - Python Project Manager
|
|
2
|
+
|
|
3
|
+
Welcome to the documentation for **pypm**.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`pypm` is a command-line tool designed to simplify Python dependency management. Instead of manually maintaining `requirements.txt` or `pyproject.toml`, `pypm` analyzes your source code imports and automatically generates the necessary configuration.
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Usage Guide](usage.md): How to use `pypm` commands.
|
|
12
|
+
- [API Reference](api.md): Developer documentation for the internal modules.
|
|
13
|
+
- [Architecture](architecture.md): How `pypm` works under the hood.
|
|
14
|
+
|
|
15
|
+
## Key Features
|
|
16
|
+
|
|
17
|
+
- **Zero Config**: Works out of the box.
|
|
18
|
+
- **AST Parsing**: Static analysis for finding imports.
|
|
19
|
+
- **Smart Resolution**: Distinguishes between module names and package names.
|
|
20
|
+
- **Cross-Platform**: Windows, macOS, Linux.
|
|
21
|
+
|
|
22
|
+
## Author
|
|
23
|
+
|
|
24
|
+
**D. Suriya Kumar**
|
|
25
|
+
Email: suriyakumardurai.sk.in@gmail.com
|
|
26
|
+
GitHub: [Suriyakumardurai/pypm](https://github.com/Suriyakumardurai/pypm)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Usage Guide
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
git clone https://github.com/Suriyakumardurai/pypm.git
|
|
7
|
+
cd pypm
|
|
8
|
+
pip install .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This will also install `uv` for fast package management.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
### `pypm infer`
|
|
16
|
+
|
|
17
|
+
Scans the current directory (recursively) for `.py` files, extracts imports, resolves them to PyPI packages, and updates `pyproject.toml`.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pypm infer
|
|
21
|
+
# OR
|
|
22
|
+
pypm infer /path/to/project
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Options:**
|
|
26
|
+
- `--dry-run`: Preview changes without modifying files.
|
|
27
|
+
|
|
28
|
+
### `pypm install`
|
|
29
|
+
|
|
30
|
+
Infers dependencies and then installs them into the current environment.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pypm install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `pypm --version`
|
|
37
|
+
|
|
38
|
+
Displays the installed version of `pypm-cli`.
|
|
39
|
+
|
|
40
|
+
## Best Practices
|
|
41
|
+
|
|
42
|
+
- Run `pypm infer --dry-run` first to see what will be added.
|
|
43
|
+
- Use a virtual environment for your projects. `pypm` respects the active environment.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pypm-cli"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Automatic, AST-based dependency inference from Python imports. Stop writing dependencies twice."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "D. Suriya Kumar", email = "suriyakumardurai.sk.in@gmail.com" }
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
keywords = [
|
|
14
|
+
"python",
|
|
15
|
+
"dependency",
|
|
16
|
+
"cli",
|
|
17
|
+
"package-manager",
|
|
18
|
+
"pyproject"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"License :: OSI Approved :: MIT License"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"requests>=2.31",
|
|
33
|
+
"uv>=0.1.0"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
pypm = "pypm.cli:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/Suriyakumardurai/pypm"
|
|
41
|
+
Repository = "https://github.com/Suriyakumardurai/pypm"
|
|
42
|
+
Issues = "https://github.com/Suriyakumardurai/pypm/issues"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["hatchling"]
|
|
46
|
+
build-backend = "hatchling.build"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/pypm"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import tomli as toml
|
|
8
|
+
except ImportError:
|
|
9
|
+
# Python 3.11+ has tomllib
|
|
10
|
+
try:
|
|
11
|
+
import tomllib as toml
|
|
12
|
+
except ImportError:
|
|
13
|
+
# Fallback for simple reading if tomli not installed (though we might need it for parsing existing pyproject.toml)
|
|
14
|
+
# For MVP, if we can't import, we might just overwrite or fail gracefully on read.
|
|
15
|
+
# But wait, tomli is not stdlib in older python.
|
|
16
|
+
# The prompt said "No heavy external dependencies".
|
|
17
|
+
# We can implement a very simple TOML writer/reader or just append/write for MVP.
|
|
18
|
+
# Actually, let's stick to standard library. Python 3.11+ has tomllib.
|
|
19
|
+
# If < 3.11 and no tomli, we can't robustly parse TOML.
|
|
20
|
+
# "Use only stdlib for MVP".
|
|
21
|
+
# So we should avoid tomli dependency if possible.
|
|
22
|
+
# But we need to update pyproject.toml.
|
|
23
|
+
# Let's use a simple string logic for writing if generated.
|
|
24
|
+
# For reading, if the user has pyproject.toml, we might need to parse it.
|
|
25
|
+
# I'll implement a basic "read dependencies" from string if needed, or just overwrite/merge blindly text-wise?
|
|
26
|
+
# "If pyproject.toml exists: Merge dependencies without overwriting."
|
|
27
|
+
# This implies parsing.
|
|
28
|
+
# I will assume Python 3.11+ for `tomllib` OR rudimentary parsing.
|
|
29
|
+
# Let's stick to a robust-enough manual parser for [project.dependencies] section for MVP if stdlib only on <3.11.
|
|
30
|
+
toml = None
|
|
31
|
+
|
|
32
|
+
from .scanner import scan_directory
|
|
33
|
+
from .parser import get_imports_from_file
|
|
34
|
+
from .resolver import resolve_dependencies
|
|
35
|
+
from .installer import install_packages
|
|
36
|
+
from .utils import log, print_step, print_success, print_error, print_warning, BOLD, CYAN, GREEN, RESET
|
|
37
|
+
|
|
38
|
+
def get_project_dependencies(root_path: Path) -> List[str]:
|
|
39
|
+
"""
|
|
40
|
+
Core logic to scan, parse, and resolve dependencies.
|
|
41
|
+
"""
|
|
42
|
+
print_step("Scanning for .py files...")
|
|
43
|
+
py_files = scan_directory(root_path)
|
|
44
|
+
if not py_files:
|
|
45
|
+
print_warning("No .py files found.")
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
all_imports = set()
|
|
49
|
+
for file in py_files:
|
|
50
|
+
imports = get_imports_from_file(file)
|
|
51
|
+
all_imports.update(imports)
|
|
52
|
+
|
|
53
|
+
log(f"Found imports: {', '.join(sorted(all_imports))}", level="DEBUG")
|
|
54
|
+
|
|
55
|
+
dependencies = resolve_dependencies(all_imports, str(root_path))
|
|
56
|
+
return dependencies
|
|
57
|
+
|
|
58
|
+
def generate_pyproject_toml(dependencies: List[str], path: Path):
|
|
59
|
+
"""
|
|
60
|
+
Generates or updates pyproject.toml with dependencies (PEP 621).
|
|
61
|
+
"""
|
|
62
|
+
pyproject_path = path / "pyproject.toml"
|
|
63
|
+
|
|
64
|
+
current_deps = set()
|
|
65
|
+
content_lines = []
|
|
66
|
+
|
|
67
|
+
if pyproject_path.exists():
|
|
68
|
+
log("Found existing pyproject.toml, attempting to merge...", level="DEBUG")
|
|
69
|
+
try:
|
|
70
|
+
with open(pyproject_path, "r", encoding="utf-8") as f:
|
|
71
|
+
content_lines = f.readlines()
|
|
72
|
+
except Exception as e:
|
|
73
|
+
log(f"Failed to read pyproject.toml: {e}", level="ERROR")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Very basic parsing to find existing dependencies in `dependencies = [...]`
|
|
77
|
+
# This assumes a specific format for MVP simplicity if tomlib is missing.
|
|
78
|
+
# We look for lines containing strings inside the dependencies list.
|
|
79
|
+
in_deps = False
|
|
80
|
+
for line in content_lines:
|
|
81
|
+
stripped = line.strip()
|
|
82
|
+
if stripped.startswith("dependencies = ["):
|
|
83
|
+
in_deps = True
|
|
84
|
+
# Check for inline list like dependencies = ["a", "b"]
|
|
85
|
+
if "]" in stripped:
|
|
86
|
+
# Parse inline (rudimentary)
|
|
87
|
+
parts = stripped.split("[")[1].split("]")[0].split(",")
|
|
88
|
+
for p in parts:
|
|
89
|
+
d = p.strip().strip('"').strip("'")
|
|
90
|
+
if d: current_deps.add(d)
|
|
91
|
+
in_deps = False # Ended on same line
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if in_deps:
|
|
95
|
+
if stripped.startswith("]"):
|
|
96
|
+
in_deps = False
|
|
97
|
+
continue
|
|
98
|
+
# Line should be a string literal like "pkg",
|
|
99
|
+
clean_dep = stripped.strip(",").strip('"').strip("'")
|
|
100
|
+
if clean_dep:
|
|
101
|
+
current_deps.add(clean_dep)
|
|
102
|
+
else:
|
|
103
|
+
log("Generating new pyproject.toml...", level="DEBUG")
|
|
104
|
+
content_lines = [
|
|
105
|
+
"[project]\n",
|
|
106
|
+
"name = \"auto-generated\"\n",
|
|
107
|
+
"version = \"0.1.0\"\n",
|
|
108
|
+
"dependencies = [\n",
|
|
109
|
+
"]\n"
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# Calculate new deps - NO, we essentially overwrite with *dependencies* (which are the source of truth)
|
|
113
|
+
# But checking for diff is good for logging.
|
|
114
|
+
# Actually, we just want to enforce the inferred state.
|
|
115
|
+
|
|
116
|
+
new_deps_set = set(dependencies)
|
|
117
|
+
|
|
118
|
+
# We need to inject new deps into the `dependencies = [...]` list.
|
|
119
|
+
# Logic: Find the line with `dependencies = [` (start) and `]` (end).
|
|
120
|
+
# If not found, append valid block.
|
|
121
|
+
|
|
122
|
+
new_content_lines = []
|
|
123
|
+
deps_written = False
|
|
124
|
+
|
|
125
|
+
# Check if we have a dependencies block
|
|
126
|
+
has_deps_block = any(line.strip().startswith("dependencies = [") for line in content_lines)
|
|
127
|
+
|
|
128
|
+
if has_deps_block:
|
|
129
|
+
in_old_deps_block = False
|
|
130
|
+
for line in content_lines:
|
|
131
|
+
stripped = line.strip()
|
|
132
|
+
|
|
133
|
+
# Start of block
|
|
134
|
+
if stripped.startswith("dependencies = ["):
|
|
135
|
+
new_content_lines.append("dependencies = [\n")
|
|
136
|
+
|
|
137
|
+
for dep in sorted(new_deps_set):
|
|
138
|
+
new_content_lines.append(f' "{dep}",\n')
|
|
139
|
+
new_content_lines.append("]\n")
|
|
140
|
+
deps_written = True
|
|
141
|
+
|
|
142
|
+
# Check formatting of old block
|
|
143
|
+
if "]" in stripped:
|
|
144
|
+
# Inline block ended on same line.
|
|
145
|
+
continue
|
|
146
|
+
else:
|
|
147
|
+
# Multi-line block starts here. Skip lines until closing bracket.
|
|
148
|
+
in_old_deps_block = True
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if in_old_deps_block:
|
|
152
|
+
if stripped.startswith("]"):
|
|
153
|
+
in_old_deps_block = False
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
new_content_lines.append(line)
|
|
157
|
+
else:
|
|
158
|
+
# Append to end if no block found
|
|
159
|
+
new_content_lines = content_lines
|
|
160
|
+
if new_content_lines and not new_content_lines[-1].endswith("\n"):
|
|
161
|
+
new_content_lines.append("\n")
|
|
162
|
+
|
|
163
|
+
new_content_lines.append("dependencies = [\n")
|
|
164
|
+
for dep in sorted(new_deps_set):
|
|
165
|
+
new_content_lines.append(f' "{dep}",\n')
|
|
166
|
+
new_content_lines.append("]\n")
|
|
167
|
+
deps_written = True
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
with open(pyproject_path, "w", encoding="utf-8") as f:
|
|
171
|
+
f.writelines(new_content_lines)
|
|
172
|
+
log(f"Updated {pyproject_path} with {len(new_deps_set)} dependencies.", level="DEBUG")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
print_error(f"Failed to write pyproject.toml: {e}")
|
|
175
|
+
|
|
176
|
+
def command_infer(args):
|
|
177
|
+
root_path = Path(args.path).resolve()
|
|
178
|
+
if not root_path.exists():
|
|
179
|
+
log(f"Error: Path '{root_path}' does not exist.", level="ERROR")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
dependencies = get_project_dependencies(root_path)
|
|
183
|
+
|
|
184
|
+
# We proceed even if empty, to ensure pyproject.toml is cleared/updated.
|
|
185
|
+
if dependencies:
|
|
186
|
+
print_success(f"Found {len(dependencies)} external dependencies:")
|
|
187
|
+
for dep in dependencies:
|
|
188
|
+
print(f" {GREEN}+ {dep}{RESET}")
|
|
189
|
+
else:
|
|
190
|
+
print_warning("No external dependencies found.")
|
|
191
|
+
|
|
192
|
+
if getattr(args, "dry_run", False):
|
|
193
|
+
print_step("Dry run enabled. No files were modified.")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
generate_pyproject_toml(dependencies, root_path)
|
|
197
|
+
print_success(f"Updated {BOLD}{root_path / 'pyproject.toml'}{RESET}")
|
|
198
|
+
|
|
199
|
+
def command_install(args):
|
|
200
|
+
root_path = Path(args.path).resolve()
|
|
201
|
+
if not root_path.exists():
|
|
202
|
+
log(f"Error: Path '{root_path}' does not exist.", level="ERROR")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
log(f"Inferring dependencies in {root_path}...", level="INFO")
|
|
206
|
+
dependencies = get_project_dependencies(root_path)
|
|
207
|
+
|
|
208
|
+
if not dependencies:
|
|
209
|
+
log("No dependencies to install.", level="INFO")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# We need to install in the CONTEXT of that project?
|
|
213
|
+
# uv run? or just install packages?
|
|
214
|
+
# install_packages runs `uv pip install ...`.
|
|
215
|
+
# If we are in global mode, it installs to current environment.
|
|
216
|
+
# If the user wants to install TO that project's venv, they should activate it first?
|
|
217
|
+
# Or we just install the packages.
|
|
218
|
+
# MVP: Install packages to the current environment / active venv.
|
|
219
|
+
install_packages(dependencies)
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
parser = argparse.ArgumentParser(description="pypm - Python Project Manager")
|
|
223
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
|
224
|
+
|
|
225
|
+
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
226
|
+
|
|
227
|
+
# Infer command
|
|
228
|
+
parser_infer = subparsers.add_parser("infer", help="Infer dependencies and generate pyproject.toml")
|
|
229
|
+
parser_infer.add_argument("path", nargs="?", default=".", help="Path to project directory (default: current)")
|
|
230
|
+
parser_infer.add_argument("--dry-run", action="store_true", help="Print dependencies without modifying files")
|
|
231
|
+
parser_infer.set_defaults(func=command_infer)
|
|
232
|
+
|
|
233
|
+
# Install command
|
|
234
|
+
parser_install = subparsers.add_parser("install", help="Infer and install dependencies")
|
|
235
|
+
parser_install.add_argument("path", nargs="?", default=".", help="Path to project directory (default: current)")
|
|
236
|
+
parser_install.set_defaults(func=command_install)
|
|
237
|
+
|
|
238
|
+
# Version command
|
|
239
|
+
parser.add_argument("--version", action="store_true", help="Show version and exit")
|
|
240
|
+
|
|
241
|
+
args = parser.parse_args()
|
|
242
|
+
|
|
243
|
+
if args.version:
|
|
244
|
+
import importlib.metadata
|
|
245
|
+
try:
|
|
246
|
+
version = importlib.metadata.version("pypm-cli")
|
|
247
|
+
except importlib.metadata.PackageNotFoundError:
|
|
248
|
+
version = "0.0.1"
|
|
249
|
+
|
|
250
|
+
print(f"pypm-cli {version}")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if args.verbose:
|
|
254
|
+
import logging
|
|
255
|
+
from .utils import logger
|
|
256
|
+
logger.setLevel(logging.DEBUG)
|
|
257
|
+
|
|
258
|
+
if hasattr(args, "func"):
|
|
259
|
+
args.func(args)
|
|
260
|
+
else:
|
|
261
|
+
parser.print_help()
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from typing import List
|
|
4
|
+
from .utils import log, run_command, check_command_exists
|
|
5
|
+
|
|
6
|
+
def install_packages(packages: List[str]):
|
|
7
|
+
"""
|
|
8
|
+
Installs packages using uv if available, otherwise pip.
|
|
9
|
+
"""
|
|
10
|
+
if not packages:
|
|
11
|
+
log("No packages to install.", level="INFO")
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
use_uv = check_command_exists("uv")
|
|
15
|
+
|
|
16
|
+
if use_uv:
|
|
17
|
+
log("Found uv, using it for installation...", level="INFO")
|
|
18
|
+
command_str = "uv pip install "
|
|
19
|
+
# Check if running in venv
|
|
20
|
+
if sys.prefix == sys.base_prefix:
|
|
21
|
+
log("No virtual environment detected, using --system for uv.", level="WARNING")
|
|
22
|
+
command_str += "--system "
|
|
23
|
+
command_str += " ".join(packages)
|
|
24
|
+
else:
|
|
25
|
+
log("uv not found, falling back to pip...", level="INFO")
|
|
26
|
+
command_str = f"{sys.executable} -m pip install " + " ".join(packages)
|
|
27
|
+
|
|
28
|
+
log(f"Installing: {', '.join(packages)}", level="INFO")
|
|
29
|
+
if run_command(command_str):
|
|
30
|
+
log("Successfully installed packages.", level="INFO")
|
|
31
|
+
else:
|
|
32
|
+
log("Failed to install packages.", level="ERROR")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Set
|
|
4
|
+
from .utils import log
|
|
5
|
+
|
|
6
|
+
class ImportVisitor(ast.NodeVisitor):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.imports = set()
|
|
9
|
+
|
|
10
|
+
def visit_Import(self, node):
|
|
11
|
+
for alias in node.names:
|
|
12
|
+
self.imports.add(alias.name.split('.')[0])
|
|
13
|
+
self.generic_visit(node)
|
|
14
|
+
|
|
15
|
+
def visit_ImportFrom(self, node):
|
|
16
|
+
if node.module:
|
|
17
|
+
# Handle "from . import module" (node.module is None)
|
|
18
|
+
# node.module is the "package.module" part
|
|
19
|
+
self.imports.add(node.module.split('.')[0])
|
|
20
|
+
self.generic_visit(node)
|
|
21
|
+
|
|
22
|
+
def visit_Constant(self, node):
|
|
23
|
+
# Scan string literals for database connection strings
|
|
24
|
+
if isinstance(node.value, str):
|
|
25
|
+
val = node.value
|
|
26
|
+
if "mysql" + "+aiomysql://" in val:
|
|
27
|
+
self.imports.add("aiomysql")
|
|
28
|
+
elif "postgresql" + "+asyncpg://" in val:
|
|
29
|
+
self.imports.add("asyncpg")
|
|
30
|
+
elif "postgresql" + "+psycopg2://" in val:
|
|
31
|
+
self.imports.add("psycopg2")
|
|
32
|
+
elif "mssql" + "+pyodbc://" in val:
|
|
33
|
+
self.imports.add("pyodbc")
|
|
34
|
+
self.generic_visit(node)
|
|
35
|
+
|
|
36
|
+
def visit_Str(self, node):
|
|
37
|
+
# Python < 3.8 fallback
|
|
38
|
+
self.visit_Constant(node)
|
|
39
|
+
|
|
40
|
+
def get_imports_from_file(filepath: Path) -> Set[str]:
|
|
41
|
+
"""
|
|
42
|
+
Parses a python file and returns a set of top-level import names.
|
|
43
|
+
"""
|
|
44
|
+
imports = set()
|
|
45
|
+
try:
|
|
46
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
47
|
+
tree = ast.parse(f.read(), filename=str(filepath))
|
|
48
|
+
|
|
49
|
+
visitor = ImportVisitor()
|
|
50
|
+
visitor.visit(tree)
|
|
51
|
+
imports = visitor.imports
|
|
52
|
+
|
|
53
|
+
except SyntaxError as e:
|
|
54
|
+
log(f"Syntax error in {filepath}: {e}", level="ERROR")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
log(f"Error parsing {filepath}: {e}", level="ERROR")
|
|
57
|
+
|
|
58
|
+
return imports
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import urllib.error
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .utils import log
|
|
7
|
+
|
|
8
|
+
# Cache Setup
|
|
9
|
+
CACHE_DIR = Path.home() / ".cache" / "pypm"
|
|
10
|
+
CACHE_FILE = CACHE_DIR / "cache.json"
|
|
11
|
+
|
|
12
|
+
def load_cache() -> dict:
|
|
13
|
+
if not CACHE_FILE.exists():
|
|
14
|
+
return {}
|
|
15
|
+
try:
|
|
16
|
+
with open(CACHE_FILE, "r") as f:
|
|
17
|
+
return json.load(f)
|
|
18
|
+
except Exception:
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
def save_cache(cache: dict):
|
|
22
|
+
try:
|
|
23
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
with open(CACHE_FILE, "w") as f:
|
|
25
|
+
json.dump(cache, f)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
log(f"Failed to save cache: {e}", level="DEBUG")
|
|
28
|
+
|
|
29
|
+
# Global Cache
|
|
30
|
+
_PACKAGE_CACHE = load_cache()
|
|
31
|
+
|
|
32
|
+
def check_package_exists(package_name: str) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Checks if a package exists on PyPI using the JSON API.
|
|
35
|
+
Uses usage of local cache to avoid redundant network requests.
|
|
36
|
+
"""
|
|
37
|
+
if package_name in _PACKAGE_CACHE:
|
|
38
|
+
return _PACKAGE_CACHE[package_name]
|
|
39
|
+
|
|
40
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
41
|
+
exists = False
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(url, timeout=3) as response:
|
|
44
|
+
if response.status == 200:
|
|
45
|
+
exists = True
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
if e.code == 404:
|
|
48
|
+
exists = False
|
|
49
|
+
else:
|
|
50
|
+
log(f"Error checking PyPI for {package_name}: {e}", level="DEBUG")
|
|
51
|
+
# Don't cache errors other than 404
|
|
52
|
+
return False
|
|
53
|
+
except Exception as e:
|
|
54
|
+
log(f"Network error checking PyPI for {package_name}: {e}", level="DEBUG")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
# Update Cache
|
|
58
|
+
_PACKAGE_CACHE[package_name] = exists
|
|
59
|
+
save_cache(_PACKAGE_CACHE)
|
|
60
|
+
return exists
|
|
61
|
+
|
|
62
|
+
def get_latest_version(package_name: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Fetches the latest version of a package from PyPI.
|
|
65
|
+
Returns the version string (e.g., "1.0.0") or None if not found.
|
|
66
|
+
"""
|
|
67
|
+
clean_name = package_name.split("[")[0]
|
|
68
|
+
|
|
69
|
+
# We could cache versions too, but versions change.
|
|
70
|
+
# For now, let's strictly cache existence. Version fetching is rarer (only on install?).
|
|
71
|
+
# Actually, let's trust the network for versions to be fresh.
|
|
72
|
+
|
|
73
|
+
url = f"https://pypi.org/pypi/{clean_name}/json"
|
|
74
|
+
req = urllib.request.Request(url, headers={"User-Agent": "pypm-cli/0.0.1"})
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
77
|
+
if response.status == 200:
|
|
78
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
79
|
+
return data["info"]["version"]
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log(f"Failed to fetch version for {package_name}: {e}", level="DEBUG")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def find_pypi_package(import_name: str) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Attempts to find the correct PyPI package name for a given import.
|
|
87
|
+
"""
|
|
88
|
+
# 1. Exact match
|
|
89
|
+
if check_package_exists(import_name):
|
|
90
|
+
return import_name
|
|
91
|
+
|
|
92
|
+
# 2. Common patterns
|
|
93
|
+
variations = [
|
|
94
|
+
f"python-{import_name}",
|
|
95
|
+
f"{import_name}-python",
|
|
96
|
+
f"py{import_name}",
|
|
97
|
+
f"{import_name}py",
|
|
98
|
+
f"py-{import_name}"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for variant in variations:
|
|
102
|
+
if check_package_exists(variant):
|
|
103
|
+
return variant
|
|
104
|
+
|
|
105
|
+
return None
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Set, List, Dict
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import os
|
|
5
|
+
from .utils import log
|
|
6
|
+
from .pypi import find_pypi_package
|
|
7
|
+
|
|
8
|
+
# Load standard library module names
|
|
9
|
+
try:
|
|
10
|
+
STDLIB_MODULES = sys.stdlib_module_names
|
|
11
|
+
except AttributeError:
|
|
12
|
+
# Python < 3.10 fallback (simplified list for MVP, expandable)
|
|
13
|
+
STDLIB_MODULES = {
|
|
14
|
+
"os", "sys", "re", "math", "random", "datetime", "json", "logging",
|
|
15
|
+
"argparse", "subprocess", "typing", "pathlib", "collections", "itertools",
|
|
16
|
+
"functools", "ast", "shutil", "time", "io", "copy", "platform", "enum",
|
|
17
|
+
"threading", "multiprocessing", "socket", "email", "http", "urllib",
|
|
18
|
+
"dataclasses", "contextlib", "abc", "inspect", "warnings", "traceback"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Common import name -> PyPI package name mappings
|
|
22
|
+
# Kept as a fast-path cache for known non-obvious mappings
|
|
23
|
+
COMMON_MAPPINGS = {
|
|
24
|
+
"sklearn": "scikit-learn",
|
|
25
|
+
"PIL": "Pillow",
|
|
26
|
+
"cv2": "opencv-python",
|
|
27
|
+
"yaml": "PyYAML",
|
|
28
|
+
"bs4": "beautifulsoup4",
|
|
29
|
+
"jose": "python-jose[cryptography]",
|
|
30
|
+
"barcode": "python-barcode",
|
|
31
|
+
"pydantic_settings": "pydantic-settings",
|
|
32
|
+
"mysqldb": "mysqlclient",
|
|
33
|
+
"MySQLdb": "mysqlclient",
|
|
34
|
+
"dotenv": "python-dotenv",
|
|
35
|
+
"dateutil": "python-dateutil",
|
|
36
|
+
"psycopg2": "psycopg2-binary",
|
|
37
|
+
"tls_client": "tls-client",
|
|
38
|
+
"google.protobuf": "protobuf",
|
|
39
|
+
"telegram": "python-telegram-bot",
|
|
40
|
+
"mysql": "pymysql",
|
|
41
|
+
"qrcode": "qrcode[pil]",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Framework specific additions (if key is found, add value)
|
|
45
|
+
FRAMEWORK_EXTRAS = {
|
|
46
|
+
"fastapi": ["uvicorn[standard]", "python-multipart", "email-validator"],
|
|
47
|
+
"passlib": ["passlib[bcrypt]", "bcrypt==4.1.2"], # Pin bcrypt to 4.1.2 for passlib compatibility
|
|
48
|
+
"sqlalchemy": ["greenlet"], # Async SQLAlchemy often needs greenlet explicitly
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def is_stdlib(module_name: str) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Checks if a module is in the standard library.
|
|
54
|
+
"""
|
|
55
|
+
if module_name.startswith("_"):
|
|
56
|
+
return True
|
|
57
|
+
return module_name in STDLIB_MODULES
|
|
58
|
+
|
|
59
|
+
def is_local_module(module_name: str, project_root: str) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Checks if a module is part of the local project by searching for it.
|
|
62
|
+
"""
|
|
63
|
+
# 1. Simple Top-Level Check
|
|
64
|
+
if os.path.exists(os.path.join(project_root, f"{module_name}.py")):
|
|
65
|
+
return True
|
|
66
|
+
if os.path.exists(os.path.join(project_root, module_name, "__init__.py")):
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# 2. Recursive Check (for flat layouts or src layouts)
|
|
70
|
+
# If we are in root, and imports are like `import utils`, but utils is at `pypm/utils.py`
|
|
71
|
+
# This implies the user is running from root but the code expects `pypm` to be in path?
|
|
72
|
+
# Or relative imports were parsed as absolute?
|
|
73
|
+
# AST parser: `from . import utils` -> module="utils" (level=1)? No.
|
|
74
|
+
# `from .utils import log` -> module="utils"?
|
|
75
|
+
|
|
76
|
+
# Wait, my parser logic:
|
|
77
|
+
# `elif isinstance(node, ast.ImportFrom): if node.module: imports.add(node.module.split('.')[0])`
|
|
78
|
+
# If `from .utils import log` -> node.module is likely None or "utils" depending on python version?
|
|
79
|
+
# Actually, if level > 0, module might be None or the "utils" part.
|
|
80
|
+
# But `node.module` IS `utils` for `from .utils import ...`?
|
|
81
|
+
# Let's assume standard absolute imports for now.
|
|
82
|
+
|
|
83
|
+
# Heuristic: Walk the directory to see if `module_name.py` exists ANYWHERE in the project.
|
|
84
|
+
# This is risky (might match `tests/utils.py` and think `import utils` is local).
|
|
85
|
+
# But for a project manager, if `utils.py` exists in the project, it's safer to assume it's local
|
|
86
|
+
# than to install `utils` from PyPI (which is a common mistake).
|
|
87
|
+
|
|
88
|
+
for root, dirs, files in os.walk(project_root):
|
|
89
|
+
# Skip venvs
|
|
90
|
+
if "site-packages" in root or ".venv" in root or "venv" in root:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if f"{module_name}.py" in files:
|
|
94
|
+
return True
|
|
95
|
+
if module_name in dirs:
|
|
96
|
+
if os.path.exists(os.path.join(root, module_name, "__init__.py")):
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
# Packages that exist on PyPI but are almost always local modules in user projects
|
|
102
|
+
SUSPICIOUS_PACKAGES = {
|
|
103
|
+
"core", "modules", "crm", "ledgers", "config", "utils", "common", "tests", "settings", "db", "database"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def get_installed_version(package_name: str) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Attempts to get the installed version of a package.
|
|
109
|
+
Returns the package name with version specifier if found, else just package name.
|
|
110
|
+
"""
|
|
111
|
+
# Clean package name for lookup (remove extras)
|
|
112
|
+
clean_name = package_name.split("[")[0]
|
|
113
|
+
try:
|
|
114
|
+
version = importlib.metadata.version(clean_name)
|
|
115
|
+
return f"{package_name}=={version}"
|
|
116
|
+
except importlib.metadata.PackageNotFoundError:
|
|
117
|
+
return package_name
|
|
118
|
+
|
|
119
|
+
def resolve_dependencies(imports: Set[str], project_root: str) -> List[str]:
|
|
120
|
+
"""
|
|
121
|
+
Resolves imports to distribution packages using Online PyPI verification.
|
|
122
|
+
"""
|
|
123
|
+
dependencies = []
|
|
124
|
+
|
|
125
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
126
|
+
|
|
127
|
+
# Prepare list of candidates to check
|
|
128
|
+
candidates_to_check = []
|
|
129
|
+
|
|
130
|
+
for module in imports:
|
|
131
|
+
# 1. Filter Standard Library
|
|
132
|
+
if is_stdlib(module):
|
|
133
|
+
log(f"Ignored stdlib module: {module}", level="DEBUG")
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# 2. Filter Local Modules
|
|
137
|
+
if is_local_module(module, project_root):
|
|
138
|
+
log(f"Ignored local module: {module}", level="DEBUG")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# 3. Filter Suspicious/Generic Names
|
|
142
|
+
if module in SUSPICIOUS_PACKAGES:
|
|
143
|
+
log(f"Ignored suspicious/generic module name: {module} (likely local)", level="DEBUG")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# 4. Fast Path: Common Mappings
|
|
147
|
+
if module in COMMON_MAPPINGS:
|
|
148
|
+
dependencies.append(COMMON_MAPPINGS[module])
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# 5. Queue for Online Check
|
|
152
|
+
candidates_to_check.append(module)
|
|
153
|
+
|
|
154
|
+
# 5. Online Verification (Parallelized for speed)
|
|
155
|
+
if candidates_to_check:
|
|
156
|
+
from .utils import print_step
|
|
157
|
+
print_step(f"Verifying {len(candidates_to_check)} packages on PyPI...")
|
|
158
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
159
|
+
future_to_module = {executor.submit(find_pypi_package, m): m for m in candidates_to_check}
|
|
160
|
+
|
|
161
|
+
for future in as_completed(future_to_module):
|
|
162
|
+
module = future_to_module[future]
|
|
163
|
+
try:
|
|
164
|
+
pypi_name = future.result()
|
|
165
|
+
if pypi_name:
|
|
166
|
+
log(f"Verified '{module}' -> '{pypi_name}' on PyPI", level="DEBUG")
|
|
167
|
+
dependencies.append(pypi_name)
|
|
168
|
+
else:
|
|
169
|
+
log(f"Warning: Could not find package for import '{module}' on PyPI.", level="DEBUG")
|
|
170
|
+
# Do NOT add it if not found (Zero Error policy)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
log(f"Error verifying {module}: {e}", level="ERROR")
|
|
173
|
+
|
|
174
|
+
# 6. Apply Framework Extras
|
|
175
|
+
# If specific packages are present, ensure their common companions are added.
|
|
176
|
+
final_deps = set(dependencies)
|
|
177
|
+
|
|
178
|
+
framework_additions = []
|
|
179
|
+
|
|
180
|
+
# Check for keys in FRAMEWORK_EXTRAS
|
|
181
|
+
# We need to check if the triggers are in final_deps (names might vary)
|
|
182
|
+
# Simplified check: if any dep contains the key string?
|
|
183
|
+
# No, matching "fastapi" against "fastapi" is safe.
|
|
184
|
+
# But "sqlalchemy" might be "SQLAlchemy" or "sqlalchemy==2.0"?
|
|
185
|
+
# We haven't pinned yet, so it's just names.
|
|
186
|
+
|
|
187
|
+
for trigger, extras in FRAMEWORK_EXTRAS.items():
|
|
188
|
+
# Check if Trigger is in dependencies.
|
|
189
|
+
# Handle case where dependency is "passlib[bcrypt]" but trigger is "passlib"
|
|
190
|
+
trace_found = False
|
|
191
|
+
for dep in final_deps:
|
|
192
|
+
if dep.split("[")[0].lower() == trigger.lower():
|
|
193
|
+
trace_found = True
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
if trace_found:
|
|
197
|
+
framework_additions.extend(extras)
|
|
198
|
+
|
|
199
|
+
if framework_additions:
|
|
200
|
+
from .utils import print_step
|
|
201
|
+
print_step(f"Detected frameworks, adding extras: {', '.join(framework_additions)}")
|
|
202
|
+
final_deps.update(framework_additions)
|
|
203
|
+
|
|
204
|
+
# 6b. Deduplicate: If package[extra] is present, remove package
|
|
205
|
+
# e.g. if "passlib[bcrypt]" is in list, remove "passlib"
|
|
206
|
+
# Logic: Collect all base names of satisfied extras.
|
|
207
|
+
deps_with_extras = [d for d in final_deps if "[" in d]
|
|
208
|
+
bases_to_remove = set()
|
|
209
|
+
for d in deps_with_extras:
|
|
210
|
+
base = d.split("[")[0]
|
|
211
|
+
bases_to_remove.add(base)
|
|
212
|
+
|
|
213
|
+
# Remove dependencies that differ from the base only by not having the extra
|
|
214
|
+
# AND are in the removal set.
|
|
215
|
+
# Note: "passlib" matches base "passlib". "passlib[bcrypt]" does NOT match base "passlib".
|
|
216
|
+
# We want to remove "passlib" if "passlib" in final_deps AND "passlib" in bases_to_remove.
|
|
217
|
+
final_deps = {d for d in final_deps if d not in bases_to_remove} | set(deps_with_extras)
|
|
218
|
+
|
|
219
|
+
# 7. Version Pinning
|
|
220
|
+
# Attempt to resolve versions for all final dependencies
|
|
221
|
+
pinned_deps = []
|
|
222
|
+
|
|
223
|
+
# Strategy: Use `uv pip compile` to resolve compatible versions if available.
|
|
224
|
+
# This respects python version constraints and solves for conflicts.
|
|
225
|
+
|
|
226
|
+
from .utils import check_command_exists, run_command
|
|
227
|
+
import subprocess
|
|
228
|
+
import shutil
|
|
229
|
+
|
|
230
|
+
resolved_map = {}
|
|
231
|
+
uv_success = False
|
|
232
|
+
|
|
233
|
+
if check_command_exists("uv"):
|
|
234
|
+
from .utils import print_step
|
|
235
|
+
print_step("Resolving versions with 'uv' for compatibility...")
|
|
236
|
+
try:
|
|
237
|
+
# Create a requirements input string
|
|
238
|
+
# We use local python version by default by not specifying --python-version
|
|
239
|
+
# But we should use --python-platform maybe? No, defaults are usually fine for "standard".
|
|
240
|
+
input_reqs = "\n".join(final_deps)
|
|
241
|
+
|
|
242
|
+
# Run uv pip compile
|
|
243
|
+
# We use --universal if we want broader compatibility, but user wants "standard" for THIS env mostly.
|
|
244
|
+
# Let's use default behavior which resolves for current environment.
|
|
245
|
+
proc = subprocess.run(
|
|
246
|
+
["uv", "pip", "compile", "-", "--quiet", "--no-header", "--no-annotate", "--no-emit-index-url"],
|
|
247
|
+
input=input_reqs.encode("utf-8"),
|
|
248
|
+
capture_output=True,
|
|
249
|
+
check=False
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if proc.returncode == 0:
|
|
253
|
+
output = proc.stdout.decode("utf-8")
|
|
254
|
+
# Parse output: "package==version"
|
|
255
|
+
for line in output.splitlines():
|
|
256
|
+
line = line.strip()
|
|
257
|
+
if "==" in line and not line.startswith("#"):
|
|
258
|
+
parts = line.split("==")
|
|
259
|
+
pkg = parts[0].strip()
|
|
260
|
+
ver = parts[1].strip()
|
|
261
|
+
# Clean extras from pkg name for matching if needed,
|
|
262
|
+
# but pip compile output usually has clean names?
|
|
263
|
+
# Actually verify against our deps.
|
|
264
|
+
resolved_map[pkg.lower()] = f"{pkg}=={ver}"
|
|
265
|
+
# Also handle extras syntax in output if present (rare in list, usually just name==ver)
|
|
266
|
+
uv_success = True
|
|
267
|
+
else:
|
|
268
|
+
log(f"uv resolution failed: {proc.stderr.decode('utf-8')}", level="DEBUG")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
log(f"Error running uv: {e}", level="DEBUG")
|
|
271
|
+
|
|
272
|
+
if not uv_success:
|
|
273
|
+
# Fallback to PyPI JSON "latest"
|
|
274
|
+
log("Falling back to PyPI latest version check...", level="INFO")
|
|
275
|
+
from .pypi import get_latest_version
|
|
276
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
277
|
+
future_to_dep = {executor.submit(get_latest_version, dep): dep for dep in final_deps}
|
|
278
|
+
for future in as_completed(future_to_dep):
|
|
279
|
+
dep = future_to_dep[future]
|
|
280
|
+
try:
|
|
281
|
+
version = future.result()
|
|
282
|
+
if version:
|
|
283
|
+
# Clean dep name to avoid extras mess in map?
|
|
284
|
+
# Just use dep as key.
|
|
285
|
+
resolved_map[dep.lower()] = f"{dep}=={version}"
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
# Apply resolved versions
|
|
290
|
+
for dep in final_deps:
|
|
291
|
+
# dep might be "passlib[bcrypt]"
|
|
292
|
+
# resolved_map key might be "passlib"
|
|
293
|
+
clean_name = dep.split("[")[0].lower()
|
|
294
|
+
|
|
295
|
+
if clean_name in resolved_map:
|
|
296
|
+
# Map has "passlib==1.7.4". We want "passlib[bcrypt]==1.7.4"
|
|
297
|
+
# Extract version from resolved string
|
|
298
|
+
resolved_str = resolved_map[clean_name]
|
|
299
|
+
# Use original dep string + version
|
|
300
|
+
version = resolved_str.split("==")[1]
|
|
301
|
+
pinned_deps.append(f"{dep}=={version}")
|
|
302
|
+
else:
|
|
303
|
+
# If the dependency already has a version pin (from framework extras), keep it
|
|
304
|
+
if "==" in dep:
|
|
305
|
+
pinned_deps.append(dep)
|
|
306
|
+
else:
|
|
307
|
+
pinned_deps.append(dep)
|
|
308
|
+
|
|
309
|
+
return sorted(list(set(pinned_deps)))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Set
|
|
4
|
+
from .utils import log
|
|
5
|
+
|
|
6
|
+
def is_virtual_env(path: Path) -> bool:
|
|
7
|
+
"""
|
|
8
|
+
Simple heuristic to check if a directory is a virtual environment.
|
|
9
|
+
"""
|
|
10
|
+
# Common venv indicators
|
|
11
|
+
return (path / "pyvenv.cfg").exists() or \
|
|
12
|
+
(path / "bin" / "activate").exists() or \
|
|
13
|
+
(path / "Scripts" / "activate").exists() or \
|
|
14
|
+
path.name in (".venv", "venv", "env", ".git", ".idea", "__pycache__", "tests", "test", "testing")
|
|
15
|
+
|
|
16
|
+
def scan_directory(root_path: Path) -> List[Path]:
|
|
17
|
+
"""
|
|
18
|
+
Recursively scans the directory for .py files, excluding git, venvs, etc.
|
|
19
|
+
"""
|
|
20
|
+
py_files: List[Path] = []
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
# Walk the tree
|
|
24
|
+
for root, dirs, files in os.walk(root_path):
|
|
25
|
+
current_root = Path(root)
|
|
26
|
+
|
|
27
|
+
# Modify dirs in-place to skip ignored directories and test directories
|
|
28
|
+
dirs[:] = [d for d in dirs if not is_virtual_env(current_root / d) and d not in ("tests", "test", "testing")]
|
|
29
|
+
|
|
30
|
+
for file in files:
|
|
31
|
+
if file.endswith(".py"):
|
|
32
|
+
# Skip test files
|
|
33
|
+
if file.startswith("test_") or file.endswith("_test.py") or file in ("test.py", "tests.py"):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
py_files.append(current_root / file)
|
|
37
|
+
|
|
38
|
+
except PermissionError as e:
|
|
39
|
+
log(f"Permission denied accessing {root_path}: {e}", level="ERROR")
|
|
40
|
+
|
|
41
|
+
return py_files
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import subprocess
|
|
3
|
+
import shlex
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
# Configure logging
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
level=logging.INFO,
|
|
10
|
+
format="%(message)s",
|
|
11
|
+
handlers=[logging.StreamHandler(sys.stdout)]
|
|
12
|
+
)
|
|
13
|
+
logger = logging.getLogger("pypm")
|
|
14
|
+
|
|
15
|
+
# ANSI Colors
|
|
16
|
+
CYAN = "\033[36m"
|
|
17
|
+
GREEN = "\033[32m"
|
|
18
|
+
YELLOW = "\033[33m"
|
|
19
|
+
RED = "\033[31m"
|
|
20
|
+
BOLD = "\033[1m"
|
|
21
|
+
RESET = "\033[0m"
|
|
22
|
+
|
|
23
|
+
# Icons
|
|
24
|
+
Green_Check = f"{GREEN}✔{RESET}"
|
|
25
|
+
Red_Cross = f"{RED}✖{RESET}"
|
|
26
|
+
Yellow_Warn = f"{YELLOW}⚠{RESET}"
|
|
27
|
+
|
|
28
|
+
def log(message: str, level: str = "INFO"):
|
|
29
|
+
"""
|
|
30
|
+
Wrapper around logging for compatibility with existing code.
|
|
31
|
+
"""
|
|
32
|
+
if level == "DEBUG":
|
|
33
|
+
logger.debug(f"{CYAN}[DEBUG] {message}{RESET}")
|
|
34
|
+
elif level == "WARNING":
|
|
35
|
+
logger.warning(f"{Yellow_Warn} {YELLOW}{message}{RESET}")
|
|
36
|
+
elif level == "ERROR":
|
|
37
|
+
logger.error(f"{Red_Cross} {RED}{message}{RESET}")
|
|
38
|
+
else:
|
|
39
|
+
logger.info(message)
|
|
40
|
+
|
|
41
|
+
def print_step(message: str):
|
|
42
|
+
logger.info(f"{BOLD}{CYAN}==>{RESET} {BOLD}{message}{RESET}")
|
|
43
|
+
|
|
44
|
+
def print_success(message: str):
|
|
45
|
+
logger.info(f"{Green_Check} {GREEN}{message}{RESET}")
|
|
46
|
+
|
|
47
|
+
def print_error(message: str):
|
|
48
|
+
logger.error(f"{Red_Cross} {RED}{message}{RESET}")
|
|
49
|
+
|
|
50
|
+
def print_warning(message: str):
|
|
51
|
+
logger.warning(f"{Yellow_Warn} {YELLOW}{message}{RESET}")
|
|
52
|
+
|
|
53
|
+
def run_command(command: str, cwd: Optional[str] = None) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Runs a shell command. Returns True if successful, False otherwise.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
# On Windows, shlex.split might not handle backslashes as expected for paths if not careful,
|
|
59
|
+
# but for simple commands like 'uv pip install ...' it should be fine.
|
|
60
|
+
# Check platform to decide whether to use shell=True or not (often needed on Windows for built-ins, but uv is an executable).
|
|
61
|
+
# We will use shell=False and split command for better security/control, unless complex shell features are needed.
|
|
62
|
+
|
|
63
|
+
args = shlex.split(command, posix=(sys.platform != "win32"))
|
|
64
|
+
|
|
65
|
+
log(f"Running: {command}", level="DEBUG")
|
|
66
|
+
result = subprocess.run(args, cwd=cwd, check=False, text=True, capture_output=False)
|
|
67
|
+
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
log(f"Command failed with return code {result.returncode}", level="ERROR")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
return True
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
log(f"Command not found: {command}", level="ERROR")
|
|
75
|
+
return False
|
|
76
|
+
except Exception as e:
|
|
77
|
+
log(f"Error running command: {e}", level="ERROR")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def check_command_exists(command: str) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Checks if a command exists effectively by trying to find it.
|
|
83
|
+
"""
|
|
84
|
+
from shutil import which
|
|
85
|
+
return which(command) is not None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pypm.resolver import is_stdlib
|
|
3
|
+
|
|
4
|
+
def test_is_stdlib_true():
|
|
5
|
+
assert is_stdlib("os") is True
|
|
6
|
+
assert is_stdlib("sys") is True
|
|
7
|
+
assert is_stdlib("json") is True
|
|
8
|
+
assert is_stdlib("math") is True
|
|
9
|
+
|
|
10
|
+
def test_is_stdlib_false():
|
|
11
|
+
assert is_stdlib("requests") is False
|
|
12
|
+
assert is_stdlib("pypm") is False
|
|
13
|
+
assert is_stdlib("black") is False
|
|
14
|
+
assert is_stdlib("numpy") is False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from pypm.scanner import is_virtual_env
|
|
4
|
+
|
|
5
|
+
def test_is_virtual_env_true(tmp_path):
|
|
6
|
+
# Case 1: pyvenv.cfg exists
|
|
7
|
+
venv_dir = tmp_path / "venv"
|
|
8
|
+
venv_dir.mkdir()
|
|
9
|
+
(venv_dir / "pyvenv.cfg").touch()
|
|
10
|
+
assert is_virtual_env(venv_dir) is True
|
|
11
|
+
|
|
12
|
+
# Case 2: bin/activate exists
|
|
13
|
+
venv_dir2 = tmp_path / "env"
|
|
14
|
+
venv_dir2.mkdir()
|
|
15
|
+
(venv_dir2 / "bin").mkdir()
|
|
16
|
+
(venv_dir2 / "bin" / "activate").touch()
|
|
17
|
+
assert is_virtual_env(venv_dir2) is True
|
|
18
|
+
|
|
19
|
+
def test_is_virtual_env_false(tmp_path):
|
|
20
|
+
# Case: Normal directory
|
|
21
|
+
normal_dir = tmp_path / "src"
|
|
22
|
+
normal_dir.mkdir()
|
|
23
|
+
assert is_virtual_env(normal_dir) is False
|
|
24
|
+
|
|
25
|
+
def test_is_virtual_env_name_match(tmp_path):
|
|
26
|
+
# Case: Name is .venv
|
|
27
|
+
dot_venv = tmp_path / ".venv"
|
|
28
|
+
dot_venv.mkdir()
|
|
29
|
+
assert is_virtual_env(dot_venv) is True
|