├── .github └── workflows │ ├── hf_installer.yml │ ├── macos.yml │ ├── ubuntu.yml │ └── win.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── examples └── README.md ├── pup.ps1 ├── pup.py ├── pup.sh └── pyproject.toml /.github/workflows/hf_installer.yml: -------------------------------------------------------------------------------- 1 | name: HF installer on Ubuntu runner 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | env: 9 | INSTALL_URL: "https://pup-py-fetch.hf.space" 10 | PYTHONIOENCODING: "utf8" # https://github.com/pallets/click/issues/2121 11 | 12 | jobs: 13 | puppy-from-hf-312: 14 | runs-on: ubuntu-latest 15 | if: "!contains(github.event.head_commit.message, 'nogha')" 16 | steps: 17 | - name: Set up custom PATH 18 | run: echo "PATH=$HOME/.pixi/bin:$PATH" >> $GITHUB_ENV 19 | - name: Install puppy from HF spaces 20 | run: | 21 | curl -fsSL "$INSTALL_URL?python=3.12&pixi=pytest&t1=cowsay,httpx&t2=xmltodict" | bash 22 | - name: new env with pup new 23 | run: | 24 | pup add t3/with/nesting "cowsay<6" 25 | pup list 26 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: Tests on MacOS runner 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | env: 9 | MAIN_BRANCH_URL: "https://raw.githubusercontent.com/liquidcarbon/puppy/main/" 10 | PYTHONIOENCODING: "utf8" # https://github.com/pallets/click/issues/2121 11 | 12 | jobs: 13 | puppy-macos-310: 14 | runs-on: macos-latest 15 | if: "contains(github.event.head_commit.message, 'allgha')" 16 | steps: 17 | - name: Set up custom PATH 18 | run: echo "PATH=$HOME/.pixi/bin:$PATH" >> $GITHUB_ENV 19 | - name: Install default puppy 20 | run: | 21 | sleep 9; # give it time to update MAIN_BRANCH_URL 22 | pwd && ls -la 23 | curl -fsSL $MAIN_BRANCH_URL/pup.sh | bash -s 3.10 24 | - name: new env with pup new 25 | run: | 26 | pup new t1/with/nesting 27 | pup add t1/with/nesting "cowsay<6" 28 | - name: new env with pup add from another folder 29 | run: | 30 | cd t1 31 | pup add t2 cowsay requests 32 | pup list 33 | - name: pup remove 34 | run: | 35 | pup remove t1/with/nesting cowsay 36 | pup list -f 37 | - name: import pup with fetch 38 | run: | 39 | pixi run python -c 'import pup; pup.fetch("t1/with/nesting", "httpx"); import httpx; print(httpx.get("https://example.com"))' 40 | - name: pup update after deleting pup 41 | run: | 42 | rm pup.py && pup update 43 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: Tests on Ubuntu runner 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | env: 9 | MAIN_BRANCH_URL: "https://raw.githubusercontent.com/liquidcarbon/puppy/main/" 10 | PYTHONIOENCODING: "utf8" # https://github.com/pallets/click/issues/2121 11 | 12 | jobs: 13 | puppy-bash-default: 14 | runs-on: ubuntu-latest 15 | if: "!contains(github.event.head_commit.message, 'nogha')" 16 | steps: 17 | - name: Set up custom PATH 18 | run: echo "PATH=$HOME/.pixi/bin:$PATH" >> $GITHUB_ENV 19 | - name: Install default puppy 20 | run: | 21 | sleep 9; # give it time to update MAIN_BRANCH_URL 22 | pwd && ls -la 23 | curl -fsSL $MAIN_BRANCH_URL/pup.sh | bash 24 | - name: new env with pup new 25 | run: | 26 | pup new t1/with/nesting 27 | pup add t1/with/nesting "cowsay<6" 28 | - name: new env with pup add from another folder 29 | run: | 30 | cd t1 31 | pup add t2 cowsay requests 32 | pup list 33 | - name: pup remove 34 | run: | 35 | pup remove t1/with/nesting cowsay 36 | pup list 37 | - name: pup clone and sync 38 | run: | 39 | pup clone https://github.com/liquidcarbon/affinity 40 | # now force an older version, then sync 41 | pixi run uv pip install \ 42 | https://github.com/liquidcarbon/affinity/releases/download/2024-11-07-90bfd62/affinity-0.7.0-py3-none-any.whl \ 43 | -p affinity/.venv/bin/python 44 | pup sync affinity 45 | pup sync affinity -U 46 | pup list 47 | pup list -f 48 | affinity/.venv/bin/pytest -vvsx affinity/ 49 | - name: import pup with fetch 50 | run: | 51 | pixi run python -c 'import pup; pup.fetch("t1/with/nesting", "httpx"); import httpx; print(httpx.get("https://example.com"))' 52 | - name: pup update after deleting local pixi files 53 | run: | 54 | rm -rf .pixi pixi.toml && pup update 55 | -------------------------------------------------------------------------------- /.github/workflows/win.yml: -------------------------------------------------------------------------------- 1 | name: Tests on Windows runner 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | env: 9 | INSTALL_URL: "https://pup-py-fetch.hf.space" 10 | PYTHONIOENCODING: "utf8" # https://github.com/pallets/click/issues/2121 11 | # https://github.com/actions/runner-images/issues/10953 12 | PATH: "C:\\Users\\runneradmin\\.pixi\\bin;C:\\ProgramData\\chocolatey\\bin;C:\\Program Files\\Git\\bin" 13 | 14 | jobs: 15 | puppy-win-311: 16 | runs-on: windows-latest 17 | if: "!contains(github.event.head_commit.message, 'nogha')" 18 | steps: 19 | - name: Install puppy from HF spaces 20 | run: | 21 | sleep 9; # give it time to update MAIN_BRANCH_URL 22 | iex (iwr -useb "$($env:INSTALL_URL)?python=3.11&pixi=notebook&t1=cowsay").Content 23 | - name: new env with pup new 24 | run: | 25 | pup new t2/with/nesting 26 | pup add t2/with/nesting "cowsay<6" 27 | - name: new env with pup add from another folder 28 | run: | 29 | cd t2 30 | pup add t3 cowsay requests 31 | pup list 32 | - name: pup remove 33 | run: | 34 | pup remove t2/with/nesting cowsay 35 | pup list 36 | - name: install git, check PATH 37 | run: | 38 | choco install git --no-progress 39 | echo $env:PATH 40 | - name: pup clone and sync 41 | run: | 42 | pup clone https://github.com/liquidcarbon/affinity 43 | # now force an older version, then sync 44 | pixi run uv pip install ` 45 | https://github.com/liquidcarbon/affinity/releases/download/2024-11-07-90bfd62/affinity-0.7.0-py3-none-any.whl ` 46 | -p affinity/.venv/Scripts/python.exe 47 | pup sync affinity 48 | pup sync affinity -U 49 | pup list 50 | pup list -f 51 | affinity/.venv/Scripts/pytest.exe -vvsx affinity/ 52 | - name: import pup with fetch 53 | run: | 54 | pixi run python -c 'import pup; pup.fetch("t2/with/nesting", "httpx"); import httpx; print(httpx.get("https://example.com"))' 55 | - name: pup update after deleting pixi.exe 56 | run: | 57 | rm C:\\Users\\runneradmin\\.pixi\\bin\\pixi.exe 58 | pup update 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | .gitattributes 3 | 4 | # pup 5 | woof.log 6 | t* 7 | 8 | # pixi environments 9 | .pixi 10 | *.egg-info 11 | pixi.lock 12 | pixi.toml 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.ignore": [ "*" ], 3 | "[python]": { 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "charliermarsh.ruff" 6 | } 7 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puppy 2 | 3 | Puppy helps you set up and manage your python projects. It's the easiest way to get started with modern python on any platform, install packages in virtual environments, and contribute to external projects. 4 | 5 | Puppy Logo 6 | 7 | 8 | ## Get started 9 | 10 | 11 | You need only `curl` / `iwr` and an empty folder; pup will handle the rest, with a little help from its powerful friends [pixi](https://github.com/prefix-dev/pixi/) and [uv](https://github.com/astral-sh/uv). 12 | 13 | 14 | ### Linux 15 | 16 | ```bash 17 | curl -fsSL https://pup-py-fetch.hf.space | bash 18 | ``` 19 | 20 | 21 | ### Windows 22 | 23 | ```powershell 24 | iex (iwr https://pup-py-fetch.hf.space).Content 25 | ``` 26 | 27 | 28 | ### One Installer To Rule Them All 29 | 30 | The `pup-py-fetch` API accepts query parameters that allow specifying the exact environment recipe you want to build: 31 | - `python`: [3.10](https://pup-py-fetch.hf.space?python=3.10) through [3.13](https://pup-py-fetch.hf.space?python=3.13) 32 | - `pixi`: [comma-separated list of pixi/Conda dependencies](https://pup-py-fetch.hf.space?pixi=jupyter,quarto) 33 | - `clone`: [comma-separated list of GitHub repos to clone and build in a virtual environment](https://pup-py-fetch.hf.space?clone=marimo-team/marimo) 34 | - virtual environments: [all other query parameters with comma-separated package names](https://pup-py-fetch.hf.space?env1=duckdb,pandas&env2=cowsay), including: 35 | - regular PyPI packages (no support for version pinning at this time) 36 | - packages from GitHub repos using `/` (only GitHub at this time; repo must contain must contain buildable `pyproject.toml` in its root) 37 | 38 | > [!NOTE] 39 | > As of Dec 2024, many packages still do not support python 3.13; thus, the default version in puppy is 3.12. 40 | 41 | 42 | The URLs above return installation scripts. You can mix and match query parameters, unlocking single-command recipes for complex builds: 43 | 44 | 45 | ```bash 46 | curl -fsSL "https://pup-py-fetch.hf.space?pixi=marimo&env1=duckdb,pandas&env2=cowsay" | bash 47 | ``` 48 | 49 | ```powershell 50 | iex (iwr "https://pup-py-fetch.hf.space?python=3.11&pixi=marimo&tables=duckdb,pandas,polars").Content 51 | ``` 52 | 53 | Or just grab the base build and use pup commands: 54 | 55 | https://github.com/user-attachments/assets/9cdd5173-5358-404a-84cc-f569da9972f8 56 | 57 | 58 | ## How It Works 59 | 60 | Puppy is a transparent wrapper around [pixi](https://github.com/prefix-dev/pixi/) and [uv](https://github.com/astral-sh/uv), two widely used Rust-based tools that belong together. 61 | 62 | Puppy can be used as a CLI in a Linux or Windows shell, or as a [module](#using-pup-as-a-module-pupfetch) in any python shell/script/[notebook](#puppy--environments-in-notebooks). 63 | 64 | Installing puppy preps the folder to house python, in complete isolation from system or any other python on your system: 65 | 66 | 0) 🐍 this folder is home to one and only one python executable, managed by pixi 67 | 1) ✨ puppy installs pixi; pixi installs core components: python, uv, [click](https://github.com/pallets/click) 68 | 2) ⚙ [Bash](https://github.com/liquidcarbon/puppy/blob/main/pup.sh) or [Powershell](https://github.com/liquidcarbon/puppy/blob/main/pup.ps1) runner/installer is placed into `~/.pixi/bin` (the only folder that goes on PATH) 69 | 3) 🐶 `pup.py` is the python/click CLI that wraps pixi and uv commands 70 | 4) 🟣 `pup new` and `pup add` use uv to handle projects, packages and virtual environments 71 | 5) 📀 `pup clone` and `pup sync` help build environments from external `pyproject.toml` project files 72 | 73 | 74 | 75 | ## Using `pup` as a Module: `pup.fetch` 76 | 77 | Pup can help you construct and activate python projects interactively, such as from (i)python shells, jupyter notebooks, or [marimo notebooks](https://github.com/marimo-team/marimo/discussions/2994). 78 | 79 | Pup scans for folders containing valid `pyproject.toml` and python executable inside `.venv` folder. If a venv does not exist, pup can create it. 80 | 81 | ``` 82 | a@a-Aon-L1:~/Desktop/puppy$ .pixi/envs/default/bin/python 83 | Python 3.12.7 84 | >>> import pup; pup.fetch() 85 | [2024-10-26 16:50:37] 🐶 said: woof! run `pup.fetch()` to get started 86 | [2024-10-26 16:50:37] 🐶 virtual envs available: ['tbsky', 't1/web', 't2', 'tmpl', 'test-envs/e1'] 87 | Choose venv to fetch: t1/web 88 | [2024-10-26 16:51:56] 🐶 heard: pup list t1/web 89 | { 90 | "t1/web": [ 91 | "httpx>=0.27.2", 92 | "requests>=2.32.3" 93 | ] 94 | } 95 | [2024-10-26 16:51:56] fetched packages from 't1/web': /home/a/Desktop/puppy/t1/web/.venv/lib/python3.12/site-packages added to `sys.path` 96 | ``` 97 | 98 | 99 | Now the "kernel" `t1/web` is activated. In other words, packages installed `t1/web/.venv` are available on `sys.path`. 100 | 101 | > [!NOTE] 102 | > Folders inside pup's home folder are scanned recursively, and nested paths are supported. Symlinks are not followed. 103 | 104 | Need to install more packages on the go, or create a new venv? Just provide the destination, and list of packages. 105 | 106 | ```python 107 | pup.fetch("myenv") # activate existing environment 108 | pup.fetch("myenv", quiet=True) # activate quietly (output suppressed) 109 | pup.fetch("myenv", "duckdb", "pandas", "pyarrow") # create/update venv, install packages, activate 110 | ``` 111 | 112 | 113 | Here is the signature of `pup.fetch()`: 114 | ```python 115 | def fetch( 116 | venv: str | None = None, 117 | *packages: Optional[str], 118 | site_packages: bool = True, 119 | root: bool = False, 120 | quiet: bool = False, 121 | ) -> None: 122 | """Create, modify, or fetch (activate) existing venvs. 123 | 124 | Activating an environment means placing its site-packages folder on `sys.path`, 125 | allowing to import the modules that are installed in that venv. 126 | 127 | `venv`: folder containing `pyproject.toml` and installed packages in `.venv` 128 | if venv does not exist, puppy will create it, install *packages, 129 | and fetch newly created venv 130 | `*packages`: names of packages to `pup add` 131 | `site_packages`: if True, appends venv's site-packages to `sys.path` 132 | `root`: if True, appends venv's root folder to `sys.path` 133 | (useful for packages under development) 134 | `quiet`: suppress output 135 | """ 136 | ``` 137 | 138 | With `root=True`, you also add new project's root folder to your environment, making its modules available for import. 139 | This is useful for working with projects that themselves aren't yet packaged and built. 140 | You also have the option to omit the site-packages folder with `site_packages=False`. 141 | 142 | ``` 143 | pixi run python 144 | Python 3.12.7 | packaged by conda-forge | (main, Oct 4 2024, 16:05:46) [GCC 13.3.0] on linux 145 | Type "help", "copyright", "credits" or "license" for more information. 146 | >>> import pup; pup.fetch("test-only-root", root=True, site_packages=False) 147 | [2024-11-22 13:10:49] 🐶 said: woof! run `pup.fetch()` to get started 148 | [2024-11-22 13:10:49] 🐶 virtual envs available: ['gr'] 149 | [2024-11-22 13:10:49] 🐶 heard: pup new test-only-root 150 | [2024-11-22 13:10:49] 🐶 said: pixi run uv init /home/a/puppy/test-only-root -p /home/a/puppy/.pixi/envs/default/bin/python --no-workspace 151 | Initialized project `test-only-root` at `/home/a/puppy/test-only-root` 152 | [2024-11-22 13:10:49] 🐶 said: pixi run uv venv /home/a/puppy/test-only-root/.venv -p /home/a/puppy/.pixi/envs/default/bin/python 153 | Using CPython 3.12.7 interpreter at: .pixi/envs/default/bin/python 154 | Creating virtual environment at: test-only-root/.venv 155 | Activate with: source test-only-root/.venv/bin/activate 156 | Specify what to install: 157 | [2024-11-22 13:10:50] 🐶 virtual envs available: ['gr', 'test-only-root'] 158 | [2024-11-22 13:10:50] fetched packages from 'test-only-root': /home/a/puppy/test-only-root added to `sys.path` 159 | [2024-11-22 13:10:50] 🐶 heard: pup list test-only-root 160 | { 161 | "test-only-root": [] 162 | } 163 | >>> import hello; hello.main() # `hello.py` is included in uv's project template 164 | Hello from test-only-root! 165 | ``` 166 | 167 | ## Puppy & Environments in Notebooks 168 | 169 | > [!NOTE] 170 | > Conda or PyPI packages installed with `pixi add ...` always remain on `sys.path` and stay available across all environments. Though one could exclude them, I have yet to find a reason to do so. 171 | 172 | ### Jupyter 173 | 174 | There's a good chance you're confused about how Jupyter kernels work and find setting up kernels with virtual environments too complicated to bother. Puppy's [v1](https://github.com/liquidcarbon/puppy/tree/v1) was addressing that problem, but in v2 (current version) this is taken care of by `pup.fetch`. Here's the gist: 175 | 176 | 1) install ONE instance of jupyter with `pixi add jupyter` per major version of python 177 | 2) run it with `pixi run jupyter lab` or `pixi run jupyter notebook` 178 | 3) use `pup.fetch` to build and activate your environment - THAT'S IT! 179 | 180 | For details, scan through the [previous section](#using-pup-as-a-module-pupfetch). In brief, `pup.fetch` creates/modifies and/or activates your venv by appending its folder to `sys.path`. This is pretty very similar to how venvs and kernels work. A jupyter kernel is a pointer to a python executable. Within a venv, the executable `.venv/bin/python` is just a symlink to the parent python - in our case, to pixi's python. The activation and separation of packages is achieved by manipulating `sys.path` to include local `site-packages` folder(s). 181 | 182 | 183 | ### Marimo 184 | 185 | With marimo, you have more options: [Unified environment management for any computational notebooks](https://github.com/marimo-team/marimo/discussions/2994) - no more Jupyter kernels! 186 | 187 | 188 | ## Where Pixi Shines 🎇 189 | 190 | UV is rightfully getting much love in the community, but Pixi is indispensable for: 191 | 192 | 1. Conda channels support 193 | 2. Setting up really complex build environments for multi-language projects. For example, try pulling together what's done here in one API call (python, NodeJS, pnpm, cloned and synced repo): 194 | \ 195 | \ 196 | Pixi build with python, Node, pnpm, and cloned repos 197 | 198 | 199 | ## Multi-Puppy-Verse 200 | 201 | Can I have multiple puppies? As many as you want! Puppy is not just a package installer, but also a system to organize multiple python projects. 202 | 203 | A pup/py home is defined by one and only one python executable, which is managed by pixi, along with tools like uv, jupyter, hatch, pytest, and conda-managed packages. We use home-specific tools through a pixi shell from anywhere within the folder, e.g. `pixi run python`, `pixi run jupyter`, or, to be explicit, by calling their absolute paths. 204 | 205 | > [!NOTE] 206 | > If you need a "kernel" with a different version of python, install puppy in a new folder. **Puppy's folders are completely isolated from each other and any other python installation on your system.** Remember, one puppy folder = one python executable, managed by Pixi. Pup commands work the same from anywhere within a pup folder, run relative to its root, via `.pixi/envs/default/bin/python`. Place puppy folders side-by-side, not within other puppy folders - nested puppies might misbehave. 207 | 208 | ``` 209 | # ├── puphome/ # python 3.12 lives here 210 | # │ ├── public-project/ 211 | # │ │ ├── .git # this folder may be a git repo (see pup clone) 212 | # │ │ ├── .venv 213 | # │ │ └── pyproject.toml 214 | # │ ├── env2/ 215 | # │ │ ├── .venv/ # this one is in pre-git development 216 | # │ │ └── pyproject.toml 217 | # │ ├── pixi.toml 218 | # │ └── pup.py 219 | # ├── pup311torch/ # python 3.11 here 220 | # │ ├── env3/ 221 | # │ ├── env4/ 222 | # │ ├── pixi.toml 223 | # │ └── pup.py 224 | # └── pup313beta/ # 3.13 here 225 | # ├── env5/ 226 | # ├── pixi.toml 227 | # └── pup.py 228 | ``` 229 | 230 | The blueprint for a pup/py home is in `pixi.toml`; at this level, git is usually not needed. The inner folders are git-ready project environments managed by pup and uv. In each of the inner folders, there is a classic `.venv` folder and a `pyproject.toml` file populated by uv. When you run `pup list`, pup scans this folder structure and looks inside each `pyproject.toml`. The whole setup is very easy to [containerize](examples/) (command to generate `Dockerfile` coming soon!). 231 | 232 | > [!TIP] 233 | > Use `pup list -f` to list all dependencies spelled out in `uv.lock`. 234 | 235 | ## But Why 236 | 237 | Python packages, virtual environments, notebooks, and how they all play together remains a confusing and controversial topic in the python world. 238 | 239 | The problems began when the best idea from the Zen of python was ignored by pip: 240 | 241 | ``` 242 | ~$ python -c 'import this' 243 | The Zen of Python, by Tim Peters 244 | 245 | ... 246 | Explicit is better than implicit. 247 | ... 248 | 249 | ~$ pip install numpy 250 | Collecting numpy 251 | Downloading numpy-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (60 kB) 252 | Downloading numpy-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (16.3 MB) 253 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.3/16.3 MB 62.5 MB/s eta 0:00:00 254 | Installing collected packages: numpy 255 | Successfully installed numpy-2.1.2 256 | ``` 257 | 258 | The command worked, yay! Antigravity! But *which* pip did the work and *where* did the packages go? 259 | 260 | ![confused Travolta](https://i.kym-cdn.com/photos/images/newsfeed/001/042/619/4ea.jpg) 261 | 262 | Most tools that came later followed the same pattern. 263 | 264 | Puppy makes implicitly sensible choices while being explicitly transparent. Compare: 265 | 266 | ``` 267 | PS C:\Users\a\Desktop\code\puppy> pup add try-ml numpy 268 | [2024-10-13 00:02:19] 🐶 heard: pup new try-ml 269 | [2024-10-13 00:02:19] 🐶 said: pixi run uv init C:\Users\a\Desktop\code\puppy\try-ml -p C:\Users\a\Desktop\code\puppy\.pixi\envs\default\python.exe --no-workspace 270 | Initialized project `try-ml` at `C:\Users\a\Desktop\code\puppy\try-ml` 271 | [2024-10-13 00:02:20] 🐶 said: pixi run uv venv C:\Users\a\Desktop\code\puppy\try-ml/.venv -p C:\Users\a\Desktop\code\puppy\.pixi\envs\default\python.exe 272 | Using CPython 3.12.7 interpreter at: .pixi\envs\default\python.exe 273 | Creating virtual environment at: try-ml/.venv 274 | Activate with: try-ml\.venv\Scripts\activate 275 | [2024-10-13 00:02:21] 🐶 heard: pup add try-ml numpy 276 | [2024-10-13 00:02:21] 🐶 said: pixi run uv add numpy --project C:\Users\a\Desktop\code\puppy\try-ml 277 | Resolved 2 packages in 87ms 278 | Installed 1 package in 344ms 279 | + numpy==2.1.2 280 | ``` 281 | 282 | Then came Jupyter notebooks, a wonderful tool that unlocked the floodgates of interest to python. But the whole `import` thing remained a confusing mess. 283 | 284 | (to be continued) [[Medium article](https://medium.com/pythoneers/puppy-pythons-best-friend-c03578d9b491)] 285 | 286 | ## Future 287 | 288 | - `pup swim` (build Dockerfiles) 289 | - you tell me? 290 | 291 | ## Past 292 | 293 | - [v0](https://github.com/liquidcarbon/puppy/tree/b474b1cd6c63b9fc80db5d81f954536a58aeab2a) was a big Bash script 294 | - [v1](https://github.com/liquidcarbon/puppy/tree/v1) remains a functional CLI with `pup play` focused on Jupyter kernels 295 | 296 | ## Built with Puppy 297 | 298 | See [examples](examples/README.md). 299 | 300 | ## Support 301 | 302 | Thanks for checking out this repo. Hope you try it out and like it! Feedback, discussion, and ⭐s are welcome! 303 | 304 | ``` 305 | ┌──────────────────────────────────────────────────────────────────────────────┐ 306 | │ ~===========@ >>> .-. │ 307 | | >>> (___________________________()6 `-, | 308 | │ %%%%%%%%%%%%%%% >>> ( ______________________ /''"` │ 309 | │ %site_packages% >>> //\\ //\\ | 310 | │ %%%%%%%%%%%%%%% | 311 | │ !!!!!!!!!!!!!!!!!!!!! +++++ │ 312 | │ ******* !ModuleNotFoundError! │ 313 | │ *.venv* """ !!!!!!!!!!!!!!!!!!!!! ~~~~~~~~~~~~~~ │ 314 | │ ******* ../.. ~ (base) :~$ ~ │ 315 | │ """ [================] ~~~~~~~~~~~~~~ │ 316 | │ @ + [ pyproject.toml ] │ 317 | │ @ $$$$$$$$$$$$$$$$$ + [================] │ 318 | │ @ $ Collecting $ +++ + + │ 319 | │ @ $ Downloading $ + + + │ 320 | │ $$$$$$$$$$$$$$$$$ +++ ######################### + + │ 321 | │ + + # >>> from bla import * # + + │ 322 | │ @ &&&&&&&&&&&&&&& + +++ ######################### + + │ 323 | │ @ & dist & + + + + │ 324 | │ @ & ├───.tar.gz & + +++ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + │ 325 | │ @ & └───.whl & + !ERROR: ResolutionImpossible:! + + │ 326 | │ @ &&&&&&&&&&&&&&& +++++ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + │ 327 | │ │ 328 | └──────────────────────────────────────────────────────────────────────────────┘ 329 | Eat fruits and vegetables. Don't run into walls. Don't eat your own tail. 330 | 331 | ASCII dog by Joan Stark, the rest by author 332 | ``` 333 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Built with Puppy 🐶 2 | 3 | ## DuckDB Query Editor in Gradio ([source](https://huggingface.co/spaces/liquidcarbon/duckdb-fastapi-gradio/tree/main), [app](https://huggingface.co/spaces/liquidcarbon/duckdb-fastapi-gradio)) 4 | 5 | [Interactive SQL query editor](https://medium.com/@liquidc/live-duckdb-editor-on-gradio-533addd0666c): 6 | - accepts variables to use in query templates 7 | - saves queries and their execution data into an internal database (simply a JSON file) 8 | - displays results as an interactive table that can be sorted and filtered 9 | - creates shareable links, for query editor and results 10 | - works on mobile (for those who always wanted to practice SQL on the toilet) 11 | 12 | ## Marimo Tutorials ([source](https://huggingface.co/spaces/liquidcarbon/puppy-hf-marimo/tree/main), [app](https://huggingface.co/spaces/liquidcarbon/puppy-hf-marimo)) 13 | 14 | [One notebook, many environments](https://github.com/marimo-team/marimo/discussions/2994) - no more Jupyter kernels! 15 | Uses a cool trick (if I can say so myself) to inject dependencies into a python script at launch time. 16 | 17 | ## Puppy Installer ([source](https://huggingface.co/spaces/pup-py/fetch/tree/main), [app](https://huggingface.co/spaces/pup-py/fetch)) 18 | 19 | FastAPI app serving installation recipes. 20 | 21 | 22 | ## Affinity ([source](https://github.com/liquidcarbon/affinity)) 23 | 24 | Typed, annotated vectors for well-documented datasets. -------------------------------------------------------------------------------- /pup.ps1: -------------------------------------------------------------------------------- 1 | 2 | $DEFAULT_PY_VERSION = "3.13" 3 | $GH_BRANCH = "main" 4 | $GH_URL = "https://raw.githubusercontent.com/liquidcarbon/puppy/$GH_BRANCH/" 5 | $PIXI_INSTALL_URL = "https://pixi.sh/install.ps1" 6 | 7 | function Main { 8 | $DIR = Get-Location 9 | $global:PIXI_TOML = "" 10 | $global:PUP = "" 11 | while ($DIR -ne [System.IO.Path]::GetPathRoot($DIR)) { 12 | if (Test-Path "$DIR\pixi.toml") { 13 | $global:PIXI_TOML = "$DIR\pixi.toml" 14 | } 15 | if (Test-Path "$DIR\pup.py") { 16 | $global:PUP = "$DIR\pup.py" 17 | $global:PUP_HOME = $DIR 18 | break 19 | } 20 | $DIR = Split-Path $DIR 21 | } 22 | if ($args.Count -gt 0) { 23 | if ($PUP -and $args[0] -ne "update") { 24 | Run @args 25 | } elseif ($PUP -and $args[0] -eq "update") { 26 | Update 27 | } else { 28 | Install @args 29 | } 30 | } else { 31 | if ($PUP) { 32 | Run $null # make PS happy 33 | } else { 34 | Install $null 35 | } 36 | } 37 | } 38 | 39 | function Run { 40 | $PY = "$PUP_HOME\.pixi\envs\default\python.exe" 41 | if (Test-Path $PY) { 42 | & "$PY" "$PUP" @args 43 | } else { 44 | pixi run python "$PUP" @args 45 | } 46 | } 47 | 48 | function Update { 49 | Get-Pixi 50 | pixi self-update 51 | Pixi-Init 52 | Get-Python-UV-Click $null 53 | pixi update 54 | Get-Pup 55 | } 56 | 57 | function Install { 58 | if ((Get-ChildItem | Measure-Object).Count -gt 0) { 59 | $response = Read-Host -Prompt "$(Get-Location) is not empty; do you want to make it puppy's home? (y/n)" 60 | if ($response -ne "y") { exit 1 } 61 | } 62 | Get-Pixi 63 | Pixi-Init 64 | if ($args.Count -gt 0) { 65 | Get-Python-UV-Click $args[0] 66 | } else { 67 | Get-Python-UV-Click $null # make PS 5 happy 68 | } 69 | Get-Pup 70 | } 71 | 72 | function Get-Pixi { 73 | if (-not (Get-Command pixi -ErrorAction SilentlyContinue)) { 74 | iwr -useb $PIXI_INSTALL_URL | iex 75 | $env:PATH = "$HOME\.pixi\bin;" + $env:PATH # new installs need this 76 | Write-Host "✨ $(pixi -V) installed" 77 | } else { 78 | Write-Host "✨ $(pixi -V) found" 79 | } 80 | $global:PIXI_HOME = Split-Path (Get-Command pixi).Source 81 | } 82 | 83 | function Pixi-Init { 84 | if ($PIXI_TOML -ne "") { 85 | Write-Host "✨ here be pixies! pixi.toml found" 86 | } else { 87 | pixi init . 88 | $global:PIXI_TOML = (Resolve-Path pixi.toml).Path 89 | } 90 | } 91 | 92 | function Py-Ver-Prompt { 93 | $PromptMessage = "Enter desired base Python version (supported: 3.9|3.10|3.11|3.12|3.13; blank=3.12)" 94 | $PY_VERSION = Read-Host -Prompt $PromptMessage 95 | if (-not $PY_VERSION) { $PY_VERSION = $DEFAULT_PY_VERSION } 96 | return $PY_VERSION 97 | } 98 | 99 | function Get-Python-UV-Click { 100 | param([string]$version) 101 | if ($version) { 102 | $PY_VERSION = $version 103 | $INSTALL = 1 104 | } else { 105 | if (Select-String -Path $PIXI_TOML -Pattern "python") { 106 | $INSTALL = 0 107 | } else { 108 | $PY_VERSION = Py-Ver-Prompt 109 | $INSTALL = 1 110 | } 111 | } 112 | if ($INSTALL -eq 1) { 113 | pixi add "python=$PY_VERSION" 114 | pixi add "uv>=0" 115 | Write-Host "🟣 $(pixi run uv --version)" 116 | pixi add "click>=8" 117 | # using ">=" overrides pixi's default ">=,<" and allows updates to new major versions 118 | } else { 119 | Write-Host "🐍 python lives here!" 120 | } 121 | pixi run python -VV 122 | } 123 | 124 | function Get-Pup { 125 | if ($PUP -ne "") { 126 | Invoke-WebRequest -Uri "$GH_URL/pup.py" -OutFile "$PUP" 127 | } else { 128 | Invoke-WebRequest -Uri "$GH_URL/pup.py" -OutFile "pup.py" 129 | } 130 | Invoke-WebRequest -Uri "$GH_URL/pup.ps1" -OutFile "$PIXI_HOME/pup.ps1" 131 | & "$PIXI_HOME/pup" hi 132 | } 133 | 134 | Main @args 135 | -------------------------------------------------------------------------------- /pup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __doc__ = """ 4 | The CLI for pup, a cute python project manager. 5 | """ 6 | 7 | __version__ = "2.7.0" 8 | 9 | import collections 10 | import json 11 | import os 12 | import platform 13 | import subprocess 14 | import sys 15 | from contextlib import contextmanager, nullcontext 16 | from pathlib import Path 17 | from time import strftime 18 | from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple 19 | 20 | if sys.version.startswith("3.10"): 21 | try: 22 | import tomli as tomllib 23 | except ModuleNotFoundError: 24 | subprocess.run([(Path.home() / ".pixi/bin/pixi").as_posix(), "add", "tomli"]) 25 | import tomli as tomllib 26 | else: 27 | import tomllib 28 | 29 | 30 | if TYPE_CHECKING: 31 | import click 32 | 33 | 34 | class PupException(Exception): 35 | pass 36 | 37 | 38 | class Pup: 39 | """Settings and initialization for pup CLI. 40 | 41 | Puppy is designed to work the same from anywhere within the project folder. 42 | This means there's some discovery to be done at every invocation, 43 | so we run Pup.welcome() prior to main(). 44 | """ 45 | 46 | COLOR: str = "yellow" 47 | FILE: Path = Path(__file__) 48 | HOME: Path = Path(__file__).parent # initial assumption 49 | HOME_MARKER: str = "pup.py" 50 | LOG_FILE: Path = Path("woof.log") 51 | LOG_TIME_FORMAT: str = "%Y-%m-%d %H:%M:%S" 52 | PLATFORM: str = platform.system() 53 | PYTHON: Path = Path(sys.executable) 54 | PYTHON_VER: str = f"{sys.version_info.major}.{sys.version_info.minor}" 55 | RESERVED: Tuple[str] = ("nb",) 56 | SP_PREFIX: str = "Lib" if PLATFORM == "Windows" else f"lib/python{PYTHON_VER}" 57 | SP_VENV: str = f".venv/{SP_PREFIX}/site-packages" 58 | VENV_MARKER: str = "pyproject.toml" 59 | VENV_PYTHON: str = ( 60 | ".venv/Scripts/python.exe" if PLATFORM == "Windows" else ".venv/bin/python" 61 | ) 62 | 63 | @classmethod 64 | def find_home(cls, prefix: Path = Path(sys.prefix)) -> Path: 65 | """Search in current folder and its parents for HOME_MARKER file.""" 66 | if (prefix / cls.HOME_MARKER).exists(): 67 | return prefix 68 | elif prefix.parent in (prefix, prefix.root): 69 | # should never happen using Bash/PS runners, only if pup.py used directly 70 | if click.confirm(UserInput.PupHomeNotFound): 71 | exit(1) 72 | else: 73 | exit(1) 74 | else: 75 | return cls.find_home(prefix.parent) 76 | 77 | @classmethod 78 | def import_click(cls) -> None: 79 | """This hack to makes click and pup available in any venv.""" 80 | cls.SP_ROOT_PUP.write_bytes(cls.FILE.read_bytes()) 81 | sys.path.append(cls.SP_ROOT_PATH.as_posix()) 82 | import click # noqa: F401 83 | 84 | globals()["click"] = click 85 | sys.path = sys.path[:-1] 86 | 87 | @classmethod 88 | def welcome(cls) -> None: 89 | """Prep pup's environment.""" 90 | cls.HOME = cls.find_home() 91 | cls.PIXI_ENV = cls.HOME / ".pixi/envs" / "default" 92 | cls.SP_ROOT_PATH = cls.PIXI_ENV / cls.SP_PREFIX / "site-packages" 93 | cls.SP_ROOT_PUP = cls.SP_ROOT_PATH.parent / "pup.py" 94 | cls.import_click() 95 | 96 | cls.LOG_FILE = cls.HOME / cls.LOG_FILE 97 | if not cls.LOG_FILE.exists(): 98 | cls.log(f"🐶 has arrived to {cls.HOME}", cls.LOG_FILE) 99 | 100 | @classmethod 101 | def pedigree(cls) -> str: 102 | """Pup's origins.""" 103 | return f"🐶 = {cls.PYTHON} {cls.FILE} # v{__version__}" 104 | 105 | @staticmethod 106 | def log( 107 | msg: str, file: Path | None = None, color: str | None = None, tee: bool = True 108 | ) -> None: 109 | """Log to stdout. Tee also logs to file (like '| tee -a $LOG_FILE').""" 110 | timestamp = strftime(Pup.LOG_TIME_FORMAT) 111 | log_message = f"[{timestamp}] {msg}" 112 | click.secho(log_message, fg=color) 113 | if file and tee: 114 | with open(file, "a", encoding="utf-8") as f: 115 | f.write(log_message + "\n") 116 | 117 | @staticmethod 118 | def do(command: str, tee: bool = True) -> None: 119 | Pup.say(command, tee=tee) 120 | subprocess.run(command.split()) 121 | 122 | @staticmethod 123 | def hear(message: str, tee: bool = True) -> None: 124 | """Log pup's input.""" 125 | Pup.log(f"🐶 heard: {message}", Pup.LOG_FILE, None, tee) 126 | 127 | @staticmethod 128 | def say(message: str, tee: bool = True) -> None: 129 | """Log pup's output.""" 130 | Pup.log(f"🐶 said: {message}", Pup.LOG_FILE, Pup.COLOR, tee) 131 | 132 | @staticmethod 133 | def list_venvs() -> list[Path]: 134 | """List of virtual environments known to pup. 135 | 136 | A virtual environment is a non-hidden folder that contains 137 | `Pup.VENV_MARKER` file (`pyproject.toml` by default). 138 | """ 139 | 140 | all_venvs = [] 141 | for d in Pup.HOME.iterdir(): 142 | # do not follow symlinks or hidden folders 143 | if d.is_symlink() or d.name.startswith(".") or not d.is_dir(): 144 | continue 145 | all_venvs.extend(d.rglob(Pup.VENV_MARKER)) 146 | 147 | # exclude files found inside .venv folders 148 | _venvs = [p.parent for p in all_venvs if ".venv" not in str(p)] 149 | 150 | # exclude folders without python executable inside .venv 151 | complete_venvs = [p for p in _venvs if (p / Pup.VENV_PYTHON).exists()] 152 | return complete_venvs 153 | 154 | @staticmethod 155 | def list_venvs_relative() -> list[Path]: 156 | """List of relative paths to virtual environments known to pup.""" 157 | return [p.relative_to(Pup.HOME) for p in Pup.list_venvs()] 158 | 159 | @staticmethod 160 | def load_pixi_toml() -> Dict[str, Any]: 161 | """Load Pup's `pixi.toml` file.""" 162 | return tomllib.load((Pup.HOME / "pixi.toml").open("rb")) 163 | 164 | @staticmethod 165 | def load_pyproject_toml(path: Path) -> Dict[str, Any]: 166 | """Load folder's `pyproject.toml` file.""" 167 | return tomllib.load((path / "pyproject.toml").open("rb")) 168 | 169 | 170 | # prep Pup attributes and environments before setting up CLI 171 | Pup.welcome() 172 | 173 | 174 | class UserInput: 175 | """User input prompts and other messages.""" 176 | 177 | COLOR = "bright_cyan" 178 | COLOR_WARN = "magenta" 179 | PupHomeNotFound = click.style( 180 | f"🐶's {Pup.HOME_MARKER} not found in this folder or its parents;" 181 | "\nwould you like to set up a new pup home here?", 182 | fg=COLOR, 183 | ) 184 | NewVenvFolder = click.style("Folder to create venv in", fg=COLOR) 185 | NewVenvFolderOverwrite = click.style( 186 | "Folder `{}` already exists. Overwrite the venv?", fg=COLOR_WARN 187 | ) 188 | AddWhere = click.style("Specify folder/venv where to add packages", fg=COLOR) 189 | AddWhat = click.style("Specify what to install", fg=COLOR) 190 | RemoveWhere = click.style( 191 | "Specify folder/venv from where to remove packages", fg=COLOR 192 | ) 193 | RemoveWhat = click.style("Specify what to remove", fg=COLOR) 194 | SyncWhere = click.style("Specify folder/venv to sync", fg=COLOR) 195 | FetchWhat = click.style("Choose venv to fetch", fg=COLOR) 196 | 197 | 198 | class OrderedGroup(click.Group): 199 | """Class to register commands in the order in which they're written.""" 200 | 201 | def __init__(self, name=None, commands=None, **attrs): 202 | super(OrderedGroup, self).__init__(name, commands, **attrs) 203 | self.commands = commands or collections.OrderedDict() 204 | 205 | def list_commands(self, ctx): 206 | return self.commands 207 | 208 | 209 | @click.group(cls=OrderedGroup) 210 | @click.pass_context 211 | def main(ctx): 212 | """Call pup and friends for all your python needs.""" 213 | pass 214 | 215 | 216 | @main.command(name="hi") 217 | def say_hi(): 218 | """Say hi to pup.""" 219 | Pup.log(Pup.pedigree(), Pup.LOG_FILE) 220 | Pup.log(f"🏠 = {Pup.HOME}", Pup.LOG_FILE) 221 | Pup.log(f"🐍 = {sys.version}", Pup.LOG_FILE) 222 | Pup.hear("pup hi") 223 | Pup.say("woof! Nice to meet you! Where you been? I can show you incredible things") 224 | Pup.say("run `pup` for help; check woof.log for pup command history") 225 | 226 | 227 | @main.command(name="new", context_settings={"ignore_unknown_options": True}) 228 | @click.argument("folder", nargs=1, required=False) 229 | def uv_init(folder: str): 230 | """Create new project and virtual environment with `uv init`.""" 231 | 232 | if folder is None: 233 | folder = click.prompt(UserInput.NewVenvFolder) 234 | if folder in ("", "."): 235 | Pup.say("use `pixi add` to install packages in pup's home folder") 236 | exit(1) 237 | if folder in Pup.RESERVED: 238 | Pup.say(f"folder name `{folder}` is reserved; please use another name") 239 | exit(1) 240 | 241 | Pup.hear(f"pup new {folder}") 242 | if (Pup.HOME / folder).exists(): 243 | if not confirm(UserInput.NewVenvFolderOverwrite.format(folder), default="y"): 244 | return 245 | 246 | Pup.do(f"pixi run uv init {Pup.HOME / folder} -p {Pup.PYTHON} --no-workspace") 247 | Pup.do(f"pixi run uv venv {Pup.HOME / folder}/.venv -p {Pup.PYTHON}") 248 | 249 | 250 | @main.command(name="add", context_settings={"ignore_unknown_options": True}) 251 | @click.argument("folder", nargs=1, required=False) 252 | @click.argument("packages", nargs=-1, required=False) 253 | def uv_add(folder: str, packages: Tuple[str], uv_options: Tuple[str] = tuple()) -> bool: 254 | """Install packages into specified venv with `uv add`.""" 255 | 256 | if folder is None: 257 | folder = click.prompt(UserInput.AddWhere) 258 | folder_abs_path = (Pup.HOME / folder).absolute() 259 | if not folder_abs_path.exists(): 260 | uv_init.callback(folder) 261 | if packages in (None, ()): 262 | packages = click.prompt( 263 | UserInput.AddWhat, default="", show_default=False, err=True 264 | ).split() 265 | if len(packages) == 0 or not packages[0]: 266 | return False 267 | 268 | packages = " ".join(packages) 269 | _uv_options = " ".join(uv_options) 270 | _python = Pup.HOME / folder / Pup.VENV_PYTHON 271 | 272 | Pup.hear(f"pup add {folder} {packages} {_uv_options}") 273 | Pup.do( 274 | f"pixi run uv add {packages} " 275 | f"--project {folder_abs_path} " 276 | f"{_uv_options} -p {_python}" 277 | ) 278 | return True 279 | 280 | 281 | @main.command(name="remove") 282 | @click.argument("folder", nargs=1, required=False) 283 | @click.argument("packages", nargs=-1, required=False) 284 | def uv_remove(folder: str, packages: Tuple[str]): 285 | """Remove packages from specified venv with `uv remove`.""" 286 | 287 | if folder is None: 288 | folder = click.prompt(UserInput.RemoveWhere) 289 | if packages in (None, ()): 290 | packages = click.prompt(UserInput.RemoveWhat).split() 291 | folder_abs_path = (Pup.HOME / folder).absolute() 292 | packages = " ".join(packages) 293 | _python = Pup.HOME / folder / Pup.VENV_PYTHON 294 | 295 | Pup.hear(f"pup remove {folder} {packages}") 296 | Pup.do( 297 | f"pixi run uv remove {packages} " 298 | f"--project {folder_abs_path} " 299 | f"-p {_python}" 300 | ) 301 | 302 | 303 | # TODO: uv pip install and uninstall (get/remove package bypassing pyproject.toml) 304 | 305 | 306 | @main.command(name="sync", context_settings={"ignore_unknown_options": True}) 307 | @click.argument("folder", nargs=1, required=False) 308 | @click.argument("uv_options", nargs=-1, required=False) 309 | @click.option("--upgrade", "-U", is_flag=True, help="sync and upgrade packages") 310 | def uv_sync( 311 | folder: str, 312 | uv_options: Tuple[str] = tuple(), 313 | upgrade: bool = False, 314 | ): 315 | """Sync virtual environment to match `pyproject.toml`. 316 | 317 | By default, uv uses lower bound for package version. 318 | When newer versions of dependencies are released, `pup sync -U` 319 | will upgrade them, but will not touch those that are pinned with "==" or "<=". 320 | """ 321 | 322 | if folder is None: 323 | folder = click.prompt(UserInput.SyncWhere) 324 | folder_abs_path = (Pup.HOME / folder).absolute() 325 | if not folder_abs_path.exists(): 326 | click.secho(f"venv folder {folder} not found", fg=UserInput.COLOR_WARN) 327 | exit(1) 328 | elif not (folder_abs_path / "pyproject.toml").exists(): 329 | click.secho(f"pyproject.toml not found in {folder}", fg=UserInput.COLOR_WARN) 330 | exit(1) 331 | elif not (folder_abs_path / ".venv").exists(): 332 | Pup.do(f"pixi run uv venv {folder_abs_path}/.venv -p {Pup.PYTHON}") 333 | 334 | _uv_options = " ".join(uv_options) 335 | Pup.hear(f"""pup sync {folder} {"-U" if upgrade else ""} {_uv_options}""") 336 | 337 | _python = Pup.HOME / folder / Pup.VENV_PYTHON 338 | cmd = ( 339 | f"pixi run uv sync " 340 | f"--project {folder_abs_path} " 341 | f"{_uv_options} -p {_python}" 342 | ) 343 | if upgrade: 344 | cmd += " -U" 345 | Pup.do(cmd) 346 | 347 | 348 | @main.command(name="clone") 349 | @click.argument("uri", required=True) 350 | @click.argument("folder", required=False) 351 | @click.option("--sync", is_flag=True) 352 | def pup_clone(uri: str, folder: str | None = None, sync: bool = False) -> None: 353 | """Clone a repo and setup venv using `pyproject.toml` or `requirements.txt`.""" 354 | 355 | folder = folder or Path(uri).stem 356 | Pup.hear(f"""pup clone {uri} {"--sync" if sync else ""}""") 357 | Pup.do(f"git clone {uri} {(Pup.HOME / folder).as_posix()}") 358 | uv_sync.callback(folder) 359 | 360 | 361 | @main.command(name="list") 362 | @click.argument("venv", required=False) 363 | @click.option("--full", "-f", is_flag=True, help="List all packages with `uv pip list`") 364 | @click.option( 365 | "---", help="Use `pup list .` for root dependencies from `pixi.toml`", type=Path 366 | ) 367 | def pup_list( 368 | venv: str | None = None, full: bool = False, _: None = None 369 | ) -> Dict[str, str]: 370 | """List venvs and their `pyproject.toml` dependencies.""" 371 | 372 | Pup.hear(f"pup list {'' if venv is None else venv} {'--full' if full else ''}") 373 | if venv != ".": 374 | pup_venvs = Pup.list_venvs_relative() 375 | pup_venvs_dict = { 376 | p.as_posix(): Pup.load_pyproject_toml(Pup.HOME / p) 377 | .get("project", {}) 378 | .get("dependencies", None) 379 | for p in pup_venvs 380 | } 381 | if venv: 382 | pup_venvs_dict = {venv: pup_venvs_dict.get(venv, None)} 383 | else: 384 | pup_venvs_dict = {"🏠": Pup.load_pixi_toml().get("dependencies", {})} 385 | if full: 386 | for p in pup_venvs_dict: 387 | cmd = f"pixi run uv pip list -p {Pup.HOME / p / Pup.VENV_PYTHON}" 388 | Pup.do(cmd) 389 | else: 390 | click.secho( 391 | json.dumps(pup_venvs_dict, indent=2, ensure_ascii=False), 392 | fg=Pup.COLOR, 393 | ) 394 | 395 | 396 | ### Utils ### 397 | 398 | 399 | def confirm(text, **kwargs) -> bool: 400 | """Prompt with click.confirm or silently return True in non-interactive shells.""" 401 | if sys.stdin.isatty() or hasattr(sys, "ps1"): 402 | return click.confirm(text, **kwargs) 403 | else: 404 | return True 405 | 406 | 407 | @contextmanager 408 | def no_output(): 409 | with open(os.devnull, "w") as fnull: 410 | stdout = sys.stdout 411 | sys.stdout = fnull 412 | try: 413 | yield 414 | finally: 415 | sys.stdout = stdout 416 | 417 | 418 | ### CLI and pup-as-a-module 419 | 420 | if __name__ == "__main__": 421 | # CLI 422 | main() 423 | 424 | 425 | # below runs on "import pup" 426 | def fetch( 427 | venv: str | None = None, 428 | *packages: Optional[str], 429 | site_packages: bool = True, 430 | root: bool = False, 431 | quiet: bool = False, 432 | ) -> None: 433 | """Create, modify, or fetch (activate) existing venvs. 434 | 435 | Activating an environment means placing its site-packages folder on `sys.path`, 436 | allowing to import the modules that are installed in that venv. 437 | 438 | `venv`: folder containing `pyproject.toml` and installed packages in `.venv` 439 | if venv does not exist, puppy will create it, install *packages, 440 | and fetch newly created venv 441 | `*packages`: names of packages to `pup add` 442 | `site_packages`: if True, appends venv's site-packages to `sys.path` 443 | `root`: if True, appends venv's root folder to `sys.path` 444 | (useful for packages under development) 445 | `quiet`: suppress output 446 | """ 447 | context = no_output() if quiet else nullcontext() 448 | with context: 449 | pup_venvs = Pup.list_venvs_relative() 450 | venvs_names = [p.as_posix() for p in pup_venvs] 451 | Pup.log(f"🐶 virtual envs available: {venvs_names}", file=None, tee=False) 452 | if not venv: 453 | venv = click.prompt(UserInput.FetchWhat, default="", show_default=False) 454 | if not venv: 455 | return 456 | 457 | venv_sp_path = Pup.HOME / venv / Pup.SP_VENV 458 | if not venv_sp_path.exists(): 459 | # create/modify venv 460 | if uv_add.callback(folder=venv, packages=packages): 461 | fetch(venv, site_packages=site_packages, root=root) 462 | else: 463 | if len(packages) > 0: # add new packages to venv 464 | uv_add.callback(folder=venv, packages=packages) 465 | new_paths = [] 466 | if site_packages: 467 | new_paths.append(str(venv_sp_path)) 468 | if root: 469 | new_paths.append(str(Pup.HOME / venv)) 470 | for path in new_paths: 471 | if path not in sys.path: 472 | _action = "added to" 473 | sys.path.append(path) 474 | else: 475 | _action = "already on" 476 | Pup.log( 477 | f"fetched packages from '{venv}': {path} {_action} `sys.path`", 478 | file=None, 479 | tee=False, 480 | ) 481 | pup_list.callback(venv) 482 | return 483 | -------------------------------------------------------------------------------- /pup.sh: -------------------------------------------------------------------------------- 1 | # Runner / installer / updater for puppy v2. 2 | 3 | #!/usr/bin/bash 4 | 5 | DEFAULT_PY_VERSION=3.13 6 | GH_BRANCH=main 7 | GH_URL=https://raw.githubusercontent.com/liquidcarbon/puppy/"$GH_BRANCH"/ 8 | PIXI_INSTALL_URL=https://pixi.sh/install.sh 9 | 10 | main() { 11 | DIR=$(pwd) 12 | PUP="" 13 | while [ "$DIR" != "/" ]; do 14 | if [ -f "$DIR/pixi.toml" ]; then 15 | PIXI_TOML="$DIR/pixi.toml" 16 | fi 17 | if [ -f "$DIR/pup.py" ]; then 18 | PUP="$DIR/pup.py" 19 | PUP_HOME="$DIR" 20 | break 21 | fi 22 | DIR=$(dirname "$DIR") 23 | done 24 | 25 | if [ "$PUP" != "" ] && [ "$1" != "update" ]; then 26 | run "$@" 27 | elif [ -f "$PUP" ] && [ "$1" == "update" ]; then 28 | update 29 | else 30 | install "$@" 31 | fi 32 | } 33 | 34 | run() { 35 | PY="$PUP_HOME"/.pixi/envs/default/bin/python 36 | if [ -e "$PY" ]; then 37 | "$PY" "$PUP" "$@" 38 | else 39 | if ! pixi run python "$PUP" "$@"; then 40 | install "$@" 41 | fi 42 | fi 43 | } 44 | 45 | 46 | update() { 47 | get_pixi 48 | pixi self-update 49 | pixi_init 50 | get_python_uv_click 51 | pixi update 52 | get_pup 53 | } 54 | 55 | 56 | install() { 57 | if [ "$(ls -A)" != "" ]; then 58 | read -ei "y" -p \ 59 | "$(pwd) is not empty; do you want to make it puppy's home? (y/n): " 60 | if [ "$REPLY" == "n" ]; then 61 | exit 1 62 | fi 63 | fi 64 | get_pixi 65 | pixi_init 66 | get_python_uv_click "$1" 67 | get_pup 68 | } 69 | 70 | 71 | get_pixi() { 72 | if ! command -v pixi &> /dev/null; then 73 | curl -fsSL $PIXI_INSTALL_URL | bash 74 | export PATH=$HOME/.pixi/bin:$PATH # new installs & GHA need this 75 | echo "✨ $(pixi -V) installed" 76 | else 77 | echo "✨ $(pixi -V) found" 78 | fi 79 | PIXI_HOME=$(dirname $(command -v pixi)) 80 | } 81 | 82 | 83 | pixi_init() { 84 | if [ -f "$PUP_HOME/pixi.toml" ]; then 85 | echo "✨ here be pixies! pixi.toml found" 86 | else 87 | pixi init . 88 | fi 89 | } 90 | 91 | 92 | py_ver_prompt() { 93 | if [ -t 0 ]; then 94 | read -ei "$DEFAULT_PY_VERSION" -p "$(cat <<-EOF 95 | Enter desired base Python version 96 | (supported: 3.9|3.10|3.11|3.12|3.13; blank=3.12):$(printf '\u00A0') 97 | EOF 98 | )" PY_VERSION 99 | else 100 | PY_VERSION="$DEFAULT_PY_VERSION" 101 | fi 102 | } 103 | 104 | 105 | get_python_uv_click() { 106 | if [ -n "$1" ]; then 107 | # if a version is passed as argument, update/reinstall 108 | PY_VERSION="$1" 109 | INSTALL=1 110 | else 111 | if grep -q python "$PUP_HOME/pixi.toml" &> /dev/null; then 112 | INSTALL=0 113 | else 114 | # no argument and no python? prompt w/default for non-interactive shell & install 115 | py_ver_prompt 116 | INSTALL=1 117 | fi 118 | fi 119 | if [ $INSTALL -eq 1 ]; then 120 | pixi add python${PY_VERSION:+=$PY_VERSION} 121 | pixi add "uv>=0" && echo "🟣 $(pixi run uv --version)" 122 | pixi add "click>=8" 123 | # using ">=" overrides pixi's default ">=,<" and allows updates to new major versions 124 | else 125 | echo "🐍 python lives here!" 126 | fi 127 | pixi run python -VV 128 | # PYTHON_EXECUTABLE=$(pixi run python -c 'import sys; print(sys.executable)') 129 | } 130 | 131 | 132 | get_pup() { 133 | if [ -n $PUP ] && [ -f "$PUP" ]; then 134 | curl -fsSL "$GH_URL/pup.py" -o "$PUP" && chmod +x "$PUP" 135 | else 136 | curl -fsSL "$GH_URL/pup.py" -o pup.py && chmod +x pup.py 137 | fi 138 | curl -fsSL "$GH_URL/pup.sh" -o "$PIXI_HOME/pup" 139 | chmod +x "$PIXI_HOME/pup" 140 | "$PIXI_HOME/pup" hi 141 | } 142 | 143 | 144 | main "$@" 145 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "puppy" 3 | version = "2.7.0" 4 | description = "Your new best friend in the python world." 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [] 8 | classifiers = [ 9 | "Intended Audience :: Developers", 10 | "Operating System :: OS Independent", 11 | "Programming Language :: Python :: 3.10", 12 | "Programming Language :: Python :: 3.11", 13 | "Programming Language :: Python :: 3.12", 14 | "Programming Language :: Python :: 3.13", 15 | ] 16 | [tool.ruff] 17 | line-length=88 18 | --------------------------------------------------------------------------------