├── .github ├── FUNDING.yml ├── scripts │ └── get_version.py └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── changelogithub.config.json ├── pdm.lock ├── pdm_build.py ├── pyproject.toml ├── src ├── onepm │ ├── __init__.py │ ├── cli.py │ ├── core.py │ ├── pm │ │ ├── __init__.py │ │ ├── base.py │ │ ├── pdm.py │ │ ├── pip.py │ │ ├── pipenv.py │ │ ├── poetry.py │ │ └── uv.py │ └── py.typed └── onepm_shims │ ├── __init__.py │ └── shims.py └── tests ├── conftest.py ├── test_pdm.py ├── test_pip.py ├── test_pipenv.py ├── test_poetry.py ├── test_utils.py └── test_uv.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [frostming] 2 | custom: 3 | - https://afdian.net/a/frostming 4 | -------------------------------------------------------------------------------- /.github/scripts/get_version.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import subprocess 3 | import sys 4 | 5 | 6 | def get_current_version() -> str: 7 | # get the latest tag 8 | tag = subprocess.check_output( 9 | ["git", "describe", "--abbrev=0", "--tags"], text=True 10 | ).strip() 11 | version = tag.lstrip("v") 12 | return version 13 | 14 | 15 | def get_next_version(patch: bool = False) -> str: 16 | current_version = get_current_version() 17 | version_parts = current_version.split(".") 18 | if len(version_parts) != 3: 19 | version_parts.append("0") 20 | if patch: 21 | version_parts[2] = str(int(version_parts[2]) + 1) 22 | return ".".join(version_parts) 23 | else: 24 | major, minor, _ = version_parts 25 | this_year = str(datetime.datetime.now(tz=datetime.UTC).year)[-2:] 26 | if this_year == major: 27 | minor = str(int(minor) + 1) 28 | else: 29 | major = this_year 30 | minor = "0" 31 | return f"{major}.{minor}" 32 | 33 | 34 | if __name__ == "__main__": 35 | patch = "--patch" in sys.argv or "-p" in sys.argv 36 | print(get_next_version(patch)) 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | Testing: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12"] 15 | os: [ubuntu-latest, macOS-latest, windows-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: pdm-project/setup-pdm@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | cache: true 24 | - name: Install Dependencies 25 | run: | 26 | pdm sync 27 | - name: Run Tests 28 | run: | 29 | pdm run test 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | patch: 7 | description: 'Patch version' 8 | required: false 9 | default: false 10 | type: boolean 11 | package: 12 | description: 'Package to release, or "all" to release all packages' 13 | required: true 14 | default: 'all' 15 | type: choice 16 | options: 17 | - all 18 | - main 19 | - shims 20 | 21 | jobs: 22 | release-pypi: 23 | name: release-pypi 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | with: 29 | fetch-depth: 0 30 | 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: '3.11' 34 | 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: 20.x 38 | 39 | - name: Get version 40 | id: get_version 41 | run: | 42 | if [ "${{ github.event.inputs.patch }}" = "true" ]; then 43 | echo "VERSION=$(python .github/scripts/get_version.py --patch)" >> $GITHUB_OUTPUT 44 | else 45 | echo "VERSION=$(python .github/scripts/get_version.py)" >> $GITHUB_OUTPUT 46 | fi 47 | 48 | - name: Create Release 49 | uses: softprops/action-gh-release@v1 50 | with: 51 | name: '${{ github.event.inputs.package }}@v${{ steps.get_version.outputs.VERSION }}' 52 | tag_name: 'v${{ steps.get_version.outputs.VERSION }}' 53 | 54 | - name: Pull tags 55 | run: git pull --tags 56 | 57 | - run: npx changelogithub 58 | env: 59 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 60 | 61 | - name: Build main 62 | if: github.event.inputs.package == 'all' || github.event.inputs.package == 'main' 63 | run: pipx run build 64 | 65 | - name: Build shims 66 | if: github.event.inputs.package == 'all' || github.event.inputs.package == 'shims' 67 | run: pipx run build -C mina-target=shims 68 | 69 | - name: Upload to Pypi 70 | run: | 71 | pipx run twine upload --username __token__ --password ${{ secrets.PYPI_TOKEN }} dist/* 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__/ 3 | .pdm-python 4 | dist/ 5 | build/ 6 | *.egg-info/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/astral-sh/ruff-pre-commit 12 | rev: 'v0.1.9' 13 | hooks: 14 | - id: ruff 15 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 16 | - id: ruff-format 17 | 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v1.8.0 20 | hooks: 21 | - id: mypy 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Frost Ming 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # onepm 2 | 3 | Picks the right package manager for you. 4 | 5 | Don't make me think about which package manager to use when I clone a project from other people. OnePM will pick the right package manager by searching for the lock files and/or the project settings in `pyproject.toml`. 6 | 7 | This project is created in the same spirit as [@antfu/ni](https://www.npmjs.com/package/@antfu/ni). 8 | 9 | Supported package managers: [pip], [pipenv], [poetry], [pdm], [uv] 10 | 11 | [pip]: https://pypi.org/project/pip/ 12 | [pipenv]: https://pypi.org/project/pipenv/ 13 | [poetry]: https://pypi.org/project/poetry/ 14 | [pdm]: https://pypi.org/project/pdm/ 15 | [uv]: https://pypi.org/project/uv/ 16 | 17 | ## Install onepm 18 | 19 | Install with `pipx`: 20 | 21 | ```bash 22 | pipx install onepm 23 | ``` 24 | 25 | Or use pdm global install: 26 | 27 | ```bash 28 | pdm add -g onepm 29 | ``` 30 | 31 | ## Provided Shortcuts 32 | 33 | ### `pi` - install 34 | 35 | ```bash 36 | pi 37 | 38 | # (venv) pip install . or pip install -r requirements.txt 39 | # pipenv install 40 | # poetry install 41 | # pdm install 42 | ``` 43 | 44 | ```bash 45 | pi requests 46 | 47 | # (venv) pip install requests 48 | # pipenv install requests 49 | # poetry add requests 50 | # pdm add requests 51 | ``` 52 | 53 | ### `pu` - update 54 | 55 | ```bash 56 | pu 57 | 58 | # not available for pip 59 | # pipenv update 60 | # poetry update 61 | # pdm update 62 | ``` 63 | 64 | ### `pr` - run 65 | 66 | ```bash 67 | pr ...args 68 | 69 | # (venv) ...args 70 | # pipenv run ...args 71 | # poetry run ...args 72 | # pdm run ...args 73 | ``` 74 | 75 | ### `pun` - uninstall 76 | 77 | ```bash 78 | pun requests 79 | 80 | # pip uninstall requests 81 | # pipenv uninstall requests 82 | # poetry remove requests 83 | # pdm remove requests 84 | ``` 85 | 86 | ### `pa` - Alias for the package manager 87 | 88 | ```bash 89 | pa 90 | 91 | # pip 92 | # pipenv 93 | # poetry 94 | # pdm 95 | ``` 96 | 97 | If the package manager agent is pip, **OnePM will enforce an activated virtualenv, or a `.venv` under the current directory**. 98 | 99 | ## Shims for Package Managers 100 | 101 | OnePM also provides shim for the package managers like [corepack](https://nodejs.org/api/corepack.html), 102 | so you don't need to install package managers yourself. To enable it, install OnePM with `shims` extra: 103 | 104 | ```bash 105 | pipx install --include-deps onepm[shims] 106 | ``` 107 | 108 | OnePM reads the `package-manager` field under `[tool.onepm]` table in `pyproject.toml`, and install the required package manager with the correct version in an isolated environment. 109 | 110 | ```toml 111 | [tool.onepm] 112 | package-manager = "poetry" 113 | ``` 114 | 115 | Or you can restrict the version range: 116 | 117 | ```toml 118 | [tool.onepm] 119 | package-manager = "poetry>=1.1.0" 120 | ``` 121 | 122 | _For Python package management, OnePM is all you need._ 123 | 124 | ## OnePM Management Commands 125 | 126 | - `onepm install`: Install the package manager configured in project file 127 | - `onepm use $SPEC`: Use the package manager given by the requirement spec 128 | - `onepm update|up`: Update the package manager used in the project 129 | - `onepm cleanup [$NAME]`: Clean up installations of specified package manager or all 130 | - `onepm list|ls $NAME`: List all installed versions of the given package manager 131 | -------------------------------------------------------------------------------- /changelogithub.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": { 3 | "feat": { "title": "🚀 Features" }, 4 | "fix": { "title": "🐞 Bug Fixes" }, 5 | "doc": { "title": "📝 Documentation" }, 6 | "chore": { "title": "💻 Chores" } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev", "test"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.5.0" 8 | content_hash = "sha256:77e0a3fcbbc57f11caa12d4c500b68b11d0c48a9f7a30b9994faed623da46fc1" 9 | 10 | [[metadata.targets]] 11 | requires_python = ">=3.10" 12 | 13 | [[package]] 14 | name = "certifi" 15 | version = "2024.2.2" 16 | requires_python = ">=3.6" 17 | summary = "Python package for providing Mozilla's CA Bundle." 18 | groups = ["dev"] 19 | files = [ 20 | {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, 21 | {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, 22 | ] 23 | 24 | [[package]] 25 | name = "charset-normalizer" 26 | version = "3.3.2" 27 | requires_python = ">=3.7.0" 28 | summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 29 | groups = ["dev"] 30 | files = [ 31 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 32 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, 33 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, 34 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, 35 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, 36 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, 37 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, 38 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, 39 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, 40 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, 41 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, 42 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, 43 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, 44 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, 45 | {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, 46 | {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, 47 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 48 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 49 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 50 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 51 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 52 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 53 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 54 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 55 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 56 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 57 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 58 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 59 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 60 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 61 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 62 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, 63 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, 64 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, 65 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, 66 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, 67 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, 68 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, 69 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, 70 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, 71 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, 72 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, 73 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, 74 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, 75 | {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, 76 | {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, 77 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 78 | ] 79 | 80 | [[package]] 81 | name = "colorama" 82 | version = "0.4.6" 83 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 84 | summary = "Cross-platform colored terminal text." 85 | groups = ["test"] 86 | marker = "sys_platform == \"win32\"" 87 | files = [ 88 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 89 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 90 | ] 91 | 92 | [[package]] 93 | name = "exceptiongroup" 94 | version = "1.2.0" 95 | requires_python = ">=3.7" 96 | summary = "Backport of PEP 654 (exception groups)" 97 | groups = ["test"] 98 | marker = "python_version < \"3.11\"" 99 | files = [ 100 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 101 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 102 | ] 103 | 104 | [[package]] 105 | name = "idna" 106 | version = "3.6" 107 | requires_python = ">=3.5" 108 | summary = "Internationalized Domain Names in Applications (IDNA)" 109 | groups = ["dev"] 110 | files = [ 111 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 112 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 113 | ] 114 | 115 | [[package]] 116 | name = "iniconfig" 117 | version = "2.0.0" 118 | requires_python = ">=3.7" 119 | summary = "brain-dead simple config-ini parsing" 120 | groups = ["test"] 121 | files = [ 122 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 123 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 124 | ] 125 | 126 | [[package]] 127 | name = "packaging" 128 | version = "23.2" 129 | requires_python = ">=3.7" 130 | summary = "Core utilities for Python packages" 131 | groups = ["dev", "test"] 132 | files = [ 133 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 134 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 135 | ] 136 | 137 | [[package]] 138 | name = "pluggy" 139 | version = "1.4.0" 140 | requires_python = ">=3.8" 141 | summary = "plugin and hook calling mechanisms for python" 142 | groups = ["test"] 143 | files = [ 144 | {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, 145 | {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, 146 | ] 147 | 148 | [[package]] 149 | name = "pytest" 150 | version = "8.0.0" 151 | requires_python = ">=3.8" 152 | summary = "pytest: simple powerful testing with Python" 153 | groups = ["test"] 154 | dependencies = [ 155 | "colorama; sys_platform == \"win32\"", 156 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 157 | "iniconfig", 158 | "packaging", 159 | "pluggy<2.0,>=1.3.0", 160 | "tomli>=1.0.0; python_version < \"3.11\"", 161 | ] 162 | files = [ 163 | {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, 164 | {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, 165 | ] 166 | 167 | [[package]] 168 | name = "pytest-mock" 169 | version = "3.12.0" 170 | requires_python = ">=3.8" 171 | summary = "Thin-wrapper around the mock package for easier use with pytest" 172 | groups = ["test"] 173 | dependencies = [ 174 | "pytest>=5.0", 175 | ] 176 | files = [ 177 | {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, 178 | {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, 179 | ] 180 | 181 | [[package]] 182 | name = "requests" 183 | version = "2.31.0" 184 | requires_python = ">=3.7" 185 | summary = "Python HTTP for Humans." 186 | groups = ["dev"] 187 | dependencies = [ 188 | "certifi>=2017.4.17", 189 | "charset-normalizer<4,>=2", 190 | "idna<4,>=2.5", 191 | "urllib3<3,>=1.21.1", 192 | ] 193 | files = [ 194 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 195 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 196 | ] 197 | 198 | [[package]] 199 | name = "tomli" 200 | version = "2.0.1" 201 | requires_python = ">=3.7" 202 | summary = "A lil' TOML parser" 203 | groups = ["test"] 204 | marker = "python_version < \"3.11\"" 205 | files = [ 206 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 207 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 208 | ] 209 | 210 | [[package]] 211 | name = "tomlkit" 212 | version = "0.12.3" 213 | requires_python = ">=3.7" 214 | summary = "Style preserving TOML library" 215 | groups = ["dev"] 216 | files = [ 217 | {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, 218 | {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, 219 | ] 220 | 221 | [[package]] 222 | name = "unearth" 223 | version = "0.14.0" 224 | requires_python = ">=3.8" 225 | summary = "A utility to fetch and download python packages" 226 | groups = ["dev"] 227 | dependencies = [ 228 | "packaging>=20", 229 | "requests>=2.25", 230 | ] 231 | files = [ 232 | {file = "unearth-0.14.0-py3-none-any.whl", hash = "sha256:a2b937ca22198043f5360192bce38708f11ddc5d4cdea973ee38583219b97d5d"}, 233 | {file = "unearth-0.14.0.tar.gz", hash = "sha256:f3cddfb94ac0f865fbcf964231556ef7183010379c00b01205517a50c78a186d"}, 234 | ] 235 | 236 | [[package]] 237 | name = "urllib3" 238 | version = "2.2.0" 239 | requires_python = ">=3.8" 240 | summary = "HTTP library with thread-safe connection pooling, file post, and more." 241 | groups = ["dev"] 242 | files = [ 243 | {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, 244 | {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, 245 | ] 246 | -------------------------------------------------------------------------------- /pdm_build.py: -------------------------------------------------------------------------------- 1 | # Local copy of mina_build.hooks 2 | # https://github.com/GreyElaina/mina/blob/main/src/mina_backend/hooks.py 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | import sys 8 | from pathlib import Path 9 | from typing import Any, Mapping, MutableMapping 10 | 11 | from pdm.backend.config import Config 12 | from pdm.backend.hooks import Context 13 | 14 | if sys.version_info >= (3, 11): 15 | import tomllib as tomli 16 | else: 17 | from pdm.backend._vendor import tomli 18 | 19 | 20 | def pdm_build_hook_enabled(context: Context) -> bool: 21 | tool_mina = context.config.data.get("tool", {}).get("mina", {}) 22 | return bool(tool_mina.get("enabled")) 23 | 24 | 25 | def _get_build_target(context: Context) -> str | None: 26 | tool_mina = context.config.data.get("tool", {}).get("mina", {}) 27 | return ( 28 | context.config_settings.get("mina-target") 29 | or os.environ.get("MINA_BUILD_TARGET") 30 | or tool_mina.get("default-build-target") 31 | ) 32 | 33 | 34 | def _using_override(config: Config, package_conf: dict[str, Any]) -> bool: 35 | if "override" in package_conf: 36 | return package_conf["override"] 37 | return config.data.get("tool", {}).get("mina", {}).get("override-global", False) 38 | 39 | 40 | def _get_standalone_config(root: Path, pkg: str): 41 | config_file = root / ".mina" / f"{pkg}.toml" 42 | if not config_file.exists(): 43 | return 44 | 45 | return tomli.loads(config_file.read_text()) 46 | 47 | 48 | def _update_config(config: Config, package: str) -> None: 49 | package_conf = _get_standalone_config(config.root, package) 50 | if package_conf is not None: 51 | package_conf.setdefault("includes", []).append(f".mina/{package}.toml") 52 | else: 53 | package_conf = ( 54 | config.data.get("tool", {}) 55 | .get("mina", {}) 56 | .get("packages", {}) 57 | .get(package, None) 58 | ) 59 | if package_conf is None: 60 | raise ValueError(f"No package named '{package}'") 61 | 62 | package_metadata = package_conf.pop("project", {}) 63 | using_override = _using_override(config, package_conf) 64 | 65 | build_config = config.build_config 66 | 67 | # Override build config 68 | build_config.update(package_conf) 69 | 70 | if using_override: 71 | config.data["project"] = package_metadata 72 | else: 73 | deep_merge(config.metadata, package_metadata) 74 | # dependencies are already merged, restore them 75 | config.metadata["dependencies"] = package_metadata.get("dependencies", []) 76 | config.metadata["optional-dependencies"] = package_metadata.get( 77 | "optional-dependencies", {} 78 | ) 79 | 80 | config.validate() 81 | 82 | 83 | def deep_merge(source: MutableMapping, target: Mapping) -> Mapping: 84 | for key, value in target.items(): 85 | if key in source and isinstance(value, list): 86 | source[key].extend(value) 87 | elif key in source and isinstance(value, dict): 88 | deep_merge(source[key], value) 89 | else: 90 | source[key] = value 91 | return source 92 | 93 | 94 | def pdm_build_initialize(context: Context) -> None: 95 | if not pdm_build_hook_enabled(context): 96 | return 97 | 98 | mina_target = _get_build_target(context) 99 | if mina_target is None: 100 | return 101 | 102 | _update_config(context.config, mina_target) 103 | # Disable mina after update 104 | context.config.data.setdefault("tool", {}).setdefault("mina", {})["enabled"] = False 105 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "onepm-workspace" 7 | description = "The workspace for onepm" 8 | authors = [ 9 | {name = "Frost Ming", email = "mianghong@gmail.com"}, 10 | ] 11 | requires-python = ">=3.10" 12 | readme = "README.md" 13 | license = {text = "MIT"} 14 | classifiers = [ 15 | "Topic :: Software Development :: Build Tools", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | ] 22 | dynamic = ["version"] 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/frostming/onepm" 26 | Repository = "https://github.com/frostming/onepm" 27 | Changelog = "https://github.com/frostming/onepm/releases" 28 | 29 | [tool.pdm.version] 30 | source = "scm" 31 | 32 | [tool.pdm.dev-dependencies] 33 | test = [ 34 | "pytest>=7.1.2", 35 | "pytest-mock>=3.12.0", 36 | ] 37 | dev = [ 38 | "unearth>=0.14.0", 39 | "tomlkit>=0.12.3", 40 | "packaging>=23.2", 41 | ] 42 | 43 | [tool.pdm.scripts] 44 | test = "pytest -ra tests" 45 | 46 | [tool.ruff] 47 | target-version = "py310" 48 | 49 | [tool.mina] 50 | enabled = true 51 | default-build-target = "onepm" 52 | 53 | [tool.mina.packages.onepm] 54 | includes = ["src/onepm"] 55 | source-includes = ["tests/"] 56 | 57 | [tool.mina.packages.onepm.project] 58 | name = "onepm" 59 | description = "Picks the right package manager for you" 60 | dependencies = [ 61 | "packaging>=22.1", 62 | "tomlkit>=0.12.3", 63 | ] 64 | 65 | [tool.mina.packages.onepm.project.optional-dependencies] 66 | shims = ["onepm-shims"] 67 | 68 | [tool.mina.packages.onepm.project.scripts] 69 | pi = "onepm:pi" 70 | pu = "onepm:pu" 71 | pr = "onepm:pr" 72 | pun = "onepm:pun" 73 | pa = "onepm:pa" 74 | onepm = "onepm.cli:main" 75 | 76 | [tool.mina.packages.shims] 77 | includes = ["src/onepm_shims"] 78 | 79 | [tool.mina.packages.shims.project] 80 | name = "onepm-shims" 81 | dependencies = ["unearth>=0.14.0"] 82 | 83 | [tool.mina.packages.shims.project.scripts] 84 | pdm = "onepm_shims.shims:pdm" 85 | pipenv = "onepm_shims.shims:pipenv" 86 | poetry = "onepm_shims.shims:poetry" 87 | uv = "onepm_shims.shims:uv" 88 | 89 | [tool.ruff.lint] 90 | extend-select = [ 91 | "I", # isort 92 | "B", # flake8-bugbear 93 | "C4", # flake8-comprehensions 94 | "RUF", # ruff 95 | "W", # pycodestyle 96 | "YTT", # flake8-2020 97 | ] 98 | 99 | [tool.ruff.isort] 100 | known-first-party = ["onepm"] 101 | 102 | [tool.onepm] 103 | package-manager = "pdm>=2.12.0" 104 | 105 | [tool.pyright] 106 | venvPath = "." 107 | venv = ".venv" 108 | pythonVersion = "3.10" 109 | -------------------------------------------------------------------------------- /src/onepm/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Callable, NoReturn 5 | 6 | from onepm.core import OneManager 7 | 8 | 9 | def make_shortcut(method_name: str) -> Callable[[list[str] | None], NoReturn]: 10 | def main(args: list[str] | None = None) -> NoReturn: # type: ignore[misc] 11 | if args is None: 12 | args = sys.argv[1:] 13 | package_manager = OneManager().get_package_manager() 14 | getattr(package_manager, method_name)(*args) 15 | 16 | return main 17 | 18 | 19 | pi = make_shortcut("install") 20 | pu = make_shortcut("update") 21 | pun = make_shortcut("uninstall") 22 | pr = make_shortcut("run") 23 | pa = make_shortcut("execute") 24 | -------------------------------------------------------------------------------- /src/onepm/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | def parse_args() -> argparse.Namespace: 5 | from onepm.core import PACKAGE_MANAGERS 6 | 7 | parser = argparse.ArgumentParser("onepm") 8 | commands = parser.add_subparsers(dest="command", title="Command") 9 | commands.add_parser( 10 | "install", help="Install the package manager configured in project file" 11 | ) 12 | use_cmd = commands.add_parser( 13 | "use", help="Use the package manager given by the requirement spec" 14 | ) 15 | use_cmd.add_argument("spec", help="package manager requirement spec") 16 | update_cmd = commands.add_parser( 17 | "update", aliases=["up"], help="Update the package manager used in the project" 18 | ) 19 | update_cmd.add_argument( 20 | "name", 21 | help="The name of package manager", 22 | choices=PACKAGE_MANAGERS, 23 | nargs=argparse.OPTIONAL, 24 | ) 25 | cleanup_cmd = commands.add_parser( 26 | "cleanup", help="Clean up installations of specified package manager or all" 27 | ) 28 | cleanup_cmd.add_argument( 29 | "name", 30 | nargs=argparse.OPTIONAL, 31 | choices=list(PACKAGE_MANAGERS), 32 | help="The name of package manager", 33 | ) 34 | cleanup_cmd.add_argument( 35 | "version", 36 | nargs=argparse.OPTIONAL, 37 | help="The version of the package to remove", 38 | ) 39 | list_cmd = commands.add_parser( 40 | "list", 41 | aliases=["ls"], 42 | help="List all installed versions of the given package manager", 43 | ) 44 | list_cmd.add_argument( 45 | "name", help="The name of package manager", choices=list(PACKAGE_MANAGERS) 46 | ) 47 | return parser.parse_args() 48 | 49 | 50 | def main(): 51 | from onepm.core import OneManager 52 | 53 | args = parse_args() 54 | core = OneManager() 55 | match args.command: 56 | case "install": 57 | core.get_package_manager() 58 | case "update" | "up ": 59 | core.update_package_manager(args.name) 60 | case "use": 61 | core.use_package_manager(args.spec) 62 | case "cleanup": 63 | core.cleanup(args.name, args.version) 64 | case "list" | "ls": 65 | for installation in core.get_installations(args.name): 66 | print(f"- {installation.version} ({installation.venv})") 67 | -------------------------------------------------------------------------------- /src/onepm/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shutil 5 | import subprocess 6 | import sys 7 | import uuid 8 | from dataclasses import dataclass 9 | from functools import cached_property 10 | from importlib.metadata import Distribution 11 | from pathlib import Path 12 | from typing import TYPE_CHECKING, Any 13 | 14 | import tomlkit 15 | from packaging.requirements import Requirement 16 | from packaging.utils import canonicalize_name 17 | from packaging.version import Version 18 | 19 | from onepm.pm.base import PackageManager 20 | from onepm.pm.pdm import PDM 21 | from onepm.pm.pip import Pip 22 | from onepm.pm.pipenv import Pipenv 23 | from onepm.pm.poetry import Poetry 24 | from onepm.pm.uv import Uv 25 | 26 | if TYPE_CHECKING: 27 | from unearth import PackageFinder 28 | 29 | 30 | PACKAGE_MANAGERS: dict[str, type[PackageManager]] = { 31 | p.name: p # type: ignore[type-abstract] 32 | for p in [Pipenv, PDM, Poetry, Uv, Pip] 33 | } 34 | 35 | MAX_VERSION_NUMBER = 5 # keep 5 versions of each package at most 36 | 37 | 38 | class OneManager: 39 | pyproject: dict[str, Any] 40 | 41 | def __init__( 42 | self, path: Path | None = None, *, index_url: str | None = None 43 | ) -> None: 44 | self.path = path or Path.cwd() 45 | self.index_url = index_url 46 | try: 47 | with open(self.path / "pyproject.toml", "rb") as f: 48 | self.pyproject = tomlkit.load(f) 49 | except FileNotFoundError: 50 | self.pyproject = {} 51 | 52 | self._tool_dir = Path.home() / ".onepm" 53 | 54 | def shim_enabled(self) -> bool: 55 | try: 56 | import unearth 57 | except ModuleNotFoundError: 58 | return False 59 | return True 60 | 61 | @cached_property 62 | def package_finder(self) -> PackageFinder: 63 | if not self.shim_enabled(): 64 | raise ImportError( 65 | "Package manager shims are disabled, please re-install onepm with '[shims]' extra." 66 | ) 67 | 68 | import unearth 69 | 70 | index_urls = [self.index_url] if self.index_url else [] 71 | return unearth.PackageFinder(index_urls=index_urls) 72 | 73 | def detect_package_manager( 74 | self, specified: str | None = None 75 | ) -> tuple[type[PackageManager], Requirement]: 76 | requested: str | None = ( 77 | self.pyproject.get("tool", {}).get("onepm", {}).get("package-manager") 78 | ) 79 | package_manager: type[PackageManager] | None = None 80 | requirement: Requirement | None = None 81 | if requested: 82 | requirement = Requirement(requested) 83 | name = canonicalize_name(requirement.name) 84 | if specified and specified != name: 85 | name = specified 86 | requirement = Requirement(specified) 87 | if name in PACKAGE_MANAGERS: 88 | package_manager = PACKAGE_MANAGERS[name] 89 | else: 90 | raise ValueError(f"Not supported package-manager: {requested}") 91 | elif specified: 92 | package_manager = PACKAGE_MANAGERS[specified] 93 | else: 94 | for pm in PACKAGE_MANAGERS.values(): 95 | if pm.matches(self.pyproject): 96 | package_manager = pm 97 | break 98 | assert package_manager is not None 99 | if requirement is None: 100 | requirement = Requirement(package_manager.name) 101 | return package_manager, requirement 102 | 103 | def get_package_manager(self, specified: str | None = None) -> PackageManager: 104 | package_manager, requirement = self.detect_package_manager(specified) 105 | executable = str(package_manager.ensure_executable(self, requirement)) 106 | return package_manager(executable) 107 | 108 | def package_dir(self, name: str) -> Path: 109 | return self._tool_dir / "venvs" / name 110 | 111 | def get_installations(self, name: str) -> list[Installation]: 112 | venvs = self.package_dir(name) 113 | if not venvs.exists(): 114 | return [] 115 | versions: list[Installation] = [] 116 | for venv in venvs.iterdir(): 117 | candidate = next( 118 | venv.glob(f"lib/**/site-packages/{name}-*.dist-info"), None 119 | ) 120 | if candidate is None: 121 | continue 122 | versions.append(Installation(name, Distribution.at(candidate), venv)) 123 | return sorted(versions, key=lambda i: i.version, reverse=True) 124 | 125 | def cleanup(self, name: str | None, version: str | None) -> None: 126 | if name is None: 127 | shutil.rmtree(self._tool_dir / "venvs", ignore_errors=True) 128 | return 129 | if version is not None: 130 | matched = next( 131 | ( 132 | i 133 | for i in self.get_installations(name) 134 | if i.version == Version(version) 135 | ), 136 | None, 137 | ) 138 | if matched is None: 139 | raise ValueError(f"No installation of {name}=={version} is found") 140 | shutil.rmtree(matched.venv) 141 | return 142 | package_dir = self.package_dir(name) 143 | if package_dir.exists(): 144 | shutil.rmtree(package_dir) 145 | 146 | def install_tool(self, name: str, requirement: Requirement) -> Installation: 147 | best_match = self.package_finder.find_best_match(requirement).best 148 | if best_match is None: 149 | raise Exception(f"Cannot find package matching requirement {requirement}") 150 | version = Version(best_match.version or "") 151 | installed_versions = self.get_installations(name) 152 | if ( 153 | installed := next( 154 | (i for i in installed_versions if i.version == version), None 155 | ) 156 | ) is not None: 157 | return installed 158 | 159 | installed_versions.sort(key=Installation.get_access_time) 160 | 161 | if len(installed_versions) >= MAX_VERSION_NUMBER: 162 | to_remove = installed_versions[ 163 | : len(installed_versions) - MAX_VERSION_NUMBER + 1 164 | ] 165 | for v in to_remove: 166 | shutil.rmtree(v.venv) 167 | venv_dir = self.package_dir(name) / str(uuid.uuid4()) 168 | self._run_pip("install", f"{name}=={version}", venv=venv_dir) 169 | dist = Distribution.at( 170 | next(venv_dir.glob(f"lib/**/site-packages/{name}-*.dist-info")) 171 | ) 172 | return Installation(name, dist, venv_dir) 173 | 174 | def _run_pip(self, *args: str, venv: Path) -> None: 175 | venv = PackageManager.make_venv(venv, with_pip=False) 176 | bin_dir = "Scripts" if sys.platform == "win32" else "bin" 177 | pip_command = [ 178 | str(venv / bin_dir / "python"), 179 | "-I", 180 | str(self._pip_location), 181 | *args, 182 | ] 183 | subprocess.run( 184 | pip_command, 185 | check=True, 186 | stdout=subprocess.DEVNULL, 187 | stderr=subprocess.DEVNULL, 188 | ) 189 | 190 | @cached_property 191 | def _pip_location(self) -> Path: 192 | try: 193 | from pip import __file__ as pip_file 194 | except ImportError: 195 | pass 196 | else: 197 | # copy to the shared location and use it from that 198 | shared_pip = self._tool_dir / "shared" / "pip" 199 | if not shared_pip.exists(): 200 | shutil.copytree(Path(pip_file).parent, shared_pip) 201 | return shared_pip 202 | # pip is not installed, download the wheel from PyPI 203 | shared_pip = self._tool_dir / "shared" / "pip.whl" 204 | if not shared_pip.exists(): 205 | best_pip = self.package_finder.find_best_match("pip").best 206 | assert best_pip is not None and best_pip.link.is_wheel 207 | shared_pip.parent.mkdir(parents=True, exist_ok=True) 208 | wheel = self.package_finder.download_and_unpack( 209 | best_pip.link, shared_pip.parent 210 | ) 211 | os.replace(wheel, shared_pip) 212 | return shared_pip / "pip" 213 | 214 | def update_package_manager(self, name: str | None = None) -> None: 215 | pm, requirement = self.detect_package_manager(name) 216 | self.install_tool(pm.name, requirement) 217 | 218 | def use_package_manager(self, spec: str) -> None: 219 | req = Requirement(spec) 220 | name = canonicalize_name(req.name) 221 | self.pyproject.setdefault("tool", {}).setdefault("onepm", {})[ 222 | "package-manager" 223 | ] = str(req) 224 | with open(self.path / "pyproject.toml", "w") as f: 225 | tomlkit.dump(self.pyproject, f) 226 | for installation in self.get_installations(name): 227 | if installation.version in req.specifier: 228 | return 229 | self.install_tool(canonicalize_name(req.name), req) 230 | 231 | 232 | @dataclass(frozen=True) 233 | class Installation: 234 | name: str 235 | distribution: Distribution 236 | venv: Path 237 | 238 | @property 239 | def version(self) -> Version: 240 | return Version(self.distribution.version) 241 | 242 | def get_access_time(self) -> float: 243 | return self.venv.stat().st_atime 244 | -------------------------------------------------------------------------------- /src/onepm/pm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frostming/onepm/580437765c27e65de8cce2e7f54c53d93f52d1d9/src/onepm/pm/__init__.py -------------------------------------------------------------------------------- /src/onepm/pm/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING, Any, Iterable, Mapping, overload 10 | 11 | from packaging.requirements import Requirement 12 | 13 | if TYPE_CHECKING: 14 | from typing import Literal, NoReturn 15 | 16 | from onepm.core import OneManager 17 | 18 | 19 | class PackageManager(metaclass=abc.ABCMeta): 20 | name: str 21 | 22 | @staticmethod 23 | def get_unknown_args( 24 | args: Iterable[str], 25 | expecting_values: list[str] | None = None, 26 | no_values: list[str] | None = None, 27 | ) -> list[str]: 28 | args_iter = iter(args) 29 | 30 | def takes_value(arg_name: str) -> bool: 31 | if expecting_values is not None and arg_name in expecting_values: 32 | return True 33 | if no_values is not None and arg_name not in no_values: 34 | return True 35 | return False 36 | 37 | unknown_args: list[str] = [] 38 | 39 | for arg in args_iter: 40 | if arg[:2] == "--": 41 | arg_name = arg[2:] 42 | if takes_value(arg_name): 43 | next(args_iter, None) 44 | elif arg[0] == "-": 45 | arg_name = arg[1:] 46 | if takes_value(arg_name): 47 | next(args_iter, None) 48 | else: 49 | unknown_args.append(arg) 50 | return unknown_args 51 | 52 | def __init__(self, executable: str) -> None: 53 | self.executable = executable 54 | 55 | @staticmethod 56 | def find_executable(name: str, path: str | Path | None = None) -> str: 57 | # TODO: to keep it simple, only search in PATH(no alias/shell function) 58 | executable = shutil.which(name, path=path) 59 | if not executable: 60 | raise Exception(f"{name} is not found in PATH, did you install it?") 61 | return executable 62 | 63 | @overload 64 | def execute( 65 | self, 66 | *args: str, 67 | env: Mapping[str, str] | None = ..., 68 | exit: Literal[False] = ..., 69 | ) -> None: ... 70 | 71 | @overload 72 | def execute( 73 | self, *args: str, env: Mapping[str, str] | None = ..., exit: Literal[True] = ... 74 | ) -> NoReturn: ... 75 | 76 | def execute( 77 | self, *args: str, env: Mapping[str, str] | None = None, exit: bool = True 78 | ) -> Any: 79 | command_args = self.get_command() + list(args) 80 | self._execute_command(command_args, env, exit=exit) 81 | 82 | @overload 83 | @staticmethod 84 | def _execute_command( 85 | args: list[str], env: Mapping[str, str] | None = ..., exit: Literal[True] = ... 86 | ) -> NoReturn: ... 87 | 88 | @overload 89 | @staticmethod 90 | def _execute_command( 91 | args: list[str], env: Mapping[str, str] | None = ..., exit: Literal[False] = ... 92 | ) -> None: ... 93 | 94 | @staticmethod 95 | def _execute_command( 96 | args: list[str], env: Mapping[str, str] | None = None, exit: bool = True 97 | ) -> Any: 98 | process_env = {**os.environ, **env} if env else None 99 | if not exit: 100 | subprocess.run(args, env=process_env, check=True) 101 | return 102 | if sys.platform == "win32": 103 | sys.exit(subprocess.run(args, env=process_env).returncode) 104 | else: 105 | if env: 106 | os.execvpe(args[0], args, process_env) 107 | else: 108 | os.execvp(args[0], args) 109 | 110 | def get_command(self) -> list[str]: 111 | return [self.executable] 112 | 113 | @abc.abstractmethod 114 | def install(self, *args: str) -> NoReturn: ... 115 | 116 | @abc.abstractmethod 117 | def uninstall(self, *args: str) -> NoReturn: ... 118 | 119 | @abc.abstractmethod 120 | def update(self, *args: str) -> NoReturn: ... 121 | 122 | @abc.abstractmethod 123 | def run(self, *args: str) -> NoReturn: ... 124 | 125 | @classmethod 126 | @abc.abstractmethod 127 | def matches(cls, pyproject: dict[str, Any]) -> bool: ... 128 | 129 | @classmethod 130 | def get_executable_name(cls) -> str: 131 | return cls.name 132 | 133 | @classmethod 134 | def ensure_executable(cls, core: OneManager, requirement: Requirement) -> str: 135 | name = cls.get_executable_name() 136 | if not core.shim_enabled(): 137 | return cls.find_executable(name) 138 | versions = core.get_installations(cls.name) 139 | best_match = next( 140 | filter(lambda v: requirement.specifier.contains(v.version), versions), None 141 | ) 142 | bin_dir = "Scripts" if sys.platform == "win32" else "bin" 143 | if best_match is None: 144 | best_match = core.install_tool(cls.name, requirement) 145 | return str(best_match.venv / bin_dir / name) 146 | 147 | @staticmethod 148 | def make_venv(venv_path: Path, with_pip: bool = True) -> Path: 149 | if venv_path.exists() and venv_path.joinpath("pyvenv.cfg").exists(): 150 | return venv_path 151 | 152 | import venv 153 | 154 | venv.create(venv_path, with_pip=with_pip, symlinks=True) 155 | return venv_path 156 | -------------------------------------------------------------------------------- /src/onepm/pm/pdm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Any, NoReturn 5 | 6 | from onepm.pm.base import PackageManager 7 | 8 | 9 | class PDM(PackageManager): 10 | name = "pdm" 11 | 12 | @classmethod 13 | def matches(cls, pyproject: dict[str, Any]) -> bool: 14 | if os.path.exists("pdm.lock"): 15 | return True 16 | build_backend = pyproject.get("build-system", {}).get("build-backend", "") 17 | if "pdm" in build_backend: 18 | return True 19 | return "pdm" in pyproject.get("tool", {}) 20 | 21 | def install(self, *args: str) -> NoReturn: 22 | if self.get_unknown_args(args, ["p", "project", "G", "group", "L", "lockfile"]): 23 | command = "add" 24 | else: 25 | command = "install" 26 | self.execute(command, *args) 27 | 28 | def uninstall(self, *args: str) -> NoReturn: 29 | self.execute("remove", *args) 30 | 31 | def update(self, *args: str) -> NoReturn: 32 | self.execute("update", *args) 33 | 34 | def run(self, *args: str) -> NoReturn: 35 | self.execute("run", *args) 36 | -------------------------------------------------------------------------------- /src/onepm/pm/pip.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import os 5 | import subprocess 6 | import sys 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Any, NoReturn 9 | 10 | from packaging.requirements import Requirement 11 | 12 | from onepm.pm.base import PackageManager 13 | 14 | if TYPE_CHECKING: 15 | from onepm.core import OneManager 16 | 17 | 18 | class Pip(PackageManager): 19 | name = "pip" 20 | 21 | @classmethod 22 | def matches(cls, pyproject: dict[str, Any]) -> bool: 23 | """Fallback package manager, always matches.""" 24 | return True 25 | 26 | @classmethod 27 | def ensure_executable(cls, core: OneManager, requirement: Requirement) -> str: 28 | from importlib.metadata import Distribution 29 | 30 | if "VIRTUAL_ENV" in os.environ: 31 | venv = Path(os.environ["VIRTUAL_ENV"]) 32 | else: 33 | venv = cls.make_venv(Path(".venv")) 34 | bin_dir = "Scripts" if sys.platform == "win32" else "bin" 35 | executable = cls.find_executable("python", venv / bin_dir) 36 | lib_dir = venv / "lib" 37 | pip_dist = next(lib_dir.glob("**/site-packages/pip-*.dist-info")) 38 | dist = Distribution.at(pip_dist) 39 | if dist.version not in requirement.specifier: 40 | if not core.shim_enabled(): 41 | subprocess.run( 42 | [executable, "-m", "pip", "install", "-U", str(requirement)], 43 | check=True, 44 | stdout=subprocess.DEVNULL, 45 | stderr=subprocess.DEVNULL, 46 | ) 47 | else: 48 | core._run_pip("install", "-U", str(requirement), venv=venv) 49 | return executable 50 | 51 | def _find_requirements_txt(self) -> str | None: 52 | for filename in ["requirements.txt", "requirements.in"]: 53 | if os.path.exists(filename): 54 | return filename 55 | return None 56 | 57 | def _find_pyproject(self) -> str | None: 58 | if os.path.exists("setup.py"): 59 | return "setup.py" 60 | with contextlib.suppress(FileNotFoundError): 61 | with open("pyproject.toml") as f: 62 | pyproject = f.read().splitlines() 63 | if "[project]" in pyproject: 64 | return "pyproject.toml" 65 | return None 66 | 67 | def get_command(self) -> list[str]: 68 | return [self.executable, "-m", "pip"] 69 | 70 | def install(self, *args: str) -> NoReturn: 71 | if not args: 72 | requirements = self._find_requirements_txt() 73 | pyproject = self._find_pyproject() 74 | if requirements: 75 | expanded_args = ["install", "-r", requirements] 76 | elif pyproject: 77 | expanded_args = ["install", "."] 78 | else: 79 | raise Exception( 80 | "No requirements.txt or setup.py/pyproject.toml is found, " 81 | "please specify packages to install." 82 | ) 83 | else: 84 | expanded_args = ["install", *args] 85 | self.execute(*expanded_args) 86 | 87 | def update(self, *args: str) -> NoReturn: 88 | raise NotImplementedError("pip does not support the `pu` shortcut.") 89 | 90 | def uninstall(self, *args: str) -> NoReturn: 91 | self.execute("uninstall", *args) 92 | 93 | def run(self, *args: str) -> NoReturn: 94 | if len(args) == 0: 95 | raise Exception("Please specify a command to run.") 96 | command, *rest = args 97 | bin_dir = os.path.dirname(self.executable) 98 | path = os.getenv("PATH", "") 99 | command = self.find_executable(command, os.pathsep.join([bin_dir, path])) 100 | self._execute_command([command, *rest]) 101 | -------------------------------------------------------------------------------- /src/onepm/pm/pipenv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Any, NoReturn 5 | 6 | from onepm.pm.base import PackageManager 7 | 8 | 9 | class Pipenv(PackageManager): 10 | name = "pipenv" 11 | 12 | @classmethod 13 | def matches(cls, pyproject: dict[str, Any]) -> bool: 14 | return os.path.exists("Pipfile.lock") or os.path.exists("Pipfile") 15 | 16 | def install(self, *args: str) -> NoReturn: 17 | self.execute("install", *args) 18 | 19 | def uninstall(self, *args: str) -> NoReturn: 20 | self.execute("uninstall", *args) 21 | 22 | def update(self, *args: str) -> NoReturn: 23 | self.execute("update", *args) 24 | 25 | def run(self, *args: str) -> NoReturn: 26 | self.execute("run", *args) 27 | -------------------------------------------------------------------------------- /src/onepm/pm/poetry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Any, NoReturn 5 | 6 | from onepm.pm.base import PackageManager 7 | 8 | 9 | class Poetry(PackageManager): 10 | name = "poetry" 11 | 12 | @classmethod 13 | def matches(cls, pyproject: dict[str, Any]) -> bool: 14 | if os.path.exists("poetry.lock"): 15 | return True 16 | build_backend = pyproject.get("build-system", {}).get("build-backend", "") 17 | if "poetry" in build_backend: 18 | return True 19 | return "poetry" in pyproject.get("tool", {}) 20 | 21 | def install(self, *args: str) -> NoReturn: 22 | if self.get_unknown_args(args, ["E", "extras"]): 23 | command = "add" 24 | else: 25 | command = "install" 26 | self.execute(command, *args) 27 | 28 | def uninstall(self, *args: str) -> NoReturn: 29 | self.execute("remove", *args) 30 | 31 | def update(self, *args: str) -> NoReturn: 32 | self.execute("update", *args) 33 | 34 | def run(self, *args: str) -> NoReturn: 35 | self.execute("run", *args) 36 | -------------------------------------------------------------------------------- /src/onepm/pm/uv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Any, NoReturn 5 | 6 | from onepm.pm.base import PackageManager 7 | 8 | UV_INDEX_FLAGS = ["--no-index"] 9 | UV_INSTALLER_FLAGS = ["--reinstall", "--compile-bytecode"] 10 | UV_RESOLVER_FLAGS = ["-U", "--upgrade", "--no-source"] 11 | UV_BUILD_FLAGS = ["--no-build-isolation", "--no-build", "--no-binary"] 12 | UV_CACHE_FLAGS = ["-n", "--no-cache", "--refresh"] 13 | UV_PYTHON_FLAGS = ["--no-python-downloads"] 14 | UV_GLOBAL_FLAGS = [ 15 | "-q", 16 | "--quiet", 17 | "-v", 18 | "--verbose", 19 | "--native-tls", 20 | "--offline", 21 | "--no-progress", 22 | "--no-config", 23 | "--help", 24 | "-V", 25 | "--version", 26 | ] 27 | 28 | UV_LOCK_FLAGS = [ 29 | "--locked", 30 | "--frozen", 31 | *UV_INDEX_FLAGS, 32 | *UV_RESOLVER_FLAGS, 33 | *UV_BUILD_FLAGS, 34 | *UV_CACHE_FLAGS, 35 | *UV_PYTHON_FLAGS, 36 | *UV_GLOBAL_FLAGS, 37 | ] 38 | UV_SYNC_FLAGS = [ 39 | "--all-extras", 40 | "--no-dev", 41 | "--only-dev", 42 | "--no-editable", 43 | "--inexact", 44 | "--no-install-project", 45 | "--no-install-workspace", 46 | *UV_INSTALLER_FLAGS, 47 | *UV_LOCK_FLAGS, 48 | ] 49 | 50 | 51 | class Uv(PackageManager): 52 | name = "uv" 53 | UV_LOCK_FILENAME = "uv.lock" 54 | 55 | @classmethod 56 | def matches(cls, pyproject: dict[str, Any]) -> bool: 57 | return os.path.exists(cls.UV_LOCK_FILENAME) or "project" in pyproject 58 | 59 | def install(self, *args: str) -> NoReturn: 60 | if ( 61 | self.get_unknown_args(args, no_values=UV_SYNC_FLAGS) 62 | or "-r" in args 63 | or any(arg.startswith("--requirements") for arg in args) 64 | ): 65 | return self.execute("add", *args) 66 | return self.execute("sync", *args) 67 | 68 | def update(self, *args: str) -> NoReturn: 69 | if rest_args := self.get_unknown_args(args, no_values=UV_LOCK_FLAGS): 70 | packages = [name for name in rest_args if not name.startswith("-")] 71 | args = [arg for arg in args if arg not in packages] 72 | for package in packages: 73 | args.extend(["--upgrade-package", package]) 74 | self.execute("lock", *args, exit=False) 75 | else: 76 | self.execute("lock", "--upgrade", *args, exit=False) 77 | self.execute("sync") 78 | 79 | def uninstall(self, *args: str) -> NoReturn: 80 | return self.execute("remove", *args) 81 | 82 | def run(self, *args: str) -> NoReturn: 83 | return self.execute("run", *args) 84 | -------------------------------------------------------------------------------- /src/onepm/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frostming/onepm/580437765c27e65de8cce2e7f54c53d93f52d1d9/src/onepm/py.typed -------------------------------------------------------------------------------- /src/onepm_shims/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frostming/onepm/580437765c27e65de8cce2e7f54c53d93f52d1d9/src/onepm_shims/__init__.py -------------------------------------------------------------------------------- /src/onepm_shims/shims.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from functools import partial 5 | from typing import NoReturn 6 | 7 | from onepm.core import OneManager 8 | 9 | 10 | def shim(package_manager: str, args: list[str] | None = None) -> NoReturn: 11 | if args is None: 12 | args = sys.argv[1:] 13 | pm = OneManager().get_package_manager(package_manager) 14 | pm.execute(*args) 15 | 16 | 17 | pdm = partial(shim, "pdm") 18 | pipenv = partial(shim, "pipenv") 19 | poetry = partial(shim, "poetry") 20 | uv = partial(shim, "uv") 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from onepm.core import OneManager 6 | 7 | 8 | @pytest.fixture() 9 | def execute_command(mocker): 10 | return mocker.patch("onepm.pm.base.PackageManager._execute_command") 11 | 12 | 13 | @pytest.fixture() 14 | def project(tmp_path): 15 | cwd = os.getcwd() 16 | os.chdir(tmp_path) 17 | try: 18 | yield tmp_path 19 | finally: 20 | os.chdir(cwd) 21 | 22 | 23 | @pytest.fixture() 24 | def pip(mocker, project): 25 | with open("pyproject.toml", "a") as f: 26 | f.write('[tool.onepm]\npackage-manager = "pip"\n') 27 | mocker.patch("onepm.pm.pip.Pip.ensure_executable", return_value="python") 28 | assert OneManager().get_package_manager().name == "pip" 29 | 30 | 31 | @pytest.fixture() 32 | def poetry(mocker, project): 33 | with open("pyproject.toml", "a") as f: 34 | f.write('[tool.onepm]\npackage-manager = "poetry"\n') 35 | mocker.patch("onepm.pm.poetry.Poetry.ensure_executable", return_value="poetry") 36 | assert OneManager().get_package_manager().name == "poetry" 37 | 38 | 39 | @pytest.fixture() 40 | def pipenv(mocker, project): 41 | with open("pyproject.toml", "a") as f: 42 | f.write('[tool.onepm]\npackage-manager = "pipenv"\n') 43 | mocker.patch("onepm.pm.pipenv.Pipenv.ensure_executable", return_value="pipenv") 44 | assert OneManager().get_package_manager().name == "pipenv" 45 | 46 | 47 | @pytest.fixture() 48 | def pdm(mocker, project): 49 | with open("pyproject.toml", "a") as f: 50 | f.write('[tool.onepm]\npackage-manager = "pdm"') 51 | mocker.patch("onepm.pm.pdm.PDM.ensure_executable", return_value="pdm") 52 | assert OneManager().get_package_manager().name == "pdm" 53 | 54 | 55 | @pytest.fixture() 56 | def uv(mocker, project): 57 | with open("pyproject.toml", "a") as f: 58 | f.write('[tool.onepm]\npackage-manager = "uv"') 59 | mocker.patch("onepm.pm.uv.Uv.ensure_executable", return_value="uv") 60 | assert OneManager().get_package_manager().name == "uv" 61 | -------------------------------------------------------------------------------- /tests/test_pdm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from onepm import pa, pi, pr, pu, pun 4 | 5 | pytestmark = pytest.mark.usefixtures("pdm") 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "args, expected_command", 10 | [ 11 | ([], "install"), 12 | (["-d"], "install"), 13 | (["-Gsecurity"], "install"), 14 | (["-d", "--group=security", "--foo"], "install"), 15 | (["--no-sync", "-e", "./foo"], "add"), 16 | (["-G", "security", "foo"], "add"), 17 | ], 18 | ) 19 | def test_pdm_pi_dispatch(project, execute_command, args, expected_command): 20 | pi(args) 21 | execute_command.assert_called_with( 22 | ["pdm", expected_command, *args], None, exit=True 23 | ) 24 | 25 | 26 | def test_pdm_pr(project, execute_command): 27 | pr(["test", "--no-report"]) 28 | execute_command.assert_called_with( 29 | ["pdm", "run", "test", "--no-report"], None, exit=True 30 | ) 31 | 32 | 33 | def test_pdm_pu(project, execute_command): 34 | pu(["-Gtest", "requests"]) 35 | execute_command.assert_called_with( 36 | ["pdm", "update", "-Gtest", "requests"], None, exit=True 37 | ) 38 | 39 | 40 | def test_pdm_pun(project, execute_command): 41 | pun(["-Gtest", "requests"]) 42 | execute_command.assert_called_with( 43 | ["pdm", "remove", "-Gtest", "requests"], None, exit=True 44 | ) 45 | 46 | 47 | def test_pdm_pa(project, execute_command): 48 | pa(["config", "--local", "python.use_venv", "on"]) 49 | execute_command.assert_called_with( 50 | ["pdm", "config", "--local", "python.use_venv", "on"], None, exit=True 51 | ) 52 | -------------------------------------------------------------------------------- /tests/test_pip.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import venv 4 | 5 | import pytest 6 | 7 | from onepm import pa, pi, pr, pu, pun 8 | from onepm.core import OneManager 9 | 10 | 11 | @pytest.fixture 12 | def find_executable(mocker): 13 | @staticmethod 14 | def mock_find_executable(name: str, path: str | None = None) -> str: 15 | return name 16 | 17 | mocker.patch( 18 | "onepm.pm.base.PackageManager.find_executable", side_effect=mock_find_executable 19 | ) 20 | 21 | 22 | def test_pip_detect_activated_venv(project, monkeypatch): 23 | venv.create("foo", clear=True, with_pip=True) 24 | monkeypatch.setenv("VIRTUAL_ENV", "foo") 25 | pm = OneManager().get_package_manager() 26 | assert pm.name == "pip" 27 | if sys.platform == "win32": 28 | assert pm.get_command() == ["foo\\Scripts\\python.EXE", "-m", "pip"] 29 | else: 30 | assert pm.get_command() == ["foo/bin/python", "-m", "pip"] 31 | 32 | 33 | @pytest.mark.parametrize("pre_create", [True, False]) 34 | def test_pip_detect_dot_venv(project, pre_create, monkeypatch): 35 | monkeypatch.delenv("VIRTUAL_ENV", raising=False) 36 | if pre_create: 37 | venv.create(".venv", clear=True, with_pip=True) 38 | pm = OneManager().get_package_manager() 39 | assert os.path.exists(".venv/pyvenv.cfg") 40 | assert pm.name == "pip" 41 | if sys.platform == "win32": 42 | assert pm.get_command() == [".venv\\Scripts\\python.EXE", "-m", "pip"] 43 | else: 44 | assert pm.get_command() == [".venv/bin/python", "-m", "pip"] 45 | 46 | 47 | @pytest.mark.usefixtures("pip") 48 | def test_install_without_args_error(project, execute_command): 49 | with pytest.raises( 50 | Exception, match="No requirements.txt or setup.py/pyproject.toml is found" 51 | ): 52 | pi([]) 53 | 54 | 55 | @pytest.mark.usefixtures("pip") 56 | @pytest.mark.parametrize("filename", ["requirements.txt", "requirements.in"]) 57 | def test_pip_install_without_args_from_requirements_txt( 58 | project, execute_command, filename 59 | ): 60 | project.joinpath(filename).touch() 61 | pi([]) 62 | execute_command.assert_called_with( 63 | ["python", "-m", "pip", "install", "-r", filename], None, exit=True 64 | ) 65 | 66 | 67 | @pytest.mark.usefixtures("pip") 68 | def test_pip_install_with_args(project, execute_command): 69 | pi(["--upgrade", "bar"]) 70 | execute_command.assert_called_with( 71 | ["python", "-m", "pip", "install", "--upgrade", "bar"], None, exit=True 72 | ) 73 | 74 | 75 | @pytest.mark.usefixtures("pip") 76 | def test_pip_run(project, find_executable, execute_command): 77 | pr(["bar", "--version"]) 78 | execute_command.assert_called_with(["bar", "--version"]) 79 | 80 | 81 | @pytest.mark.usefixtures("pip") 82 | def test_pip_pu_not_supported(project): 83 | with pytest.raises(Exception, match="pip does not support the `pu` shortcut"): 84 | pu([]) 85 | 86 | 87 | @pytest.mark.usefixtures("pip") 88 | def test_pip_pun(project, execute_command): 89 | pun(["bar"]) 90 | execute_command.assert_called_with( 91 | ["python", "-m", "pip", "uninstall", "bar"], None, exit=True 92 | ) 93 | 94 | 95 | @pytest.mark.usefixtures("pip") 96 | def test_pip_pa(project, execute_command): 97 | pa(["freeze", "--path", "foo"]) 98 | execute_command.assert_called_with( 99 | ["python", "-m", "pip", "freeze", "--path", "foo"], None, exit=True 100 | ) 101 | -------------------------------------------------------------------------------- /tests/test_pipenv.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from onepm import pa, pi, pr, pu, pun 4 | 5 | pytestmark = pytest.mark.usefixtures("pipenv") 6 | 7 | 8 | @pytest.mark.parametrize("args", [[], ["--dev"], ["--keep-outdated", "requests"]]) 9 | def test_pipenv_pi(project, execute_command, args): 10 | pi(args) 11 | execute_command.assert_called_with(["pipenv", "install", *args], None, exit=True) 12 | 13 | 14 | def test_pipenv_pr(project, execute_command): 15 | pr(["test", "--no-report"]) 16 | execute_command.assert_called_with( 17 | ["pipenv", "run", "test", "--no-report"], None, exit=True 18 | ) 19 | 20 | 21 | def test_pipenv_pu(project, execute_command): 22 | pu(["requests"]) 23 | execute_command.assert_called_with( 24 | ["pipenv", "update", "requests"], None, exit=True 25 | ) 26 | 27 | 28 | def test_pipenv_pun(project, execute_command): 29 | pun(["-d", "requests"]) 30 | execute_command.assert_called_with( 31 | ["pipenv", "uninstall", "-d", "requests"], None, exit=True 32 | ) 33 | 34 | 35 | def test_pipenv_pa(project, execute_command): 36 | pa(["--venv", "--system-site-packages", "--python", "3.7"]) 37 | execute_command.assert_called_with( 38 | [ 39 | "pipenv", 40 | "--venv", 41 | "--system-site-packages", 42 | "--python", 43 | "3.7", 44 | ], 45 | None, 46 | exit=True, 47 | ) 48 | -------------------------------------------------------------------------------- /tests/test_poetry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from onepm import pa, pi, pr, pu, pun 4 | 5 | pytestmark = pytest.mark.usefixtures("poetry") 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "args, expected_command", 10 | [ 11 | ([], "install"), 12 | (["-E", "foo", "-E", "bar"], "install"), 13 | (["--extras=foo", "--extras=bar"], "install"), 14 | (["-D", "requests"], "add"), 15 | (["-D", "-E", "foo", "-E", "bar", "requests"], "add"), 16 | ], 17 | ) 18 | def test_poetry_pi_dispatch(project, execute_command, args, expected_command): 19 | pi(args) 20 | execute_command.assert_called_with( 21 | ["poetry", expected_command, *args], None, exit=True 22 | ) 23 | 24 | 25 | def test_poetry_pu(project, execute_command): 26 | pu(["requests"]) 27 | execute_command.assert_called_with( 28 | ["poetry", "update", "requests"], None, exit=True 29 | ) 30 | 31 | 32 | def test_poetry_pun(project, execute_command): 33 | pun(["requests"]) 34 | execute_command.assert_called_with( 35 | ["poetry", "remove", "requests"], None, exit=True 36 | ) 37 | 38 | 39 | def test_poetry_pr(project, execute_command): 40 | pr(["test", "--no-report"]) 41 | execute_command.assert_called_with( 42 | ["poetry", "run", "test", "--no-report"], None, exit=True 43 | ) 44 | 45 | 46 | def test_poetry_pa(project, execute_command): 47 | pa(["env", "--python"]) 48 | execute_command.assert_called_with(["poetry", "env", "--python"], None, exit=True) 49 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from onepm.core import OneManager 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def ensure_excutable(mocker): 8 | mocker.patch("onepm.pm.pip.Pip.ensure_executable", return_value="python") 9 | mocker.patch( 10 | "onepm.pm.base.PackageManager.ensure_executable", return_value="python" 11 | ) 12 | 13 | 14 | @pytest.mark.parametrize("filename", ["Pipfile", "Pipfile.lock"]) 15 | def test_detect_pipenv(project, filename): 16 | project.joinpath(filename).touch() 17 | assert OneManager().get_package_manager().name == "pipenv" 18 | 19 | 20 | def test_detect_pdm_lock(project): 21 | project.joinpath("pdm.lock").touch() 22 | assert OneManager().get_package_manager().name == "pdm" 23 | 24 | 25 | def test_detect_poetry_lock(project): 26 | project.joinpath("poetry.lock").touch() 27 | assert OneManager().get_package_manager().name == "poetry" 28 | 29 | 30 | def test_detect_pdm_tool_table(project): 31 | project.joinpath("pyproject.toml").write_text("[tool.pdm]\n") 32 | assert OneManager().get_package_manager().name == "pdm" 33 | 34 | 35 | def test_detect_poetry_tool_table(project): 36 | project.joinpath("pyproject.toml").write_text("[tool.poetry]\n") 37 | assert OneManager().get_package_manager().name == "poetry" 38 | 39 | 40 | def test_detect_default_pip(project): 41 | assert OneManager().get_package_manager().name == "pip" 42 | 43 | project.joinpath("pyproject.toml").write_text("[tool.black]\n") 44 | assert OneManager().get_package_manager().name == "pip" 45 | -------------------------------------------------------------------------------- /tests/test_uv.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from onepm import pa, pi, pu, pun 6 | 7 | pytestmark = pytest.mark.usefixtures("uv") 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "args", [[], ["--no-dev"], ["--extra-index-url", "http://example.org"]] 12 | ) 13 | def test_uv_pi_sync(project, execute_command, args): 14 | pi(args) 15 | execute_command.assert_called_with(["uv", "sync", *args], mock.ANY, exit=True) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "args", 20 | [ 21 | ["requests"], 22 | ["--extra-index-url", "http://example.org", "requests"], 23 | ["-r", "requirements.txt"], 24 | ], 25 | ) 26 | def test_uv_pi_install(project, execute_command, args): 27 | pi(args) 28 | execute_command.assert_called_with(["uv", "add", *args], mock.ANY, exit=True) 29 | 30 | 31 | def test_uv_pu(project, execute_command): 32 | pu([]) 33 | execute_command.assert_any_call(["uv", "lock", "--upgrade"], mock.ANY, exit=False) 34 | execute_command.assert_any_call(["uv", "sync"], mock.ANY, exit=True) 35 | 36 | 37 | def test_uv_pun(project, execute_command): 38 | pun(["requests"]) 39 | execute_command.assert_called_with( 40 | ["uv", "remove", "requests"], mock.ANY, exit=True 41 | ) 42 | 43 | 44 | def test_uv_pa(project, execute_command): 45 | pa(["--venv", "--system-site-packages", "--python", "3.7"]) 46 | execute_command.assert_called_with( 47 | [ 48 | "uv", 49 | "--venv", 50 | "--system-site-packages", 51 | "--python", 52 | "3.7", 53 | ], 54 | mock.ANY, 55 | exit=True, 56 | ) 57 | --------------------------------------------------------------------------------