├── .github └── workflows │ ├── build_and_test.yml │ └── codespell.yml ├── .gitignore ├── .rpm └── pyflow.spec ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── GRCOV.md ├── LICENSE ├── README.md ├── RELEASE_CHECKLIST.md ├── ROADMAP.md ├── demo.gif ├── rustfmt.toml ├── snapcraft.yaml ├── src ├── actions │ ├── clear.rs │ ├── init.rs │ ├── install.rs │ ├── list.rs │ ├── mod.rs │ ├── new.rs │ ├── package.rs │ ├── reset.rs │ ├── run.rs │ └── switch.rs ├── build.rs ├── build_new.rs ├── cli_options.rs ├── commands.rs ├── dep_parser.rs ├── dep_resolution.rs ├── dep_types.rs ├── files.rs ├── install.rs ├── main.rs ├── py_versions.rs ├── pyproject │ ├── current.rs │ └── mod.rs ├── script.rs └── util │ ├── deps.rs │ ├── mod.rs │ ├── os.rs │ ├── paths.rs │ └── prompts.rs ├── update_version.py └── wix ├── License.rtf ├── main.wxs └── pyflow.json /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request, push] 2 | name: build_and_test 3 | jobs: 4 | build_and_test: 5 | env: 6 | RUST_BACKTRACE: 1 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: [macos-latest, ubuntu-latest, windows-latest] 11 | toolchain: [stable, beta] # , nightly] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: ${{ matrix.toolchain }} 18 | - uses: actions-rs/cargo@v1 19 | with: 20 | command: build 21 | args: --verbose --all 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | command: test 25 | args: --verbose --all 26 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | name: codespell 2 | on: [pull_request, push] 3 | jobs: 4 | codespell: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: codespell-project/actions-codespell@master 9 | with: 10 | ignore_words_list: crate,dows,pard,raison 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | #PyCharm 5 | *.iws 6 | *.workspace.xml 7 | .idea 8 | 9 | # Prevent generated and test files from being committed 10 | requirements.txt 11 | Pipfile 12 | pyflow.lock 13 | pyproject.toml 14 | /__pypackages__ 15 | script.py 16 | *.egg-info/ 17 | *.wheel 18 | *.snap 19 | *.snap.* 20 | */snap.* 21 | build/ 22 | dist/ 23 | 24 | # grcov 25 | *.profraw 26 | -------------------------------------------------------------------------------- /.rpm/pyflow.spec: -------------------------------------------------------------------------------- 1 | %define __spec_install_post %{nil} 2 | %define __os_install_post %{_dbpath}/brp-compress 3 | %define debug_package %{nil} 4 | 5 | Name: pyflow 6 | Summary: A modern Python dependency manager 7 | Version: @@VERSION@@ 8 | Release: @@RELEASE@@ 9 | License: MIT 10 | Group: Applications/System 11 | Source0: %{name}-%{version}.tar.gz 12 | URL: https://www.github.com/David-OConnor/pyflow 13 | 14 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root 15 | 16 | %description 17 | %{summary} 18 | 19 | %prep 20 | %setup -q 21 | 22 | %install 23 | rm -rf %{buildroot} 24 | mkdir -p %{buildroot} 25 | cp -a * %{buildroot} 26 | 27 | %clean 28 | rm -rf %{buildroot} 29 | 30 | %files 31 | %defattr(-,root,root,-) 32 | %{_bindir}/* 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## V0.3.1 4 | - Allow dependency versions with more than 3 digits 5 | - Add checing of python compatibility to `get_version_info` 6 | - Add Brew support 7 | - Fixed various issues that let to some packages not installing 8 | 9 | ## V0.3.0 10 | - Misc internal cleanup 11 | - Numerous bug fixes and usability enhancements 12 | - Fixed several bugs related to dependency parsing 13 | 14 | ## v0.2.9 15 | - Pyflow no longer requires updates to use future versions of python 16 | 17 | ## v0.2.8 18 | - Fixed some warnings, and check status codes of every subprocess 19 | - `pyflow init` now prompts for the Python version to use 20 | - Refactored parsing mechanism (internal) 21 | - Fixed some bugs related to `manylinux2014` targets 22 | - Fixed a bug from prev release on Linux/mac Cargo version 23 | 24 | ## v0.2.7 25 | - Fixed a recently-introduced bug with installing zip files from source. 26 | 27 | ## v0.2.6 28 | - Fixed a bug causing source only packages to fail to install 29 | - Fixed a bug relating to `manylinux2014_i686` wheels 30 | 31 | ## v0.2.5 32 | - Added support for `manylinux2014` spec 33 | - If a dependencies dependencies are specified multiple times, merge `extras` 34 | by omission. This led to bugs where dependencies didn't get installed when 35 | specified both as an extras and as not 36 | - Dependencies specifying `sys_platform == "win32"` now applies to 64-bit 37 | Windows installations as well. This should fix cases where Windows dependencies 38 | weren't being installed. 39 | - Fixed a dependency-installation bug triggered by symlinks inside Pypi source archives 40 | - `pyflow new` no longer creates a `LICENSE` file 41 | 42 | ## v0.2.4 43 | - Fixed a bug where `pyflow script` was broken 44 | - Fixed a bug where `pyflow init` was broken 45 | - Fixed parsing `Pipfile` 46 | - `pyflow switch` now sets up the environment/dependencies with the new version 47 | - Fixed a bug where `bsd` specified as an OS on Pypi would cause a crash 48 | 49 | ## v0.2.3 50 | - Fixed a potential conflict between the built-in `typing` module, and one on pypi 51 | - Now can parse deps with brackets listed in requirements 52 | - Now supports dependencies specified using `~=`. (Treat same as `~`) 53 | 54 | ## v0.2.1 55 | - Running `pyflow install` is now no longer required; Running `pyflow`, `pyflow list` etc 56 | will now install dependencies as required from `pyproject.toml`. 57 | - `pyflow new` now asks for the Python version instead of using a default. 58 | - Now searches parent directories for `pyproject.toml`, if we can't find one 59 | in the current path. 60 | 61 | ## v0.1.9 62 | - Can now parse subdependencies of `path` requirements from built-wheels 63 | - Fixed a bug where subdep constraints specified on multiple lines would 64 | cause resolution to fail 65 | - Fixed a bug parsing METADATA requirements that includes extras, but no version 66 | 67 | ## v0.1.8 68 | - Fixed a bug in auto-filling name and email in `pyflow init` and `pyflow new` 69 | - Running `pyflow` alone in a directory without a `pyproject.toml` will now no 70 | longer attempt to initialize a project 71 | - Added support for specifying a build script 72 | - Treat `python_version` on `pypi` as a caret requirement, if specified like `3.6`. 73 | - Improved error messages 74 | 75 | ## v0.1.7 76 | - Fixed bugs in `path` dependencies 77 | 78 | ## v0.1.6 79 | - Added installation from local paths and Git repositories 80 | - Improved error messages and instructions 81 | 82 | ## v0.1.5 83 | - Combined `author` and `author_email` cfg into one field, `authors`, which takes 84 | - a list. Populates automatically from git. `pyflow new` creates 85 | a new git repository. (Breaking) 86 | - Fixed a bug with uninstalling packages that use non-standard naming conventions 87 | - Fixed a bug with installing on Mac 88 | - Fixed a bug uninstalling packages from the CLI 89 | 90 | ## v0.1.4 91 | - Clear now lets the user choose which parts of the cache to clear 92 | - Fixed a bug with dev reqs 93 | - Fixed a bug with CLI-added deps editing `pyproject.toml` 94 | - Added `--dev` flag to `install` 95 | 96 | ## v0.1.3 97 | - Added support for dev dependencies 98 | - Fixed a bug where dependencies weren't being set up with the `package` command 99 | 100 | ## v0.1.2 101 | - Added support for installing Python on most Linux distros 102 | - Wheel is now installed directly, instead of with Pip; should only be dependent on 103 | pip now to install `twine`. 104 | - Now doesn't ask to choose between aliases pointing to the same Python install. 105 | - Fixed a bug related to creating `pyflow` directory 106 | - Fixed a bug in specifying package url with the `publish` command. 107 | 108 | 109 | ## v0.1.1 110 | - Fixed a bug, where spaces could prevent console scripts from being installed 111 | - Fixed parsing pypi requirements that omit parentheses 112 | - Now uses `~/.local/share/pyflow` on Linux, `~\AppData\Roaming\pyflow` on Windows, and 113 | `~/Library/Application Support/pyflow` on Mac, instead of `~/.python-installs` 114 | 115 | ## v0.1.0 116 | - Installing Python binaries now works correctly on Windows, Ubuntu≥18.4, and Debian 117 | - Running `pyflow` with no arguments now runs a Python REPL 118 | - Made error messages more detailed 119 | 120 | ## v0.0.4 121 | - Renamed from `pypackage` to `pyflow` 122 | - Added support for running minimally-configured scripts 123 | - Implemented `pyflow switch` to change py versions. Improved related prompts 124 | - Misc API tweaks 125 | 126 | ## v0.0.3 127 | - Now manages and installs Python as required 128 | - Stores downloaded packages in a global cache 129 | - Can run console scripts specified in `pyproject.toml` directly, instead of just 130 | ones installed by dependencies 131 | - `pypackage reset` now cleans up the lock file 132 | - Misc tweaks and bugfixes 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thanks for your interest in contributing to Pyflow. Bug reports, API improvements, 2 | performance improvements and new features are all welcome - as issues, or PRs. 3 | 4 | Required tools to build and test: 5 | - Rust 6 | - Clippy 7 | - Rustfmt 8 | 9 | Before submitting a PR, please run `cargo fmt`, `cargo clippy`, and `cargo test`. 10 | 11 | Recommended starting points: 12 | - Open issues 13 | - Dependency graphs that don't resolve correctly 14 | - Adding and improving tests 15 | - Bugs or inconvenient behavior you've encountered 16 | - Friction points in your workflow not already addressed 17 | - `//todo` comments in code 18 | 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pyflow" 3 | version = "0.3.5" 4 | authors = ["David O'Connor "] 5 | description = "A modern Python installation and dependency manager" 6 | license = "MIT" 7 | homepage = "https://www.github.com/David-OConnor/pyflow" 8 | repository = "https://www.github.com/David-OConnor/pyflow" 9 | readme = "README.md" 10 | edition = "2021" 11 | keywords = ["python","dependency", "packaging", "build"] 12 | categories = ["development-tools::build-utils"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | anyhow = "^1" 18 | termcolor = "^1.1" 19 | atty = "^0.2.14" 20 | data-encoding = "^2.6.0" 21 | directories = "^5.0.1" 22 | flate2 = "^1.0.32" 23 | fs_extra = "^1.3.0" 24 | rust-ini = "^0.21.1" 25 | xz2 = "^0.1.6" 26 | regex = "^1.10.6" 27 | ring = "^0.17.8" 28 | 29 | # We disable, by omission, suggestions, so it doesn't think `pyflow ipython` is a misspelling 30 | # of `pyflow python`. 31 | structopt = { version = "^0.3.26", default_features = false, features = ["color", "wrap_help", "doc"] } 32 | serde = {version = "^1.0.101", features = ["derive"]} 33 | tar = "^0.4.41" 34 | toml = "^0.8.19" 35 | zip = "^2.2.0" 36 | nom = "^5.1.2" 37 | # We don't use native TLS, to avoid dependency issues on different linux distros. 38 | reqwest = { version = "^0.12.7", default-features = false, features = ["rustls-tls", "blocking", "json"] } 39 | 40 | # Vendorize OpenSSl on Linux, to avoid compatibility problems. 41 | # todo: target-specific features aren't currently supported. 42 | #[target.'cfg(target_os = "linux")'.dependencies] 43 | #reqwest = { version = "^0.9.21", default-features = false, features = ["rustls-tls"] } 44 | # 45 | #[target.'cfg(not(target_os = "linux"))'.dependencies] 46 | #reqwest = "^0.9.21" 47 | 48 | 49 | [package.metadata.deb] 50 | section = "Python" 51 | # Non-MD subsection of readme. 52 | extended-description = """This tool implements 53 | PEP 582 -- Python local packages directory. 54 | It manages dependencies, keeping them isolated in the project directory, and runs 55 | python in an environment which uses this directory. Per PEP 582, dependencies 56 | are stored in the project directory → `__pypackages__` → `3.7`(etc) → `lib`. 57 | A virtual environment is created in the same directory as `lib`, and is used 58 | transparently.""" 59 | 60 | 61 | [package.metadata.rpm] 62 | buildflags = ["--release"] 63 | 64 | [package.metadata.rpm.targets] 65 | pyflow = { path = "/usr/bin/pyflow" } 66 | 67 | 68 | [package.metadata.maturin] 69 | # todo 70 | classifier = ["Programming Language :: Python"] 71 | -------------------------------------------------------------------------------- /GRCOV.md: -------------------------------------------------------------------------------- 1 | # Getting test coverage report with grcov # 2 | 3 | Get [grcov](https://github.com/mozilla/grcov ) and install their requirements for your system. 4 | 5 | To get a complete test report run the following commands with these env 6 | variables set. (I found some flags they suggested broke with some of our packages.) 7 | 8 | `.bashrc` 9 | ```shell 10 | export LLVM_PROFILE_FILE="your_name-%p-%m.profraw" 11 | export RUSTDOCFLAGS="-Cpanic=abort" 12 | ``` 13 | 14 | 1. `RUSTFLAGS="-Zinstrument-coverage -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests" CARGO_INCREMENTAL=0 RUSTC_BOOTSTRAP=1 cargo build` 15 | 1. `RUSTFLAGS="-Zinstrument-coverage -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests" CARGO_INCREMENTAL=0 RUSTC_BOOTSTRAP=1 cargo test` 16 | 1. `grcov . -s . --binary-path ./target/debug/ -t html --branch --ignore-not-existing -o ./target/debug/coverage/` 17 | 18 | 19 | ## Known issues ## 20 | 21 | - If you run `grcov` on Windows you will need to run it as admin or it will fail 22 | with an error related to symlinks. [Issue 561](https://github.com/mozilla/grcov/issues/561) 23 | - `[ERROR] Execution count overflow detected.` can occur when running `grcov` 24 | but is not fatal and a report will still be created. [Issue 613](https://github.com/mozilla/grcov/issues/613) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 David O'Connor 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![crates.io version](https://img.shields.io/crates/v/pyflow.svg)](https://crates.io/crates/pyflow) 2 | [![Build Status](https://travis-ci.org/David-OConnor/pyflow.svg?branch=master)](https://travis-ci.org/David-OConnor/pyflow) 3 | 4 | 5 | # Pyflow 6 | 7 | #### *Simple is better than complex* - The Zen of Python 8 | 9 | Pyflow streamlines working with Python projects and files. It's an 10 | easy-to-use CLI app with a minimalist API. Never worry about having the right 11 | version of Python or dependencies. 12 | 13 | Example use, including setting up a project and switching Py versions: 14 | ![Demonstration](https://raw.githubusercontent.com/david-oconnor/pyflow/master/demo.gif) 15 | 16 | If your project's already configured, the only command you need is `pyflow`, 17 | or `pyflow myscript.py`; setting up Python and its dependencies are automatic. 18 | 19 | **Goals**: Make using and publishing Python projects as simple as possible. Actively 20 | managing Python environments shouldn't be required to use dependencies safely. We're attempting 21 | to fix each stumbling block in the Python workflow, so that it's as elegant 22 | as the language itself. 23 | 24 | You don't need Python or any other tools installed to use Pyflow. 25 | 26 | It runs standalone scripts in their 27 | own environments with no config, and project functions directly from the CLI. 28 | 29 | It implements [PEP 582 -- Python local packages directory](https://www.python.org/dev/peps/pep-0582/) 30 | and [Pep 518 (pyproject.toml)](https://www.python.org/dev/peps/pep-0518/). 31 | 32 | 33 | ## Installation 34 | - **Windows** - Download and run 35 | [this installer](https://github.com/David-OConnor/pyflow/releases/download/0.3.1/pyflow-0.3.1-x86_64.msi). 36 | Or, if you have [Scoop](https://scoop.sh) installed, run `scoop install pyflow`. 37 | 38 | - **Ubuntu, or another Os that uses Snap** - Run `snap install pyflow --classic`. 39 | 40 | - **Ubuntu or Debian without Snap** - Download and run 41 | [this deb](https://github.com/David-OConnor/pyflow/releases/download/0.3.1/pyflow_0.3.1_amd64.deb). 42 | 43 | - **Fedora, CentOs, RedHat, or older versions of SUSE** - Download and run 44 | [this rpm](https://github.com/David-OConnor/pyflow/releases/download/0.3.1/pyflow-0.3.1.x86_64.rpm). 45 | 46 | - **A different Linux distro** - Download this 47 | [standalone binary](https://github.com/David-OConnor/pyflow/releases/download/0.3.1/pyflow) 48 | and place it somewhere accessible by the PATH. For example, `/usr/bin`. 49 | 50 | - **Mac** - Run `brew install pyflow` 51 | 52 | - **With Pip** - Run `pip install pyflow`. The linux install using this method is much larger than 53 | with the above ones, and it doesn't yet work with Mac. This method will likely not work 54 | with Red Hat, CentOs, or Fedora. 55 | 56 | - **If you have [Rust](https://www.rust-lang.org) installed** - Run `cargo install pyflow`. 57 | 58 | 59 | ## Quickstart 60 | - *(Optional)* Run `pyflow init` in an existing project folder, or `pyflow new projname` 61 | to create a new project folder. `init` imports data from `requirements.txt` or `Pipfile`; `new` 62 | creates a folder with the basics. 63 | - Run `pyflow install requests` etc to install packages. Alternatively, edit `pyproject.toml` directly. 64 | - Run `pyflow` or `pyflow myfile.py` to run Python. 65 | 66 | 67 | ## Quick-and-dirty start for quick-and-dirty scripts 68 | - Add the line `__requires__ = ['numpy', 'requests']` somewhere in your script, where `numpy` and 69 | `requests` are dependencies. 70 | - Optionally add the line `__python__ = X.Y.Z`, where `X.Y.Z` is a Python version specification. 71 | Without this line, you will be prompted to choose a version when running the script. 72 | - Run `pyflow script myscript.py`, where `myscript.py` is the name of your script. 73 | This will set up an isolated environment for this script, and install 74 | dependencies as required. This is a safe way 75 | to run one-off Python files that aren't attached to a project, but have dependencies. 76 | 77 | 78 | ## Why add another Python manager? 79 | `Pipenv`, `Poetry`, and `Pyenv` address parts of 80 | Pyflow's *raison d'être*, but expose stumbling blocks that may frustrate new users, 81 | both when installing and using. Some reasons why this is different: 82 | 83 | - It behaves consistently regardless of how your system and Python installations 84 | are configured. 85 | 86 | - It automatically manages Python installations and environments. You specify a Python version 87 | in `pyproject.toml` (if omitted, it asks), and it ensures that version is used. 88 | If the version's not installed, Pyflow downloads a binary, and uses that. 89 | If multiple installations are found for that version, it asks which to use. 90 | `Pyenv` can be used to install Python, but only if your system is configured in a certain way: 91 | I don’t think expecting a user’s computer to compile Python is reasonable. 92 | 93 | - By not using Python to install or run, it remains environment-agnostic. 94 | This is important for making setup and use as simple and decision-free as 95 | possible. It's common for Python-based CLI tools 96 | to not run properly when installed from `pip` due to the `PATH` or user directories 97 | not being configured in the expected way. 98 | 99 | - Its dependency resolution and locking is faster due to using a cached 100 | database of dependencies, vice downloading and checking each package, or relying 101 | on the incomplete data available on the [pypi warehouse](https://github.com/pypa/warehouse). 102 | `Pipenv`’s resolution in particular may be prohibitively-slow on weak internet connections. 103 | 104 | - It keeps dependencies in the project directory, in `__pypackages__`. This is subtle, 105 | but reinforces the idea that there's 106 | no hidden state. 107 | 108 | - It will always use the specified version of Python. This is a notable limitation in `Poetry`; Poetry 109 | may pick the wrong installation (eg Python2 vice Python3), with no obvious way to change it. 110 | Poetry allows projects to specify version, but neither selects, 111 | nor provides a way to select the right one. If it chooses the wrong one, it will 112 | install the wrong environment, and produce a confusing 113 | error message. This can be worked around using `Pyenv`, but this solution isn't 114 | documented, and adds friction to the 115 | workflow. It may confuse new users, as it occurs 116 | by default on popular linux distros like Ubuntu. Additionally, `Pyenv's` docs are 117 | confusing: It's not obvious how to install it, what operating systems 118 | it's compatible with, or what additional dependencies are required. 119 | 120 | - Multiple versions of a dependency can be installed, allowing resolution 121 | of conflicting sub-dependencies. (ie: Your package requires `Dep A>=1.0` and `Dep B`. 122 | `Dep B` requires Dep `A==0.9`) There are many cases where `Poetry` and `Pipenv` will fail 123 | to resolve dependencies. Try it for yourself with a few 124 | random dependencies from [pypi](https://pypi.org/); there's a good chance you'll 125 | hit this problem using `Poetry` or `Pipenv`. *Limitations: This will not work for 126 | some compiled dependencies, and attempting to package something using this will 127 | trigger an error.* 128 | 129 | Perhaps the biggest philosophical difference is that Pyflow abstracts over environments, 130 | rather than expecting users to manage them. 131 | 132 | 133 | ## My OS comes with Python, and Virtual environments are easy. What's the point of this? 134 | Hopefully we're not replacing [one problem](https://xkcd.com/1987/) with [another](https://xkcd.com/927/). 135 | 136 | Some people like the virtual-environment workflow - it requires only tools included 137 | with Python, and uses few console commands to create, 138 | and activate and environments. However, it may be tedious depending on workflow: 139 | The commands may be long depending on the path of virtual envs and projects, 140 | and it requires modifying the state of the terminal for each project, each time 141 | you use it, which you may find inconvenient or inelegant. 142 | 143 | I think we can do better. This is especially relevant for new Python users 144 | who don't understand venvs, or are unaware of the hazards of working with a system Python. 145 | 146 | `Pipenv` improves the workflow by automating environment use, and 147 | allowing reproducible dependency graphs. `Poetry` improves upon `Pipenv's` API, 148 | speed, and dependency resolution, as well as improving 149 | the packaging and distributing process by using a consolidating project config. Both 150 | are sensitive to the environment they run in, and won't work 151 | correctly if it's not as expected. 152 | 153 | `Conda` addresses these problems elegantly, but maintains a separate repository 154 | of binaries from `PyPi`. If all packages you need are available on `Conda`, it may 155 | be the best solution. If not, it requires falling back to `Pip`, which means 156 | using two separate package managers. 157 | 158 | When building and deploying packages, a set of overlapping files are 159 | traditionally used: `setup.py`, `setup.cfg`, `requirements.txt` and `MANIFEST.in`. We use 160 | `pyproject.toml` as the single-source of project info required to build 161 | and publish. 162 | 163 | 164 | ## A thoroughly biased feature table 165 | These tools have different scopes and purposes: 166 | 167 | | Name | [Pip + venv](https://docs.python.org/3/library/venv.html) | [Pipenv](https://docs.pipenv.org) | [Poetry](https://poetry.eustace.io) | [pyenv](https://github.com/pyenv/pyenv) | [pythonloc](https://github.com/cs01/pythonloc) | [Conda](https://docs.conda.io/en/latest/) |this | 168 | |------|------------|--------|--------|-------|-----------|-------|-----| 169 | | **Manages dependencies** | ✓ | ✓ | ✓ | | | ✓ | ✓| 170 | | **Resolves/locks deps** | | ✓ | ✓ | | | ✓ | ✓| 171 | | **Manages Python installations** | | | | ✓ | | ✓ | ✓ | 172 | | **Py-environment-agnostic** | | | | ✓ | | ✓ | ✓ | 173 | | **Included with Python** | ✓ | | | | | | | 174 | | **Stores deps with project** | | |✓| | ✓ | | ✓| 175 | | **Requires changing session state** | ✓ | | | ✓ | | | | 176 | | **Clean build/publish flow** | | | ✓ | | | | ✓ | 177 | | **Supports old Python versions** | with `virtualenv` | ✓ | ✓ | ✓ | ✓ | ✓ | | 178 | | **Isolated envs for scripts** | | | | | | | ✓ | 179 | | **Runs project fns from CLI** | | ✓ | ✓ | | | | ✓ | 180 | 181 | 182 | ## Use 183 | - Optionally, create a `pyproject.toml` file in your project directory. Otherwise, this 184 | file will be created automatically. You may wish to use `pyflow new` to create a basic 185 | project folder (With a .gitignore, source directory etc), or `pyflow init` to populate 186 | info from `requirements.txt` or `Pipfile`. See 187 | [PEP 518](https://www.python.org/dev/peps/pep-0518/) for details. 188 | 189 | Example contents: 190 | ```toml 191 | [tool.pyflow] 192 | py_version = "3.7" 193 | name = "runcible" 194 | version = "0.3.1" 195 | authors = ["John Hackworth "] 196 | 197 | [tool.pyflow.dependencies] 198 | numpy = "^1.16.4" 199 | diffeqpy = "1.1.0" 200 | ``` 201 | The `[tool.pyflow]` section is used for metadata. The only required item in it is 202 | `py_version`, unless 203 | building and distributing a package. The `[tool.pyflow.dependencies]` section 204 | contains all dependencies, and is an analog to `requirements.txt`. You can specify 205 | developer dependencies in the `[tool.pyflow.dev-dependencies]` section. These 206 | won't be packed or published, but will be installed locally. You can install these 207 | from the cli using the `--dev` flag. Eg: `pyflow install black --dev` 208 | 209 | You can specify `extra` dependencies, which will only be installed when passing 210 | explicit flags to `pyflow install`, or when included in another project with the appropriate 211 | flag enabled. Ie packages requiring this one can enable with 212 | `pip install -e` etc. 213 | ```toml 214 | [tool.pyflow.extras] 215 | test = ["pytest", "nose"] 216 | secure = ["crypto"] 217 | ``` 218 | 219 | If you'd like to an install a dependency with extras, use syntax like this: 220 | ```toml 221 | [tool.pyflow.dependencies] 222 | ipython = { version = "^7.7.0", extras = ["qtconsole"] } 223 | ``` 224 | 225 | To install from a local path instead of `pypi`, use syntax like this: 226 | ```toml 227 | [tool.pyflow.dependencies] 228 | # packagename = { path = "path-to-package"} 229 | numpy = { path = "../numpy" } 230 | ``` 231 | 232 | To install from a `git` repo, use syntax like this: 233 | ```toml 234 | [tool.pyflow.dependencies] 235 | saturn = { git = "https://github.com/david-oconnor/saturn.git" } # The trailing `.git` here is optional. 236 | ``` 237 | 238 | `git`dependencies are currently experimental. If you run into problems with them, 239 | please submit an issue. 240 | 241 | To install a package that includes a `.` in its name, enclose the name in quotes. 242 | 243 | For details on 244 | how to specify dependencies in this `Cargo.toml`-inspired 245 | [semver](https://semver.org) format, 246 | reference 247 | [this guide](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html). 248 | 249 | We also attempt to parse metadata and dependencies from [tool.poetry](https://poetry.eustace.io/docs/pyproject/) 250 | sections of `pyproject.toml`, so there's no need to modify the format 251 | if you're using that. 252 | 253 | You can specify direct entry points to parts of your program using something like this in `pyproject.toml`: 254 | ```toml 255 | [tool.pyflow.scripts] 256 | name = "module:function" 257 | ``` 258 | Where you replace `name`, `function`, and `module` with the name to call your script with, the 259 | function you wish to run, and the module it's in respectively. This is similar to specifying 260 | scripts in `setup.py` for built packages. The key difference is that functions specified here 261 | can be run at any time, 262 | without having to build the package. Run with `pyflow name` to do this. 263 | 264 | If you run `pyflow package` on on a package using this, the result will work like normal script 265 | entry points for someone using the package, regardless of if they're using this tool. 266 | 267 | 268 | ## What you can do 269 | 270 | ### Managing dependencies: 271 | - `pyflow install` - Install all packages in `pyproject.toml`, and remove ones not (recursively) specified. 272 | If an environment isn't already set up for the version specified in `pyproject.toml`, sets one up. 273 | Note that this command isn't required to sync dependencies; any relevant `pyflow` 274 | command will do so automatically. 275 | - `pyflow install requests` - If you specify one or more packages after `install`, those packages will 276 | be added to `pyproject.toml` and installed. You can use the `--dev` flag to install dev dependencies. eg: 277 | `pyflow install black --dev`. 278 | - `pyflow install numpy==1.16.4 matplotlib>=3.1` - Example with multiple dependencies, and specified versions 279 | - `pyflow uninstall requests` - Remove one or more dependencies 280 | 281 | ### Running REPL and Python files in the environment: 282 | - `pyflow` - Run a Python REPL 283 | - `pyflow main.py` - Run a python file 284 | - `pyflow ipython`, `pyflow black` etc - Run a CLI tool like `ipython`, or a project function 285 | For the former, this must have been installed by a dependency; for the latter, it's specified 286 | under `[tool.pyflow]`, `scripts` 287 | - `pyflow script myscript.py` - Run a one-off script, outside a project directory, with per-file 288 | package management 289 | 290 | ### Building and publishing: 291 | - `pyflow package` - Package for distribution (uses setuptools internally, and 292 | builds both source and wheel.) 293 | - `pyflow package --extras "test all"` - Package for distribution with extra features enabled, 294 | as defined in `pyproject.toml` 295 | - `pyflow publish` - Upload to PyPi (Repo specified in `pyproject.toml`. Uses `Twine` internally.) 296 | 297 | ### Misc: 298 | - `pyflow list` - Display all installed packages and console scripts 299 | - `pyflow new projname` - Create a directory containing the basics for a project: 300 | a readme, pyproject.toml, .gitignore, and directory for code 301 | - `pyflow init` - Create a `pyproject.toml` file in an existing project directory. Pull info from 302 | `requirements.text` and `Pipfile` as required. 303 | - `pyflow reset` - Remove the environment, and uninstall all packages 304 | - `pyflow clear` - Clear the cache, of downloaded dependencies, Python installations, or script- 305 | environments; it will ask you which ones you'd like to clear. 306 | - `pyflow -V` - Get the current version of this tool 307 | - `pyflow help` Get help, including a list of available commands 308 | 309 | 310 | ## How installation and locking work 311 | Running `pyflow install` syncs the project's installed dependencies with those 312 | specified in `pyproject.toml`. It generates `pyflow.lock`, which on subsequent runs, 313 | keeps dependencies each package a fixed version, as long as it continues to meet the constraints 314 | specified in `pyproject.toml`. Adding a 315 | package name via the CLI, eg `pyflow install matplotlib` simply adds that requirement before proceeding. 316 | `pyflow.lock` isn't meant to be edited directly. 317 | 318 | Each dependency listed in `pyproject.toml` is checked for a compatible match in `pyflow.lock` 319 | If a constraint is met by something in the lock file, 320 | the version we'll sync will match that listed in the lock file. If not met, a new entry 321 | is added to the lock file, containing the highest version allowed by `pyproject.toml`. 322 | Once complete, packages are installed and removed in order to exactly meet those listed 323 | in the updated lock file. 324 | 325 | This tool downloads and unpacks wheels from `pypi`, or builds 326 | wheels from source if none are available. It verifies the integrity of the downloaded file 327 | against that listed on `pypi` using `SHA256`, and the exact 328 | versions used are stored in a lock file. 329 | 330 | When a dependency is removed from `pyproject.toml`, it, and its subdependencies not 331 | also required by other packages are removed from the `__pypackages__` folder. 332 | 333 | 334 | ## How dependencies are resolved 335 | 336 | Compatible versions of dependencies are determined using info from 337 | the [PyPi Warehouse](https://github.com/pypa/warehouse) (available versions, and hash info), 338 | and the `pydeps` database. We use `pydeps`, which is built specifically for this project, 339 | due to inconsistent dependency information stored on `pypi`. A dependency graph is built 340 | using this cached database. We attempt to use the newest compatible version of each package. 341 | 342 | If all packages are either only specified once, or specified multiple times with the same 343 | newest-compatible version, we're done resolving, and ready to install and sync. 344 | 345 | If a package is included more than once with different newest-compatible versions, but one 346 | of those newest-compatible is compatible with all requirements, we install that one. If not, 347 | we search all versions to find one that's compatible. 348 | 349 | If still unable to find a version of a package that satisfies all requirements, we install 350 | multiple versions of it as-required, store them in separate directories, and modify 351 | their parents' imports as required. 352 | 353 | Note that it may be possible to resolve dependencies in cases not listed above, instead 354 | of installing multiple versions. Ie we could try different combinations of top-level packages, 355 | check for resolutions, then vary children as-required down the hierarchy. We don't do this because 356 | it's slow, has no guarantee of success, and involves installing older versions of packages. 357 | 358 | 359 | ## Not-yet-implemented 360 | - Installing global CLI tools 361 | - The lock file is missing some info like hashes 362 | - Adding a dependency via the CLI with a specific version constraint, or extras. 363 | - Install packages from a local `wheel` directly. In the meanwhile, you can use a `path` 364 | dependency of the unpacked wheel. 365 | - Dealing with multiple-installed-versions of a dependency that uses importlib 366 | or dynamic imports 367 | - Install Python on Mac 368 | 369 | ## Building and uploading your project to PyPi 370 | In order to build and publish your project, additional info is needed in 371 | `pyproject.toml`, that mimics what would be in `setup.py`. Example: 372 | ```toml 373 | [tool.pyflow] 374 | name = "everythingkiller" 375 | py_version = "3.6" 376 | version = "0.3.1" 377 | authors = ["Fraa Erasmas "] 378 | description = "Small, but packs a punch!" 379 | homepage = "https://everything.math" 380 | repository = "https://github.com/raz/everythingkiller" 381 | license = "MIT" 382 | keywords = ["nanotech", "weapons"] 383 | classifiers = [ 384 | "Topic :: System :: Hardware", 385 | "Topic :: Scientific/Engineering :: Human Machine Interfaces", 386 | ] 387 | python_requires = ">=3.6" 388 | # If not included, will default to `test.pypi.org` 389 | package_url = "https://upload.pypi.org/legacy/" 390 | 391 | 392 | [tool.pyflow.scripts] 393 | # name = "module:function" 394 | activate = "jeejah:activate" 395 | 396 | 397 | [tool.pyflow.dependencies] 398 | numpy = "^1.16.4" 399 | manimlib = "0.3.1" 400 | ipython = {version = "^7.7.0", extras=["qtconsole"]} 401 | 402 | 403 | [tool.pyflow.dev-dependencies] 404 | black = "^18.0" 405 | ``` 406 | `package_url` is used to determine which package repository to upload to. If omitted, 407 | `Pypi test` is used (`https://test.pypi.org/legacy/`). 408 | 409 | Other items you can specify in `[tool.pyflow]`: 410 | - `readme`: The readme filename, use this if it's named something other than `README.md`. 411 | - `build`: A python script to execute building non-python extensions when running `pyflow package`. 412 | 413 | ## Building this from source 414 | If you’d like to build from source, [download and install Rust]( https://www.rust-lang.org/tools/install), 415 | clone the repo, and in the repo directory, run `cargo build --release`. 416 | 417 | Ie on linux or Mac: 418 | ```bash 419 | curl https://sh.rustup.rs -sSf | sh 420 | git clone https://github.com/david-oconnor/pyflow.git 421 | cd pyflow 422 | cargo build --release 423 | ``` 424 | 425 | ## Updating 426 | - If installed via `Scoop`, run `scoop update pyflow`. 427 | - If installed via `Snap`, run `snap refresh pyflow`. 428 | - If installed via `Cargo`, run `cargo install pyflow --force`. 429 | - If installed via `Pip`, run `pip install --upgrade pyflow`. 430 | - If using an installer or 431 | deb, run the new version's installer or deb. If manually calling a binary, replace it. 432 | 433 | ## Uninstalling 434 | - If installed via `Scoop`, run `scoop uninstall pyflow`. 435 | - If installed via `Snap`, run `snap remove pyflow`. 436 | - If installed via `Cargo`, run `cargo uninstall pyflow`. 437 | - If installed via `Pip`, run `pip uninstall pyflow`. 438 | - If installed via Windows installer, run the Installer again and select `Remove` when asked, 439 | or use `Apps & features`. 440 | - If installed via a `deb`, use the `Software Center`. 441 | - If manually calling a binary, remove it. 442 | 443 | ## Contributing 444 | If you notice unexpected behavior or missing features, please post an issue, 445 | or submit a PR. If you see unexpected 446 | behavior, it's probably a bug! Post an issue listing the dependencies that did 447 | not install correctly. 448 | 449 | 450 | ## Why not to use this 451 | - It's adding another tool to an already complex field. 452 | - Most of the features here are already provided by a range of existing packages, 453 | like the ones in the table above. 454 | - The field of contributors is expected to be small, since it's written in a different language. 455 | - Dependency managers like Pipenv and Poetry work well enough for many cases, 456 | have dedicated dev teams, and large userbases. 457 | - `Conda` in particular handles many things this does quite well. 458 | 459 | 460 | ## Dependency cache repo: 461 | - [Github](https://github.com/David-OConnor/pydeps) 462 | Example API calls: `https://pydeps.herokuapp.com/requests`, 463 | `https://pydeps.herokuapp.com/requests/2.21.0`. 464 | This pulls all top-level 465 | dependencies for the `requests` package, and the dependencies for version `2.21.0` respectively. 466 | There is also a `POST` API for pulling info on specified versions. 467 | The first time this command is run 468 | for a package/version combo, it may be slow. Subsequent calls, by anyone, 469 | should be fast. This is due to having to download and install each package 470 | on the server to properly determine dependencies, due to unreliable information 471 | on the `pypi warehouse`. 472 | 473 | 474 | ## Python binary sources: 475 | ### [Repo binaries are downloaded from](https://github.com/David-OConnor/pybin/releases) 476 | - Windows: [Python official Visual Studio package](https://www.nuget.org/packages/python), 477 | by Steve Dower. 478 | - Newer linux distros: Built on Ubuntu 18.04, using standard procedures. 479 | - Older linux distros: Built on CentOS 7, using standard procedures. 480 | 481 | 482 | ## Gotchas 483 | - Make sure `__pypackages__` is in your `.gitignore` file. 484 | - You may need to set up IDEs to find packages in `__pypackages__`. If using PyCharm: 485 | `Settings` → `Project` → `Project Interpreter` → `⚙` → `Show All...` → 486 | (Select the interpreter, ie `(projname)/__pypackages__/3.x/.venv/bin/python` on 487 | Linux/Mac, or `(projname)/__pypackages__/3.x/Scripts/python` on Windows) → 488 | Click the folder-tree icon at the bottom of the pop-out window → 489 | Click the `+` icon at the bottom of the new pop-out window → 490 | Navigate to and select `(projname)/__pypackages__/3.x/lib` 491 | - If using VsCode: `Settings` → search `python extra paths` → 492 | `Edit in settings.json` → Add or modify the line: 493 | `"python.autoComplete.extraPaths": ["(projname)/__pypackages__/3.7/lib"]` 494 | 495 | 496 | # References 497 | - [PEP 582 - Python local packages directory](https://www.python.org/dev/peps/pep-0582/) 498 | - [PEP 518 - pyproject.toml](https://www.python.org/dev/peps/pep-0518/) 499 | - [Semantic versioning](https://semver.org/) 500 | - [PEP 440 -- Version Identification and Dependency Specification](https://www.python.org/dev/peps/pep-0440/) 501 | - [Specifying dependencies in Cargo](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html) 502 | - [Predictable dependency management blog entry](https://blog.rust-lang.org/2016/05/05/cargo-pillars.html) 503 | - [Blog on why Python dependencies are hard to determine](https://dustingram.com/articles/2018/03/05/why-pypi-doesnt-know-dependencies/) 504 | -------------------------------------------------------------------------------- /RELEASE_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | This is a list of steps to complete when making a new release. 4 | 5 | System prereqs: 6 | - Ubuntu 16.04: `sudo apt update`, `sudo apt install build-essential`, 7 | `pip3 install maturin twine`, `cargo install cargo-deb`, `sudo apt install snapcraft`. 8 | - Centos 7: `yum update`, `yum install gcc gcc-c++ make`, 9 | `cargo install cargo-rpm`, `yum install rpm-build`. 10 | - Windows: Install Visual Studio Community, and Wix. `cargo install cargo-wix` 11 | 12 | `Ubuntu` below shall refer to Ubuntu 16.04. Builds tend to be more forward-compatible 13 | than backwards. 14 | 15 | ## Preliminary 16 | 1. Review the commit and PR history since last release. Ensure that all relevant 17 | changes are included in `CHANGELOG.md` 18 | 1. Ensure the readme and homepage website reflects API changes. This includes changing the download 19 | links to reflect the latest version. 20 | 1. Run `python update_version.py v.v.v`. 21 | 1. Update Rust tools: `rustup update` 22 | 1. Run `cargo test`, `cargo fmt` 23 | 1. Run `cargo clippy -- -W clippy::pedantic -W clippy::nursery -W clippy::cargo` 24 | 1. Commit and push the repo 25 | 1. Check that CI pipeline passed 26 | 27 | ## Build binaries 28 | 1. Run `cargo build --release` on Windows and Ubuntu. 29 | 1. Run `cargo deb` on Ubuntu. 30 | 1. Run `cargo rpm build` on Centos 7. Remove the unnecessary `-1` in the filename. 31 | (This allows easy installation for Red Hat, Fedora, and CentOs. 32 | Also note that the standalone Linux binary may not work on these distros.) 33 | users, and binaries built on other OSes appear not to work on these due to OpenSSL issues. 34 | 1. Run `cargo wix` on Windows. 35 | 1. Zip the Windows `.exe`, along with `README.md` and `LICENSE`. 36 | 1. Run `maturin build` on Windows and Ubuntu. 37 | 1. Run `snapcraft` on Ubuntu. 38 | 39 | ## Publish binaries 40 | 1. Run `cargo package` and `cargo publish` (Any os). 41 | 1. Run `snapcraft login`, then `snapcraft push --release=stable pyflow_x.x.x_amd64.snap` on Ubuntu. 42 | 1. For the Windows and Ubuntu wheels, run `twine upload (wheelname)`. 43 | 1. Add a release on [Github](https://github.com/David-OConnor/seed/releases), following the format of previous releases. 44 | 1. Upload the following binaries to the release page: zipped Windows binary (This is all `Scoop` needs), 45 | Linux binary, Msi, Deb, Rpm. 46 | 47 | 48 | 49 | Note on buildling python binaries for the Pybin repo: 50 | ## Windows: 51 | Install, copy the file from Appdata/Local/programs/python, 52 | and match the filename/compression format with existing entries 53 | 54 | ## Linux: 55 | Download source. Run these: 56 | ```bash 57 | ./configure --prefix=$HOME/python_built 58 | make 59 | sudo make install 60 | ``` 61 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | WIP: This file defines roadmap for project 4 | 5 | - [ ]: Custom build system 6 | - [ ]: Fix pydeps caching timeout 7 | - [ ]: Make binaries work on any linux distro 8 | - [ ]: Mac binaries for pyflow and python 9 | - [ ]: "fatal: destination path exists" when using git deps 10 | - [ ]: add hash and git/path info to locks 11 | - [ ]: clear download git source as an option. In general, git install is a mess 12 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/David-OConnor/pyflow/6a54c24c2d8cd8b962d0a4b9dc07a48573701226/demo.gif -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | group_imports = "StdExternalCrate" -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: pyflow 2 | version: 0.3.1 3 | license: MIT # todo: This appears to cause the `snapcraft` command to fail. 4 | summary: A Python installation and dependency manager. 5 | description: | 6 | Pyflow manages Python installations and dependencies. 7 | 8 | Goals: Make using and publishing Python projects as simple as possible. Actively 9 | managing Python environments shouldn't be required to use dependencies safely. We're attempting 10 | to fix each stumbling block in the Python workflow, so that it's as elegant 11 | as the language itself. 12 | 13 | You don't need Python or any other tools installed to use Pyflow. 14 | 15 | It runs standalone scripts in their 16 | own environments with no config, and project functions directly from the CLI. 17 | 18 | grade: stable 19 | confinement: classic 20 | 21 | base: core18 22 | parts: 23 | pyflow: 24 | plugin: rust 25 | rust-channel: stable 26 | source: . 27 | build-packages: ["pkg-config", "libssl-dev"] # Required to prevent OpenSSL errors. 28 | 29 | apps: 30 | pyflow: 31 | command: pyflow 32 | # Plugs not required for classic mode. 33 | # plugs: ["home", "removable-media", "network"] 34 | -------------------------------------------------------------------------------- /src/actions/clear.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::Path}; 2 | 3 | use crate::util::{self, abort, success}; 4 | 5 | #[derive(Clone)] 6 | enum ClearChoice { 7 | Dependencies, 8 | ScriptEnvs, 9 | PyInstalls, 10 | // Global, 11 | All, 12 | } 13 | 14 | impl ToString for ClearChoice { 15 | fn to_string(&self) -> String { 16 | "".into() 17 | } 18 | } 19 | 20 | /// Clear `Pyflow`'s cache. Allow the user to select which parts to clear based on a prompt. 21 | pub fn clear(pyflow_path: &Path, cache_path: &Path, script_env_path: &Path) { 22 | let result = util::prompts::list( 23 | "Which cached items would you like to clear?", 24 | "choice", 25 | &[ 26 | ("Downloaded dependencies".into(), ClearChoice::Dependencies), 27 | ( 28 | "Standalone-script environments".into(), 29 | ClearChoice::ScriptEnvs, 30 | ), 31 | ("Python installations".into(), ClearChoice::PyInstalls), 32 | ("All of the above".into(), ClearChoice::All), 33 | ], 34 | false, 35 | ); 36 | 37 | // todo: DRY 38 | match result.1 { 39 | ClearChoice::Dependencies => { 40 | if fs::remove_dir_all(&cache_path).is_err() { 41 | abort(&format!( 42 | "Problem removing the dependency-cache path: {:?}", 43 | cache_path 44 | )); 45 | } 46 | } 47 | ClearChoice::ScriptEnvs => { 48 | if fs::remove_dir_all(&script_env_path).is_err() { 49 | abort(&format!( 50 | "Problem removing the script env path: {:?}", 51 | script_env_path 52 | )); 53 | } 54 | } 55 | ClearChoice::PyInstalls => {} 56 | ClearChoice::All => { 57 | if fs::remove_dir_all(&pyflow_path).is_err() { 58 | abort(&format!( 59 | "Problem removing the Pyflow path: {:?}", 60 | pyflow_path 61 | )); 62 | } 63 | } 64 | } 65 | success("Cache is cleared") 66 | } 67 | -------------------------------------------------------------------------------- /src/actions/init.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use termcolor::Color; 4 | 5 | use crate::{ 6 | files, 7 | pyproject::Config, 8 | util::{self, abort}, 9 | }; 10 | 11 | pub fn init(cfg_filename: &str) { 12 | let cfg_path = PathBuf::from(cfg_filename); 13 | if cfg_path.exists() { 14 | abort("pyproject.toml already exists - not overwriting.") 15 | } 16 | 17 | let mut cfg = match PathBuf::from("Pipfile").exists() { 18 | true => Config::from_pipfile(&PathBuf::from("Pipfile")).unwrap_or_default(), 19 | false => Config::default(), 20 | }; 21 | 22 | cfg.py_version = Some(util::prompts::py_vers()); 23 | 24 | files::parse_req_dot_text(&mut cfg, &PathBuf::from("requirements.txt")); 25 | 26 | cfg.write_file(&cfg_path); 27 | util::print_color("Created `pyproject.toml`", Color::Green); 28 | } 29 | -------------------------------------------------------------------------------- /src/actions/install.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use termcolor::Color; 4 | 5 | use crate::{ 6 | dep_types::{LockPackage, Version}, 7 | util::{self, process_reqs, Os, Paths}, 8 | Config, 9 | }; 10 | 11 | use util::deps::sync; 12 | 13 | // TODO: Refactor this function 14 | #[allow(clippy::too_many_arguments)] 15 | pub fn install( 16 | cfg_path: &Path, 17 | cfg: &Config, 18 | git_path: &Path, 19 | paths: &Paths, 20 | found_lock: bool, 21 | packages: &[String], 22 | dev: bool, 23 | lockpacks: &[LockPackage], 24 | os: &Os, 25 | py_vers: &Version, 26 | lock_path: &Path, 27 | ) { 28 | if !cfg_path.exists() { 29 | cfg.write_file(cfg_path); 30 | } 31 | 32 | if found_lock { 33 | util::print_color("Found lockfile", Color::Green); 34 | } 35 | 36 | // Merge reqs added via cli with those in `pyproject.toml`. 37 | let (updated_reqs, up_dev_reqs) = util::merge_reqs(packages, dev, cfg, cfg_path); 38 | 39 | let dont_uninstall = util::find_dont_uninstall(&updated_reqs, &up_dev_reqs); 40 | 41 | let updated_reqs = process_reqs(updated_reqs, git_path, paths); 42 | let up_dev_reqs = process_reqs(up_dev_reqs, git_path, paths); 43 | 44 | sync( 45 | paths, 46 | lockpacks, 47 | &updated_reqs, 48 | &up_dev_reqs, 49 | &dont_uninstall, 50 | *os, 51 | py_vers, 52 | lock_path, 53 | ); 54 | util::print_color("Installation complete", Color::Green); 55 | } 56 | -------------------------------------------------------------------------------- /src/actions/list.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, process}; 2 | 3 | use termcolor::Color; 4 | 5 | use crate::{ 6 | dep_types::Req, 7 | pyproject, 8 | util::{self, abort, print_color, print_color_}, 9 | }; 10 | 11 | /// List all installed dependencies and console scripts, by examining the `libs` and `bin` folders. 12 | /// Also include path requirements, which won't appear in the `lib` folder. 13 | pub fn list(lib_path: &Path, path_reqs: &[Req]) { 14 | // This part check that project and venvs exists 15 | let pcfg = pyproject::current::get_config().unwrap_or_else(|| process::exit(1)); 16 | let num_venvs = util::find_venvs(&pcfg.pypackages_path).len(); 17 | 18 | if !pcfg.config_path.exists() && num_venvs == 0 { 19 | abort("Can't find a project in this directory") 20 | } else if num_venvs == 0 { 21 | abort("There's no python environment set up for this project") 22 | } 23 | 24 | let installed = util::find_installed(lib_path); 25 | let scripts = find_console_scripts(&lib_path.join("../bin")); 26 | 27 | if installed.is_empty() { 28 | print_color("No packages are installed.", Color::Blue); // Dark 29 | } else { 30 | print_color("These packages are installed:", Color::Blue); // Dark 31 | for (name, version, _tops) in installed { 32 | print_color_(&name, Color::Cyan); 33 | print_color(&format!("=={}", version.to_string_color()), Color::White); 34 | } 35 | for req in path_reqs { 36 | print_color_(&req.name, Color::Cyan); 37 | print_color( 38 | &format!(", at path: {}", req.path.as_ref().unwrap()), 39 | Color::White, 40 | ); 41 | } 42 | } 43 | 44 | if scripts.is_empty() { 45 | print_color("\nNo console scripts are installed.", Color::Blue); // Dark 46 | } else { 47 | print_color("\nThese console scripts are installed:", Color::Blue); // Dark 48 | for script in scripts { 49 | print_color(&script, Color::Cyan); // Dark 50 | } 51 | } 52 | } 53 | 54 | /// Find console scripts installed, by browsing the (custom) bin folder 55 | pub fn find_console_scripts(bin_path: &Path) -> Vec { 56 | let mut result = vec![]; 57 | if !bin_path.exists() { 58 | return vec![]; 59 | } 60 | 61 | for entry in bin_path 62 | .read_dir() 63 | .expect("Trouble opening bin path") 64 | .flatten() 65 | { 66 | if entry.file_type().unwrap().is_file() { 67 | result.push(entry.file_name().to_str().unwrap().to_owned()) 68 | } 69 | } 70 | result 71 | } 72 | -------------------------------------------------------------------------------- /src/actions/mod.rs: -------------------------------------------------------------------------------- 1 | mod clear; 2 | mod init; 3 | mod install; 4 | mod list; 5 | mod new; 6 | mod package; 7 | mod reset; 8 | mod run; 9 | mod switch; 10 | 11 | pub use clear::clear; 12 | pub use init::init; 13 | pub use install::install; 14 | pub use list::list; 15 | pub use new::new; 16 | pub use package::package; 17 | pub use reset::reset; 18 | pub use run::run; 19 | pub use switch::switch; 20 | -------------------------------------------------------------------------------- /src/actions/new.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fs, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use termcolor::Color; 8 | 9 | use crate::{ 10 | commands, 11 | util::{self, abort, success}, 12 | Config, 13 | }; 14 | 15 | const GITIGNORE_INIT: &str = indoc::indoc! {r##" 16 | # General Python ignores 17 | build/ 18 | dist/ 19 | __pycache__/ 20 | __pypackages__/ 21 | .ipynb_checkpoints/ 22 | *.pyc 23 | *~ 24 | */.mypy_cache/ 25 | 26 | 27 | # Project ignores 28 | "##}; 29 | 30 | pub const NEW_ERROR_MESSAGE: &str = indoc::indoc! {r#" 31 | Problem creating the project. This may be due to a permissions problem. 32 | If on linux, please try again with `sudo`. 33 | "#}; 34 | 35 | pub fn new(name: &str) { 36 | if new_internal(name).is_err() { 37 | abort(NEW_ERROR_MESSAGE); 38 | } 39 | success(&format!("Created a new Python project named {}", name)) 40 | } 41 | 42 | // TODO: Join this function after refactoring 43 | /// Create a template directory for a python project. 44 | fn new_internal(name: &str) -> Result<(), Box> { 45 | if !PathBuf::from(name).exists() { 46 | fs::create_dir_all(&format!("{}/{}", name, name.replace("-", "_")))?; 47 | fs::File::create(&format!("{}/{}/__init__.py", name, name.replace("-", "_")))?; 48 | fs::File::create(&format!("{}/README.md", name))?; 49 | fs::File::create(&format!("{}/.gitignore", name))?; 50 | } 51 | 52 | let readme_init = &format!("# {}\n\n{}", name, "(A description)"); 53 | 54 | fs::write(&format!("{}/.gitignore", name), GITIGNORE_INIT)?; 55 | fs::write(&format!("{}/README.md", name), readme_init)?; 56 | 57 | let cfg = Config { 58 | name: Some(name.to_string()), 59 | authors: util::get_git_author(), 60 | py_version: Some(util::prompts::py_vers()), 61 | ..Default::default() 62 | }; 63 | 64 | cfg.write_file(&PathBuf::from(format!("{}/pyproject.toml", name))); 65 | 66 | if commands::git_init(Path::new(name)).is_err() { 67 | util::print_color( 68 | "Unable to initialize a git repo for your project", 69 | Color::Yellow, // Dark 70 | ); 71 | }; 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/actions/package.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::{ 4 | build, 5 | dep_types::{LockPackage, Version}, 6 | util::{self, deps::sync}, 7 | }; 8 | 9 | pub fn package( 10 | paths: &util::Paths, 11 | lockpacks: &[LockPackage], 12 | os: util::Os, 13 | py_vers: &Version, 14 | lock_path: &Path, 15 | cfg: &crate::Config, 16 | extras: &[String], 17 | ) { 18 | sync( 19 | paths, 20 | lockpacks, 21 | &cfg.reqs, 22 | &cfg.dev_reqs, 23 | &util::find_dont_uninstall(&cfg.reqs, &cfg.dev_reqs), 24 | os, 25 | py_vers, 26 | lock_path, 27 | ); 28 | 29 | build::build(lockpacks, paths, cfg, extras) 30 | } 31 | -------------------------------------------------------------------------------- /src/actions/reset.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, process}; 2 | 3 | use crate::{ 4 | pyproject, 5 | util::{abort, success}, 6 | }; 7 | 8 | pub fn reset() { 9 | let pcfg = pyproject::current::get_config().unwrap_or_else(|| process::exit(1)); 10 | if (&pcfg.pypackages_path).exists() && fs::remove_dir_all(&pcfg.pypackages_path).is_err() { 11 | abort("Problem removing `__pypackages__` directory") 12 | } 13 | if (&pcfg.lock_path).exists() && fs::remove_file(&pcfg.lock_path).is_err() { 14 | abort("Problem removing `pyflow.lock`") 15 | } 16 | success("`__pypackages__` folder and `pyflow.lock` removed") 17 | } 18 | -------------------------------------------------------------------------------- /src/actions/run.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use regex::Regex; 4 | 5 | use crate::{commands, pyproject::Config, util::abort}; 6 | 7 | /// Execute a python CLI tool, either specified in `pyproject.toml`, or in a dependency. 8 | pub fn run(lib_path: &Path, bin_path: &Path, vers_path: &Path, cfg: &Config, args: Vec) { 9 | // Allow both `pyflow run ipython` (args), and `pyflow ipython` (opt.script) 10 | if args.is_empty() { 11 | return; 12 | } 13 | 14 | let name = if let Some(a) = args.get(0) { 15 | a.clone() 16 | } else { 17 | abort("`run` must be followed by the script to run, eg `pyflow run black`"); 18 | }; 19 | 20 | // If the script we're calling is specified in `pyproject.toml`, ensure it exists. 21 | 22 | // todo: Delete these scripts as required to sync with pyproject.toml. 23 | let re = Regex::new(r"(.*?):(.*)").unwrap(); 24 | 25 | let mut specified_args: Vec = args.into_iter().skip(1).collect(); 26 | 27 | // If a script name is specified by by this project and a dependency, favor 28 | // this project. 29 | if let Some(s) = cfg.scripts.get(&name) { 30 | let abort_msg = format!( 31 | "Problem running the function {}, specified in `pyproject.toml`", 32 | name, 33 | ); 34 | 35 | if let Some(caps) = re.captures(s) { 36 | let module = caps.get(1).unwrap().as_str(); 37 | let function = caps.get(2).unwrap().as_str(); 38 | let mut args_to_pass = vec![ 39 | "-c".to_owned(), 40 | format!(r#"import {}; {}.{}()"#, module, module, function), 41 | ]; 42 | 43 | args_to_pass.append(&mut specified_args); 44 | if commands::run_python(bin_path, &[lib_path.to_owned()], &args_to_pass).is_err() { 45 | abort(&abort_msg); 46 | } 47 | } else { 48 | abort(&format!("Problem parsing the following script: {:#?}. Must be in the format module:function_name", s)); 49 | } 50 | return; 51 | } 52 | // None => { 53 | let abort_msg = format!( 54 | "Problem running the CLI tool {}. Is it installed? \ 55 | Try running `pyflow install {}`", 56 | name, name 57 | ); 58 | let script_path = vers_path.join("bin").join(name); 59 | if !script_path.exists() { 60 | abort(&abort_msg); 61 | } 62 | 63 | let mut args_to_pass = vec![script_path 64 | .to_str() 65 | .expect("Can't find script path") 66 | .to_owned()]; 67 | 68 | args_to_pass.append(&mut specified_args); 69 | if commands::run_python(bin_path, &[lib_path.to_owned()], &args_to_pass).is_err() { 70 | abort(&abort_msg); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/actions/switch.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, process}; 2 | 3 | use termcolor::Color; 4 | 5 | use crate::{files, pyproject, util}; 6 | 7 | /// Updates `pyproject.toml` with a new python version 8 | pub fn switch(version: &str) { 9 | let mut pcfg = pyproject::current::get_config().unwrap_or_else(|| process::exit(1)); 10 | 11 | let specified = util::fallible_v_parse(version); 12 | pcfg.config.py_version = Some(specified.clone()); 13 | files::change_py_vers(&PathBuf::from(&pcfg.config_path), &specified); 14 | util::print_color( 15 | &format!("Switched to Python version {}", specified.to_string()), 16 | Color::Green, 17 | ); 18 | // Don't exit program here; now that we've changed the cfg version, let's run the normal flow. 19 | } 20 | -------------------------------------------------------------------------------- /src/build.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, env, fs, path::Path, process::Command}; 2 | 3 | use regex::Regex; 4 | use termcolor::Color; 5 | 6 | use crate::{dep_types::Req, util}; 7 | 8 | // https://packaging.python.org/tutorials/packaging-projects/ 9 | 10 | /// Serialize to a python list of strings. 11 | fn serialize_py_list(items: &[String], indent_level: u8) -> String { 12 | let mut pad = "".to_string(); 13 | for _ in 0..indent_level { 14 | pad.push_str(" "); 15 | } 16 | 17 | let mut result = "[\n".to_string(); 18 | for item in items.iter() { 19 | result.push_str(&format!("{} \"{}\",\n", &pad, item)); 20 | } 21 | result.push_str(&pad); 22 | result.push(']'); 23 | result 24 | } 25 | 26 | /// Serialize to a Python dict of lists of strings. 27 | fn _serialize_py_dict(hm: &HashMap>) -> String { 28 | let mut result = "{\n".to_string(); 29 | for (key, val) in hm.iter() { 30 | result.push_str(&format!(" \"{}\": {}\n", key, serialize_py_list(val, 0))); 31 | } 32 | result.push('}'); 33 | result 34 | } 35 | 36 | /// Serialize to a Python dict of strings. 37 | //fn serialize_scripts(hm: &HashMap) -> String { 38 | // let mut result = "{\n".to_string(); 39 | // 40 | // for (key, val) in hm.iter() { 41 | // result.push_str(&format!(" \"{}\": {}\n", key, serialize_py_list(val))); 42 | // } 43 | // result.push('}'); 44 | // result 45 | //} 46 | 47 | ///// A different format, as used in console_scripts 48 | //fn serialize_py_dict2(hm: &HashMap) -> String { 49 | // let mut result = "{\n".to_string(); 50 | // for (key, val) in hm.iter() { 51 | // result.push_str(&format!(" \"{}\": {}\n", key, serialize_py_list(val))); 52 | // } 53 | // result.push('}'); 54 | // result 55 | //} 56 | 57 | fn cfg_to_setup(cfg: &crate::Config) -> String { 58 | let cfg = cfg.clone(); 59 | 60 | let version = match cfg.version { 61 | Some(v) => v.to_string(), 62 | None => "".into(), 63 | }; 64 | 65 | let mut keywords = String::new(); 66 | for (i, kw) in cfg.keywords.iter().enumerate() { 67 | if i != 0 { 68 | keywords.push(' '); 69 | } 70 | keywords.push_str(kw); 71 | } 72 | 73 | let author_re = Regex::new(r"^(.*?)\s*(?:<(.*?)>)?\s*$").unwrap(); 74 | 75 | let mut author = "".to_string(); 76 | let mut author_email = "".to_string(); 77 | if let Some(first) = cfg.authors.get(0) { 78 | let caps = if let Some(c) = author_re.captures(first) { 79 | c 80 | } else { 81 | util::abort(&format!( 82 | "Problem parsing the `authors` field in `pyproject.toml`: {:?}", 83 | &cfg.authors 84 | )); 85 | }; 86 | author = caps.get(1).unwrap().as_str().to_owned(); 87 | author_email = caps.get(2).unwrap().as_str().to_owned(); 88 | } 89 | 90 | let deps: Vec = cfg.reqs.iter().map(Req::to_setup_py_string).collect(); 91 | 92 | // todo: Entry pts! 93 | format!( 94 | r#"import setuptools 95 | 96 | with open("{}", "r") as fh: 97 | long_description = fh.read() 98 | 99 | setuptools.setup( 100 | name="{}", 101 | version="{}", 102 | author="{}", 103 | author_email="{}", 104 | license="{}", 105 | description="{}", 106 | long_description=long_description, 107 | long_description_content_type="text/markdown", 108 | url="{}", 109 | packages=setuptools.find_packages(), 110 | keywords="{}", 111 | classifiers={}, 112 | python_requires="{}", 113 | install_requires={}, 114 | ) 115 | "#, 116 | // entry_points={{ 117 | // "console_scripts": , 118 | // }}, 119 | cfg.readme.unwrap_or_else(|| "README.md".into()), 120 | cfg.name.unwrap_or_else(|| "".into()), 121 | version, 122 | author, 123 | author_email, 124 | cfg.license.unwrap_or_else(|| "".into()), 125 | cfg.description.unwrap_or_else(|| "".into()), 126 | cfg.homepage.unwrap_or_else(|| "".into()), 127 | keywords, 128 | serialize_py_list(&cfg.classifiers, 1), 129 | // serialize_py_list(&cfg.console_scripts), 130 | cfg.python_requires.unwrap_or_else(|| "".into()), 131 | serialize_py_list(&deps, 1), 132 | // todo: 133 | // extras_require="{}", 134 | // match cfg.extras { 135 | // Some(e) => serialize_py_dict(&e), 136 | // None => "".into(), 137 | // } 138 | ) 139 | } 140 | 141 | /// Creates a temporary file which imitates setup.py 142 | fn create_dummy_setup(cfg: &crate::Config, filename: &str) { 143 | fs::write(filename, cfg_to_setup(cfg)).expect("Problem writing dummy setup.py"); 144 | if util::wait_for_dirs(&[env::current_dir() 145 | .expect("Problem finding current dir") 146 | .join(filename)]) 147 | .is_err() 148 | { 149 | util::abort("Problem waiting for setup.py to be created.") 150 | }; 151 | } 152 | 153 | pub fn build( 154 | lockpacks: &[crate::dep_types::LockPackage], 155 | paths: &util::Paths, 156 | cfg: &crate::Config, 157 | _extras: &[String], 158 | ) { 159 | for lp in lockpacks.iter() { 160 | if lp.rename.is_some() { 161 | // if lockpacks.iter().any(|lp| lp.rename.is_some()) { 162 | util::abort(&format!( 163 | "{} is installed with multiple versions. We can't create a package that \ 164 | relies on multiple versions of a dependency - \ 165 | this would cause this package not work work correctly if not used with pyflow.", 166 | lp.name 167 | )) 168 | } 169 | } 170 | 171 | let dummy_setup_fname = "setup_temp_pyflow.py"; 172 | 173 | // Twine has too many dependencies to install when the environment, like we do with `wheel`, and 174 | // for now, it's easier to install using pip 175 | // todo: Install using own tools instead of pip; this is the last dependence on pip. 176 | let output = Command::new(paths.bin.join("python")) 177 | .args(&["-m", "pip", "install", "twine"]) 178 | .output() 179 | .expect("Problem installing Twine"); 180 | util::check_command_output(&output, "failed to install twine"); 181 | 182 | // let twine_url = "https://files.pythonhosted.org/packages/c4/43/b9c56d378f5d0b9bee7be564b5c5fb65c65e5da6e82a97b6f50c2769249a/twine-2.0.0-py3-none-any.whl"; 183 | // install::download_and_install_package( 184 | // "twine", 185 | // &Version::new(2, 0, 0), 186 | // twine_url, 187 | // "twine-2.0.0-py3-none-any.whl", 188 | // "5319dd3e02ac73fcddcd94f0…1f4699d57365199d85261e1", 189 | // &paths, 190 | // install::PackageType::Wheel, 191 | // &None, 192 | // ) 193 | // .expect("Problem installing `twine`"); 194 | 195 | create_dummy_setup(cfg, dummy_setup_fname); 196 | 197 | util::set_pythonpath(&[paths.lib.to_owned()]); 198 | println!("🛠️️ Building the package..."); 199 | // todo: Run build script first, right? 200 | if let Some(build_file) = &cfg.build { 201 | let output = Command::new(paths.bin.join("python")) 202 | .arg(&build_file) 203 | .output() 204 | .unwrap_or_else(|_| panic!("Problem building using {}", build_file)); 205 | util::check_command_output(&output, "failed to run build script"); 206 | } 207 | 208 | // Command::new(paths.bin.join("python")) 209 | // .args(&[dummy_setup_fname, "sdist", "bdist_wheel"]) 210 | // .status() 211 | // .expect("Problem building"); 212 | 213 | util::print_color("Build complete.", Color::Green); 214 | 215 | if fs::remove_file(dummy_setup_fname).is_err() { 216 | println!("Problem removing temporary setup file while building ") 217 | }; 218 | } 219 | 220 | pub(crate) fn publish(bin_path: &Path, cfg: &crate::Config) { 221 | let repo_url = match cfg.package_url.clone() { 222 | Some(pu) => { 223 | let mut r = pu; 224 | if !r.ends_with('/') { 225 | r.push('/'); 226 | } 227 | r 228 | } 229 | None => "https://test.pypi.org/legacy/".to_string(), 230 | }; 231 | 232 | println!("Uploading to {}", repo_url); 233 | let output = Command::new(bin_path.join("twine")) 234 | .args(&["upload", "--repository-url", &repo_url, "dist/*"]) 235 | .output() 236 | .expect("Problem publishing"); 237 | util::check_command_output(&output, "publishing"); 238 | } 239 | 240 | #[cfg(test)] 241 | pub mod test { 242 | use super::*; 243 | use crate::dep_types::{ 244 | Constraint, Req, 245 | ReqType::{Caret, Exact}, 246 | Version, 247 | }; 248 | 249 | #[test] 250 | fn setup_creation() { 251 | let mut scripts = HashMap::new(); 252 | scripts.insert("activate".into(), "jeejah:activate".into()); 253 | 254 | let cfg = crate::Config { 255 | name: Some("everythingkiller".into()), 256 | py_version: Some(Version::new_short(3, 6)), 257 | version: Some(Version::new(0, 1, 0)), 258 | authors: vec!["Fraa Erasmas ".into()], 259 | homepage: Some("https://everything.math".into()), 260 | description: Some("Small, but packs a punch!".into()), 261 | repository: Some("https://github.com/raz/everythingkiller".into()), 262 | license: Some("MIT".into()), 263 | keywords: vec!["nanotech".into(), "weapons".into()], 264 | classifiers: vec![ 265 | "Topic :: System :: Hardware".into(), 266 | "Topic :: Scientific/Engineering :: Human Machine Interfaces".into(), 267 | ], 268 | python_requires: Some(">=3.6".into()), 269 | package_url: Some("https://upload.pypi.org/legacy/".into()), 270 | scripts, 271 | readme: Some("README.md".into()), 272 | reqs: vec![ 273 | Req::new( 274 | "numpy".into(), 275 | vec![Constraint::new(Caret, Version::new(1, 16, 4))], 276 | ), 277 | Req::new( 278 | "manimlib".into(), 279 | vec![Constraint::new(Exact, Version::new(0, 1, 8))], 280 | ), 281 | Req::new( 282 | "ipython".into(), 283 | vec![Constraint::new(Caret, Version::new(7, 7, 0))], 284 | ), 285 | ], 286 | dev_reqs: vec![Req::new( 287 | "black".into(), 288 | vec![Constraint::new(Caret, Version::new(18, 0, 0))], 289 | )], 290 | extras: HashMap::new(), 291 | repo_url: None, 292 | build: None, 293 | }; 294 | 295 | let expected = r#"import setuptools 296 | 297 | with open("README.md", "r") as fh: 298 | long_description = fh.read() 299 | 300 | setuptools.setup( 301 | name="everythingkiller", 302 | version="0.1.0", 303 | author="Fraa Erasmas", 304 | author_email="raz@edhar.math", 305 | license="MIT", 306 | description="Small, but packs a punch!", 307 | long_description=long_description, 308 | long_description_content_type="text/markdown", 309 | url="https://everything.math", 310 | packages=setuptools.find_packages(), 311 | keywords="nanotech weapons", 312 | classifiers=[ 313 | "Topic :: System :: Hardware", 314 | "Topic :: Scientific/Engineering :: Human Machine Interfaces", 315 | ], 316 | python_requires=">=3.6", 317 | install_requires=[ 318 | "numpy>=1.16.4", 319 | "manimlib==0.1.8", 320 | "ipython>=7.7.0", 321 | ], 322 | ) 323 | "#; 324 | 325 | assert_eq!(expected, &cfg_to_setup(&cfg)); 326 | } 327 | 328 | #[test] 329 | fn py_list() { 330 | let expected = r#"[ 331 | "Programming Language :: Python :: 3", 332 | "License :: OSI Approved :: MIT License", 333 | "Operating System :: OS Independent", 334 | ]"#; 335 | 336 | let actual = serialize_py_list( 337 | &[ 338 | "Programming Language :: Python :: 3".into(), 339 | "License :: OSI Approved :: MIT License".into(), 340 | "Operating System :: OS Independent".into(), 341 | ], 342 | 0, 343 | ); 344 | 345 | assert_eq!(expected, actual); 346 | } 347 | 348 | // todo: Re-impl if you end up using this 349 | // #[test] 350 | // fn py_dict() { 351 | // let expected = r#"{ 352 | // "PDF": [ 353 | // "ReportLab>=1.2", 354 | // "RXP" 355 | // ], 356 | // "reST": [ 357 | // "docutils>=0.3" 358 | // ], 359 | // }"#; 360 | // 361 | // let mut data = HashMap::new(); 362 | // data.insert("PDF".into(), vec!["ReportLab>=1.2".into(), "RXP".into()]); 363 | // data.insert("reST".into(), vec!["docutils>=0.3".into()]); 364 | // 365 | // assert_eq!(expected, serialize_py_dict(&data)); 366 | // } 367 | } 368 | -------------------------------------------------------------------------------- /src/build_new.rs: -------------------------------------------------------------------------------- 1 | // todo: This uses a custom build system instead of setuptools. 2 | // todo: Once you get this working, replace relevant fns in build.rs, and 3 | // todo remove this file. 4 | 5 | use std::path::Path; 6 | 7 | pub fn build_sdist(source: &Path) { 8 | 9 | } 10 | 11 | pub fn build_wheel(source: &Path) { 12 | 13 | } -------------------------------------------------------------------------------- /src/cli_options.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use structopt::StructOpt; 4 | 5 | #[derive(StructOpt, Debug)] 6 | #[structopt(name = "pyflow", about = "Python packaging and publishing")] 7 | pub struct Opt { 8 | #[structopt(subcommand)] 9 | pub subcmds: SubCommand, 10 | 11 | /// Force a color option: auto (default), always, ansi, never 12 | #[structopt(short, long)] 13 | pub color: Option, 14 | } 15 | 16 | #[derive(StructOpt, Debug)] 17 | pub enum SubCommand { 18 | /// Create a project folder with the basics 19 | #[structopt(name = "new")] 20 | New { 21 | #[structopt(name = "name")] 22 | name: String, // holds the project name. 23 | }, 24 | 25 | /// Add packages to `pyproject.toml` and sync an environment 26 | #[structopt(name = "add")] 27 | Add { 28 | #[structopt(name = "packages")] 29 | packages: Vec, // holds the packages names. 30 | /// Save package to your dev-dependencies section 31 | #[structopt(short, long)] 32 | dev: bool, 33 | }, 34 | 35 | /** Install packages from `pyproject.toml`, `pyflow.lock`, or specified ones. Example: 36 | 37 | `pyflow install`: sync your installation with `pyproject.toml`, or `pyflow.lock` if it exists. 38 | `pyflow install numpy scipy`: install `numpy` and `scipy`.*/ 39 | #[structopt(name = "install")] 40 | Install { 41 | #[structopt(name = "packages")] 42 | packages: Vec, 43 | /// Save package to your dev-dependencies section 44 | #[structopt(short, long)] 45 | dev: bool, 46 | }, 47 | /// Uninstall all packages, or ones specified 48 | #[structopt(name = "uninstall")] 49 | Uninstall { 50 | #[structopt(name = "packages")] 51 | packages: Vec, 52 | }, 53 | /// Display all installed packages and console scripts 54 | #[structopt(name = "list")] 55 | List, 56 | /// Build the package - source and wheel 57 | #[structopt(name = "package")] 58 | Package { 59 | #[structopt(name = "extras")] 60 | extras: Vec, 61 | }, 62 | /// Publish to `pypi` 63 | #[structopt(name = "publish")] 64 | Publish, 65 | /// Create a `pyproject.toml` from requirements.txt, pipfile etc, setup.py etc 66 | #[structopt(name = "init")] 67 | Init, 68 | /// Remove the environment, and uninstall all packages 69 | #[structopt(name = "reset")] 70 | Reset, 71 | /// Remove cached packages, Python installs, or script-environments. Eg to free up hard drive space. 72 | #[structopt(name = "clear")] 73 | Clear, 74 | /// Run a CLI script like `ipython` or `black`. Note that you can simply run `pyflow black` 75 | /// as a shortcut. 76 | // Dummy option with space at the end for documentation 77 | #[structopt(name = "run ")] // We don't need to invoke this directly, but the option exists 78 | Run, 79 | 80 | /// Run the project python or script with the project python environment. 81 | /// As a shortcut you can simply specify a script name ending in `.py` 82 | // Dummy option with space at the end for documentation 83 | #[structopt(name = "python ")] 84 | Python, 85 | 86 | /// Run a standalone script not associated with a project 87 | // Dummy option with space at the end for documentation 88 | #[structopt(name = "script ")] 89 | Script, 90 | // /// Run a package globally; used for CLI tools like `ipython` and `black`. Doesn't 91 | // /// interfere Python installations. Must have been installed with `pyflow install -g black` etc 92 | // #[structopt(name = "global")] 93 | // Global { 94 | // #[structopt(name = "name")] 95 | // name: String, 96 | // }, 97 | /// Change the Python version for this project. eg `pyflow switch 3.8`. Equivalent to setting 98 | /// `py_version` in `pyproject.toml`. 99 | #[structopt(name = "switch")] 100 | Switch { 101 | #[structopt(name = "version")] 102 | version: String, 103 | }, 104 | // Documentation for supported external subcommands can be documented by 105 | // adding a `dummy` subcommand with the name having a trailing space. 106 | // #[structopt(name = "external ")] 107 | #[structopt(external_subcommand, name = "external")] 108 | External(Vec), 109 | } 110 | 111 | #[derive(Clone, Debug)] 112 | pub enum ExternalSubcommands { 113 | Run, 114 | Script, 115 | Python, 116 | ImpliedRun(String), 117 | ImpliedPython(String), 118 | } 119 | 120 | impl ToString for ExternalSubcommands { 121 | fn to_string(&self) -> String { 122 | match self { 123 | Self::Run => "run".into(), 124 | Self::Script => "script".into(), 125 | Self::Python => "python".into(), 126 | Self::ImpliedRun(x) => x.into(), 127 | Self::ImpliedPython(x) => x.into(), 128 | } 129 | } 130 | } 131 | 132 | impl FromStr for ExternalSubcommands { 133 | type Err = anyhow::Error; 134 | fn from_str(s: &str) -> anyhow::Result { 135 | let result = match s { 136 | "run" => Self::Run, 137 | "script" => Self::Script, 138 | "python" => Self::Python, 139 | x if x.ends_with(".py") => Self::ImpliedPython(x.to_string()), 140 | x => Self::ImpliedRun(x.to_string()), 141 | }; 142 | Ok(result) 143 | } 144 | } 145 | 146 | #[derive(Clone, Debug)] 147 | pub struct ExternalCommand { 148 | pub cmd: ExternalSubcommands, 149 | pub args: Vec, 150 | } 151 | 152 | impl ExternalCommand { 153 | pub fn from_opt(args: Vec) -> Self { 154 | let cmd = ExternalSubcommands::from_str(&args[0]).unwrap(); 155 | let cmd_args = match cmd { 156 | ExternalSubcommands::Run 157 | | ExternalSubcommands::Script 158 | | ExternalSubcommands::Python => &args[1..], 159 | ExternalSubcommands::ImpliedRun(_) | ExternalSubcommands::ImpliedPython(_) => &args, 160 | }; 161 | let cmd = match cmd { 162 | ExternalSubcommands::ImpliedRun(_) => ExternalSubcommands::Run, 163 | ExternalSubcommands::ImpliedPython(_) => ExternalSubcommands::Python, 164 | x => x, 165 | }; 166 | Self { 167 | cmd, 168 | args: cmd_args.to_vec(), 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt, 4 | path::{Path, PathBuf}, 5 | process::{Command, Stdio}, 6 | }; 7 | 8 | use regex::Regex; 9 | 10 | use crate::util; 11 | 12 | #[derive(Debug)] 13 | struct _ExecutionError { 14 | details: String, 15 | } 16 | 17 | impl Error for _ExecutionError { 18 | fn description(&self) -> &str { 19 | &self.details 20 | } 21 | } 22 | 23 | impl fmt::Display for _ExecutionError { 24 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 25 | write!(f, "{}", self.details) 26 | } 27 | } 28 | 29 | /// Todo: Dry from `find_py_version` 30 | pub fn find_py_dets(alias: &str) -> Option { 31 | let output = Command::new(alias).args(&["--version, --version"]).output(); 32 | 33 | let output_bytes = match output { 34 | Ok(ob) => { 35 | // Old versions of python output `--version` to `stderr`; newer ones to `stdout`, 36 | // so check both. 37 | if ob.stdout.is_empty() { 38 | ob.stderr 39 | } else { 40 | ob.stdout 41 | } 42 | } 43 | Err(_) => return None, 44 | }; 45 | 46 | match std::str::from_utf8(&output_bytes) { 47 | Ok(r) => Some(r.to_owned()), 48 | Err(_) => None, 49 | } 50 | } 51 | 52 | /// Find the Python version from the `python --py_version` command. Eg: "Python 3.7". 53 | pub fn find_py_version(alias: &str) -> Option { 54 | let output = Command::new(alias).arg("--version").output(); 55 | 56 | let output_bytes = match output { 57 | Ok(ob) => { 58 | // Old versions of python output `--version` to `stderr`; newer ones to `stdout`, 59 | // so check both. 60 | if ob.stdout.is_empty() { 61 | ob.stderr 62 | } else { 63 | ob.stdout 64 | } 65 | } 66 | Err(_) => return None, 67 | }; 68 | 69 | if let Ok(version) = std::str::from_utf8(&output_bytes) { 70 | let re = Regex::new(r"Python\s+(\d{1,4})\.(\d{1,4})\.(\d{1,4})").unwrap(); 71 | match re.captures(version) { 72 | Some(caps) => { 73 | let major = caps.get(1).unwrap().as_str().parse::().unwrap(); 74 | let minor = caps.get(2).unwrap().as_str().parse::().unwrap(); 75 | let patch = caps.get(3).unwrap().as_str().parse::().unwrap(); 76 | Some(crate::Version::new(major, minor, patch)) 77 | } 78 | None => None, 79 | } 80 | } else { 81 | None 82 | } 83 | } 84 | 85 | /// Create the virtual env. Assume we're running Python 3.3+, where `venv` is included. 86 | /// Additionally, create the __pypackages__ directory if not already created. 87 | pub fn create_venv(py_alias: &str, lib_path: &Path, name: &str) -> Result<(), Box> { 88 | // While creating the lib path, we're creating the __pypackages__ structure. 89 | let output = Command::new(py_alias) 90 | .args(&["-m", "venv", name]) 91 | .current_dir(lib_path.join("../")) 92 | .output()?; 93 | util::check_command_output(&output, "creating virtual environment"); 94 | 95 | Ok(()) 96 | } 97 | 98 | // todo: DRY for using a path instead of str. use impl Into ? 99 | pub fn create_venv2(py_alias: &Path, lib_path: &Path, name: &str) -> Result<(), Box> { 100 | // While creating the lib path, we're creating the __pypackages__ structure. 101 | let output = Command::new(py_alias) 102 | .args(&["-m", "venv", name]) 103 | .current_dir(lib_path.join("../")) 104 | .output()?; 105 | util::check_command_output(&output, "creating virtual environment"); 106 | 107 | Ok(()) 108 | } 109 | 110 | pub fn run_python( 111 | bin_path: &Path, 112 | lib_paths: &[PathBuf], 113 | args: &[String], 114 | ) -> Result<(), Box> { 115 | util::set_pythonpath(lib_paths); 116 | Command::new(bin_path.join("python")) 117 | .args(args) 118 | .stdin(Stdio::inherit()) 119 | .stdout(Stdio::inherit()) 120 | .stderr(Stdio::inherit()) 121 | .output()?; 122 | Ok(()) 123 | } 124 | 125 | pub fn download_git_repo(repo: &str, dest_path: &Path) -> Result<(), Box> { 126 | // todo: Download directly instead of using git clone? 127 | // todo: Suppress this output. 128 | if Command::new("git").arg("--version").status().is_err() { 129 | util::abort("Can't find Git on the PATH. Is it installed?"); 130 | } 131 | 132 | let output = Command::new("git") 133 | .current_dir(dest_path) 134 | .args(&["clone", repo]) 135 | .output()?; 136 | util::check_command_output(&output, "cloning repo"); 137 | Ok(()) 138 | } 139 | 140 | /// Initialize a new git repo. 141 | pub fn git_init(dir: &Path) -> Result<(), Box> { 142 | let output = Command::new("git") 143 | .current_dir(dir) 144 | .args(&["init", "--quiet"]) 145 | .output()?; 146 | util::check_command_output(&output, "initializing git repository"); 147 | Ok(()) 148 | } 149 | -------------------------------------------------------------------------------- /src/dep_parser.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use nom::{ 4 | branch::alt, 5 | bytes::complete::{tag, take, take_till}, 6 | character::{ 7 | complete::{digit1, space0, space1}, 8 | is_alphabetic, 9 | }, 10 | combinator::{flat_map, map, map_parser, map_res, opt, value}, 11 | multi::separated_list, 12 | sequence::{delimited, preceded, separated_pair, tuple}, 13 | AsChar, IResult, InputTakeAtPosition, 14 | }; 15 | 16 | use crate::{ 17 | dep_types::{Constraint, Extras, Req, ReqType, Version, VersionModifier}, 18 | util::Os, 19 | }; 20 | 21 | enum ExtrasPart { 22 | Extra(String), 23 | SysPlatform(ReqType, Os), 24 | PythonVersion(Constraint), 25 | } 26 | 27 | pub fn parse_req(input: &str) -> IResult<&str, Req> { 28 | // eg saturn = ">=0.3.4", as in pyproject.toml 29 | map( 30 | alt(( 31 | separated_pair( 32 | parse_package_name, 33 | tuple((space0, tag("="), space0)), 34 | delimited(quote, parse_constraints, quote), 35 | ), 36 | map(parse_package_name, |x| (x, vec![])), 37 | )), 38 | |(name, constraints)| Req::new(name.to_string(), constraints), 39 | )(input) 40 | } 41 | 42 | pub fn parse_req_pypi_fmt(input: &str) -> IResult<&str, Req> { 43 | // eg saturn (>=0.3.4) or argon2-cffi (>=16.1.0) ; extra == 'argon2' 44 | // Note: We specify what chars are acceptable in a name instead of using 45 | // wildcard, so we don't accidentally match a semicolon here if a 46 | // set of parens appears later. The non-greedy ? in the version-matching 47 | // expression's important as well, in some cases of extras. 48 | map( 49 | alt(( 50 | tuple(( 51 | tuple((parse_package_name, opt(parse_install_with_extras))), 52 | alt(( 53 | preceded(space0, delimited(tag("("), parse_constraints, tag(")"))), 54 | preceded(space1, parse_constraints), 55 | )), 56 | opt(preceded(tuple((space0, tag(";"), space0)), parse_extras)), 57 | )), 58 | map( 59 | tuple(( 60 | tuple((parse_package_name, opt(parse_install_with_extras))), 61 | opt(preceded(tuple((space0, tag(";"), space0)), parse_extras)), 62 | )), 63 | |(x, y)| (x, vec![], y), 64 | ), 65 | )), 66 | |((name, install_with_extras), constraints, extras_opt)| { 67 | let mut r = if let Some(extras) = extras_opt { 68 | Req::new_with_extras(name.to_string(), constraints, extras) 69 | } else { 70 | Req::new(name.to_string(), constraints) 71 | }; 72 | r.install_with_extras = install_with_extras; 73 | r 74 | }, 75 | )(input) 76 | } 77 | 78 | pub fn parse_pip_str(input: &str) -> IResult<&str, Req> { 79 | map( 80 | tuple((parse_package_name, opt(parse_constraint))), 81 | |(name, constraint)| Req::new(name.to_string(), constraint.into_iter().collect()), 82 | )(input) 83 | } 84 | 85 | pub fn parse_wh_py_vers(input: &str) -> IResult<&str, Vec> { 86 | alt(( 87 | map(tag("any"), |_| { 88 | vec![Constraint::new(ReqType::Gte, Version::new(2, 0, 0))] 89 | }), 90 | map(tag("source"), |_| { 91 | vec![Constraint::new(ReqType::Gte, Version::new(2, 0, 0))] 92 | }), 93 | map(parse_version, |v| vec![Constraint::new(ReqType::Caret, v)]), 94 | separated_list(tag("."), parse_wh_py_ver), 95 | ))(input) 96 | } 97 | 98 | fn parse_wh_py_ver(input: &str) -> IResult<&str, Constraint> { 99 | map( 100 | tuple(( 101 | alt((tag("cp"), tag("py"), tag("pp"))), 102 | alt((tag("2"), tag("3"), tag("4"))), 103 | opt(map_parser(take(1u8), digit1)), 104 | opt(digit1), 105 | )), 106 | |(_, major, minor, patch): (_, &str, Option<&str>, Option<&str>)| { 107 | let major: u32 = major.parse().unwrap(); 108 | let patch = patch.map(|p| p.parse().unwrap()); 109 | match minor { 110 | Some(mi) => Constraint::new( 111 | ReqType::Exact, 112 | Version::new_opt(Some(major), Some(mi.parse().unwrap()), patch), 113 | ), 114 | None => { 115 | if major == 2 { 116 | Constraint::new(ReqType::Lte, Version::new_short(2, 10)) 117 | } else { 118 | Constraint::new(ReqType::Gte, Version::new_short(3, 0)) 119 | } 120 | } 121 | } 122 | }, 123 | )(input) 124 | } 125 | 126 | fn quote(input: &str) -> IResult<&str, &str> { 127 | alt((tag("\""), tag("'")))(input) 128 | } 129 | 130 | fn parse_install_with_extras(input: &str) -> IResult<&str, Vec> { 131 | map( 132 | delimited( 133 | tag("["), 134 | separated_list(tag(","), parse_package_name), 135 | tag("]"), 136 | ), 137 | |extras| extras.iter().map(|x| x.to_string()).collect(), 138 | )(input) 139 | } 140 | 141 | pub fn parse_extras(input: &str) -> IResult<&str, Extras> { 142 | map( 143 | separated_list( 144 | delimited(space0, tag("and"), space0), 145 | delimited( 146 | opt(preceded(tag("("), space0)), 147 | parse_extra_part, 148 | opt(preceded(space0, tag(")"))), 149 | ), 150 | ), 151 | |ps| { 152 | let mut extra = None; 153 | let mut sys_platform = None; 154 | let mut python_version = None; 155 | 156 | for p in ps { 157 | match p { 158 | ExtrasPart::Extra(s) => extra = Some(s), 159 | ExtrasPart::SysPlatform(r, o) => sys_platform = Some((r, o)), 160 | ExtrasPart::PythonVersion(c) => python_version = Some(c), 161 | } 162 | } 163 | 164 | Extras { 165 | extra, 166 | sys_platform, 167 | python_version, 168 | } 169 | }, 170 | )(input) 171 | } 172 | 173 | fn parse_extra_part(input: &str) -> IResult<&str, ExtrasPart> { 174 | flat_map( 175 | alt((tag("extra"), tag("sys_platform"), tag("python_version"))), 176 | |type_| { 177 | move |input: &str| match type_ { 178 | "extra" => map( 179 | preceded( 180 | separated_pair(space0, tag("=="), space0), 181 | delimited(quote, parse_package_name, quote), 182 | ), 183 | |x| ExtrasPart::Extra(x.to_string()), 184 | )(input), 185 | "sys_platform" => map( 186 | tuple(( 187 | delimited(space0, tag("=="), space0), 188 | delimited(quote, parse_package_name, quote), 189 | )), 190 | |(_, o)| ExtrasPart::SysPlatform(ReqType::Exact, Os::from_str(o).unwrap()), 191 | )(input), 192 | "python_version" => map( 193 | tuple(( 194 | delimited(space0, parse_req_type, space0), 195 | delimited(quote, parse_version, quote), 196 | )), 197 | |(r, v)| ExtrasPart::PythonVersion(Constraint::new(r, v)), 198 | )(input), 199 | _ => panic!("Found unexpected"), 200 | } 201 | }, 202 | )(input) 203 | } 204 | 205 | pub fn parse_constraints(input: &str) -> IResult<&str, Vec> { 206 | separated_list(tuple((space0, tag(","), space0)), parse_constraint)(input) 207 | } 208 | 209 | pub fn parse_constraint(input: &str) -> IResult<&str, Constraint> { 210 | map( 211 | alt(( 212 | value((Some(ReqType::Gte), Version::new(0, 0, 0)), tag("*")), 213 | tuple((opt(parse_req_type), parse_version)), 214 | )), 215 | |(r, v)| Constraint::new(r.unwrap_or(ReqType::Exact), v), 216 | )(input) 217 | } 218 | 219 | pub fn parse_version(input: &str) -> IResult<&str, Version> { 220 | let (remain, (major, minor, patch, extra_num)) = tuple(( 221 | parse_digit_or_wildcard, 222 | opt(preceded(tag("."), parse_digit_or_wildcard)), 223 | opt(preceded(tag("."), parse_digit_or_wildcard)), 224 | opt(preceded(tag("."), parse_digit_or_wildcard)), 225 | ))(input)?; 226 | let (remain, modifire) = parse_modifier(remain)?; 227 | let mut version = Version::new_opt(Some(major), minor, patch); 228 | version.extra_num = extra_num; 229 | version.modifier = modifire; 230 | // check if u32::MAX in any version. (marker for `*`). then set that field 231 | // and any subsequent fields to `None` 232 | version.star = vec![Some(major), minor, patch, extra_num].contains(&Some(u32::MAX)); 233 | if version.star { 234 | if version.major == Some(u32::MAX) { 235 | version.major = None; 236 | version.minor = None; 237 | version.patch = None; 238 | version.extra_num = None; 239 | version.modifier = None; 240 | } else if version.minor == Some(u32::MAX) { 241 | version.minor = None; 242 | version.patch = None; 243 | version.extra_num = None; 244 | version.modifier = None; 245 | } else if version.patch == Some(u32::MAX) { 246 | version.patch = None; 247 | version.extra_num = None; 248 | version.modifier = None; 249 | } else if version.extra_num == Some(u32::MAX) { 250 | version.extra_num = None; 251 | version.modifier = None; 252 | } 253 | } 254 | 255 | Ok((remain, version)) 256 | } 257 | 258 | pub fn parse_req_type(input: &str) -> IResult<&str, ReqType> { 259 | map_res( 260 | alt(( 261 | tag("=="), 262 | tag(">="), 263 | tag("<="), 264 | tag(">"), 265 | tag("<"), 266 | tag("!="), 267 | tag("^"), 268 | tag("~="), 269 | tag("~"), 270 | )), 271 | ReqType::from_str, 272 | )(input) 273 | } 274 | 275 | fn parse_package_name(input: &str) -> IResult<&str, &str> { 276 | input.split_at_position1_complete(|x| !is_package_char(x), nom::error::ErrorKind::Tag) 277 | } 278 | 279 | fn is_package_char(c: char) -> bool { 280 | match c { 281 | '-' => true, 282 | '.' => true, 283 | '_' => true, 284 | _ => c.is_alpha() || c.is_dec_digit(), 285 | } 286 | } 287 | 288 | fn parse_digit_or_wildcard(input: &str) -> IResult<&str, u32> { 289 | map( 290 | alt((digit1, value("4294967295", tag("*")))), 291 | |digit: &str| digit.parse().unwrap(), 292 | )(input) 293 | } 294 | 295 | fn parse_modifier(input: &str) -> IResult<&str, Option<(VersionModifier, u32)>> { 296 | opt(map( 297 | tuple((opt(tag(".")), parse_modifier_version, digit1)), 298 | |(_, version_modifier, n)| (version_modifier, n.parse().unwrap()), 299 | ))(input) 300 | } 301 | 302 | fn parse_modifier_version(input: &str) -> IResult<&str, VersionModifier> { 303 | map(take_till(|c| !is_alphabetic(c as u8)), |x| match x { 304 | "a" => VersionModifier::Alpha, 305 | "b" => VersionModifier::Beta, 306 | "rc" => VersionModifier::ReleaseCandidate, 307 | "dep" => VersionModifier::Dep, 308 | x => VersionModifier::Other(x.to_string()), 309 | })(input) 310 | } 311 | 312 | #[cfg(test)] 313 | mod tests { 314 | use rstest::rstest; 315 | 316 | use super::*; 317 | use crate::dep_types::{Version, VersionModifier}; 318 | 319 | #[test] 320 | fn dummy_test() {} 321 | 322 | #[rstest(input, expected, 323 | case("*", Ok(("", Constraint::new(ReqType::Gte, Version::new(0, 0, 0))))), 324 | case("==1.9.2", Ok(("", Constraint::new(ReqType::Exact, Version::new(1, 9, 2))))), 325 | case("1.9.2", Ok(("", Constraint::new(ReqType::Exact, Version::new(1, 9, 2))))), 326 | case("~=1.9.2", Ok(("", Constraint::new(ReqType::TildeEq, Version::new(1, 9, 2))))), 327 | )] 328 | fn test_parse_constraint(input: &str, expected: IResult<&str, Constraint>) { 329 | assert_eq!(parse_constraint(input), expected); 330 | } 331 | 332 | #[rstest(input, expected, 333 | case("3.12.5", Ok(("", Version { 334 | major: Some(3), 335 | minor: Some(12), 336 | patch: Some(5), 337 | extra_num: None, 338 | modifier: None, 339 | star: false, 340 | }))), 341 | case("0.1.0", Ok(("", Version { 342 | major: Some(0), 343 | minor: Some(1), 344 | patch: Some(0), 345 | extra_num: None, 346 | modifier: None, 347 | star: false, 348 | }))), 349 | case("3.7", Ok(("", Version { 350 | major: Some(3), 351 | minor: Some(7), 352 | patch: Some(0), 353 | extra_num: None, 354 | modifier: None, 355 | star: false, 356 | }))), 357 | case("1", Ok(("", Version { 358 | major: Some(1), 359 | minor: Some(0), 360 | patch: Some(0), 361 | extra_num: None, 362 | modifier: None, 363 | star: false, 364 | }))), 365 | case("3.2.*", Ok(("", Version { 366 | major: Some(3), 367 | minor: Some(2), 368 | patch: None, 369 | extra_num: None, 370 | modifier: None, 371 | star: true, 372 | }))), 373 | case("1.*", Ok(("", Version { 374 | major: Some(1), 375 | minor: None, 376 | patch: None, 377 | extra_num: None, 378 | modifier: None, 379 | star: true, 380 | }))), 381 | case("1.*.*", Ok(("", Version { 382 | major: Some(1), 383 | minor: None, 384 | patch: None, 385 | extra_num: None, 386 | modifier: None, 387 | star: true, 388 | }))), 389 | case("19.3", Ok(("", Version { 390 | major: Some(19), 391 | minor: Some(3), 392 | patch: Some(0), 393 | extra_num: None, 394 | modifier: None, 395 | star: false, 396 | }))), 397 | case("19.3b0", Ok(("", Version { 398 | major: Some(19), 399 | minor: Some(3), 400 | patch: Some(0), 401 | extra_num: None, 402 | modifier: Some((VersionModifier::Beta, 0)), 403 | star: false, 404 | }))), 405 | // This package version showed up in boltons history 406 | case("0.4.3.dev0", Ok(("", Version { 407 | major: Some(0), 408 | minor: Some(4), 409 | patch: Some(3), 410 | extra_num: None, 411 | modifier: Some((VersionModifier::Other("dev".to_string()), 0)), 412 | star: false, 413 | }))), 414 | )] 415 | fn test_parse_version(input: &str, expected: IResult<&str, Version>) { 416 | assert_eq!(parse_version(input), expected); 417 | } 418 | 419 | #[rstest(input, expected, 420 | case("pyflow", Ok(("", "pyflow"))), 421 | case("py-flow", Ok(("", "py-flow"))), 422 | case("py_flow", Ok(("", "py_flow"))), 423 | case("py.flow", Ok(("", "py.flow"))), 424 | case("py.flow2", Ok(("", "py.flow2"))), 425 | )] 426 | fn test_parse_package_name(input: &str, expected: IResult<&str, &str>) { 427 | assert_eq!(parse_package_name(input), expected); 428 | } 429 | 430 | #[rstest(input, expected, 431 | case( 432 | "extra == \"test\" and ( python_version == \"2.7\")", 433 | Ok(("", Extras{ 434 | extra: Some("test".to_string()), 435 | sys_platform: None, 436 | python_version: Some(Constraint{ type_: ReqType::Exact, version: Version::new(2, 7, 0)}) 437 | })) 438 | ), 439 | case( 440 | "( python_version == \"2.7\")", 441 | Ok(("", Extras{ 442 | extra: None, 443 | sys_platform: None, 444 | python_version: Some(Constraint{ type_: ReqType::Exact, version: Version::new(2, 7, 0)}) 445 | })) 446 | ), 447 | case( 448 | "python_version == \"2.7\"", 449 | Ok(("", Extras{ 450 | extra: None, 451 | sys_platform: None, 452 | python_version: Some(Constraint{ type_: ReqType::Exact, version: Version::new(2, 7, 0)}) 453 | })) 454 | ), 455 | case( 456 | "( python_version==\"2.7\")", 457 | Ok(("", Extras{ 458 | extra: None, 459 | sys_platform: None, 460 | python_version: Some(Constraint{ type_: ReqType::Exact, version: Version::new(2, 7, 0)}) 461 | })) 462 | ), 463 | case( 464 | "sys_platform == \"win32\" and python_version < \"3.6\"", 465 | Ok(("", Extras{ 466 | extra: None, 467 | sys_platform: Some((ReqType::Exact, Os::Windows32)), 468 | python_version: Some(Constraint{ type_: ReqType::Lt, version: Version::new(3, 6, 0)}) 469 | })) 470 | ), 471 | )] 472 | fn test_parse_extras(input: &str, expected: IResult<&str, Extras>) { 473 | assert_eq!(parse_extras(input), expected); 474 | } 475 | 476 | #[rstest(input, expected, 477 | case::gte("saturn = \">=0.3.4\"", Ok(("", Req::new( 478 | "saturn".to_string(), 479 | vec![Constraint::new(ReqType::Gte, Version::new(0, 3, 4))])))), 480 | case::no_version("saturn", Ok(("", Req::new("saturn".to_string(), vec![])))), 481 | case::star_patch("saturn = \"0.3.*\"", Ok(("", Req::new( 482 | "saturn".to_string(), 483 | vec![ 484 | Constraint::new(ReqType::Exact, Version::new_star(Some(0), Some(3), None, true)) 485 | ] 486 | )))), 487 | case::star_extra_num("saturn = \"0.3.4.*\"", Ok(("", Req::new( 488 | "saturn".to_string(), 489 | vec![ 490 | Constraint::new(ReqType::Exact, Version::new_star(Some(0), Some(3), Some(4), true)) 491 | ] 492 | )))) 493 | )] 494 | fn test_parse_req(input: &str, expected: IResult<&str, Req>) { 495 | assert_eq!(parse_req(input), expected); 496 | } 497 | 498 | #[rstest(input, expected, 499 | case("saturn (>=0.3.4)", Ok(("", Req::new("saturn".to_string(), vec![Constraint::new(ReqType::Gte, Version::new(0, 3, 4))])))), 500 | )] 501 | fn test_parse_req_pypi(input: &str, expected: IResult<&str, Req>) { 502 | assert_eq!(parse_req_pypi_fmt(input), expected); 503 | } 504 | } 505 | -------------------------------------------------------------------------------- /src/files.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fs, 4 | io::{BufRead, BufReader}, 5 | path::Path, 6 | }; 7 | 8 | use regex::Regex; 9 | use serde::Deserialize; 10 | use termcolor::Color; 11 | 12 | use crate::{ 13 | dep_types::{Req, Version}, 14 | util, Config, 15 | }; 16 | 17 | #[derive(Debug, Deserialize)] 18 | pub struct Pipfile { 19 | // Pipfile doesn't use a prefix; assume `[packages]` and [`dev-packages`] sections 20 | // are from it, and use the same format as this tool and `Poetry`. 21 | pub packages: Option>, 22 | #[serde(rename = "dev-packages")] 23 | pub dev_packages: Option>, 24 | } 25 | 26 | /// This nested structure is required based on how the `toml` crate handles dots. 27 | #[derive(Debug, Deserialize)] 28 | pub struct Pyproject { 29 | pub tool: Tool, 30 | } 31 | 32 | #[derive(Debug, Deserialize)] 33 | pub struct Tool { 34 | pub pyflow: Option, 35 | pub poetry: Option, 36 | } 37 | 38 | #[derive(Debug, Deserialize)] 39 | #[serde(untagged)] 40 | /// Allows use of both Strings, ie "ipython = "^7.7.0", and maps: "ipython = {version = "^7.7.0", extras=["qtconsole"]}" 41 | pub enum DepComponentWrapper { 42 | A(String), 43 | B(DepComponent), 44 | } 45 | 46 | #[derive(Debug, Deserialize)] 47 | #[serde(untagged)] 48 | pub enum DepComponentWrapperPoetry { 49 | A(String), 50 | B(DepComponentPoetry), 51 | } 52 | 53 | #[derive(Debug, Deserialize)] 54 | pub struct DepComponent { 55 | #[serde(rename = "version")] 56 | pub constrs: Option, 57 | pub extras: Option>, 58 | pub path: Option, 59 | pub git: Option, 60 | pub branch: Option, 61 | pub service: Option, 62 | pub python: Option, 63 | } 64 | 65 | #[derive(Debug, Deserialize)] 66 | pub struct DepComponentPoetry { 67 | #[serde(rename = "version")] 68 | pub constrs: String, 69 | pub python: Option, 70 | pub extras: Option>, 71 | pub optional: Option, 72 | // todo: more fields 73 | // pub repository: Option, 74 | // pub branch: Option, 75 | // pub service: Option, 76 | } 77 | 78 | #[derive(Debug, Deserialize)] 79 | pub struct Pyflow { 80 | pub py_version: Option, 81 | pub name: Option, 82 | pub version: Option, 83 | pub authors: Option>, 84 | pub license: Option, 85 | pub description: Option, 86 | pub classifiers: Option>, // https://pypi.org/classifiers/ 87 | pub keywords: Option>, 88 | pub homepage: Option, 89 | pub repository: Option, 90 | pub repo_url: Option, 91 | pub package_url: Option, 92 | pub readme: Option, 93 | pub build: Option, 94 | // pub entry_points: Option>>, 95 | pub scripts: Option>, 96 | pub python_requires: Option, 97 | pub dependencies: Option>, 98 | #[serde(rename = "dev-dependencies")] 99 | pub dev_dependencies: Option>, 100 | pub extras: Option>, 101 | } 102 | 103 | #[derive(Debug, Deserialize)] 104 | pub struct Poetry { 105 | pub name: Option, 106 | pub version: Option, 107 | pub description: Option, 108 | pub license: Option, 109 | pub authors: Option>, 110 | pub homepage: Option, 111 | pub repository: Option, 112 | pub documentation: Option, 113 | pub keywords: Option>, 114 | pub readme: Option, 115 | pub build: Option, 116 | pub classifiers: Option>, 117 | pub packages: Option>>, 118 | pub include: Option>, 119 | pub exclude: Option>, 120 | pub extras: Option>, 121 | 122 | pub dependencies: Option>, 123 | pub dev_dependencies: Option>, 124 | // todo: Include these 125 | // pub source: Option>, 126 | pub scripts: Option>, 127 | // pub extras: Option>, 128 | } 129 | 130 | /// Encapsulate one section of the `pyproject.toml`. 131 | /// 132 | /// # Attributes: 133 | /// * lines: A vector containing each line of the section 134 | /// * i_start: Zero-indexed indicating the line of the header. 135 | /// * i_end: Zero-indexed indicating the line number of the next section header, 136 | /// or the last line of the file. 137 | struct Section { 138 | lines: Vec, 139 | i_start: usize, 140 | i_end: usize, 141 | } 142 | 143 | /// Identify the start index, end index, and lines of a particular section. 144 | fn collect_section(cfg_lines: &[String], title: &str) -> Option
{ 145 | // This will tell us when we've reached a new section 146 | let section_re = Regex::new(r"^\[.*\]$").unwrap(); 147 | 148 | let mut existing_entries = Vec::new(); 149 | let mut in_section = false; 150 | let mut i_start = 0usize; 151 | 152 | for (i, line) in cfg_lines.iter().enumerate() { 153 | if in_section && section_re.is_match(line) { 154 | return Some(Section { 155 | lines: existing_entries, 156 | i_start, 157 | i_end: i, 158 | }); 159 | } 160 | 161 | if in_section { 162 | existing_entries.push(line.parse().unwrap()) 163 | } 164 | 165 | // This must be the last step of the loop to work properly 166 | if line.replace(" ", "") == title { 167 | existing_entries.push(title.into()); 168 | i_start = i; 169 | in_section = true; 170 | } 171 | } 172 | // We've reached the end of the file without detecting a new section 173 | if in_section { 174 | Some(Section { 175 | lines: existing_entries, 176 | i_start, 177 | i_end: cfg_lines.len(), 178 | }) 179 | } else { 180 | None 181 | } 182 | } 183 | 184 | /// Main logic for adding dependencies to a particular section. 185 | /// 186 | /// If the section is detected, then the dependencies are appended to that section. Otherwise, 187 | /// a new section is appended to the end of the file. 188 | fn extend_or_insert(mut cfg_lines: Vec, section_header: &str, reqs: &[Req]) -> Vec { 189 | let collected = collect_section(&cfg_lines, section_header); 190 | 191 | match collected { 192 | // The section already exists, so we can just add the new reqs 193 | Some(section) => { 194 | // To enforce proper spacing we first remove any empty lines, 195 | // and later we append a trailing empty line 196 | let mut all_deps: Vec = section 197 | .lines 198 | .to_owned() 199 | .into_iter() 200 | .filter(|x| !x.is_empty()) 201 | .collect(); 202 | 203 | for req in reqs { 204 | all_deps.push(req.to_cfg_string()) 205 | } 206 | all_deps.push("".into()); 207 | 208 | // Replace the original lines with our new updated lines 209 | cfg_lines.splice(section.i_start..section.i_end, all_deps); 210 | cfg_lines 211 | } 212 | // The section did not already exist, so we must create it 213 | None => { 214 | // A section is composed of its header, followed by all the requirements 215 | // and then an empty line 216 | let mut section = vec![section_header.to_string()]; 217 | section.extend(reqs.iter().map(|r| r.to_cfg_string())); 218 | section.push("".into()); 219 | 220 | // We want an empty line before adding the new section 221 | if let Some(last) = cfg_lines.last() { 222 | if !last.is_empty() { 223 | cfg_lines.push("".into()) 224 | } 225 | } 226 | cfg_lines.extend(section); 227 | cfg_lines 228 | } 229 | } 230 | } 231 | 232 | /// Add dependencies and dev-dependencies to `cfg-data`, creating the sections if necessary. 233 | /// 234 | /// The added sections are appended to the end of the file. Split from `add_reqs_to_cfg` 235 | /// to accommodate testing. 236 | fn update_cfg(cfg_data: &str, added: &[Req], added_dev: &[Req]) -> String { 237 | let cfg_lines: Vec = cfg_data.lines().map(str::to_string).collect(); 238 | 239 | // First we update the dependencies section 240 | let cfg_lines_with_reqs = if !added.is_empty() { 241 | extend_or_insert(cfg_lines, "[tool.pyflow.dependencies]", added) 242 | } else { 243 | cfg_lines 244 | }; 245 | 246 | // Then we move onto the dev-dependencies 247 | let cfg_lines_with_all_reqs = if !added_dev.is_empty() { 248 | extend_or_insert( 249 | cfg_lines_with_reqs, 250 | "[tool.pyflow.dev-dependencies]", 251 | added_dev, 252 | ) 253 | } else { 254 | cfg_lines_with_reqs 255 | }; 256 | 257 | cfg_lines_with_all_reqs.join("\n") 258 | } 259 | 260 | /// Write dependencies to pyproject.toml. If an entry for that package already exists, ask if 261 | /// we should update the version. Assume we've already parsed the config, and are only 262 | /// adding new reqs, or ones with a changed version. 263 | pub fn add_reqs_to_cfg(cfg_path: &Path, added: &[Req], added_dev: &[Req]) { 264 | let data = fs::read_to_string(cfg_path) 265 | .expect("Unable to read pyproject.toml while attempting to add a dependency"); 266 | 267 | let updated = update_cfg(&data, added, added_dev); 268 | fs::write(cfg_path, updated) 269 | .expect("Unable to write pyproject.toml while attempting to add a dependency"); 270 | } 271 | 272 | /// Remove dependencies from pyproject.toml. 273 | pub fn remove_reqs_from_cfg(cfg_path: &Path, reqs: &[String]) { 274 | // todo: Handle removing dev deps. 275 | // todo: DRY from parsing the config. 276 | let mut result = String::new(); 277 | let data = fs::read_to_string(cfg_path) 278 | .expect("Unable to read pyproject.toml while attempting to add a dependency"); 279 | 280 | let mut in_dep = false; 281 | let mut _in_dev_dep = false; 282 | let sect_re = Regex::new(r"^\[.*\]$").unwrap(); 283 | 284 | for line in data.lines() { 285 | if line.starts_with('#') || line.is_empty() { 286 | // todo handle mid-line comements 287 | result.push_str(line); 288 | result.push('\n'); 289 | continue; 290 | } 291 | 292 | if line == "[tool.pyflow.dependencies]" { 293 | in_dep = true; 294 | _in_dev_dep = false; 295 | result.push_str(line); 296 | result.push('\n'); 297 | continue; 298 | } 299 | 300 | if line == "[tool.pyflow.dev-dependencies]" { 301 | in_dep = true; 302 | _in_dev_dep = false; 303 | result.push_str(line); 304 | result.push('\n'); 305 | continue; 306 | } 307 | 308 | if in_dep { 309 | if sect_re.is_match(line) { 310 | in_dep = false; 311 | } 312 | // todo: handle comments 313 | let req_line = if let Ok(r) = Req::from_str(line, false) { 314 | r 315 | } else { 316 | result.push_str(line); 317 | result.push('\n'); 318 | continue; // Could be caused by a git etc req. 319 | // util::abort(&format!( 320 | // "Can't parse this line in `pyproject.toml`: {}", 321 | // line 322 | // )); 323 | // unreachable!() 324 | }; 325 | 326 | if reqs 327 | .iter() 328 | .map(|r| r.to_lowercase()) 329 | .any(|x| x == req_line.name.to_lowercase()) 330 | { 331 | continue; // ie don't append this line to result. 332 | } 333 | } 334 | result.push_str(line); 335 | result.push('\n'); 336 | } 337 | 338 | fs::write(cfg_path, result) 339 | .expect("Unable to write to pyproject.toml while attempting to add a dependency"); 340 | } 341 | 342 | pub fn parse_req_dot_text(cfg: &mut Config, path: &Path) { 343 | let file = match fs::File::open(path) { 344 | Ok(f) => f, 345 | Err(_) => return, 346 | }; 347 | 348 | for line in BufReader::new(file).lines().flatten() { 349 | match Req::from_pip_str(&line) { 350 | Some(r) => { 351 | cfg.reqs.push(r.clone()); 352 | } 353 | None => util::print_color( 354 | &format!("Problem parsing {} from requirements.txt", line), 355 | Color::Red, 356 | ), 357 | }; 358 | } 359 | } 360 | 361 | /// Update the config file with a new version. 362 | pub fn change_py_vers(cfg_path: &Path, specified: &Version) { 363 | let f = fs::File::open(&cfg_path) 364 | .expect("Unable to read pyproject.toml while adding Python version"); 365 | let mut new_data = String::new(); 366 | for line in BufReader::new(f).lines().flatten() { 367 | if line.starts_with("py_version") { 368 | new_data.push_str(&format!("py_version = \"{}\"\n", specified.to_string())); 369 | } else { 370 | new_data.push_str(&line); 371 | new_data.push('\n'); 372 | } 373 | } 374 | 375 | fs::write(cfg_path, new_data) 376 | .expect("Unable to write pyproject.toml while adding Python version"); 377 | } 378 | 379 | #[cfg(test)] 380 | pub mod tests { 381 | use super::*; 382 | use crate::dep_types::{Constraint, ReqType::Caret}; 383 | 384 | // We're not concerned with testing formatting in this func. 385 | fn base_constrs() -> Vec { 386 | vec![Constraint::new(Caret, Version::new(0, 0, 1))] 387 | } 388 | 389 | const BASELINE: &str = r#" 390 | [tool.pyflow] 391 | name = "" 392 | 393 | [tool.pyflow.dependencies] 394 | a = "^0.3.5" 395 | 396 | [tool.pyflow.dev-dependencies] 397 | dev_a = "^1.17.2" 398 | "#; 399 | 400 | const _BASELINE_NO_DEPS: &str = r#" 401 | [tool.pyflow] 402 | name = "" 403 | 404 | [tool.pyflow.dev-dependencies] 405 | dev_a = "^1.17.2" 406 | "#; 407 | 408 | const BASELINE_NO_DEV_DEPS: &str = r#" 409 | [tool.pyflow] 410 | name = "" 411 | 412 | [tool.pyflow.dependencies] 413 | a = "^0.3.5" 414 | "#; 415 | 416 | const BASELINE_NO_DEPS_NO_DEV_DEPS: &str = r#" 417 | [tool.pyflow] 418 | name = "" 419 | "#; 420 | 421 | const BASELINE_EMPTY_DEPS: &str = r#" 422 | [tool.pyflow] 423 | name = "" 424 | 425 | [tool.pyflow.dependencies] 426 | 427 | [tool.pyflow.dev-dependencies] 428 | dev_a = "^1.17.2" 429 | "#; 430 | 431 | #[test] 432 | fn add_deps_baseline() { 433 | let actual = update_cfg( 434 | BASELINE, 435 | &[ 436 | Req::new("b".into(), base_constrs()), 437 | Req::new("c".into(), base_constrs()), 438 | ], 439 | &[Req::new("dev_b".into(), base_constrs())], 440 | ); 441 | 442 | let expected = r#" 443 | [tool.pyflow] 444 | name = "" 445 | 446 | [tool.pyflow.dependencies] 447 | a = "^0.3.5" 448 | b = "^0.0.1" 449 | c = "^0.0.1" 450 | 451 | [tool.pyflow.dev-dependencies] 452 | dev_a = "^1.17.2" 453 | dev_b = "^0.0.1" 454 | "#; 455 | 456 | assert_eq!(expected, &actual); 457 | } 458 | 459 | #[test] 460 | fn add_deps_no_dev_deps_sect() { 461 | let actual = update_cfg( 462 | BASELINE_NO_DEV_DEPS, 463 | &[ 464 | Req::new("b".into(), base_constrs()), 465 | Req::new("c".into(), base_constrs()), 466 | ], 467 | &[Req::new("dev_b".into(), base_constrs())], 468 | ); 469 | 470 | let expected = r#" 471 | [tool.pyflow] 472 | name = "" 473 | 474 | [tool.pyflow.dependencies] 475 | a = "^0.3.5" 476 | b = "^0.0.1" 477 | c = "^0.0.1" 478 | 479 | [tool.pyflow.dev-dependencies] 480 | dev_b = "^0.0.1" 481 | "#; 482 | 483 | assert_eq!(expected, &actual); 484 | } 485 | 486 | #[test] 487 | fn add_deps_baseline_empty_deps() { 488 | let actual = update_cfg( 489 | BASELINE_EMPTY_DEPS, 490 | &[ 491 | Req::new("b".into(), base_constrs()), 492 | Req::new("c".into(), base_constrs()), 493 | ], 494 | &[Req::new("dev_b".into(), base_constrs())], 495 | ); 496 | 497 | let expected = r#" 498 | [tool.pyflow] 499 | name = "" 500 | 501 | [tool.pyflow.dependencies] 502 | b = "^0.0.1" 503 | c = "^0.0.1" 504 | 505 | [tool.pyflow.dev-dependencies] 506 | dev_a = "^1.17.2" 507 | dev_b = "^0.0.1" 508 | "#; 509 | 510 | assert_eq!(expected, &actual); 511 | } 512 | 513 | #[test] 514 | fn add_deps_dev_deps_baseline_no_deps_dev_deps() { 515 | let actual = update_cfg( 516 | BASELINE_NO_DEPS_NO_DEV_DEPS, 517 | &[ 518 | Req::new("b".into(), base_constrs()), 519 | Req::new("c".into(), base_constrs()), 520 | ], 521 | &[Req::new("dev_b".into(), base_constrs())], 522 | ); 523 | 524 | let expected = r#" 525 | [tool.pyflow] 526 | name = "" 527 | 528 | [tool.pyflow.dependencies] 529 | b = "^0.0.1" 530 | c = "^0.0.1" 531 | 532 | [tool.pyflow.dev-dependencies] 533 | dev_b = "^0.0.1" 534 | "#; 535 | assert_eq!(expected, &actual); 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::actions::run; 2 | use crate::cli_options::{ExternalCommand, ExternalSubcommands, Opt, SubCommand}; 3 | use crate::dep_types::{Lock, Package, Req, Version}; 4 | use crate::pyproject::{Config, CFG_FILENAME}; 5 | use crate::util::abort; 6 | use crate::util::deps::sync; 7 | 8 | <<<<<<< HEAD 9 | use std::{ 10 | collections::HashMap, 11 | env, 12 | error::Error, 13 | fs, 14 | io::{BufRead, BufReader}, 15 | path::{Path, PathBuf}, 16 | str::FromStr, 17 | sync::{Arc, RwLock}, 18 | }; 19 | 20 | use regex::Regex; 21 | use serde::Deserialize; 22 | use structopt::StructOpt; 23 | use termcolor::{Color, ColorChoice}; 24 | 25 | use crate::{ 26 | dep_resolution::res, 27 | dep_types::{Constraint, Extras, Lock, LockPackage, Package, Rename, Req, ReqType, Version}, 28 | util::{abort, Os}, 29 | }; 30 | 31 | ======= 32 | use std::process; 33 | use std::{ 34 | path::PathBuf, 35 | sync::{Arc, RwLock}, 36 | }; 37 | 38 | use termcolor::{Color, ColorChoice}; 39 | 40 | mod actions; 41 | >>>>>>> 4c6ec9bc8dcf2c486d5820627d70162e44d6b5a7 42 | mod build; 43 | mod cli_options; 44 | mod commands; 45 | mod dep_parser; 46 | mod dep_resolution; 47 | mod dep_types; 48 | mod files; 49 | mod install; 50 | mod py_versions; 51 | mod pyproject; 52 | mod script; 53 | mod util; 54 | 55 | type PackToInstall = ((String, Version), Option<(u32, String)>); // ((Name, Version), (parent id, rename name)) 56 | 57 | /////////////////////////////////////////////////////////////////////////////// 58 | /// Global multithreaded variables part 59 | /////////////////////////////////////////////////////////////////////////////// 60 | 61 | struct CliConfig { 62 | pub color_choice: ColorChoice, 63 | } 64 | 65 | impl Default for CliConfig { 66 | fn default() -> Self { 67 | Self { 68 | color_choice: ColorChoice::Auto, 69 | } 70 | } 71 | } 72 | 73 | impl CliConfig { 74 | pub fn current() -> Arc { 75 | CLI_CONFIG.with(|c| c.read().unwrap().clone()) 76 | } 77 | pub fn make_current(self) { 78 | CLI_CONFIG.with(|c| *c.write().unwrap() = Arc::new(self)) 79 | } 80 | } 81 | 82 | thread_local! { 83 | static CLI_CONFIG: RwLock> = RwLock::new(Default::default()); 84 | } 85 | 86 | /////////////////////////////////////////////////////////////////////////////// 87 | /// \ Global multithreaded variables part 88 | /////////////////////////////////////////////////////////////////////////////// 89 | 90 | /// We process input commands in a deliberate order, to ensure the required, and only the required 91 | /// setup steps are accomplished before each. 92 | #[allow(clippy::match_single_binding)] 93 | #[allow(clippy::single_match)] 94 | // TODO: Remove clippy::match_single_binding and clippy::single_match after full function refactoring 95 | fn main() { 96 | let (pyflow_path, dep_cache_path, script_env_path, git_path) = util::paths::get_paths(); 97 | let os = util::get_os(); 98 | 99 | let opt = ::from_args(); 100 | #[cfg(debug_assertions)] 101 | eprintln!("opts {:?}", opt); 102 | 103 | CliConfig { 104 | color_choice: util::handle_color_option( 105 | opt.color.unwrap_or_else(|| String::from("auto")).as_str(), 106 | ), 107 | } 108 | .make_current(); 109 | 110 | // Handle commands that don't involve operating out of a project before one that do, with setup 111 | // code in-between. 112 | let subcmd = opt.subcmds; 113 | 114 | let extcmd = if let SubCommand::External(ref x) = subcmd { 115 | Some(ExternalCommand::from_opt(x.to_owned())) 116 | } else { 117 | None 118 | }; 119 | 120 | match &subcmd { 121 | // Actions requires nothing to know about the project 122 | SubCommand::New { name } => actions::new(name), 123 | SubCommand::Init => actions::init(CFG_FILENAME), 124 | SubCommand::Reset {} => actions::reset(), 125 | SubCommand::Clear {} => actions::clear(&pyflow_path, &dep_cache_path, &script_env_path), 126 | SubCommand::Switch { version } => actions::switch(version), 127 | SubCommand::External(ref x) => match ExternalCommand::from_opt(x.to_owned()) { 128 | ExternalCommand { cmd, args } => match cmd { 129 | ExternalSubcommands::Script => { 130 | script::run_script(&script_env_path, &dep_cache_path, os, &args, &pyflow_path); 131 | } 132 | // TODO: Move branches to omitted match 133 | _ => (), 134 | }, 135 | }, 136 | 137 | // TODO: Move branches to omitted match 138 | _ => {} 139 | } 140 | 141 | let pcfg = pyproject::current::get_config().unwrap_or_else(|| process::exit(1)); 142 | let cfg_vers = if let Some(v) = pcfg.config.py_version.clone() { 143 | v 144 | } else { 145 | let specified = util::prompts::py_vers(); 146 | 147 | if !pcfg.config_path.exists() { 148 | pcfg.config.write_file(&pcfg.config_path); 149 | } 150 | files::change_py_vers(&pcfg.config_path, &specified); 151 | 152 | specified 153 | }; 154 | 155 | // Check for environments. Create one if none exist. Set `vers_path`. 156 | let (vers_path, py_vers) = util::find_or_create_venv( 157 | &cfg_vers, 158 | &pcfg.pypackages_path, 159 | &pyflow_path, 160 | &dep_cache_path, 161 | ); 162 | 163 | let paths = util::Paths { 164 | bin: util::find_bin_path(&vers_path), 165 | lib: vers_path.join("lib"), 166 | entry_pt: vers_path.join("bin"), 167 | cache: dep_cache_path, 168 | }; 169 | 170 | // Add all path reqs to the PYTHONPATH; this is the way we make these packages accessible when 171 | // running `pyflow`. 172 | let mut pythonpath = vec![paths.lib.clone()]; 173 | for r in pcfg.config.reqs.iter().filter(|r| r.path.is_some()) { 174 | pythonpath.push(PathBuf::from(r.path.clone().unwrap())); 175 | } 176 | for r in pcfg.config.dev_reqs.iter().filter(|r| r.path.is_some()) { 177 | pythonpath.push(PathBuf::from(r.path.clone().unwrap())); 178 | } 179 | 180 | let mut found_lock = false; 181 | let lock = match util::read_lock(&pcfg.lock_path) { 182 | Ok(l) => { 183 | found_lock = true; 184 | l 185 | } 186 | Err(_) => Lock::default(), 187 | }; 188 | 189 | let lockpacks = lock.package.unwrap_or_else(Vec::new); 190 | 191 | sync( 192 | &paths, 193 | &lockpacks, 194 | &pcfg.config.reqs, 195 | &pcfg.config.dev_reqs, 196 | &util::find_dont_uninstall(&pcfg.config.reqs, &pcfg.config.dev_reqs), 197 | os, 198 | &py_vers, 199 | &pcfg.lock_path, 200 | ); 201 | 202 | // Now handle subcommands that require info about the environment 203 | match subcmd { 204 | // Add package names to `pyproject.toml` if needed. Then sync installed packages 205 | // and `pyflow.lock` with the `pyproject.toml`. 206 | // We use data from three sources: `pyproject.toml`, `pyflow.lock`, and 207 | // the currently-installed packages, found by crawling metadata in the `lib` path. 208 | // See the readme section `How installation and locking work` for details. 209 | SubCommand::Install { packages, dev } | SubCommand::Add { packages, dev } => { 210 | actions::install( 211 | &pcfg.config_path, 212 | &pcfg.config, 213 | &git_path, 214 | &paths, 215 | found_lock, 216 | &packages, 217 | dev, 218 | &lockpacks, 219 | &os, 220 | &py_vers, 221 | &pcfg.lock_path, 222 | ) 223 | } 224 | 225 | SubCommand::Uninstall { packages } => { 226 | // todo: uninstall dev? 227 | // Remove dependencies specified in the CLI from the config, then lock and sync. 228 | 229 | let removed_reqs: Vec = packages 230 | .into_iter() 231 | .map(|p| { 232 | Req::from_str(&p, false) 233 | .expect("Problem parsing req while uninstalling") 234 | .name 235 | }) 236 | .collect(); 237 | 238 | files::remove_reqs_from_cfg(&pcfg.config_path, &removed_reqs); 239 | 240 | // Filter reqs here instead of re-reading the config from file. 241 | let updated_reqs: Vec = pcfg 242 | .config 243 | .clone() 244 | .reqs 245 | .into_iter() 246 | .filter(|req| !removed_reqs.contains(&req.name)) 247 | .collect(); 248 | 249 | sync( 250 | &paths, 251 | &lockpacks, 252 | &updated_reqs, 253 | &pcfg.config.dev_reqs, 254 | &[], 255 | os, 256 | &py_vers, 257 | &pcfg.lock_path, 258 | ); 259 | util::print_color("Uninstall complete", Color::Green); 260 | } 261 | 262 | SubCommand::Package { extras } => actions::package( 263 | &paths, 264 | &lockpacks, 265 | os, 266 | &py_vers, 267 | &pcfg.lock_path, 268 | &pcfg.config, 269 | &extras, 270 | ), 271 | SubCommand::Publish {} => build::publish(&paths.bin, &pcfg.config), 272 | SubCommand::List {} => actions::list( 273 | &paths.lib, 274 | &[pcfg.config.reqs.as_slice(), pcfg.config.dev_reqs.as_slice()] 275 | .concat() 276 | .into_iter() 277 | .filter(|r| r.path.is_some()) 278 | .collect::>(), 279 | ), 280 | _ => (), 281 | } 282 | 283 | if let Some(x) = extcmd { 284 | match x.cmd { 285 | ExternalSubcommands::Python => { 286 | if commands::run_python(&paths.bin, &pythonpath, &x.args).is_err() { 287 | abort("Problem running Python"); 288 | } 289 | } 290 | ExternalSubcommands::Run => { 291 | run(&paths.lib, &paths.bin, &vers_path, &pcfg.config, x.args); 292 | } 293 | x => { 294 | abort(&format!( 295 | "Sub command {:?} should have been handled already", 296 | x 297 | )); 298 | } 299 | } 300 | } 301 | } 302 | 303 | #[cfg(test)] 304 | pub mod tests {} 305 | -------------------------------------------------------------------------------- /src/py_versions.rs: -------------------------------------------------------------------------------- 1 | //! Manages Python installations 2 | 3 | use std::error::Error; 4 | #[allow(unused_imports)] 5 | use std::{fmt, fs, io, path::Path, path::PathBuf}; 6 | 7 | use termcolor::Color; 8 | 9 | use crate::{commands, dep_types::Version, install, util}; 10 | 11 | /// Only versions we've built and hosted 12 | #[derive(Clone, Copy, Debug)] 13 | enum PyVers { 14 | V3_12_0, // unreleased 15 | V3_11_0, // unreleased 16 | V3_10_2, // Win 17 | V3_9_0, // either Os 18 | V3_8_0, // either Os 19 | V3_7_4, // either Os 20 | V3_6_9, // Linux 21 | V3_6_8, // Win 22 | V3_5_7, // Linux 23 | V3_5_4, // Win 24 | V3_4_10, // Linux 25 | } 26 | 27 | /// Reduces code repetition for error messages related to Python binaries we don't support. 28 | fn abort_helper(version: &str, os: &str) { 29 | util::abort(&format!( 30 | "Automatic installation of Python {} on {} is currently unsupported. If you'd like \ 31 | to use this version of Python, please install it.", 32 | version, os 33 | )) 34 | } 35 | 36 | impl From<(Version, Os)> for PyVers { 37 | fn from(v_o: (Version, Os)) -> Self { 38 | let unsupported = "Unsupported python version requested; only Python ≥ 3.4 is supported. \ 39 | to fix this, edit the `py_version` line of `pyproject.toml`, or run `pyflow switch 3.7`"; 40 | if v_o.0.major != Some(3) { 41 | util::abort(unsupported) 42 | } 43 | match v_o.0.minor.unwrap_or(0) { 44 | 4 => match v_o.1 { 45 | Os::Windows => { 46 | abort_helper("3.4", "Windows"); 47 | unreachable!() 48 | } 49 | Os::Ubuntu | Os::Centos => Self::V3_4_10, 50 | _ => { 51 | abort_helper("3.4", "Mac"); 52 | unreachable!() 53 | } 54 | }, 55 | 5 => match v_o.1 { 56 | Os::Windows => Self::V3_5_4, 57 | Os::Ubuntu | Os::Centos => Self::V3_5_7, 58 | _ => { 59 | abort_helper("3.5", "Mac"); 60 | unreachable!() 61 | } 62 | }, 63 | 6 => match v_o.1 { 64 | Os::Windows => Self::V3_6_8, 65 | Os::Ubuntu | Os::Centos => Self::V3_6_9, 66 | _ => { 67 | abort_helper("3.6", "Mac"); 68 | unreachable!() 69 | } 70 | }, 71 | 7 => match v_o.1 { 72 | Os::Windows | Os::Ubuntu | Os::Centos => Self::V3_7_4, 73 | _ => { 74 | abort_helper("3.7", "Mac"); 75 | unreachable!() 76 | } 77 | }, 78 | 8 => match v_o.1 { 79 | Os::Windows | Os::Ubuntu | Os::Centos => Self::V3_8_0, 80 | _ => { 81 | abort_helper("3.8", "Mac"); 82 | unreachable!() 83 | } 84 | }, 85 | 9 => match v_o.1 { 86 | Os::Windows | Os::Ubuntu | Os::Centos => Self::V3_9_0, 87 | _ => { 88 | abort_helper("3.9", "Mac"); 89 | unreachable!() 90 | } 91 | }, 92 | 10 => match v_o.1 { 93 | Os::Windows => Self::V3_10_2, 94 | Os::Ubuntu | Os::Centos => { 95 | abort_helper("3.10", "Linux"); 96 | unreachable!() 97 | } 98 | _ => { 99 | abort_helper("3.10", "Mac"); 100 | unreachable!() 101 | } 102 | }, 103 | 11 => match v_o.1 { 104 | Os::Windows | Os::Ubuntu | Os::Centos => Self::V3_11_0, 105 | _ => { 106 | abort_helper("3.11", "Mac"); 107 | unreachable!() 108 | } 109 | }, 110 | 12 => match v_o.1 { 111 | Os::Windows | Os::Ubuntu | Os::Centos => Self::V3_12_0, 112 | _ => { 113 | abort_helper("3.12", "Mac"); 114 | unreachable!() 115 | } 116 | }, 117 | _ => util::abort(unsupported), 118 | } 119 | } 120 | } 121 | 122 | impl ToString for PyVers { 123 | fn to_string(&self) -> String { 124 | match self { 125 | Self::V3_12_0 => "3.12.0".into(), 126 | Self::V3_11_0 => "3.11.0".into(), 127 | Self::V3_10_2 => "3.10.2".into(), 128 | Self::V3_9_0 => "3.9.0".into(), 129 | Self::V3_8_0 => "3.8.0".into(), 130 | Self::V3_7_4 => "3.7.4".into(), 131 | Self::V3_6_9 => "3.6.9".into(), 132 | Self::V3_6_8 => "3.6.8".into(), 133 | Self::V3_5_7 => "3.5.7".into(), 134 | Self::V3_5_4 => "3.5.4".into(), 135 | Self::V3_4_10 => "3.4.10".into(), 136 | } 137 | } 138 | } 139 | 140 | impl PyVers { 141 | fn to_vers(self) -> Version { 142 | match self { 143 | Self::V3_12_0 => Version::new(3, 12, 0), 144 | Self::V3_11_0 => Version::new(3, 11, 0), 145 | Self::V3_10_2 => Version::new(3, 10, 2), 146 | Self::V3_9_0 => Version::new(3, 9, 0), 147 | Self::V3_8_0 => Version::new(3, 8, 0), 148 | Self::V3_7_4 => Version::new(3, 7, 4), 149 | Self::V3_6_9 => Version::new(3, 6, 9), 150 | Self::V3_6_8 => Version::new(3, 6, 8), 151 | Self::V3_5_7 => Version::new(3, 5, 7), 152 | Self::V3_5_4 => Version::new(3, 5, 4), 153 | Self::V3_4_10 => Version::new(3, 4, 10), 154 | } 155 | } 156 | } 157 | 158 | /// Only Oses we've built and hosted 159 | /// todo: How cross-compat are these? Eg work across diff versions of Ubuntu? 160 | /// todo: 32-bit 161 | #[derive(Clone, Copy, Debug)] 162 | #[allow(dead_code)] 163 | enum Os { 164 | // Don't confuse with crate::Os 165 | Ubuntu, // Builds on Ubuntu 18.04 work on Ubuntu 19.04, Debian, Arch, and Kali 166 | Centos, // Will this work on Red Hat and Fedora as well? 167 | Windows, 168 | Mac, 169 | } 170 | 171 | /// For use in the Linux distro prompt 172 | impl fmt::Display for Os { 173 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 174 | write!( 175 | f, 176 | "{}", 177 | match self { 178 | Self::Ubuntu => "Ubuntu", 179 | Self::Centos => "Centos", 180 | Self::Windows => "Windows", 181 | Self::Mac => "Mac", 182 | } 183 | ) 184 | } 185 | } 186 | 187 | fn download(py_install_path: &Path, version: &Version) { 188 | // We use the `.xz` format due to its small size compared to `.zip`. On order half the size. 189 | let os; 190 | let os_str; 191 | #[cfg(target_os = "windows")] 192 | { 193 | os = Os::Windows; 194 | os_str = "windows"; 195 | } 196 | #[cfg(target_os = "linux")] 197 | { 198 | let result = util::prompts::list( 199 | "Please enter the number corresponding to your Linux distro:", 200 | "Linux distro", 201 | &[ 202 | ( 203 | "2016 or newer (Ubuntu≥16.04, Debian≥9, SUSE≥15, Arch, Kali, etc)".to_owned(), 204 | Os::Ubuntu, 205 | ), 206 | ( 207 | "Older (Centos, Redhat, Fedora, older versions of distros listed in option 1)" 208 | .to_owned(), 209 | Os::Centos, 210 | ), 211 | ], 212 | false, 213 | ); 214 | os = result.1; 215 | os_str = match os { 216 | Os::Ubuntu => "ubuntu", 217 | Os::Centos => "centos", 218 | _ => { 219 | util::abort( 220 | "Unfortunately, we don't yet support other Operating systems.\ 221 | It's worth trying the other options, to see if one works anyway.", 222 | ); 223 | unreachable!() 224 | } 225 | }; 226 | } 227 | #[cfg(target_os = "macos")] 228 | { 229 | os = Os::Mac; 230 | os_str = "mac"; 231 | } 232 | 233 | // Match up our version to the closest match (major+minor will match) we've built. 234 | let vers_to_dl2: PyVers = (version.clone(), os).into(); 235 | let vers_to_dl = vers_to_dl2.to_string(); 236 | 237 | let url = format!( 238 | "https://github.com/David-OConnor/pybin/releases/\ 239 | download/{}/python-{}-{}.tar.xz", 240 | vers_to_dl, vers_to_dl, os_str 241 | ); 242 | 243 | // eg `python-3.7.4-ubuntu.tar.xz` 244 | let archive_path = py_install_path.join(&format!("python-{}-{}.tar.xz", vers_to_dl, os_str)); 245 | if !archive_path.exists() { 246 | // Save the file 247 | util::print_color( 248 | &format!("Downloading Python {}...", vers_to_dl), 249 | Color::Cyan, 250 | ); 251 | let mut resp = reqwest::blocking::get(&url).expect("Problem downloading Python"); // Download the file 252 | let mut out = 253 | fs::File::create(&archive_path).expect("Failed to save downloaded Python archive"); 254 | if let Err(e) = io::copy(&mut resp, &mut out) { 255 | // Clean up the downloaded file, or we'll get an error next time. 256 | fs::remove_file(&archive_path).expect("Problem removing the broken file"); 257 | util::abort(&format!("Problem downloading the Python archive: {:?}", e)); 258 | } 259 | } 260 | util::print_color(&format!("Installing Python {}...", vers_to_dl), Color::Cyan); 261 | 262 | util::unpack_tar_xz(&archive_path, py_install_path); 263 | 264 | // Strip the OS tag from the extracted Python folder name 265 | let extracted_path = py_install_path.join(&format!("python-{}", vers_to_dl)); 266 | 267 | fs::rename( 268 | py_install_path.join(&format!("python-{}-{}", vers_to_dl, os_str)), 269 | &extracted_path, 270 | ) 271 | .expect("Problem renaming extracted Python folder"); 272 | } 273 | 274 | #[derive(Debug)] 275 | pub struct AliasError { 276 | pub details: String, 277 | } 278 | 279 | impl Error for AliasError { 280 | fn description(&self) -> &str { 281 | &self.details 282 | } 283 | } 284 | 285 | impl fmt::Display for AliasError { 286 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 287 | write!(f, "{}", self.details) 288 | } 289 | } 290 | 291 | /// Make an educated guess at the command needed to execute python the 292 | /// current system. An alternative approach is trying to find python 293 | /// installations. 294 | pub fn find_py_aliases(version: &Version) -> Vec<(String, Version)> { 295 | let possible_aliases = &[ 296 | "python3.19", 297 | "python3.18", 298 | "python3.17", 299 | "python3.16", 300 | "python3.15", 301 | "python3.14", 302 | "python3.13", 303 | "python3.12", 304 | "python3.11", 305 | "python3.10", 306 | "python3.9", 307 | "python3.8", 308 | "python3.7", 309 | "python3.6", 310 | "python3.5", 311 | "python3.4", 312 | "python3.3", 313 | "python3.2", 314 | "python3.1", 315 | "python3", 316 | "python", 317 | "python2", 318 | ]; 319 | 320 | let mut result = Vec::new(); 321 | let mut found_dets = Vec::new(); 322 | 323 | for alias in possible_aliases { 324 | // We use the --version command as a quick+effective way to determine if 325 | // this command is associated with Python. 326 | let dets = commands::find_py_dets(alias); 327 | if let Some(v) = commands::find_py_version(alias) { 328 | if v.major == version.major && v.minor == version.minor && !found_dets.contains(&dets) { 329 | result.push((alias.to_string(), v)); 330 | found_dets.push(dets); 331 | } 332 | } 333 | } 334 | result 335 | } 336 | 337 | // Find versions installed with this tool. 338 | fn find_installed_versions(pyflow_dir: &Path) -> Vec { 339 | #[cfg(target_os = "windows")] 340 | let py_name = "python"; 341 | #[cfg(target_os = "linux")] 342 | let py_name = "bin/python3"; 343 | #[cfg(target_os = "macos")] 344 | let py_name = "bin/python3"; 345 | 346 | if !&pyflow_dir.exists() && fs::create_dir_all(&pyflow_dir).is_err() { 347 | util::abort("Problem creating the Pyflow directory") 348 | } 349 | 350 | let mut result = vec![]; 351 | for entry in pyflow_dir 352 | .read_dir() 353 | .expect("Can't open python installs path") 354 | .flatten() 355 | { 356 | if !entry.path().is_dir() { 357 | continue; 358 | } 359 | 360 | if let Some(v) = commands::find_py_version(entry.path().join(py_name).to_str().unwrap()) { 361 | result.push(v); 362 | } 363 | } 364 | result 365 | } 366 | 367 | /// Create a new virtual environment, and install `wheel`. 368 | pub fn create_venv( 369 | cfg_v: &Version, 370 | pypackages_dir: &Path, 371 | pyflow_dir: &Path, 372 | dep_cache_path: &Path, 373 | ) -> Version { 374 | let os; 375 | let python_name; 376 | #[allow(unused_mut)] 377 | let mut py_name; 378 | #[cfg(target_os = "windows")] 379 | { 380 | py_name = "python".to_string(); 381 | os = Os::Windows; 382 | python_name = "python.exe"; 383 | } 384 | #[cfg(target_os = "linux")] 385 | { 386 | py_name = "bin/python3".to_string(); 387 | os = Os::Ubuntu; 388 | python_name = "python"; 389 | } 390 | #[cfg(target_os = "macos")] 391 | { 392 | py_name = "bin/python3".to_string(); 393 | os = Os::Mac; 394 | python_name = "python"; 395 | } 396 | 397 | let mut alias = None; 398 | let mut alias_path = None; 399 | let mut py_ver = None; 400 | 401 | // If we find both a system alias, and internal version installed, go with the internal. 402 | // One's this tool installed 403 | let installed_versions = find_installed_versions(pyflow_dir); 404 | for iv in &installed_versions { 405 | if iv.major == cfg_v.major && iv.minor == cfg_v.minor { 406 | let folder_name = format!("python-{}", iv.to_string()); 407 | alias_path = Some(pyflow_dir.join(folder_name).join(&py_name)); 408 | py_ver = Some(iv.clone()); 409 | break; 410 | } 411 | } 412 | 413 | // todo perhaps move alias finding back into create_venv, or make a 414 | // todo create_venv_if_doesnt_exist fn. 415 | // Only search for a system Python if we don't have an internal one. 416 | // todo: Why did we choose to prioritize portable over system? Perhaps do the 417 | // todo other way around. 418 | if py_ver.is_none() { 419 | let aliases = find_py_aliases(cfg_v); 420 | match aliases.len() { 421 | 0 => (), 422 | 1 => { 423 | let r = aliases[0].clone(); 424 | alias = Some(r.0); 425 | py_ver = Some(r.1); 426 | } 427 | _ => { 428 | // let r = prompt_alias(&aliases); 429 | let r = util::prompts::list( 430 | "Found multiple compatible Python versions. Please enter the number associated with the one you'd like to use:", 431 | "Python alias", 432 | &aliases, 433 | true, 434 | ); 435 | alias = Some(r.0); 436 | py_ver = Some(r.1); 437 | } 438 | }; 439 | } 440 | 441 | if py_ver.is_none() { 442 | // Download and install the appropriate Python binary, if we can't find either a 443 | // custom install, or on the Path. 444 | download(pyflow_dir, cfg_v); 445 | let py_ver2: PyVers = (cfg_v.clone(), os).into(); 446 | py_ver = Some(py_ver2.to_vers()); 447 | 448 | let folder_name = format!("python-{}", py_ver2.to_string()); 449 | 450 | // We appear to have symlink issues on some builds, where `python3` won't work, but 451 | // `python3.7` (etc) will. Note that this is no longer applicable once the venv is built, 452 | // and we're using its `python`. 453 | #[cfg(target_os = "linux")] 454 | { 455 | match py_ver.clone().unwrap().minor.unwrap_or(0) { 456 | 12 => py_name += ".12", 457 | 11 => py_name += ".11", 458 | 10 => py_name += ".10", 459 | 9 => py_name += ".9", 460 | 8 => py_name += ".8", 461 | 7 => py_name += ".7", 462 | 6 => py_name += ".6", 463 | 5 => py_name += ".5", 464 | 4 => py_name += ".4", 465 | _ => panic!("Invalid python minor version"), 466 | } 467 | } 468 | 469 | alias_path = Some(pyflow_dir.join(folder_name).join(py_name)); 470 | } 471 | 472 | let py_ver = py_ver.expect("missing Python version"); 473 | 474 | let vers_path = pypackages_dir.join(py_ver.to_string_med()); 475 | 476 | let lib_path = vers_path.join("lib"); 477 | 478 | if !lib_path.exists() { 479 | fs::create_dir_all(&lib_path).expect("Problem creating __pypackages__ directory"); 480 | } 481 | 482 | #[cfg(target_os = "windows")] 483 | println!("Setting up Python..."); 484 | #[cfg(target_os = "linux")] 485 | println!("🐍 Setting up Python..."); // Beware! Snake may be invisible. 486 | #[cfg(target_os = "macos")] 487 | println!("🐍 Setting up Python..."); 488 | 489 | // For an alias on the PATH 490 | if let Some(alias) = alias { 491 | if commands::create_venv(&alias, &lib_path, ".venv").is_err() { 492 | util::abort("Problem creating virtual environment"); 493 | } 494 | // For a Python one we've installed. 495 | } else if let Some(alias_path) = alias_path { 496 | if commands::create_venv2(&alias_path, &lib_path, ".venv").is_err() { 497 | util::abort("Problem creating virtual environment"); 498 | } 499 | } 500 | 501 | let bin_path = util::find_bin_path(&vers_path); 502 | 503 | util::wait_for_dirs(&[bin_path.join(python_name)]) 504 | .expect("Timed out waiting for venv to be created."); 505 | 506 | // Try 64 first; if not, use 32. 507 | #[allow(unused_variables)] 508 | let lib = if vers_path.join(".venv").join("lib64").exists() { 509 | "lib64" 510 | } else { 511 | "lib" 512 | }; 513 | 514 | #[cfg(target_os = "windows")] 515 | let venv_lib_path = "Lib"; 516 | #[cfg(target_os = "linux")] 517 | let venv_lib_path = PathBuf::from(lib).join(&format!("python{}", py_ver.to_string_med())); 518 | #[cfg(target_os = "macos")] 519 | let venv_lib_path = PathBuf::from(lib).join(&format!("python{}", py_ver.to_string_med())); 520 | 521 | let paths = util::Paths { 522 | bin: bin_path.clone(), 523 | lib: vers_path 524 | .join(".venv") 525 | .join(venv_lib_path) 526 | .join("site-packages"), 527 | entry_pt: bin_path, 528 | cache: dep_cache_path.to_owned(), 529 | }; 530 | 531 | // We need `wheel` installed to build wheels from source. 532 | // We use `twine` to upload packages to pypi. 533 | // Note: This installs to the venv's site-packages, not __pypackages__/3.x/lib. 534 | let wheel_url = "https://files.pythonhosted.org/packages/00/83/b4a77d044e78ad1a45610eb88f745be2fd2c6d658f9798a15e384b7d57c9/wheel-0.33.6-py2.py3-none-any.whl"; 535 | 536 | install::download_and_install_package( 537 | "wheel", 538 | &Version::new(0, 33, 6), 539 | wheel_url, 540 | "wheel-0.33.6-py2.py3-none-any.whl", 541 | "f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28", 542 | &paths, 543 | install::PackageType::Wheel, 544 | &None, 545 | ) 546 | .expect("Problem installing `wheel`"); 547 | 548 | py_ver 549 | } 550 | -------------------------------------------------------------------------------- /src/pyproject/current.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf}; 2 | 3 | use termcolor::Color; 4 | 5 | use crate::util; 6 | 7 | use super::{Config, PresentConfig, CFG_FILENAME, LOCK_FILENAME}; 8 | 9 | const NOT_FOUND_ERROR_MESSAGE: &str = indoc::indoc! {r#" 10 | To get started, run `pyflow new projname` to create a project folder, or 11 | `pyflow init` to start a project in this folder. For a list of what you can do, run 12 | `pyflow help`. 13 | "#}; 14 | 15 | pub fn get_config() -> Option { 16 | let mut config_path = PathBuf::from(CFG_FILENAME); 17 | if !&config_path.exists() { 18 | // Try looking recursively in parent directories for a config file. 19 | let recursion_limit = 8; // How my levels to look up 20 | let mut current_level = env::current_dir().expect("Can't access current directory"); 21 | for _ in 0..recursion_limit { 22 | if let Some(parent) = current_level.parent() { 23 | let parent_cfg_path = parent.join(CFG_FILENAME); 24 | if parent_cfg_path.exists() { 25 | config_path = parent_cfg_path; 26 | break; 27 | } 28 | current_level = parent.to_owned(); 29 | } 30 | } 31 | 32 | if !&config_path.exists() { 33 | // we still can't find it after searching parents. 34 | util::print_color(NOT_FOUND_ERROR_MESSAGE, Color::Cyan); // Dark Cyan 35 | return None; 36 | } 37 | } 38 | 39 | // Base pypackages_path and lock_path on the `pyproject.toml` folder. 40 | let project_path = config_path 41 | .parent() 42 | .expect("Can't find project path via parent") 43 | .to_path_buf(); 44 | let pypackages_path = project_path.join("__pypackages__"); 45 | let lock_path = project_path.join(LOCK_FILENAME); 46 | 47 | let mut config = Config::from_file(&config_path).unwrap_or_default(); 48 | config.populate_path_subreqs(); 49 | Some(PresentConfig { 50 | config, 51 | config_path, 52 | project_path, 53 | pypackages_path, 54 | lock_path, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/pyproject/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod current; 2 | 3 | use std::{ 4 | collections::HashMap, 5 | fs, 6 | path::{Path, PathBuf}, 7 | str::FromStr, 8 | }; 9 | 10 | use regex::Regex; 11 | use serde::Deserialize; 12 | 13 | use crate::{ 14 | dep_types::{Constraint, Req, Version}, 15 | files, 16 | util::{self, abort}, 17 | }; 18 | 19 | pub const CFG_FILENAME: &str = "pyproject.toml"; 20 | pub const LOCK_FILENAME: &str = "pyflow.lock"; 21 | 22 | #[derive(Clone, Debug, Default)] 23 | pub struct PresentConfig { 24 | pub project_path: PathBuf, 25 | pub config_path: PathBuf, 26 | pub pypackages_path: PathBuf, 27 | pub lock_path: PathBuf, 28 | pub config: Config, 29 | } 30 | 31 | /// A config, parsed from pyproject.toml 32 | #[derive(Clone, Debug, Default, Deserialize)] 33 | // todo: Auto-desr some of these 34 | pub struct Config { 35 | pub name: Option, 36 | pub py_version: Option, 37 | pub reqs: Vec, 38 | pub dev_reqs: Vec, 39 | pub version: Option, 40 | pub authors: Vec, 41 | pub license: Option, 42 | pub extras: HashMap, 43 | pub description: Option, 44 | pub classifiers: Vec, // https://pypi.org/classifiers/ 45 | pub keywords: Vec, 46 | pub homepage: Option, 47 | pub repository: Option, 48 | pub repo_url: Option, 49 | pub package_url: Option, 50 | pub readme: Option, 51 | pub build: Option, // A python file used to build non-python extensions 52 | // entry_points: HashMap>, // todo option? 53 | pub scripts: HashMap, //todo: put under [tool.pyflow.scripts] ? 54 | // console_scripts: Vec, // We don't parse these; pass them to `setup.py` as-entered. 55 | pub python_requires: Option, 56 | } 57 | 58 | impl Config { 59 | /// Helper fn to prevent repetition 60 | pub fn parse_deps(deps: HashMap) -> Vec { 61 | let mut result = Vec::new(); 62 | for (name, data) in deps { 63 | let constraints; 64 | let mut extras = None; 65 | let mut git = None; 66 | let mut path = None; 67 | let mut python_version = None; 68 | match data { 69 | files::DepComponentWrapper::A(constrs) => { 70 | constraints = if let Ok(c) = Constraint::from_str_multiple(&constrs) { 71 | c 72 | } else { 73 | abort(&format!( 74 | "Problem parsing constraints in `pyproject.toml`: {}", 75 | &constrs 76 | )) 77 | }; 78 | } 79 | files::DepComponentWrapper::B(subdata) => { 80 | constraints = match subdata.constrs { 81 | Some(constrs) => { 82 | if let Ok(c) = Constraint::from_str_multiple(&constrs) { 83 | c 84 | } else { 85 | abort(&format!( 86 | "Problem parsing constraints in `pyproject.toml`: {}", 87 | &constrs 88 | )) 89 | } 90 | } 91 | None => vec![], 92 | }; 93 | 94 | if let Some(ex) = subdata.extras { 95 | extras = Some(ex); 96 | } 97 | if let Some(p) = subdata.path { 98 | path = Some(p); 99 | } 100 | if let Some(repo) = subdata.git { 101 | git = Some(repo); 102 | } 103 | if let Some(v) = subdata.python { 104 | let pv = Constraint::from_str(&v) 105 | .expect("Problem parsing python version in dependency"); 106 | python_version = Some(vec![pv]); 107 | } 108 | } 109 | } 110 | 111 | result.push(Req { 112 | name, 113 | constraints, 114 | extra: None, 115 | sys_platform: None, 116 | python_version, 117 | install_with_extras: extras, 118 | path, 119 | git, 120 | }); 121 | } 122 | result 123 | } 124 | 125 | // todo: DRY at the top from `from_file`. 126 | pub fn from_pipfile(path: &Path) -> Option { 127 | // todo: Lots of tweaks and QC could be done re what fields to parse, and how best to 128 | // todo parse and store them. 129 | let toml_str = match fs::read_to_string(path).ok() { 130 | Some(d) => d, 131 | None => return None, 132 | }; 133 | 134 | let decoded: files::Pipfile = if let Ok(d) = toml::from_str(&toml_str) { 135 | d 136 | } else { 137 | abort("Problem parsing `Pipfile`") 138 | }; 139 | let mut result = Self::default(); 140 | 141 | if let Some(pipfile_deps) = decoded.packages { 142 | result.reqs = Self::parse_deps(pipfile_deps); 143 | } 144 | if let Some(pipfile_dev_deps) = decoded.dev_packages { 145 | result.dev_reqs = Self::parse_deps(pipfile_dev_deps); 146 | } 147 | 148 | Some(result) 149 | } 150 | 151 | /// Pull config data from `pyproject.toml`. We use this to deserialize things like Versions 152 | /// and requirements. 153 | pub fn from_file(path: &Path) -> Option { 154 | // todo: Lots of tweaks and QC could be done re what fields to parse, and how best to 155 | // todo parse and store them. 156 | let toml_str = match fs::read_to_string(path) { 157 | Ok(d) => d, 158 | Err(_) => return None, 159 | }; 160 | 161 | let decoded: files::Pyproject = if let Ok(d) = toml::from_str(&toml_str) { 162 | d 163 | } else { 164 | abort("Problem parsing `pyproject.toml`"); 165 | }; 166 | let mut result = Self::default(); 167 | 168 | // Parse Poetry first, since we'll use pyflow if there's a conflict. 169 | if let Some(po) = decoded.tool.poetry { 170 | if let Some(v) = po.name { 171 | result.name = Some(v); 172 | } 173 | if let Some(v) = po.authors { 174 | result.authors = v; 175 | } 176 | if let Some(v) = po.license { 177 | result.license = Some(v); 178 | } 179 | 180 | if let Some(v) = po.homepage { 181 | result.homepage = Some(v); 182 | } 183 | if let Some(v) = po.description { 184 | result.description = Some(v); 185 | } 186 | if let Some(v) = po.repository { 187 | result.repository = Some(v); 188 | } 189 | if let Some(v) = po.readme { 190 | result.readme = Some(v); 191 | } 192 | if let Some(v) = po.build { 193 | result.build = Some(v); 194 | } 195 | // todo: Process entry pts, classifiers etc? 196 | if let Some(v) = po.classifiers { 197 | result.classifiers = v; 198 | } 199 | if let Some(v) = po.keywords { 200 | result.keywords = v; 201 | } 202 | 203 | // if let Some(v) = po.source { 204 | // result.source = v; 205 | // } 206 | // if let Some(v) = po.scripts { 207 | // result.console_scripts = v; 208 | // } 209 | if let Some(v) = po.extras { 210 | result.extras = v; 211 | } 212 | 213 | if let Some(v) = po.version { 214 | result.version = Some( 215 | Version::from_str(&v).expect("Problem parsing version in `pyproject.toml`"), 216 | ) 217 | } 218 | 219 | // todo: DRY (c+p) from pyflow dependency parsing, other than parsing python version here, 220 | // todo which only poetry does. 221 | // todo: Parse poetry dev deps 222 | if let Some(deps) = po.dependencies { 223 | for (name, data) in deps { 224 | let constraints; 225 | let mut extras = None; 226 | let mut python_version = None; 227 | match data { 228 | files::DepComponentWrapperPoetry::A(constrs) => { 229 | constraints = Constraint::from_str_multiple(&constrs) 230 | .expect("Problem parsing constraints in `pyproject.toml`."); 231 | } 232 | files::DepComponentWrapperPoetry::B(subdata) => { 233 | constraints = Constraint::from_str_multiple(&subdata.constrs) 234 | .expect("Problem parsing constraints in `pyproject.toml`."); 235 | if let Some(ex) = subdata.extras { 236 | extras = Some(ex); 237 | } 238 | if let Some(v) = subdata.python { 239 | let pv = Constraint::from_str(&v) 240 | .expect("Problem parsing python version in dependency"); 241 | python_version = Some(vec![pv]); 242 | } 243 | // todo repository etc 244 | } 245 | } 246 | if &name.to_lowercase() == "python" { 247 | if let Some(constr) = constraints.get(0) { 248 | result.py_version = Some(constr.version.clone()) 249 | } 250 | } else { 251 | result.reqs.push(Req { 252 | name, 253 | constraints, 254 | extra: None, 255 | sys_platform: None, 256 | python_version, 257 | install_with_extras: extras, 258 | path: None, 259 | git: None, 260 | }); 261 | } 262 | } 263 | } 264 | } 265 | 266 | if let Some(pf) = decoded.tool.pyflow { 267 | if let Some(v) = pf.name { 268 | result.name = Some(v); 269 | } 270 | 271 | if let Some(v) = pf.authors { 272 | result.authors = if v.is_empty() { 273 | util::get_git_author() 274 | } else { 275 | v 276 | }; 277 | } 278 | if let Some(v) = pf.license { 279 | result.license = Some(v); 280 | } 281 | if let Some(v) = pf.homepage { 282 | result.homepage = Some(v); 283 | } 284 | if let Some(v) = pf.description { 285 | result.description = Some(v); 286 | } 287 | if let Some(v) = pf.repository { 288 | result.repository = Some(v); 289 | } 290 | 291 | // todo: Process entry pts, classifiers etc? 292 | if let Some(v) = pf.classifiers { 293 | result.classifiers = v; 294 | } 295 | if let Some(v) = pf.keywords { 296 | result.keywords = v; 297 | } 298 | if let Some(v) = pf.readme { 299 | result.readme = Some(v); 300 | } 301 | if let Some(v) = pf.build { 302 | result.build = Some(v); 303 | } 304 | // if let Some(v) = pf.entry_points { 305 | // result.entry_points = v; 306 | // } // todo 307 | if let Some(v) = pf.scripts { 308 | result.scripts = v; 309 | } 310 | 311 | if let Some(v) = pf.python_requires { 312 | result.python_requires = Some(v); 313 | } 314 | 315 | if let Some(v) = pf.package_url { 316 | result.package_url = Some(v); 317 | } 318 | 319 | if let Some(v) = pf.version { 320 | result.version = Some( 321 | Version::from_str(&v).expect("Problem parsing version in `pyproject.toml`"), 322 | ) 323 | } 324 | 325 | if let Some(v) = pf.py_version { 326 | result.py_version = Some( 327 | Version::from_str(&v) 328 | .expect("Problem parsing python version in `pyproject.toml`"), 329 | ); 330 | } 331 | 332 | if let Some(deps) = pf.dependencies { 333 | result.reqs = Self::parse_deps(deps); 334 | } 335 | if let Some(deps) = pf.dev_dependencies { 336 | result.dev_reqs = Self::parse_deps(deps); 337 | } 338 | } 339 | 340 | Some(result) 341 | } 342 | 343 | /// For reqs of `path` type, add their sub-reqs by parsing `setup.py` or `pyproject.toml`. 344 | pub fn populate_path_subreqs(&mut self) { 345 | self.reqs.append(&mut pop_reqs_helper(&self.reqs, false)); 346 | self.dev_reqs 347 | .append(&mut pop_reqs_helper(&self.dev_reqs, true)); 348 | } 349 | 350 | /// Create a new `pyproject.toml` file. 351 | pub fn write_file(&self, path: &Path) { 352 | let file = path; 353 | if file.exists() { 354 | abort("`pyproject.toml` already exists") 355 | } 356 | 357 | let mut result = String::new(); 358 | 359 | result.push_str("\n[tool.pyflow]\n"); 360 | if let Some(name) = &self.name { 361 | result.push_str(&("name = \"".to_owned() + name + "\"\n")); 362 | } else { 363 | // Give name, and a few other fields default values. 364 | result.push_str(&("name = \"\"".to_owned() + "\n")); 365 | } 366 | if let Some(py_v) = &self.py_version { 367 | result.push_str(&("py_version = \"".to_owned() + &py_v.to_string_no_patch() + "\"\n")); 368 | } else { 369 | result.push_str(&("py_version = \"3.8\"".to_owned() + "\n")); 370 | } 371 | if let Some(vers) = self.version.clone() { 372 | result.push_str(&(format!("version = \"{}\"", vers.to_string() + "\n"))); 373 | } else { 374 | result.push_str("version = \"0.1.0\""); 375 | result.push('\n'); 376 | } 377 | if !self.authors.is_empty() { 378 | result.push_str("authors = [\""); 379 | for (i, author) in self.authors.iter().enumerate() { 380 | if i != 0 { 381 | result.push_str(", "); 382 | } 383 | result.push_str(author); 384 | } 385 | result.push_str("\"]\n"); 386 | } 387 | 388 | if let Some(v) = &self.description { 389 | result.push_str(&(format!("description = \"{}\"", v) + "\n")); 390 | } 391 | if let Some(v) = &self.homepage { 392 | result.push_str(&(format!("homepage = \"{}\"", v) + "\n")); 393 | } 394 | 395 | // todo: More fields 396 | 397 | result.push('\n'); 398 | result.push_str("[tool.pyflow.scripts]\n"); 399 | for (name, mod_fn) in &self.scripts { 400 | result.push_str(&(format!("{} = \"{}\"", name, mod_fn) + "\n")); 401 | } 402 | 403 | result.push('\n'); 404 | result.push_str("[tool.pyflow.dependencies]\n"); 405 | for dep in &self.reqs { 406 | result.push_str(&(dep.to_cfg_string() + "\n")); 407 | } 408 | 409 | result.push('\n'); 410 | result.push_str("[tool.pyflow.dev-dependencies]\n"); 411 | for dep in &self.dev_reqs { 412 | result.push_str(&(dep.to_cfg_string() + "\n")); 413 | } 414 | 415 | result.push('\n'); // trailing newline 416 | 417 | if fs::write(file, result).is_err() { 418 | abort("Problem writing `pyproject.toml`") 419 | } 420 | } 421 | } 422 | 423 | /// Reduce repetition between reqs and dev reqs when populating reqs of path reqs. 424 | fn pop_reqs_helper(reqs: &[Req], dev: bool) -> Vec { 425 | let mut result = vec![]; 426 | for req in reqs.iter().filter(|r| r.path.is_some()) { 427 | let req_path = PathBuf::from(req.path.clone().unwrap()); 428 | let pyproj = req_path.join("pyproject.toml"); 429 | let req_txt = req_path.join("requirements.txt"); 430 | // let pipfile = req_path.join("Pipfile"); 431 | 432 | let mut dummy_cfg = Config::default(); 433 | 434 | if req_txt.exists() { 435 | files::parse_req_dot_text(&mut dummy_cfg, &req_txt); 436 | } 437 | 438 | // if pipfile.exists() { 439 | // files::parse_pipfile(&mut dummy_cfg, &pipfile); 440 | // } 441 | 442 | if dev { 443 | result.append(&mut dummy_cfg.dev_reqs); 444 | } else { 445 | result.append(&mut dummy_cfg.reqs); 446 | } 447 | 448 | // We don't parse `setup.py`, since it involves running arbitrary Python code. 449 | 450 | if pyproj.exists() { 451 | let mut req_cfg = Config::from_file(&PathBuf::from(&pyproj)) 452 | .unwrap_or_else(|| panic!("Problem parsing`pyproject.toml`: {:?}", &pyproj)); 453 | result.append(&mut req_cfg.reqs) 454 | } 455 | 456 | // Check for metadata of a built wheel 457 | for folder_name in util::find_folders(&req_path) { 458 | // todo: Dry from `util` and `install`. 459 | let re_dist = Regex::new(r"^(.*?)-(.*?)\.dist-info$").unwrap(); 460 | if re_dist.captures(&folder_name).is_some() { 461 | let metadata_path = req_path.join(folder_name).join("METADATA"); 462 | let mut metadata = util::parse_metadata(&metadata_path); 463 | 464 | result.append(&mut metadata.requires_dist); 465 | } 466 | } 467 | } 468 | result 469 | } 470 | -------------------------------------------------------------------------------- /src/script.rs: -------------------------------------------------------------------------------- 1 | use crate::dep_resolution::res; 2 | use crate::dep_types::{Constraint, Extras, Lock, Req, ReqType, Version}; 3 | use crate::util; 4 | use regex::Regex; 5 | use std::fs; 6 | use std::path::Path; 7 | 8 | use crate::commands; 9 | use crate::dep_parser::parse_version; 10 | use std::str::FromStr; 11 | 12 | /// Run a standalone script file, with package management 13 | /// todo: We're using script name as unique identifier; address this in the future, 14 | /// todo perhaps with an id in a comment at the top of a file 15 | pub fn run_script( 16 | script_env_path: &Path, 17 | dep_cache_path: &Path, 18 | os: util::Os, 19 | args: &[String], 20 | pyflow_dir: &Path, 21 | ) { 22 | #[cfg(debug_assertions)] 23 | eprintln!("Run script args: {:?}", args); 24 | 25 | // todo: DRY with run_cli_tool and subcommand::Install 26 | let filename = if let Some(arg) = args.get(0) { 27 | arg 28 | } else { 29 | util::abort( 30 | "`script` must be followed by the script to run, eg `pyflow script myscript.py`", 31 | ); 32 | }; 33 | 34 | // todo: Consider a metadata file, but for now, we'll use folders 35 | // let scripts_data_path = script_env_path.join("scripts.toml"); 36 | 37 | let env_path = util::canon_join(script_env_path, filename); 38 | if !env_path.exists() { 39 | fs::create_dir_all(&env_path).expect("Problem creating environment for the script"); 40 | } 41 | 42 | // Write the version we found to a file. 43 | let cfg_vers; 44 | let py_vers_path = env_path.join("py_vers.txt"); 45 | 46 | let script = fs::read_to_string(filename).expect("Problem opening the Python script file."); 47 | let dunder_python_vers = check_for_specified_py_vers(&script); 48 | 49 | if let Some(dpv) = dunder_python_vers { 50 | cfg_vers = dpv; 51 | create_or_update_version_file(&py_vers_path, &cfg_vers); 52 | } else if py_vers_path.exists() { 53 | cfg_vers = Version::from_str( 54 | &fs::read_to_string(py_vers_path) 55 | .expect("Problem reading Python version for this script") 56 | .replace("\n", ""), 57 | ) 58 | .expect("Problem parsing version from file"); 59 | } else { 60 | cfg_vers = util::prompts::py_vers(); 61 | create_or_update_version_file(&py_vers_path, &cfg_vers); 62 | } 63 | 64 | // todo DRY 65 | let pypackages_dir = env_path.join("__pypackages__"); 66 | let (vers_path, py_vers) = 67 | util::find_or_create_venv(&cfg_vers, &pypackages_dir, pyflow_dir, dep_cache_path); 68 | 69 | let bin_path = util::find_bin_path(&vers_path); 70 | let lib_path = vers_path.join("lib"); 71 | let script_path = vers_path.join("bin"); 72 | let lock_path = env_path.join("pyproject.lock"); 73 | 74 | let paths = util::Paths { 75 | bin: bin_path, 76 | lib: lib_path, 77 | entry_pt: script_path, 78 | cache: dep_cache_path.to_owned(), 79 | }; 80 | 81 | let deps = find_deps_from_script(&script); 82 | 83 | let lock = match util::read_lock(&lock_path) { 84 | Ok(l) => l, 85 | Err(_) => Lock::default(), 86 | }; 87 | 88 | let lockpacks = lock.package.unwrap_or_else(Vec::new); 89 | 90 | let reqs: Vec = deps 91 | .iter() 92 | .map(|name| { 93 | let (fmtd_name, version) = if let Some(lp) = lockpacks 94 | .iter() 95 | .find(|lp| util::compare_names(&lp.name, name)) 96 | { 97 | ( 98 | lp.name.clone(), 99 | Version::from_str(&lp.version).expect("Problem getting version"), 100 | ) 101 | } else { 102 | let vinfo = res::get_version_info( 103 | name, 104 | Some(Req::new_with_extras( 105 | name.to_string(), 106 | vec![Constraint::new_any()], 107 | Extras::new_py(Constraint::new(ReqType::Exact, py_vers.clone())), 108 | )), 109 | ) 110 | .unwrap_or_else(|_| panic!("Problem getting version info for {}", &name)); 111 | (vinfo.0, vinfo.1) 112 | }; 113 | 114 | Req::new(fmtd_name, vec![Constraint::new(ReqType::Caret, version)]) 115 | }) 116 | .collect(); 117 | 118 | util::deps::sync( 119 | &paths, 120 | &lockpacks, 121 | &reqs, 122 | &[], 123 | &[], 124 | os, 125 | &py_vers, 126 | &lock_path, 127 | ); 128 | 129 | if commands::run_python(&paths.bin, &[paths.lib], args).is_err() { 130 | util::abort("Problem running this script") 131 | }; 132 | } 133 | 134 | /// Create the `py_vers.txt` if it doesn't exist, and then store `cfg_vers` within. 135 | fn create_or_update_version_file(py_vers_path: &Path, cfg_vers: &Version) { 136 | if !py_vers_path.exists() { 137 | fs::File::create(&py_vers_path) 138 | .expect("Problem creating a file to store the Python version for this script"); 139 | } 140 | fs::write(py_vers_path, &cfg_vers.to_string()).expect("Problem writing Python version file."); 141 | } 142 | 143 | /// Find a script's Python version specificion by looking for the `__python__` variable. 144 | /// 145 | /// If a `__python__` variable is identified, the version must have major, minor, and 146 | /// patch components to be considered valid. Otherwise, there is still some ambiguity in 147 | /// which version to use and an error is thrown. 148 | fn check_for_specified_py_vers(script: &str) -> Option { 149 | let re = Regex::new(r#"^__python__\s*=\s*"(.*?)"$"#).unwrap(); 150 | 151 | for line in script.lines() { 152 | if let Some(capture) = re.captures(line) { 153 | let specification = capture.get(1).unwrap().as_str(); 154 | let (_, version) = parse_version(specification).unwrap(); 155 | match version { 156 | Version { 157 | major: Some(_), 158 | minor: Some(_), 159 | patch: Some(_), 160 | extra_num: None, 161 | modifier: None, 162 | .. 163 | } => return Some(version), 164 | _ => { 165 | util::abort( 166 | "Problem parsing `__python__` variable. Make sure you've included \ 167 | major, minor, and patch specifications (eg `__python__ = X.Y.Z`)", 168 | ); 169 | } 170 | } 171 | } 172 | } 173 | None 174 | } 175 | 176 | /// Find a script's dependencies from a variable: `__requires__ = [dep1, dep2]` 177 | fn find_deps_from_script(script: &str) -> Vec { 178 | // todo: Helper for this type of logic? We use it several times in the program. 179 | let re = Regex::new(r"(?ms)^__requires__\s*=\s*\[(.*?)\]$").unwrap(); 180 | 181 | let mut result = vec![]; 182 | 183 | if let Some(c) = re.captures(script) { 184 | let deps_list = c.get(1).unwrap().as_str().to_owned(); 185 | result = deps_list 186 | .split(',') 187 | .map(|d| { 188 | d.to_owned() 189 | .replace(" ", "") 190 | .replace("'", "") 191 | .replace("\"", "") 192 | .replace("\n", "") 193 | }) 194 | .filter(|d| !d.is_empty()) 195 | .collect(); 196 | } 197 | result 198 | } 199 | 200 | #[cfg(test)] 201 | mod tests { 202 | use indoc::indoc; 203 | 204 | use crate::dep_types::Version; 205 | 206 | use super::*; 207 | 208 | #[test] 209 | fn parse_python_version_with_no_dunder_specified() { 210 | let script = indoc! { r#" 211 | if __name__ == "__main__": 212 | print("Hello, world") 213 | "# }; 214 | 215 | let version: Option = None; 216 | 217 | let expected = version; 218 | let actual = check_for_specified_py_vers(script); 219 | 220 | assert_eq!(expected, actual); 221 | } 222 | 223 | #[test] 224 | fn parse_python_version_with_valid_dunder_specified() { 225 | let script = indoc! { r#" 226 | __python__ = "3.9.1" 227 | 228 | if __name__ == "__main__": 229 | print("Hello, world") 230 | "# }; 231 | 232 | let version: Option = Some(Version { 233 | major: Some(3), 234 | minor: Some(9), 235 | patch: Some(1), 236 | extra_num: None, 237 | modifier: None, 238 | star: false, 239 | }); 240 | 241 | let expected = version; 242 | let actual = check_for_specified_py_vers(script); 243 | 244 | assert_eq!(expected, actual); 245 | } 246 | 247 | #[test] 248 | fn parse_no_dependencies_with_no_requires() { 249 | let script = indoc! { r#" 250 | if __name__ == "__main__": 251 | print("Hello, world") 252 | "# }; 253 | 254 | let expected: Vec<&str> = vec![]; 255 | let actual = find_deps_from_script(script); 256 | 257 | assert_eq!(expected, actual); 258 | } 259 | 260 | #[test] 261 | fn parse_no_dependencies_with_single_line_requires() { 262 | let script = indoc! { r#" 263 | __requires__ = [] 264 | 265 | if __name__ == "__main__": 266 | print("Hello, world") 267 | "# }; 268 | 269 | let expected: Vec<&str> = vec![]; 270 | let actual = find_deps_from_script(script); 271 | 272 | assert_eq!(expected, actual); 273 | } 274 | 275 | #[test] 276 | fn parse_no_dependencies_with_multi_line_requires() { 277 | let script = indoc! { r#" 278 | __requires__ = [ 279 | ] 280 | 281 | if __name__ == "__main__": 282 | print("Hello, world") 283 | "# }; 284 | 285 | let expected: Vec<&str> = vec![]; 286 | let actual = find_deps_from_script(script); 287 | 288 | assert_eq!(expected, actual); 289 | } 290 | 291 | #[test] 292 | fn parse_one_dependency_with_single_line_requires() { 293 | let script = indoc! { r#" 294 | __requires__ = ["requests"] 295 | 296 | if __name__ == "__main__": 297 | print("Hello, world") 298 | "# }; 299 | 300 | let expected: Vec<&str> = vec!["requests"]; 301 | let actual = find_deps_from_script(script); 302 | 303 | assert_eq!(expected, actual); 304 | } 305 | 306 | #[test] 307 | fn parse_one_dependency_with_multi_line_requires() { 308 | let script = indoc! { r#" 309 | __requires__ = [ 310 | "requests" 311 | ] 312 | 313 | if __name__ == "__main__": 314 | print("Hello, world") 315 | "# }; 316 | 317 | let expected: Vec<&str> = vec!["requests"]; 318 | let actual = find_deps_from_script(script); 319 | 320 | assert_eq!(expected, actual); 321 | } 322 | 323 | #[test] 324 | fn parse_multiple_dependencies_with_single_line_requires() { 325 | let script = indoc! { r#" 326 | __requires__ = ["python-dateutil", "requests"] 327 | 328 | if __name__ == "__main__": 329 | print("Hello, world") 330 | "# }; 331 | 332 | let expected: Vec<&str> = vec!["python-dateutil", "requests"]; 333 | let actual = find_deps_from_script(script); 334 | 335 | assert_eq!(expected, actual); 336 | } 337 | 338 | #[test] 339 | fn parse_multiple_dependencies_with_multi_line_requires() { 340 | let script = indoc! { r#" 341 | __requires__ = [ 342 | "python-dateutil", 343 | "requests" 344 | ] 345 | 346 | if __name__ == "__main__": 347 | print("Hello, world") 348 | "# }; 349 | 350 | let expected: Vec<&str> = vec!["python-dateutil", "requests"]; 351 | let actual = find_deps_from_script(script); 352 | 353 | assert_eq!(expected, actual); 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/util/deps.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::Path, str::FromStr}; 2 | 3 | use regex::Regex; 4 | use termcolor::Color; 5 | 6 | use crate::{ 7 | dep_resolution::res, 8 | dep_types::{Constraint, Lock, LockPackage, Package, Rename, Req, ReqType, Version}, 9 | install, 10 | util::{self, abort}, 11 | PackToInstall, 12 | }; 13 | 14 | /// Function used by `Install` and `Uninstall` subcommands to syn dependencies with 15 | /// the config and lock files. 16 | #[allow(clippy::too_many_arguments)] 17 | pub fn sync( 18 | paths: &util::Paths, 19 | lockpacks: &[LockPackage], 20 | reqs: &[Req], 21 | dev_reqs: &[Req], 22 | dont_uninstall: &[String], 23 | os: util::Os, 24 | py_vers: &Version, 25 | lock_path: &Path, 26 | ) { 27 | let installed = util::find_installed(&paths.lib); 28 | // We control the lock format, so this regex will always match 29 | let dep_re = Regex::new(r"^(.*?)\s(.*)\s.*$").unwrap(); 30 | 31 | // We don't need to resolve reqs that are already locked. 32 | let locked: Vec = lockpacks 33 | .iter() 34 | .map(|lp| { 35 | let mut deps = vec![]; 36 | for dep in lp.dependencies.as_ref().unwrap_or(&vec![]) { 37 | let caps = dep_re 38 | .captures(dep) 39 | .expect("Problem reading lock file dependencies"); 40 | let name = caps.get(1).unwrap().as_str().to_owned(); 41 | let vers = Version::from_str(caps.get(2).unwrap().as_str()) 42 | .expect("Problem parsing version from lock"); 43 | deps.push((999, name, vers)); // dummy id 44 | } 45 | 46 | Package { 47 | id: lp.id, // todo 48 | parent: 0, // todo 49 | name: lp.name.clone(), 50 | version: Version::from_str(&lp.version).expect("Problem parsing lock version"), 51 | deps, 52 | rename: Rename::No, // todo 53 | } 54 | }) 55 | .collect(); 56 | 57 | // todo: Only show this when needed. 58 | // todo: Temporarily? Removed. 59 | // Powershell doesn't like emojis 60 | // #[cfg(target_os = "windows")] 61 | // println!("Resolving dependencies..."); 62 | // #[cfg(target_os = "linux")] 63 | // println!("🔍 Resolving dependencies..."); 64 | // #[cfg(target_os = "macos")] 65 | // println!("🔍 Resolving dependencies..."); 66 | 67 | // Dev reqs and normal reqs are both installed here; we only commit dev reqs 68 | // when packaging. 69 | let mut combined_reqs = reqs.to_vec(); 70 | for dev_req in dev_reqs.to_vec() { 71 | combined_reqs.push(dev_req); 72 | } 73 | 74 | let resolved = if let Ok(r) = res::resolve(&combined_reqs, &locked, os, py_vers) { 75 | r 76 | } else { 77 | abort("Problem resolving dependencies") 78 | }; 79 | 80 | // Now merge the existing lock packages with new ones from resolved packages. 81 | // We have a collection of requirements; attempt to merge them with the already-locked ones. 82 | let mut updated_lock_packs = vec![]; 83 | 84 | for package in &resolved { 85 | let dummy_constraints = vec![Constraint::new(ReqType::Exact, package.version.clone())]; 86 | if already_locked(&locked, &package.name, &dummy_constraints) { 87 | let existing: Vec<&LockPackage> = lockpacks 88 | .iter() 89 | .filter(|lp| util::compare_names(&lp.name, &package.name)) 90 | .collect(); 91 | let existing2 = existing[0]; 92 | 93 | updated_lock_packs.push(existing2.clone()); 94 | continue; 95 | } 96 | 97 | let deps = package 98 | .deps 99 | .iter() 100 | .map(|(_, name, version)| { 101 | format!( 102 | "{} {} pypi+https://pypi.org/pypi/{}/{}/json", 103 | name, version, name, version, 104 | ) 105 | }) 106 | .collect(); 107 | 108 | updated_lock_packs.push(LockPackage { 109 | id: package.id, 110 | name: package.name.clone(), 111 | version: package.version.to_string(), 112 | source: Some(format!( 113 | "pypi+https://pypi.org/pypi/{}/{}/json", 114 | package.name, 115 | package.version.to_string() 116 | )), 117 | dependencies: Some(deps), 118 | rename: match &package.rename { 119 | Rename::Yes(parent_id, _, name) => Some(format!("{} {}", parent_id, name)), 120 | Rename::No => None, 121 | }, 122 | }); 123 | } 124 | 125 | let updated_lock = Lock { 126 | // metadata: Some(lock_metadata), 127 | metadata: HashMap::new(), // todo: Problem with toml conversion. 128 | package: Some(updated_lock_packs.clone()), 129 | }; 130 | if util::write_lock(lock_path, &updated_lock).is_err() { 131 | abort("Problem writing lock file"); 132 | } 133 | 134 | // Now that we've confirmed or modified the lock file, we're ready to sync installed 135 | // dependencies with it. 136 | sync_deps( 137 | paths, 138 | &updated_lock_packs, 139 | dont_uninstall, 140 | &installed, 141 | os, 142 | py_vers, 143 | ); 144 | } 145 | /// Install/uninstall deps as required from the passed list, and re-write the lock file. 146 | fn sync_deps( 147 | paths: &util::Paths, 148 | lock_packs: &[LockPackage], 149 | dont_uninstall: &[String], 150 | installed: &[(String, Version, Vec)], 151 | os: util::Os, 152 | python_vers: &Version, 153 | ) { 154 | let packages: Vec = lock_packs 155 | .iter() 156 | .map(|lp| { 157 | ( 158 | ( 159 | util::standardize_name(&lp.name), 160 | Version::from_str(&lp.version).expect("Problem parsing lock version"), 161 | ), 162 | lp.rename.as_ref().map(|rn| parse_lockpack_rename(rn)), 163 | ) 164 | }) 165 | .collect(); 166 | 167 | // todo shim. Use top-level A/R. We discard it temporarily while working other issues. 168 | let installed: Vec<(String, Version)> = installed 169 | .iter() 170 | // Don't standardize name here; see note below in to_uninstall. 171 | .map(|t| (t.0.clone(), t.1.clone())) 172 | .collect(); 173 | 174 | // Filter by not-already-installed. 175 | let to_install: Vec<&PackToInstall> = packages 176 | .iter() 177 | .filter(|(pack, _)| { 178 | let mut contains = false; 179 | for inst in &installed { 180 | if util::compare_names(&pack.0, &inst.0) && pack.1 == inst.1 { 181 | contains = true; 182 | break; 183 | } 184 | } 185 | 186 | // The typing module is sometimes downloaded, causing a conflict/improper 187 | // behavior compared to the built in module. 188 | !contains && pack.0 != "typing" 189 | }) 190 | .collect(); 191 | 192 | // todo: Once you include rename info in installed, you won't need to use the map logic here. 193 | let packages_only: Vec<&(String, Version)> = packages.iter().map(|(p, _)| p).collect(); 194 | let to_uninstall: Vec<&(String, Version)> = installed 195 | .iter() 196 | .filter(|inst| { 197 | // Don't standardize the name here; we need original capitalization to uninstall 198 | // metadata etc. 199 | let inst = (inst.0.clone(), inst.1.clone()); 200 | let mut contains = false; 201 | // We can't just use the contains method, due to needing compare_names(). 202 | for pack in &packages_only { 203 | if util::compare_names(&pack.0, &inst.0) && pack.1 == inst.1 { 204 | contains = true; 205 | break; 206 | } 207 | } 208 | 209 | for name in dont_uninstall { 210 | if util::compare_names(name, &inst.0) { 211 | contains = true; 212 | break; 213 | } 214 | } 215 | 216 | !contains 217 | }) 218 | .collect(); 219 | 220 | for (name, version) in &to_uninstall { 221 | // todo: Deal with renamed. Currently won't work correctly with them. 222 | install::uninstall(name, version, &paths.lib) 223 | } 224 | 225 | for ((name, version), rename) in &to_install { 226 | let data = 227 | res::get_warehouse_release(name, version).expect("Problem getting warehouse data"); 228 | 229 | let (best_release, package_type) = 230 | util::find_best_release(&data, name, version, os, python_vers); 231 | 232 | // Powershell doesn't like emojis 233 | // todo format literal issues, so repeating this whole statement. 234 | #[cfg(target_os = "windows")] 235 | util::print_color_(&format!("Installing {}", &name), Color::Cyan); 236 | #[cfg(target_os = "linux")] 237 | util::print_color_(&format!("⬇ Installing {}", &name), Color::Cyan); 238 | #[cfg(target_os = "macos")] 239 | util::print_color_(&format!("⬇ Installing {}", &name), Color::Cyan); 240 | println!(" {} ...", &version.to_string_color()); 241 | 242 | if install::download_and_install_package( 243 | name, 244 | version, 245 | &best_release.url, 246 | &best_release.filename, 247 | &best_release.digests.sha256, 248 | paths, 249 | package_type, 250 | rename, 251 | ) 252 | .is_err() 253 | { 254 | abort("Problem downloading packages"); 255 | } 256 | } 257 | // Perform renames after all packages are installed, or we may attempt to rename a package 258 | // we haven't yet installed. 259 | for ((name, version), rename) in &to_install { 260 | if let Some((id, new)) = rename { 261 | // Rename in the renamed package 262 | 263 | let renamed_path = &paths.lib.join(util::standardize_name(new)); 264 | 265 | util::wait_for_dirs(&[renamed_path.clone()]).expect("Problem creating renamed path"); 266 | install::rename_package_files(renamed_path, name, new); 267 | 268 | // Rename in the parent calling the renamed package. // todo: Multiple parents? 269 | let parent = lock_packs 270 | .iter() 271 | .find(|lp| lp.id == *id) 272 | .expect("Can't find parent calling renamed package"); 273 | install::rename_package_files( 274 | &paths.lib.join(util::standardize_name(&parent.name)), 275 | name, 276 | new, 277 | ); 278 | 279 | // todo: Handle this more generally, in case we don't have proper semver dist-info paths. 280 | install::rename_metadata( 281 | &paths 282 | .lib 283 | .join(&format!("{}-{}.dist-info", name, version.to_string())), 284 | name, 285 | new, 286 | ); 287 | } 288 | } 289 | } 290 | 291 | fn already_locked(locked: &[Package], name: &str, constraints: &[Constraint]) -> bool { 292 | let mut result = true; 293 | for constr in constraints.iter() { 294 | if !locked 295 | .iter() 296 | .any(|p| util::compare_names(&p.name, name) && constr.is_compatible(&p.version)) 297 | { 298 | result = false; 299 | break; 300 | } 301 | } 302 | result 303 | } 304 | 305 | fn parse_lockpack_rename(rename: &str) -> (u32, String) { 306 | let re = Regex::new(r"^(\d+)\s(.*)$").unwrap(); 307 | let caps = re 308 | .captures(rename) 309 | .expect("Problem reading lock file rename"); 310 | 311 | let id = caps.get(1).unwrap().as_str().parse::().unwrap(); 312 | let name = caps.get(2).unwrap().as_str().to_owned(); 313 | 314 | (id, name) 315 | } 316 | -------------------------------------------------------------------------------- /src/util/os.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use regex::Regex; 4 | use serde::Deserialize; 5 | 6 | use crate::dep_types::DependencyError; 7 | 8 | #[derive(Copy, Clone, Debug, Deserialize, PartialEq)] 9 | /// Used to determine which version of a binary package to download. Assume 64-bit. 10 | pub enum Os { 11 | Linux32, 12 | Linux, 13 | Windows32, 14 | Windows, 15 | // Mac32, 16 | Mac, 17 | Any, 18 | } 19 | 20 | impl FromStr for Os { 21 | type Err = DependencyError; 22 | 23 | fn from_str(s: &str) -> Result { 24 | let re_linux32 = Regex::new(r"(many)?linux.*i686").unwrap(); 25 | let re_linux = Regex::new(r"((many)?linux.*|cygwin|(open)?bsd6*)").unwrap(); 26 | let re_win = Regex::new(r"^win(dows|_amd64)?").unwrap(); 27 | let re_mac = Regex::new(r"(macosx.*|darwin|.*mac.*)").unwrap(); 28 | 29 | Ok(match s { 30 | x if re_linux32.is_match(x) => Self::Linux32, 31 | x if re_linux.is_match(x) => Self::Linux, 32 | "win32" => Self::Windows32, 33 | x if re_win.is_match(x) => Self::Windows, 34 | x if re_mac.is_match(x) => Self::Mac, 35 | "any" => Self::Any, 36 | _ => { 37 | return Err(DependencyError::new(&format!("Problem parsing Os: {}", s))); 38 | } 39 | }) 40 | } 41 | } 42 | 43 | pub const fn get_os() -> Os { 44 | #[cfg(target_os = "windows")] 45 | return Os::Windows; 46 | #[cfg(target_os = "linux")] 47 | return Os::Linux; 48 | #[cfg(target_os = "macos")] 49 | return Os::Mac; 50 | } 51 | -------------------------------------------------------------------------------- /src/util/paths.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | pub fn pyflow_path() -> PathBuf { 4 | directories::BaseDirs::new() 5 | .expect("Problem finding base directory") 6 | .data_dir() 7 | .to_owned() 8 | .join("pyflow") 9 | } 10 | 11 | pub fn dep_cache_path(pyflow_path: &Path) -> PathBuf { 12 | pyflow_path.join("dependency_cache") 13 | } 14 | 15 | pub fn script_env_path(pyflow_path: &Path) -> PathBuf { 16 | pyflow_path.join("script_envs") 17 | } 18 | 19 | pub fn git_path(pyflow_path: &Path) -> PathBuf { 20 | pyflow_path.join("git") 21 | } 22 | 23 | pub fn get_paths() -> (PathBuf, PathBuf, PathBuf, PathBuf) { 24 | let pyflow_path = pyflow_path(); 25 | let dep_cache_path = dep_cache_path(&pyflow_path); 26 | let script_env_path = script_env_path(&pyflow_path); 27 | let git_path = git_path(&pyflow_path); 28 | (pyflow_path, dep_cache_path, script_env_path, git_path) 29 | } 30 | -------------------------------------------------------------------------------- /src/util/prompts.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | io::{self, Write}, 4 | }; 5 | 6 | use termcolor::Color; 7 | 8 | use crate::{ 9 | dep_types::Version, 10 | util::{abort, default_python, fallible_v_parse, print_color}, 11 | }; 12 | 13 | /// Ask the user what Python version to use. 14 | pub fn py_vers() -> Version { 15 | print_color( 16 | "Please enter the Python version for this project: (eg: 3.8)", 17 | Color::Magenta, 18 | ); 19 | let default_ver = default_python(); 20 | print!("Default [{}]:", default_ver); 21 | std::io::stdout().flush().unwrap(); 22 | let mut input = String::new(); 23 | io::stdin() 24 | .read_line(&mut input) 25 | .expect("Unable to read user input for version"); 26 | 27 | input.pop(); // Remove trailing newline. 28 | let input = input.replace("\n", "").replace("\r", ""); 29 | if !input.is_empty() { 30 | fallible_v_parse(&input) 31 | } else { 32 | default_ver 33 | } 34 | } 35 | 36 | /// A generic prompt function, where the user selects from a list 37 | pub fn list( 38 | init_msg: &str, 39 | type_: &str, 40 | items: &[(String, T)], 41 | show_item: bool, 42 | ) -> (String, T) { 43 | print_color(init_msg, Color::Magenta); 44 | for (i, (name, content)) in items.iter().enumerate() { 45 | if show_item { 46 | println!("{}: {}: {}", i + 1, name, content.to_string()) 47 | } else { 48 | println!("{}: {}", i + 1, name) 49 | } 50 | } 51 | 52 | let mut mapping = HashMap::new(); 53 | for (i, item) in items.iter().enumerate() { 54 | mapping.insert(i + 1, item); 55 | } 56 | 57 | let mut input = String::new(); 58 | io::stdin() 59 | .read_line(&mut input) 60 | .expect("Problem reading input"); 61 | 62 | let input = input 63 | .chars() 64 | .next() 65 | .expect("Problem parsing input") 66 | .to_string() 67 | .parse::(); 68 | 69 | let input = if let Ok(ip) = input { 70 | ip 71 | } else { 72 | abort("Please try again; enter a number like 1 or 2 .") 73 | }; 74 | 75 | let (name, content) = if let Some(r) = mapping.get(&input) { 76 | r 77 | } else { 78 | abort(&format!( 79 | "Can't find the {} associated with that number. Is it in the list above?", 80 | type_ 81 | )) 82 | }; 83 | 84 | (name.to_string(), content.clone()) 85 | } 86 | -------------------------------------------------------------------------------- /update_version.py: -------------------------------------------------------------------------------- 1 | # A script to update the version in config files. 2 | import re 3 | import sys 4 | 5 | vers = sys.argv[1] 6 | 7 | 8 | def helper(filename: str, startswith: str, quotes: bool): 9 | data = "" 10 | with open(filename) as f: 11 | for line in f.readlines(): 12 | if line.startswith(startswith): 13 | vers2 = f'"{vers}"' if quotes else vers 14 | data += startswith + vers2 + "\n" 15 | else: 16 | data += line 17 | with open(filename, 'w') as f: 18 | f.write(data) 19 | 20 | 21 | def main(): 22 | helper('Cargo.toml', "version = ", True) 23 | helper('snapcraft.yaml', "version: ", False) 24 | 25 | data = "" 26 | with open('README.md') as f: 27 | for line in f.readlines(): 28 | line = re.sub(r'0\.\d\.(\d{1,3})', vers, line) 29 | data += line 30 | 31 | with open('README.md', 'w') as f: 32 | f.write(data) 33 | 34 | print(f"Updated version to {vers}") 35 | 36 | 37 | main() 38 | -------------------------------------------------------------------------------- /wix/License.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Arial;}{\f1\fnil\fcharset0 Courier New;}} 2 | {\*\generator Riched20 10.0.15063}\viewkind4\uc1 3 | \pard\sa180\fs24\lang9 Copyright (c) 2019 David O'Connor\par 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par 6 | \f1 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\f0\par 7 | } 8 | 9 | -------------------------------------------------------------------------------- /wix/main.wxs: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 42 | 43 | 53 | 54 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 71 | 78 | 79 | 80 | 81 | 89 | 90 | 91 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 112 | 116 | 117 | 118 | 119 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 152 | 1 153 | 1 154 | 155 | 156 | 157 | 161 | 162 | 163 | 164 | 172 | 173 | 174 | 175 | 183 | 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /wix/pyflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.5", 3 | "homepage": "https://github.com/David-OConnor/pyflow", 4 | "description": "A modern Python installation and dependency manager.", 5 | "license": "MIT", 6 | "url": "https://github.com/David-OConnor/pyflow/releases/download/0.1.5/pyflow.zip", 7 | "hash": "fc6bad25dc84ba19e887ca7fab4ce6de92be70a366525c14d116eadc7af388b6", 8 | "bin": "pyflow.exe", 9 | "checkver": "github", 10 | "autoupdate": { 11 | "url": "https://github.com/David-OConnor/pyflow/releases/download/$version/pyflow.zip" 12 | } 13 | } 14 | --------------------------------------------------------------------------------