├── .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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------