├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── feature.yml │ └── question.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── labels.yml ├── release-drafter.yml └── workflows │ ├── CI.yml │ ├── labeler.yml │ └── release_drafter.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .tag.toml ├── LICENSE ├── README.md ├── docs ├── commands │ ├── bug.md │ ├── checkout.md │ ├── config.md │ ├── find.md │ ├── gh.md │ ├── info.md │ ├── keep.md │ ├── new.md │ ├── pull.md │ ├── remove.md │ └── show.md ├── config.md ├── contributing │ ├── code_of_conduct.md │ └── contributing.md ├── css │ ├── custom.css │ └── termynal.css ├── img │ ├── checkout_local.svg │ ├── checkout_remote.svg │ ├── docs.svg │ ├── help.svg │ ├── logo.png │ ├── new.svg │ ├── new_cookie.svg │ ├── new_venv.svg │ ├── pip_install.svg │ ├── pipx_install.svg │ ├── setup.svg │ ├── show_diff.svg │ └── show_local.svg ├── index.md └── js │ ├── custom.js │ └── termynal.js ├── mkdocs.yml ├── noxfile.py ├── pyproject.toml ├── src └── pytoil │ ├── __init__.py │ ├── __main__.py │ ├── api │ ├── __init__.py │ ├── api.py │ └── queries.py │ ├── cli │ ├── __init__.py │ ├── bug.py │ ├── checkout.py │ ├── config.py │ ├── docs.py │ ├── find.py │ ├── gh.py │ ├── info.py │ ├── keep.py │ ├── new.py │ ├── printer.py │ ├── pull.py │ ├── remove.py │ ├── root.py │ ├── show.py │ └── utils.py │ ├── config │ ├── __init__.py │ ├── config.py │ └── defaults.py │ ├── editor │ ├── __init__.py │ └── editor.py │ ├── environments │ ├── __init__.py │ ├── base.py │ ├── conda.py │ ├── flit.py │ ├── poetry.py │ ├── reqs.py │ └── virtualenv.py │ ├── exceptions.py │ ├── git │ ├── __init__.py │ └── git.py │ ├── py.typed │ ├── repo │ ├── __init__.py │ └── repo.py │ └── starters │ ├── __init__.py │ ├── base.py │ ├── go.py │ ├── python.py │ └── rust.py ├── tests ├── __init__.py ├── cli │ ├── __init__.py │ └── test_root.py ├── conftest.py ├── environments │ ├── __init__.py │ ├── test_conda.py │ ├── test_flit.py │ ├── test_poetry.py │ ├── test_requirements.py │ └── test_virtualenv.py ├── starters │ ├── __init__.py │ ├── test_go.py │ ├── test_python.py │ └── test_rust.py ├── test_api.py ├── test_config.py ├── test_editor.py ├── test_git.py └── test_repo.py └── uv.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: File a bug/issue 3 | title: "" 4 | labels: 5 | - bug 6 | 7 | body: 8 | - type: checkboxes 9 | attributes: 10 | label: Is there an existing issue for this? 11 | description: Please search to see if an issue already exists for the bug you encountered. 12 | options: 13 | - label: I have searched the existing issues 14 | required: true 15 | 16 | - type: textarea 17 | attributes: 18 | label: Current Behavior 19 | description: A concise description of what you're experiencing. 20 | validations: 21 | required: false 22 | 23 | - type: textarea 24 | attributes: 25 | label: Expected Behavior 26 | description: A concise description of what you expected to happen. 27 | validations: 28 | required: false 29 | 30 | - type: textarea 31 | attributes: 32 | label: Steps To Reproduce 33 | description: Steps to reproduce the behavior. 34 | placeholder: | 35 | 1. In this environment... 36 | 2. With this config... 37 | 3. Run '...' 38 | 4. See error... 39 | validations: 40 | required: false 41 | 42 | - type: textarea 43 | attributes: 44 | label: Environment 45 | description: | 46 | Please describe your execution environment providing as much detail as possible 47 | render: Markdown 48 | validations: 49 | required: false 50 | 51 | - type: textarea 52 | attributes: 53 | label: Anything else? 54 | description: | 55 | Links? References? Anything that will give us more context about the issue you are encountering! 56 | 57 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 58 | validations: 59 | required: false 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Request a new feature or enhancement 3 | title: "<title>" 4 | labels: 5 | - feature 6 | 7 | body: 8 | - type: checkboxes 9 | attributes: 10 | label: Is there an existing issue for this? 11 | description: Please search to see if an issue already exists for the feature you want. 12 | options: 13 | - label: I have searched the existing issues 14 | required: true 15 | 16 | - type: textarea 17 | attributes: 18 | label: How would this feature be useful? 19 | description: Describe any use cases this solves or frustrations it alleviates. 20 | validations: 21 | required: false 22 | 23 | - type: textarea 24 | attributes: 25 | label: Describe the solution you'd like 26 | description: If you have an idea on how to do this, let us know here! 27 | validations: 28 | required: false 29 | 30 | - type: textarea 31 | attributes: 32 | label: Describe alternatives you've considered 33 | description: If there's some workaround or alternative solutions, let us know here! 34 | validations: 35 | required: false 36 | 37 | - type: textarea 38 | attributes: 39 | label: Anything else? 40 | description: Any other relevant information or background. 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: ❓ Question 2 | description: Ask something about the project 3 | title: "<title>" 4 | labels: 5 | - question 6 | 7 | body: 8 | - type: checkboxes 9 | attributes: 10 | label: Is there an existing issue for this? 11 | description: Please search to see if an issue already exists for the question you want to ask. 12 | options: 13 | - label: I have searched the existing issues 14 | required: true 15 | 16 | - type: textarea 17 | attributes: 18 | label: Description 19 | description: Ask your question here. 20 | placeholder: How can I...? Is it possible to...? 21 | validations: 22 | required: false 23 | 24 | - type: textarea 25 | attributes: 26 | label: Anything else? 27 | description: Any other relevant information or background. 28 | validations: 29 | required: false 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | <!-- Describe your changes in detail here, if it closes an open issue, include "Closes #<issue>" --> 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | 9 | - package-ecosystem: pip 10 | directory: / 11 | rebase-strategy: auto 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: breaking 2 | description: Breaking Changes 3 | color: bfd4f2 4 | 5 | - name: bug 6 | description: Something isn't working 7 | color: d73a4a 8 | 9 | - name: build 10 | description: Build System and Dependencies 11 | color: bfdadc 12 | 13 | - name: ci 14 | description: Continuous Integration 15 | color: 4a97d6 16 | 17 | - name: dependencies 18 | description: Pull requests that update a dependency file 19 | color: 0366d6 20 | 21 | - name: documentation 22 | description: Improvements or additions to documentation 23 | color: 0075ca 24 | 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | 29 | - name: feature 30 | description: New feature or request 31 | color: a2eeef 32 | 33 | - name: good first issue 34 | description: Good for newcomers 35 | color: 7057ff 36 | 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | 41 | - name: invalid 42 | description: This doesn't seem right 43 | color: e4e669 44 | 45 | - name: performance 46 | description: Performance 47 | color: "016175" 48 | 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | 53 | - name: refactoring 54 | description: Refactoring 55 | color: ef67c4 56 | 57 | - name: removal 58 | description: Removals and Deprecations 59 | color: 9ae7ea 60 | 61 | - name: style 62 | description: Style 63 | color: c120e5 64 | 65 | - name: chore 66 | description: General project admin 67 | color: cfd3d7 68 | 69 | - name: testing 70 | description: Testing 71 | color: b1fc6f 72 | 73 | - name: wontfix 74 | description: This will not be worked on 75 | color: ffffff 76 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | 4 | categories: 5 | - title: ":boom: Breaking Changes" 6 | label: breaking 7 | 8 | - title: ":rocket: Features" 9 | labels: 10 | - enhancement 11 | - feature 12 | 13 | - title: ":fire: Removals and Deprecations" 14 | label: removal 15 | 16 | - title: ":beetle: Fixes" 17 | label: bug 18 | 19 | - title: ":racehorse: Performance" 20 | label: performance 21 | 22 | - title: ":rotating_light: Testing" 23 | label: testing 24 | 25 | - title: ":construction_worker: Continuous Integration" 26 | label: ci 27 | 28 | - title: ":books: Documentation" 29 | label: documentation 30 | 31 | - title: ":hammer: Refactoring" 32 | label: refactoring 33 | 34 | - title: ":lipstick: Style" 35 | label: style 36 | 37 | - title: ":package: Dependencies" 38 | labels: 39 | - dependencies 40 | - build 41 | 42 | template: | 43 | ## Changes 44 | $CHANGES 45 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - v* 10 | 11 | concurrency: 12 | group: ${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | DEFAULT_PYTHON: "3.12" 17 | 18 | permissions: read-all 19 | 20 | jobs: 21 | test: 22 | name: Test 23 | runs-on: ${{ matrix.os }} 24 | 25 | strategy: 26 | matrix: 27 | os: [ubuntu-latest, macos-latest, windows-latest] 28 | python-version: ["3.9", "3.10", "3.11", "3.12"] 29 | 30 | steps: 31 | - name: Checkout Code 32 | uses: actions/checkout@v4 33 | 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | 39 | - name: Install Dependencies 40 | run: python3 -m pip install --upgrade pip nox[uv] 41 | 42 | - name: Run Tests and Coverage 43 | run: nox --non-interactive --session test 44 | 45 | codecov: 46 | name: Codecov 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - name: Checkout Code 51 | uses: actions/checkout@v4 52 | 53 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ env.DEFAULT_PYTHON }} 57 | 58 | - name: Install Dependencies 59 | run: python3 -m pip install --upgrade pip nox[uv] 60 | 61 | - name: Run Tests and Coverage 62 | run: nox --non-interactive --session test -- cover 63 | 64 | - name: Upload Coverage to Codecov 65 | uses: codecov/codecov-action@v5 66 | with: 67 | files: ./coverage.xml 68 | 69 | docs: 70 | name: Docs 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - name: Checkout Code 75 | uses: actions/checkout@v4 76 | 77 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 78 | uses: actions/setup-python@v5 79 | with: 80 | python-version: ${{ env.DEFAULT_PYTHON }} 81 | 82 | - name: Install Dependencies 83 | run: python3 -m pip install --upgrade pip nox[uv] 84 | 85 | - name: Build Docs 86 | run: nox --non-interactive --session docs 87 | 88 | publish-docs: 89 | needs: docs 90 | name: Publish Docs 91 | runs-on: ubuntu-latest 92 | permissions: 93 | contents: write 94 | 95 | # Only publish docs automatically on new release 96 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 97 | 98 | steps: 99 | - name: Checkout Code 100 | uses: actions/checkout@v4 101 | 102 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 103 | uses: actions/setup-python@v5 104 | with: 105 | python-version: ${{ env.DEFAULT_PYTHON }} 106 | 107 | - name: Install Dependencies 108 | run: python3 -m pip install --upgrade pip nox[uv] 109 | 110 | - name: Deploy Docs to GitHub Pages 111 | env: 112 | # Use the built in CI GITHUB_TOKEN 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | run: nox --non-interactive --session docs -- deploy 115 | 116 | release: 117 | name: Release 118 | runs-on: ubuntu-latest 119 | needs: 120 | - test 121 | - docs 122 | - publish-docs 123 | - codecov 124 | permissions: 125 | contents: write 126 | pull-requests: read 127 | 128 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 129 | 130 | steps: 131 | - name: Checkout Code 132 | uses: actions/checkout@v4 133 | with: 134 | fetch-depth: 0 135 | 136 | - name: Fetch Existing Tags 137 | run: git fetch --force --tags 138 | 139 | - name: Parse Release Version 140 | id: version 141 | run: | 142 | VERSION=${GITHUB_REF#refs/tags/v} 143 | echo "version=$VERSION" >> $GITHUB_OUTPUT 144 | 145 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 146 | uses: actions/setup-python@v5 147 | with: 148 | python-version: ${{ env.DEFAULT_PYTHON }} 149 | 150 | - name: Install Dependencies 151 | run: python3 -m pip install --upgrade pip nox[uv] 152 | 153 | - name: Build sdist and wheel 154 | run: nox --non-interactive --session build 155 | 156 | - name: Publish Draft Release 157 | uses: release-drafter/release-drafter@v6 158 | with: 159 | version: ${{ steps.version.outputs.version }} 160 | publish: true 161 | env: 162 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 163 | 164 | - name: Upload Dist 165 | env: 166 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 167 | run: gh release upload ${{ github.ref_name }} dist/* --repo ${{ github.repository }} 168 | 169 | - name: Publish Distribution to PyPI 170 | uses: pypa/gh-action-pypi-publish@release/v1 171 | with: 172 | user: __token__ 173 | password: ${{ secrets.PYPI_API_TOKEN }} 174 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | labeler: 12 | name: Labeler 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | issues: write 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | 22 | - name: Run Labeler 23 | uses: crazy-max/ghaction-github-labeler@v5 24 | with: 25 | skip-delete: false 26 | -------------------------------------------------------------------------------- /.github/workflows/release_drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - reopened 11 | - synchronize 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | draft_release: 17 | name: Draft Release 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: write 21 | pull-requests: read 22 | 23 | steps: 24 | - name: Run Release Drafter 25 | uses: release-drafter/release-drafter@v6 26 | env: 27 | GITHUB_TOKEN: ${{ github.token }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,jetbrains+all,python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,visualstudiocode,jetbrains+all,python 3 | 4 | ### JetBrains+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # AWS User-specific 16 | .idea/**/aws.xml 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/jarRepositories.xml 41 | # .idea/modules.xml 42 | # .idea/*.iml 43 | # .idea/modules 44 | # *.iml 45 | # *.ipr 46 | 47 | # CMake 48 | cmake-build-*/ 49 | 50 | # Mongo Explorer plugin 51 | .idea/**/mongoSettings.xml 52 | 53 | # File-based project format 54 | *.iws 55 | 56 | # IntelliJ 57 | out/ 58 | 59 | # mpeltonen/sbt-idea plugin 60 | .idea_modules/ 61 | 62 | # JIRA plugin 63 | atlassian-ide-plugin.xml 64 | 65 | # Cursive Clojure plugin 66 | .idea/replstate.xml 67 | 68 | # SonarLint plugin 69 | .idea/sonarlint/ 70 | 71 | # Crashlytics plugin (for Android Studio and IntelliJ) 72 | com_crashlytics_export_strings.xml 73 | crashlytics.properties 74 | crashlytics-build.properties 75 | fabric.properties 76 | 77 | # Editor-based Rest Client 78 | .idea/httpRequests 79 | 80 | # Android studio 3.1+ serialized cache file 81 | .idea/caches/build_file_checksums.ser 82 | 83 | ### JetBrains+all Patch ### 84 | # Ignore everything but code style settings and run configurations 85 | # that are supposed to be shared within teams. 86 | 87 | .idea/* 88 | 89 | !.idea/codeStyles 90 | !.idea/runConfigurations 91 | 92 | ### Linux ### 93 | *~ 94 | 95 | # temporary files which can be created if a process still has a handle open of a deleted file 96 | .fuse_hidden* 97 | 98 | # KDE directory preferences 99 | .directory 100 | 101 | # Linux trash folder which might appear on any partition or disk 102 | .Trash-* 103 | 104 | # .nfs files are created when an open file is removed but is still being accessed 105 | .nfs* 106 | 107 | ### macOS ### 108 | # General 109 | .DS_Store 110 | .AppleDouble 111 | .LSOverride 112 | 113 | # Icon must end with two \r 114 | Icon 115 | 116 | 117 | # Thumbnails 118 | ._* 119 | 120 | # Files that might appear in the root of a volume 121 | .DocumentRevisions-V100 122 | .fseventsd 123 | .Spotlight-V100 124 | .TemporaryItems 125 | .Trashes 126 | .VolumeIcon.icns 127 | .com.apple.timemachine.donotpresent 128 | 129 | # Directories potentially created on remote AFP share 130 | .AppleDB 131 | .AppleDesktop 132 | Network Trash Folder 133 | Temporary Items 134 | .apdisk 135 | 136 | ### macOS Patch ### 137 | # iCloud generated files 138 | *.icloud 139 | 140 | ### Python ### 141 | # Byte-compiled / optimized / DLL files 142 | __pycache__/ 143 | *.py[cod] 144 | *$py.class 145 | 146 | # C extensions 147 | *.so 148 | 149 | # Distribution / packaging 150 | .Python 151 | build/ 152 | develop-eggs/ 153 | dist/ 154 | downloads/ 155 | eggs/ 156 | .eggs/ 157 | lib/ 158 | lib64/ 159 | parts/ 160 | sdist/ 161 | var/ 162 | wheels/ 163 | share/python-wheels/ 164 | *.egg-info/ 165 | .installed.cfg 166 | *.egg 167 | MANIFEST 168 | 169 | # PyInstaller 170 | # Usually these files are written by a python script from a template 171 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 172 | *.manifest 173 | *.spec 174 | 175 | # Installer logs 176 | pip-log.txt 177 | pip-delete-this-directory.txt 178 | 179 | # Unit test / coverage reports 180 | htmlcov/ 181 | .tox/ 182 | .nox/ 183 | .coverage 184 | .coverage.* 185 | .cache 186 | nosetests.xml 187 | coverage.xml 188 | *.cover 189 | *.py,cover 190 | .hypothesis/ 191 | .pytest_cache/ 192 | cover/ 193 | 194 | # Translations 195 | *.mo 196 | *.pot 197 | 198 | # Django stuff: 199 | *.log 200 | local_settings.py 201 | db.sqlite3 202 | db.sqlite3-journal 203 | 204 | # Flask stuff: 205 | instance/ 206 | .webassets-cache 207 | 208 | # Scrapy stuff: 209 | .scrapy 210 | 211 | # Sphinx documentation 212 | docs/_build/ 213 | 214 | # PyBuilder 215 | .pybuilder/ 216 | target/ 217 | 218 | # Jupyter Notebook 219 | .ipynb_checkpoints 220 | 221 | # IPython 222 | profile_default/ 223 | ipython_config.py 224 | 225 | # pyenv 226 | # For a library or package, you might want to ignore these files since the code is 227 | # intended to run in multiple environments; otherwise, check them in: 228 | # .python-version 229 | 230 | # pipenv 231 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 232 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 233 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 234 | # install all needed dependencies. 235 | #Pipfile.lock 236 | 237 | # poetry 238 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 239 | # This is especially recommended for binary packages to ensure reproducibility, and is more 240 | # commonly ignored for libraries. 241 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 242 | #poetry.lock 243 | 244 | # pdm 245 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 246 | #pdm.lock 247 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 248 | # in version control. 249 | # https://pdm.fming.dev/#use-with-ide 250 | .pdm.toml 251 | 252 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 253 | __pypackages__/ 254 | 255 | # Celery stuff 256 | celerybeat-schedule 257 | celerybeat.pid 258 | 259 | # SageMath parsed files 260 | *.sage.py 261 | 262 | # Environments 263 | .env 264 | .venv 265 | env/ 266 | venv/ 267 | ENV/ 268 | env.bak/ 269 | venv.bak/ 270 | 271 | # Spyder project settings 272 | .spyderproject 273 | .spyproject 274 | 275 | # Rope project settings 276 | .ropeproject 277 | 278 | # mkdocs documentation 279 | /site 280 | 281 | # mypy 282 | .mypy_cache/ 283 | .dmypy.json 284 | dmypy.json 285 | 286 | # Pyre type checker 287 | .pyre/ 288 | 289 | # pytype static type analyzer 290 | .pytype/ 291 | 292 | # Cython debug symbols 293 | cython_debug/ 294 | 295 | # PyCharm 296 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 297 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 298 | # and can be added to the global gitignore or merged into this file. For a more nuclear 299 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 300 | #.idea/ 301 | 302 | ### Python Patch ### 303 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 304 | poetry.toml 305 | 306 | # ruff 307 | .ruff_cache/ 308 | 309 | ### VisualStudioCode ### 310 | .vscode/* 311 | !.vscode/settings.json 312 | !.vscode/tasks.json 313 | !.vscode/launch.json 314 | !.vscode/extensions.json 315 | !.vscode/*.code-snippets 316 | 317 | # Local History for Visual Studio Code 318 | .history/ 319 | 320 | # Built Visual Studio Code Extensions 321 | *.vsix 322 | 323 | ### VisualStudioCode Patch ### 324 | # Ignore all local history of files 325 | .history 326 | .ionide 327 | 328 | ### Windows ### 329 | # Windows thumbnail cache files 330 | Thumbs.db 331 | Thumbs.db:encryptable 332 | ehthumbs.db 333 | ehthumbs_vista.db 334 | 335 | # Dump file 336 | *.stackdump 337 | 338 | # Folder config file 339 | [Dd]esktop.ini 340 | 341 | # Recycle Bin used on file shares 342 | $RECYCLE.BIN/ 343 | 344 | # Windows Installer files 345 | *.cab 346 | *.msi 347 | *.msix 348 | *.msm 349 | *.msp 350 | 351 | # Windows shortcuts 352 | *.lnk 353 | 354 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,jetbrains+all,python 355 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: Update pre-commit hooks" 3 | autofix_commit_msg: "style: Pre-commit fixes" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/tox-dev/pyproject-fmt 18 | rev: "v2.5.0" 19 | hooks: 20 | - id: pyproject-fmt 21 | 22 | - repo: https://github.com/astral-sh/ruff-pre-commit 23 | rev: v0.8.6 24 | hooks: 25 | - id: ruff 26 | args: 27 | - --fix 28 | - id: ruff-format 29 | 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v1.14.1 32 | hooks: 33 | - id: mypy 34 | additional_dependencies: 35 | - pydantic 36 | - types-PyYAML 37 | args: 38 | - --config-file 39 | - pyproject.toml 40 | 41 | - repo: https://github.com/codespell-project/codespell 42 | rev: v2.3.0 43 | hooks: 44 | - id: codespell 45 | additional_dependencies: 46 | - tomli 47 | -------------------------------------------------------------------------------- /.tag.toml: -------------------------------------------------------------------------------- 1 | version = '0.41.0' 2 | 3 | [git] 4 | default-branch = 'main' 5 | message-template = 'Bump version {{.Current}} -> {{.Next}}' 6 | tag-template = 'v{{.Next}}' 7 | 8 | [[file]] 9 | path = 'pyproject.toml' 10 | search = 'version = "{{.Current}}"' 11 | 12 | [[file]] 13 | path = 'src/pytoil/__init__.py' 14 | search = '__version__ = "{{.Current}}"' 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/logo.png) 2 | 3 | [![License](https://img.shields.io/github/license/FollowTheProcess/pytoil)](https://github.com/FollowTheProcess/pytoil) 4 | [![PyPI](https://img.shields.io/pypi/v/pytoil.svg?logo=python)](https://pypi.python.org/pypi/pytoil) 5 | [![GitHub](https://img.shields.io/github/v/release/FollowTheProcess/pytoil?logo=github&sort=semver)](https://github.com/FollowTheProcess/pytoil) 6 | [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) 7 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 8 | [![CI](https://github.com/FollowTheProcess/pytoil/workflows/CI/badge.svg)](https://github.com/FollowTheProcess/pytoil/actions?query=workflow%3ACI) 9 | [![codecov](https://codecov.io/gh/FollowTheProcess/pytoil/branch/main/graph/badge.svg?token=OLMR2P3J6N)](https://codecov.io/gh/FollowTheProcess/pytoil) 10 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/FollowTheProcess/pytoil/main.svg)](https://results.pre-commit.ci/latest/github/FollowTheProcess/pytoil/main) 11 | [![Downloads](https://static.pepy.tech/personalized-badge/pytoil?period=total&units=international_system&left_color=grey&right_color=green&left_text=Downloads)](https://pepy.tech/project/pytoil) 12 | 13 | > ***toil:*** 14 | > *"Long, strenuous or fatiguing labour"* 15 | 16 | * **Source Code**: [https://github.com/FollowTheProcess/pytoil](https://github.com/FollowTheProcess/pytoil) 17 | 18 | * **Documentation**: [https://FollowTheProcess.github.io/pytoil/](https://FollowTheProcess.github.io/pytoil/) 19 | 20 | ![help](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/help.svg) 21 | 22 | > [!WARNING] 23 | > `pytoil` is no longer under active maintenance. I barely write any python any more, I haven't made changes to pytoil in a while and I'm focussed on other projects 🧠 24 | 25 | ## What is it? 26 | 27 | *pytoil is a small, helpful CLI to take the toil out of software development!* 28 | 29 | `pytoil` is a handy tool that helps you stay on top of all your projects, remote or local. It's primarily aimed at python developers but you could easily use it to manage any project! 30 | 31 | pytoil is: 32 | 33 | * Easy to use ✅ 34 | * Easy to configure ✅ 35 | * Safe (it won't edit your repos at all) ✅ 36 | * Snappy (it's asynchronous from the ground up and as much as possible is done concurrently, clone all your repos in seconds!) 💨 37 | * Useful! (I hope 😃) 38 | 39 | Say goodbye to janky bash scripts 👋🏻 40 | 41 | ## Background 42 | 43 | Like many developers I suspect, I quickly became bored of typing repeated commands to manage my projects, create virtual environments, install packages, fire off `cURL` snippets to check if I had a certain repo etc. 44 | 45 | So I wrote some shell functions to do some of this for me... 46 | 47 | And these shell functions grew and grew and grew. 48 | 49 | Until one day I saw that the file I kept these functions in was over 1000 lines of bash (a lot of `printf`'s so it wasn't all logic but still). And 1000 lines of bash is *waaaay* too much! 50 | 51 | And because I'd basically hacked it all together, it was **very** fragile. If a part of a function failed, it would just carry on and wreak havoc! I'd have to do `rm -rf all_my_projects`... I mean careful forensic investigation to fix it. 52 | 53 | So I decided to make a robust CLI with the proper error handling and testability of python, and here it is! 🎉 54 | 55 | ## Installation 56 | 57 | As pytoil is a CLI program, I'd recommend installing with [pipx]. 58 | 59 | ```shell 60 | pipx install pytoil 61 | ``` 62 | 63 | ![pipx-install](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/pipx_install.svg) 64 | 65 | You can always fall back to pip 66 | 67 | ![pip-install](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/pip_install.svg) 68 | 69 | pytoil will install everything it needs *in python* to work. However, it's full feature set can only be accessed if you have the following external dependencies: 70 | 71 | * [git] 72 | * [conda] (if you work with conda environments) 73 | * A directory-aware editor e.g. [VSCode] etc. (if you want to use pytoil to automatically open your projects for you) 74 | * [poetry] (if you want to create poetry environments) 75 | * [flit] (if you want to create flit environments) 76 | 77 | ## Quickstart 78 | 79 | `pytoil` is super easy to get started with. 80 | 81 | After you install pytoil, the first time you run it you'll get something like this. 82 | 83 | ![setup](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/setup.svg) 84 | 85 | If you say yes, pytoil will walk you through a few questions and fill out your config file with the values you enter. If you'd rather not do this interactively, just say no and it will instead put a default config file in the right place for you to edit later. 86 | 87 | Once you've configured it properly, you can do things like... 88 | 89 | #### See your local and remote projects 90 | 91 | ![show-local](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/show_local.svg) 92 | 93 | #### See which ones you have on GitHub, but not on your computer 94 | 95 | ![show-diff](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/show_diff.svg) 96 | 97 | #### Easily grab a project, regardless of where it is 98 | 99 | This project is available on your local machine... 100 | 101 | ![checkout-local](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/checkout_local.svg) 102 | 103 | This one is on GitHub... 104 | 105 | ![checkout-remote](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/checkout_remote.svg) 106 | 107 | #### Create a new project and virtual environment in one go 108 | 109 | ![new-venv](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/new_venv.svg) 110 | 111 | (And include custom packages, see the [docs]) 112 | 113 | #### And even do this from a [cookiecutter] template 114 | 115 | ![new-cookie](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/new_cookie.svg) 116 | 117 | And loads more! 118 | 119 | pytoil's CLI is designed such that if you don't specify any arguments, it won't do anything! just show you the `--help`. This is called being a 'well behaved' unix command line tool. 120 | 121 | This is true for any subcommand of pytoil so you won't accidentally break anything if you don't specify arguments 🎉 122 | 123 | And if you get truly stuck, you can quickly open pytoil's documentation with: 124 | 125 | ![docs](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/docs.svg) 126 | 127 | Check out the [docs] for more 💥 128 | 129 | ## Contributing 130 | 131 | `pytoil` is an open source project and, as such, welcomes contributions of all kinds 😃 132 | 133 | Your best bet is to check out the [contributing guide] in the docs! 134 | 135 | [pipx]: https://pipxproject.github.io/pipx/ 136 | [docs]: https://FollowTheProcess.github.io/pytoil/ 137 | [contributing guide]: https://followtheprocess.github.io/pytoil/contributing/contributing.html 138 | [git]: https://git-scm.com 139 | [conda]: https://docs.conda.io/en/latest/ 140 | [VSCode]: https://code.visualstudio.com 141 | [cookiecutter]: https://github.com/cookiecutter/cookiecutter 142 | [poetry]: https://python-poetry.org 143 | [flit]: https://flit.readthedocs.io 144 | -------------------------------------------------------------------------------- /docs/commands/bug.md: -------------------------------------------------------------------------------- 1 | # bug 2 | 3 | If you find a bug in pytoil, want to submit a feature request, or just have a question to ask, you can easily open up pytoil's issues page with `pytoil bug`. 4 | 5 | <div class="termy"> 6 | 7 | ```console 8 | $ pytoil bug 9 | 10 | Opening pytoil's issues in your browser... 11 | 12 | // Now you're at our issues page! 13 | ``` 14 | 15 | </div> 16 | -------------------------------------------------------------------------------- /docs/commands/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | The `config` subcommand is pytoil's programmatic access to it's own configuration file! Here you can get, show and get help about the configuration. 4 | 5 | ## Help 6 | 7 | <div class="termy"> 8 | 9 | ```console 10 | $ pytoil config --help 11 | 12 | Usage: pytoil config [OPTIONS] COMMAND [ARGS]... 13 | 14 | Interact with pytoil's configuration. 15 | 16 | The config command group allows you to get, show and explain pytoil's 17 | configuration. 18 | 19 | Options: 20 | --help Show this message and exit. 21 | 22 | Commands: 23 | edit Open pytoil's config file in $EDITOR. 24 | explain Print a list and description of pytoil config values. 25 | get Get the currently set value for a config key. 26 | show Show pytoil's config. 27 | ``` 28 | 29 | </div> 30 | 31 | ## Get 32 | 33 | `get` does what it says. It gets a valid config key-value pair from your file and shows it to you. Simple! 34 | 35 | <div class="termy"> 36 | 37 | ```console 38 | $ pytoil config get editor 39 | 40 | editor: code-insiders 41 | ``` 42 | 43 | </div> 44 | 45 | ## Show 46 | 47 | `show` is just a handy way of seeing what the current config is without having to go to the config file! 48 | 49 | <div class="termy"> 50 | 51 | ```console 52 | $ pytoil config show 53 | 54 | Key Value 55 | ───────────────────────────────────────────────────────────── 56 | projects_dir: /Users/tomfleet/Development 57 | token: skjdbakshbv82v27676cv 58 | username: FollowTheProcess 59 | editor: code-insiders 60 | conda_bin: mamba 61 | common_packages: ['black', 'mypy', 'isort', 'flake8'] 62 | git: True 63 | 64 | ``` 65 | 66 | </div> 67 | 68 | ## Edit 69 | 70 | `edit` simply opens up the pytoil config file in your $EDITOR so you can make any changes you like! 71 | 72 | <div class="termy"> 73 | 74 | ```console 75 | $pytoil config edit 76 | 77 | Opening ~/.pytoil.toml in your $EDITOR 78 | ``` 79 | 80 | </div> 81 | 82 | ## Explain 83 | 84 | The command `pytoil config explain` outputs a (hopefully) helpful description of the pytoil configuration schema. 85 | 86 | [pydantic]: https://pydantic-docs.helpmanual.io 87 | -------------------------------------------------------------------------------- /docs/commands/find.md: -------------------------------------------------------------------------------- 1 | # Find 2 | 3 | The `find` command lets you easily search for one of your projects (even if you can't exactly remember it's name 🤔). 4 | 5 | ## Help 6 | 7 | <div class="termy"> 8 | 9 | ```console 10 | $ pytoil find --help 11 | 12 | Usage: pytoil find [OPTIONS] PROJECT 13 | 14 | Quickly locate a project. 15 | 16 | The find command provides a fuzzy search for finding a project when you 17 | don't know where it is (local or on GitHub). 18 | 19 | It will perform a fuzzy search through all your local and remote projects, 20 | bring back the best matches and show you where they are. 21 | 22 | Useful if you have a lot of projects and you can't quite remember what the 23 | one you want is called! 24 | 25 | The "-l/--limit" flag can be used to alter the number of returned search 26 | results, but bare in mind that matches with sufficient match score are 27 | returned anyway so the results flag only limits the maximum number of 28 | results shown. 29 | 30 | Examples: 31 | 32 | $ pytoil find my 33 | 34 | $ pytoil find proj --limit 5 35 | 36 | Options: 37 | -l, --limit INTEGER Limit results to maximum number. [default: 5] 38 | --help Show this message and exit. 39 | ``` 40 | 41 | </div> 42 | 43 | ## Searching for Projects 44 | 45 | <div class="termy"> 46 | 47 | ```console 48 | // I swear it was called python... something 49 | $ pytoil find python 50 | 51 | 52 | Project Similarity Where 53 | ─────────────────────────────────────── 54 | py 90 Remote 55 | python-launcher 90 Remote 56 | 57 | ``` 58 | 59 | </div> 60 | 61 | What pytoil does here is it takes the argument you give it, fetches all your projects and does a fuzzy text match against 62 | all of them, wittles down the best matches and shows them to you (along with whether they are available locally or on GitHub). 63 | 64 | Isn't that useful! 🎉 65 | 66 | !!! info 67 | 68 | Under the hood, pytoil uses the excellent [thefuzz] library to do this, which implements the [Levenshtein distance] 69 | algorithm to find the best matches 🚀 70 | 71 | ## 404 - Project Not Found 72 | 73 | If `find` can't find a match in any of your projects, you'll get a helpful warning... 74 | 75 | <div class="termy"> 76 | 77 | ```console 78 | // Something that won't match 79 | $ pytoil find dingledangledongle 80 | 81 | ⚠ No matches found! 82 | ``` 83 | 84 | </div> 85 | 86 | [thefuzz]: https://github.com/seatgeek/thefuzz 87 | [Levenshtein distance]: https://en.wikipedia.org/wiki/Levenshtein_distance 88 | -------------------------------------------------------------------------------- /docs/commands/gh.md: -------------------------------------------------------------------------------- 1 | # gh 2 | 3 | Sometimes you just want to quickly go to the GitHub page for your project. Enter the incredibly simple `gh` command! 4 | 5 | <div class="termy"> 6 | 7 | ```console 8 | $ pytoil gh my_project 9 | 10 | Opening 'my_project' in your browser... 11 | 12 | // Now you're at the GitHub page for the project! 13 | ``` 14 | 15 | </div> 16 | 17 | ## PR's and Issues 18 | 19 | `gh` provides two flags to immediately open the pull requests or issues section of the specified repo. Knock yourself out! 20 | 21 | <div class="termy"> 22 | 23 | ```console 24 | $ pytoil gh my_project --help 25 | 26 | Usage: pytoil gh [OPTIONS] PROJECT 27 | 28 | Open one of your projects on GitHub. 29 | 30 | Given a project name (must exist on GitHub and be owned by you), 'gh' will 31 | open your browser and navigate to the project on GitHub. 32 | 33 | You can also use the "--issues" or "--prs" flags to immediately open up the 34 | repo's issues or pull requests page. 35 | 36 | Examples: 37 | 38 | $ pytoil gh my_project 39 | 40 | $ pytoil gh my_project --issues 41 | 42 | $ pytoil gh my_project --prs 43 | 44 | Options: 45 | -i, --issues Go to the issues page. 46 | -p, --prs Go to the pull requests page. 47 | --help Show this message and exit. 48 | ``` 49 | 50 | </div> 51 | -------------------------------------------------------------------------------- /docs/commands/info.md: -------------------------------------------------------------------------------- 1 | # Info 2 | 3 | Another easy one! `info` simply shows you some summary information about whatever project you tell it to. 4 | 5 | <div class="termy"> 6 | 7 | ```console 8 | // Let's get some info about pytoil 9 | $ pytoil info pytoil 10 | 11 | Info for pytoil: 12 | 13 | Key Value 14 | ──────────────────────────────────────────────────────────── 15 | Name: pytoil 16 | Description: CLI to automate the development workflow 🤖 17 | Created: 11 months ago 18 | Updated: 7 days ago 19 | Size: 6.4 MB 20 | License: Apache License 2.0 21 | Remote: True 22 | Local: True 23 | 24 | ``` 25 | 26 | </div> 27 | 28 | What happens here is pytoil uses the GitHub personal access token we talked about in [config] and hits the GitHub API to find out some basic information about the repo you pass to it :white_check_mark: 29 | 30 | pytoil will always prefer this way of doing it as we can get things like license information and description which is a bit more helpful to show. If however, the project you're asking for information about does not exist on GitHub yet, you'll still get some info back! 31 | 32 | <div class="termy"> 33 | 34 | ```console 35 | // Some project that's not on GitHub yet 36 | $ pytoil info my_local_project 37 | 38 | Info for testy: 39 | 40 | Key Value 41 | ─────────────────────────── 42 | Name: testy 43 | Created: 23 seconds ago 44 | Updated: 23 seconds ago 45 | Local: True 46 | Remote: False 47 | 48 | ``` 49 | 50 | </div> 51 | 52 | !!! note 53 | 54 | pytoil grabs this data from your operating system by using the `Path.stat()` method from [pathlib] :computer: 55 | 56 | [config]: ../config.md 57 | [pathlib]: https://docs.python.org/3/library/pathlib.html 58 | -------------------------------------------------------------------------------- /docs/commands/keep.md: -------------------------------------------------------------------------------- 1 | # Keep 2 | 3 | `keep` is effectively the opposite of [remove], it deletes everything **except** the projects you specify from your local projects directory. 4 | 5 | It is useful when you want to declutter your projects directory but don't want to pass lots of arguments to [remove], with `keep` you can tell pytoil the projects you want to keep, and it will remove everything else for you! 6 | 7 | ## Help 8 | 9 | <div class="termy"> 10 | 11 | ```console 12 | $ pytoil keep --help 13 | 14 | Usage: pytoil keep [OPTIONS] [PROJECTS]... 15 | 16 | Remove all but the specified projects. 17 | 18 | The keep command lets you delete all projects from your local projects 19 | directory whilst keeping the specified ones untouched. 20 | 21 | It is effectively the inverse of `pytoil remove`. 22 | 23 | As with most programmatic deleting, the directories are deleted instantly 24 | and not sent to trash. As such, pytoil will prompt you for confirmation 25 | before doing anything. 26 | 27 | The "--force/-f" flag can be used to force deletion without the confirmation 28 | prompt. Use with caution! 29 | 30 | Examples: 31 | 32 | $ pytoil keep project1 project2 project3 33 | 34 | $ pytoil keep project1 project2 project3 --force 35 | 36 | Options: 37 | -f, --force Force delete without confirmation. 38 | --help Show this message and exit. 39 | ``` 40 | 41 | </div> 42 | 43 | [remove]: ./remove.md 44 | 45 | ## Usage 46 | 47 | To use `keep` just pass the projects you want to keep as arguments. 48 | 49 | <div class="termy"> 50 | 51 | ```console 52 | $ pytoil keep project other_project another_project 53 | 54 | # This will delete remove1, remove2, remove3 from your local filesystem. Are you sure? [y/N]:$ y 55 | 56 | Deleted: remove1. 57 | Deleted: remove2. 58 | Deleted: remove3. 59 | ``` 60 | 61 | </div> 62 | 63 | And if you say no... 64 | 65 | <div class="termy"> 66 | 67 | ```console 68 | $ pytoil keep project other_project another_project 69 | 70 | # This will delete remove1, remove2, remove3 from your local filesystem. Are you sure? [y/N]:$ n 71 | 72 | Aborted! 73 | ``` 74 | 75 | </div> 76 | 77 | ## Force Deletion 78 | 79 | If you're really sure what you're doing, you can get around the confirmation prompt by using the `--force/-f` flag. 80 | 81 | <div class="termy"> 82 | 83 | ```console 84 | $ pytoil keep project1 project2 --force 85 | 86 | Removed: remove1. 87 | Removed: remove2. 88 | Removed: remove3. 89 | ``` 90 | 91 | </div> 92 | -------------------------------------------------------------------------------- /docs/commands/pull.md: -------------------------------------------------------------------------------- 1 | # Pull 2 | 3 | `pull` does exactly what it sounds like, it provides a nice easy way to pull down multiple projects at once and saves you having to type `git clone` like a million times :sleeping: 4 | 5 | Any projects you already have locally will be completely skipped by `pull` so it's impossible to overwrite any local changes to projects :white_check_mark: 6 | 7 | ## Help 8 | 9 | <div class="termy"> 10 | 11 | ```console 12 | $ pytoil pull --help 13 | 14 | Usage: pytoil pull [OPTIONS] [PROJECTS]... 15 | 16 | Pull down your remote projects. 17 | 18 | The pull command provides easy methods for pulling down remote projects. 19 | 20 | It is effectively a nice wrapper around git clone but you don't have to 21 | worry about urls or what your cwd is, pull will grab your remote projects by 22 | name and clone them to your configured projects directory. 23 | 24 | You can also use pull to batch clone multiple repos, even all of them ("-- 25 | all/-a") if you're into that sorta thing. 26 | 27 | If more than 1 repo is passed (or if "--all/-a" is used) pytoil will pull 28 | the repos concurrently, speeding up the process. 29 | 30 | Any remote project that already exists locally will be skipped and none of 31 | your local projects are changed in any way. pytoil will only pull down those 32 | projects that don't already exist locally. 33 | 34 | It's very possible to accidentally clone a lot of repos when using pull so 35 | you will be prompted for confirmation before pytoil does anything. 36 | 37 | The "--force/-f" flag can be used to override this confirmation prompt if 38 | desired. 39 | 40 | Examples: 41 | 42 | $ pytoil pull project1 project2 project3 43 | 44 | $ pytoil pull project1 project2 project3 --force 45 | 46 | $ pytoil pull --all 47 | 48 | $ pytoil pull --all --force 49 | 50 | Options: 51 | -f, --force Force pull without confirmation. 52 | -a, --all Pull down all your projects. 53 | --help Show this message and exit. 54 | ``` 55 | 56 | </div> 57 | 58 | ## All 59 | 60 | When you run `pytoil pull --all` pytoil will scan your projects directory and your GitHub repos to calculate what's missing locally and then go and grab the required repos concurrently so it's as fast as possible (useful if you have a lot of repos!) :dash: 61 | 62 | <div class="termy"> 63 | 64 | ```console 65 | $ pytoil pull --all 66 | 67 | # This will clone 7 repos. Are you sure you wish to proceed? [y/N]:$ y 68 | 69 | Cloned 'repo1'... 70 | 71 | Cloned 'repo2'... 72 | 73 | etc... 74 | ``` 75 | 76 | </div> 77 | 78 | !!! warning 79 | 80 | Even though this is done concurrently, if you have lots of GitHub repos (> 50 or so) this could still take a few seconds, you might be better off selecting specific repos to pull by using `pytoil pull [project(s)]`. More on that down here :point_down: 81 | 82 | However, it will prompt you telling you exactly how many repos it is going to clone and ask you to confirm! This confirmation can be disabled by using the `--force/-f` flag. 83 | 84 | <div class="termy"> 85 | 86 | ```console 87 | $ pytoil pull --all 88 | 89 | # This will clone 1375 repos. Are you sure you wish to proceed? [y/N]:$ n 90 | 91 | // Lol... nope! 92 | 93 | Aborted! 94 | ``` 95 | 96 | </div> 97 | 98 | ## Some 99 | 100 | If you have a lot of repos or you only want a few of them, `pytoil pull` accepts a space separated list of projects as arguments. 101 | 102 | Doing it this way, it will again check if you already have any of these locally (and skip them if you do) and finally do the cloning. Like so: 103 | 104 | <div class="termy"> 105 | 106 | ```console 107 | $ pytoil pull repo1 repo2 repo3 cloned1 108 | 109 | // In this snippet, our user already has 'cloned1' locally so it's skipped 110 | 111 | # This will clone 3 repos. Are you sure you wish to proceed? [y/N]:$ y 112 | 113 | Cloning 'repo1'... 114 | 115 | Cloning 'repo2'... 116 | 117 | etc... 118 | ``` 119 | 120 | </div> 121 | 122 | And just like `--all` you can abort the whole operation by entering `n` when prompted. 123 | 124 | <div class="termy"> 125 | 126 | ```console 127 | $ pytoil pull repo1 repo2 repo3 cloned1 128 | 129 | // In this snippet, our user already has 'cloned1' locally so it's skipped 130 | 131 | # This will clone 3 repos. Are you sure you wish to proceed? [y/N]:$ n 132 | 133 | Aborted! 134 | ``` 135 | 136 | </div> 137 | 138 | !!! note 139 | 140 | If you pass more than 1 repo as an argument, it will also be cloned concurrently :dash: 141 | -------------------------------------------------------------------------------- /docs/commands/remove.md: -------------------------------------------------------------------------------- 1 | # Remove 2 | 3 | This one is easy! `remove` does exactly what it says. It will recursively delete an entire project from your local projects directory. Since this is quite a destructive action, pytoil will prompt you to confirm before it does anything. If you say no, the entire process will be aborted and your project will be left alone! 4 | 5 | !!! warning 6 | 7 | The deletion of a project like this is irreversible. It does not send the folder to Trash, it simply erases it from all existence in the universe, so make sure you know what you're doing before saying yes! :scream: 8 | 9 | !!! success "Don't Panic!" 10 | 11 | Don't worry though, `remove` **DOES NOT** go near anything on your GitHub, only your local directories are affected by `remove`. pytoil only makes HTTP GET and POST requests to the GitHub API so you couldn't even delete a repo if you wanted to, in fact you can't make any changes to any GitHub repo with pytoil whatsoever so you're completely safe! :grin: 12 | 13 | ## Help 14 | 15 | <div class="termy"> 16 | 17 | ```console 18 | $ pytoil remove --help 19 | 20 | Usage: pytoil remove [OPTIONS] [PROJECTS]... 21 | 22 | Remove projects from your local filesystem. 23 | 24 | The remove command provides an easy interface for decluttering your local 25 | projects directory. 26 | 27 | You can selectively remove any number of projects by passing them as 28 | arguments or nuke the whole lot with "--all/-a" if you want. 29 | 30 | As with most programmatic deleting, the directories are deleted instantly 31 | and not sent to trash. As such, pytoil will prompt you for confirmation 32 | before doing anything. 33 | 34 | The "--force/-f" flag can be used to force deletion without the confirmation 35 | prompt. Use with caution! 36 | 37 | Examples: 38 | 39 | $ pytoil remove project1 project2 project3 40 | 41 | $ pytoil remove project1 project2 project3 --force 42 | 43 | $ pytoil remove --all 44 | 45 | $ pytoil remove --all --force 46 | 47 | Options: 48 | -f, --force Force delete without confirmation. 49 | -a, --all Delete all of your local projects. 50 | --help Show this message and exit. 51 | ``` 52 | 53 | </div> 54 | 55 | ## Remove Individual Projects 56 | 57 | If you want to remove one or more specific projects, just pass them to `remove` as arguments. 58 | 59 | <div class="termy"> 60 | 61 | ```console 62 | $ pytoil remove my_project my_other_project this_one_too 63 | 64 | # This will remove my_project, my_other_project, this_one_too from your local filesystem. Are you sure? [y/N]:$ y 65 | 66 | Removed: 'my_project'. 67 | Removed: 'my_other_project'. 68 | Removed: 'this_one_too' 69 | ``` 70 | 71 | </div> 72 | 73 | And if you say no... 74 | 75 | <div class="termy"> 76 | 77 | ```console 78 | $ pytoil remove my_project my_other_project this_one_too 79 | 80 | # This will remove my_project, my_other_project, this_one_too from your local filesystem. Are you sure? [y/N]:$ n 81 | 82 | Aborted! 83 | ``` 84 | 85 | </div> 86 | 87 | ## Nuke your Projects Directory 88 | 89 | And if you've completely given up and decided you don't want to be a developer anymore (we've all been there), you can erase all your local projects: 90 | 91 | <div class="termy"> 92 | 93 | ```console 94 | $ pytoil remove --all 95 | 96 | # This will remove ALL your projects. Are you okay? [y/N]:$ y 97 | 98 | Removed: 'remove1'. 99 | Removed: 'remove2'. 100 | Removed: 'remove3'. 101 | ``` 102 | 103 | </div> 104 | 105 | !!! note 106 | 107 | Because pytoil is written from the ground up to be asynchronous, all the removing happens concurrently in the asyncio event loop so it should 108 | be nice and snappy even for lots of very large projects! 🚀 109 | 110 | ## Force Deletion 111 | 112 | If you're really sure what you're doing, you can get around the confirmation prompt by using the `--force/-f` flag. 113 | 114 | <div class="termy"> 115 | 116 | ```console 117 | $ pytoil remove project1 project2 --force 118 | 119 | Removed: 'remove1'. 120 | Removed: 'remove2'. 121 | Removed: 'remove3'. 122 | ``` 123 | 124 | </div> 125 | -------------------------------------------------------------------------------- /docs/commands/show.md: -------------------------------------------------------------------------------- 1 | # Show 2 | 3 | We've seen a hint at some pytoil commands but lets dive in properly. 4 | 5 | Let's look at how you can use pytoil to help *you* :thumbsup: 6 | 7 | The first subcommand we will look at is `pytoil show`. 8 | 9 | `show` does what it says on the tin and provides a nice way of showing your local and remote projects. 10 | 11 | !!! note 12 | 13 | `show` always shows the projects in alphabetical order :abc: 14 | 15 | Let's start with the help... 16 | 17 | ## Help 18 | 19 | <div class="termy"> 20 | 21 | ```console 22 | $ pytoil show --help 23 | 24 | Usage: pytoil show [OPTIONS] COMMAND [ARGS]... 25 | 26 | View your local/remote projects. 27 | 28 | The show command provides an easy way of listing of the projects you have 29 | locally in your configured development directory and/or of those you have on 30 | GitHub (known in pytoil-land as 'remote' projects). 31 | 32 | Local projects will be the names of subdirectories in your configured 33 | projects directory. 34 | 35 | The remote projects listed here will be those owned by you on GitHub. 36 | 37 | The "--limit/-l" flag can be used if you only want to see a certain number 38 | of results. 39 | 40 | Options: 41 | --help Show this message and exit. 42 | 43 | Commands: 44 | diff Show the difference in local/remote projects. 45 | forks Show your forked projects. 46 | local Show your local projects. 47 | remote Show your remote projects. 48 | ``` 49 | 50 | </div> 51 | 52 | !!! tip 53 | 54 | Remember, each subcommand has its own help you can check out too. e.g. `pytoil show local --help` :thumbsup: 55 | 56 | ## Local 57 | 58 | `local` shows all the projects you already have in your configured projects directory (see [config] for how to set this!). If you don't have any local projects yet, pytoil will let you know. 59 | 60 | <div class="termy"> 61 | 62 | ```console 63 | $ pytoil show local 64 | Local Projects 65 | 66 | Showing 3 out of 3 local projects 67 | 68 | Name Created Modified 69 | ─────────────────────────────────────────────────── 70 | project 1 13 days ago 9 days ago 71 | project 2 a day ago a minute ago 72 | project 3 a month ago a month ago 73 | ``` 74 | 75 | </div> 76 | 77 | ## Remote 78 | 79 | `remote` shows all the projects on your GitHub (you may or may not have some of these locally too). If you don't have any remote projects yet, pytoil will let you know. 80 | 81 | <div class="termy"> 82 | 83 | ```console 84 | $ pytoil show remote 85 | Remote Projects 86 | 87 | Showing 5 out of 31 remote projects 88 | 89 | Name Size Created Modified 90 | ─────────────────────────────────────────────────────────────────────── 91 | advent_of_code_2020 46.1 kB 12 days ago 9 days ago 92 | advent_of_code_2021 154.6 kB a month ago 29 days ago 93 | aircraft_crashes 2.1 MB 1 year, 15 days ago 11 months ago 94 | cookie_pypackage 753.7 kB 1 year, 6 months ago a month ago 95 | cv 148.5 kB 2 months ago 7 days ago 96 | 97 | ``` 98 | 99 | </div> 100 | 101 | [config]: ../config.md 102 | 103 | ## Diff 104 | 105 | `diff` shows all the projects you have on GitHub, but don't yet exist locally. If your local projects folder has all your GitHub projects in it, pytoil will let you know this too. 106 | 107 | <div class="termy"> 108 | 109 | ```console 110 | $ pytoil show diff 111 | Diff: Remote - Local 112 | 113 | Showing 5 out of 26 projects 114 | 115 | Name Size Created Modified 116 | ───────────────────────────────────────────────────────────────────────────── 117 | advent_of_code_2021 154.6 kB a month ago 29 days ago 118 | aircraft_crashes 2.1 MB 1 year, 15 days ago 11 months ago 119 | cookie_pypackage 753.7 kB 1 year, 6 months ago a month ago 120 | cv 148.5 kB 2 months ago 7 days ago 121 | eu_energy_analysis 1.9 MB 1 year, 1 month ago 1 year, 25 days ago 122 | 123 | ``` 124 | 125 | </div> 126 | 127 | ## Forks 128 | 129 | You can also see all your forked repos and whether or not they are available locally! 130 | 131 | <div class="termy"> 132 | 133 | ```console 134 | $ pytoil show forks 135 | Forked Projects 136 | 137 | Showing 2 out of 2 forked projects 138 | 139 | Name Size Forked Modified Parent 140 | ──────────────────────────────────────────────────────────────────────────────────────── 141 | nox 5.2 MB 6 months ago 10 days ago theacodes/nox 142 | python-launcher 843.8 kB 2 months ago 2 months ago brettcannon/python-launcher 143 | 144 | ``` 145 | 146 | </div> 147 | 148 | [config]: ../config.md 149 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | ## Required 4 | 5 | There's really not much to configure, all pytoil *needs* you to specify is: 6 | 7 | * What your GitHub username is (`username`) 8 | * Your GitHub personal access token (`token`) 9 | 10 | If you don't know how to generate a GitHub token, check out the [docs]. 11 | 12 | !!! note 13 | 14 | If you don't specify `token` but have `GITHUB_TOKEN` set as environment variable, pytoil will use that instead :thumbsup: 15 | 16 | ## Optional 17 | 18 | There are also some *optional* configurations you can tweak: 19 | 20 | | Key | Definition | Default | 21 | | :---------------: | :---------------------------------------------------------------------------------------------------: | :-----------------: | 22 | | `projects_dir` | Where you keep your projects | `$HOME/Development` | 23 | | `editor` | Name of the binary to use when opening projects. | `$EDITOR` | 24 | | `conda_bin` | The name of the conda binary (conda or mamba) | `conda` | 25 | | `common_packages` | List of packages you want pytoil to inject in every environment it creates (linters, formatters etc.) | `None` | 26 | | `git` | Whether you want pytoil to initialise and commit a git repo when it makes a fresh project | True | 27 | 28 | These optional settings don't have to be set if you're happy using the default settings! 29 | 30 | !!! info 31 | 32 | Don't worry about giving pytoil your personal token. All we do with it is make HTTP GET and POST requests to the GitHub 33 | API using your token to authenticate the requests. This is essential to pytoil's functionality and it lets us: 34 | 35 | * See your repos and get some basic info about them (name, date created etc.) 36 | * Create forks of other people's projects when requested (e.g. when using [checkout]) 37 | 38 | In fact, the only permissions pytoil needs is repo and user access! :smiley: 39 | 40 | ## The Config File 41 | 42 | After you install pytoil, the first time you run it you'll get something like this. 43 | 44 | <div class="termy"> 45 | 46 | ```console 47 | $ pytoil 48 | 49 | No pytoil config file detected! 50 | ? Interactively configure pytoil? [y/n] 51 | ``` 52 | 53 | </div> 54 | 55 | If you say yes, pytoil will walk you through a few questions and fill out your config file with the values you enter. If you'd rather not do this interactively, just say no and it will instead put a default config file in the right place for you to edit later. 56 | 57 | !!! note 58 | 59 | This command will only write a config file if it doesn't find one already. If one already exists, running `pytoil config show` will show you the settings from that file. Remember, you can always quickly edit your pytoil config file using `pytoil config edit` 🔥 60 | 61 | When you open the config file, it will look something like this: 62 | 63 | ```toml 64 | # ~/.pytoil.toml 65 | 66 | [pytoil] 67 | common_packages = [] 68 | conda_bin = "conda" 69 | editor = "code-insiders" 70 | git = true 71 | projects_dir = "/Users/tomfleet/Development" 72 | token = "Your github personal access token" 73 | username = "Your github username" 74 | ``` 75 | 76 | !!! warning 77 | 78 | `projects_dir` must be the **absolute** path to where you keep your projects. So you'll need to explicitly state the entire path (as in the example above) starting from the root. 79 | 80 | You should now edit the config file to your liking. Your username and token are required for GitHub API access and will cause an error on most pytoil operations so these must be filled out. Everything else is optional :thumbsup: 81 | 82 | So as an example, your filled out config file might look like this: 83 | 84 | ```toml 85 | # ~/.pytoil.toml 86 | 87 | [pytoil] 88 | common_packages = ["black", "mypy", "isort", "flake8"] 89 | conda_bin = "mamba" 90 | editor = "code-insiders" 91 | git = true 92 | projects_dir = "/Users/tomfleet/Development" 93 | token = "ljbsxu9uqwd978" # This isn't real 94 | username = "FollowTheProcess" 95 | ``` 96 | 97 | !!! tip 98 | 99 | You can also interact with the pytoil config file via pytoil itself using the `pytoil config` command group. 100 | 101 | [docs]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token 102 | [checkout]: ./commands/checkout.md 103 | -------------------------------------------------------------------------------- /docs/contributing/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socioeconomic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All complaints will be reviewed 59 | and investigated and will result in a response that is deemed necessary and 60 | appropriate to the circumstances. 61 | The project team is obligated to maintain confidentiality with regard to the 62 | reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant], version 1.4, 72 | available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html) 73 | 74 | [Contributor Covenant]: https://www.contributor-covenant.org 75 | 76 | For answers to common questions about this code of conduct, see 77 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq) 78 | -------------------------------------------------------------------------------- /docs/contributing/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to pytoil 2 | 3 | I've tried to structure pytoil to make it nice and easy for people to contribute. Here's how to go about doing it! :smiley: 4 | 5 | !!! note 6 | 7 | All contributors must follow the [Code of Conduct](code_of_conduct.md) 8 | 9 | ## Developing 10 | 11 | If you want to fix a bug, improve the docs, add tests, add a feature or any other type of direct contribution to pytoil: here's how you do it! 12 | 13 | **To work on pytoil you'll need python >=3.9** 14 | 15 | ### Step 1: Fork pytoil 16 | 17 | The first thing to do is 'fork' pytoil. This will put a version of it on your GitHub page. This means you can change that fork all you want and the actual version of pytoil still works! 18 | 19 | To create a fork, go to the pytoil [repo] and click on the fork button! 20 | 21 | ### Step 2: Clone your fork 22 | 23 | Navigate to where you do your development work on your machine and open a terminal 24 | 25 | **If you use HTTPS:** 26 | 27 | ```shell 28 | git clone https://github.com/<your_github_username>/pytoil.git 29 | ``` 30 | 31 | **If you use SSH:** 32 | 33 | ```shell 34 | git clone git@github.com:<your_github_username>/pytoil.git 35 | ``` 36 | 37 | **Or you can be really fancy and use the [GH CLI]** 38 | 39 | ```shell 40 | gh repo clone <your_github_username>/pytoil 41 | ``` 42 | 43 | HTTPS is probably the one most people use! 44 | 45 | Once you've cloned the project, cd into it... 46 | 47 | ```shell 48 | cd pytoil 49 | ``` 50 | 51 | This will take you into the root directory of the project. 52 | 53 | Now add the original pytoil repo as an upstream in your forked project: 54 | 55 | ```shell 56 | git remote add upstream https://github.com/FollowTheProcess/pytoil.git 57 | ``` 58 | 59 | This makes the original version of pytoil `upstream` but not `origin`. Basically, this means that if your working on it for a while and the original project has changed in the meantime, you can do: 60 | 61 | ```shell 62 | git checkout main 63 | git fetch upstream 64 | git merge upstream/main 65 | git push origin main 66 | ``` 67 | 68 | This will (in order): 69 | 70 | * Checkout the main branch of your locally cloned fork 71 | * Fetch any changes from the original project that have happened since you forked it 72 | * Merge those changes in with what you have 73 | * Push those changes up to your fork so your fork stays up to date with the original. 74 | 75 | !!! note 76 | 77 | Good practice is to do this before you start doing anything every time you start work, then the chances of you getting conflicting commits later on is much lower! 78 | 79 | ### Step 3: Create the Environment 80 | 81 | Before you do anything, you'll want to set up your development environment... 82 | 83 | pytoil uses [hatch] for project management and task automation. 84 | 85 | I recommend using [pipx] for python command line tools like these, it installs each tool in it's own isolated environment but exposes the command to your terminal as if you installed it globally. To install [hatch] with pipx: 86 | 87 | ```shell 88 | pipx install hatch 89 | ``` 90 | 91 | To get started all you need to do is run: 92 | 93 | ```shell 94 | hatch env create 95 | ``` 96 | 97 | When you run this, hatch will create a virtual environment for you and install all the dependencies you need to develop pytoil 98 | 99 | Not bad for a single command! Doing it this way means that before you start working on pytoil you know its all been installed and works correctly. 100 | 101 | Wait for it to do it's thing and then you can get started. 102 | 103 | !!! tip 104 | 105 | If you run `hatch env show` it will show you all the different environments and the things you can do in them. 106 | 107 | ### Step 4: Do your thing 108 | 109 | **Always checkout a new branch before changing anything** 110 | 111 | ```shell 112 | git switch --create <name-of-your-bugfix-or-feature> 113 | ``` 114 | 115 | Now you're ready to start working! 116 | 117 | *Remember! pytoil aims for high test coverage. If you implement a new feature, make sure to write tests for it! Similarly, if you fix a bug, it's good practice to write a test that would have caught that bug so we can be sure it doesn't reappear in the future!* 118 | 119 | The tasks for automated testing, building the docs, formatting and linting etc. are all defined in [hatch] So when you've made your changes, just run: 120 | 121 | ```shell 122 | hatch run check 123 | ``` 124 | 125 | And it will tell you if something's wrong! 126 | 127 | ### Step 5: Commit your changes 128 | 129 | Once you're happy with what you've done, add the files you've changed: 130 | 131 | ```shell 132 | git add <changed-file(s)> 133 | 134 | # Might be easier to do 135 | git add -A 136 | 137 | # But be wary of this and check what it's added is what you wanted.. 138 | git status 139 | ``` 140 | 141 | Commit your changes: 142 | 143 | ```shell 144 | git commit 145 | 146 | # Now write a good commit message explaining what you've done and why. 147 | ``` 148 | 149 | While you were working on your changes, the original project might have changed (due to other people working on it). So first, you should rebase your current branch from the upstream destination. Doing this means that when you do your PR, it's all compatible: 150 | 151 | ```shell 152 | git pull --rebase upstream main 153 | ``` 154 | 155 | Now push your changes to your fork: 156 | 157 | ```shell 158 | git push origin <your-branch-name> 159 | ``` 160 | 161 | ### Step 6: Create a Pull Request 162 | 163 | Now go to the original pytoil [repo] and create a Pull Request. Make sure to choose upstream repo "main" as the destination branch and your forked repo "your-branch-name" as the source. 164 | 165 | That's it! Your code will be tested automatically by pytoil's CI suite and if everything passes and your PR is approved and merged then it will become part of pytoil! 166 | 167 | !!! note 168 | 169 | There is a good guide to open source contribution workflow [here] and also [here too] 170 | 171 | ## Contributing to Docs 172 | 173 | Any improvements to the documentation are always appreciated! pytoil uses [mkdocs] with the [mkdocs-material] theme so the documentation is all written in markdown and can be found in the `docs` folder in the project root. 174 | 175 | Because pytoil uses [hatch], things like building and serving the documentation is super easy. All you have to do is: 176 | 177 | ```shell 178 | # Builds the docs 179 | hatch run docs:build 180 | 181 | # Builds and serves 182 | hatch run docs:serve 183 | ``` 184 | 185 | If you use the `serve` option, you can navigate to the localhost IP address it gives you and as you make changes to the source files, it will automatically reload your browser! Automation is power! :robot: 186 | 187 | If you add pages to the docs, make sure they are placed in the nav tree in the `mkdocs.yml` file and you're good to go! 188 | 189 | [GH CLI]: https://cli.github.com 190 | [repo]: https://github.com/FollowTheProcess/pytoil 191 | [mkdocs]: https://www.mkdocs.org 192 | [mkdocs-material]: https://squidfunk.github.io/mkdocs-material/ 193 | [pipx]: https://pypa.github.io/pipx/installation/ 194 | [hatch]: https://hatch.pypa.io/latest/ 195 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | .termynal-comment { 2 | color: #4a968f; 3 | font-style: italic; 4 | display: block; 5 | } 6 | 7 | .termy [data-termynal] { 8 | white-space: pre-wrap; 9 | } 10 | 11 | a.external-link::after { 12 | /* \00A0 is a non-breaking space 13 | to make the mark be on the same line as the link 14 | */ 15 | content: "\00A0[↪]"; 16 | } 17 | 18 | a.internal-link::after { 19 | /* \00A0 is a non-breaking space 20 | to make the mark be on the same line as the link 21 | */ 22 | content: "\00A0↪"; 23 | } 24 | -------------------------------------------------------------------------------- /docs/css/termynal.css: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * 4 | * @author Ines Montani <ines@ines.io> 5 | * @version 0.0.1 6 | * @license MIT 7 | */ 8 | 9 | :root { 10 | --color-bg: #252a33; 11 | --color-text: #eee; 12 | --color-text-subtle: #a2a2a2; 13 | } 14 | 15 | [data-termynal] { 16 | width: 750px; 17 | max-width: 100%; 18 | background: var(--color-bg); 19 | color: var(--color-text); 20 | font-size: 14px; 21 | /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ 22 | font-family: "Roboto Mono", "Fira Mono", Consolas, Menlo, Monaco, "Courier New", Courier, monospace; 23 | border-radius: 4px; 24 | padding: 75px 45px 35px; 25 | position: relative; 26 | -webkit-box-sizing: border-box; 27 | box-sizing: border-box; 28 | } 29 | 30 | [data-termynal]:before { 31 | content: ""; 32 | position: absolute; 33 | top: 15px; 34 | left: 15px; 35 | display: inline-block; 36 | width: 15px; 37 | height: 15px; 38 | border-radius: 50%; 39 | /* A little hack to display the window buttons in one pseudo element. */ 40 | background: #d9515d; 41 | -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 42 | box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 43 | } 44 | 45 | [data-termynal]:after { 46 | content: "bash"; 47 | position: absolute; 48 | color: var(--color-text-subtle); 49 | top: 5px; 50 | left: 0; 51 | width: 100%; 52 | text-align: center; 53 | } 54 | 55 | a[data-terminal-control] { 56 | text-align: right; 57 | display: block; 58 | color: #aebbff; 59 | } 60 | 61 | [data-ty] { 62 | display: block; 63 | line-height: 2; 64 | } 65 | 66 | [data-ty]:before { 67 | /* Set up defaults and ensure empty lines are displayed. */ 68 | content: ""; 69 | display: inline-block; 70 | vertical-align: middle; 71 | } 72 | 73 | [data-ty="input"]:before, 74 | [data-ty-prompt]:before { 75 | margin-right: 0.75em; 76 | color: var(--color-text-subtle); 77 | } 78 | 79 | [data-ty="input"]:before { 80 | content: "$"; 81 | } 82 | 83 | [data-ty][data-ty-prompt]:before { 84 | content: attr(data-ty-prompt); 85 | } 86 | 87 | [data-ty-cursor]:after { 88 | content: attr(data-ty-cursor); 89 | font-family: monospace; 90 | margin-left: 0.5em; 91 | -webkit-animation: blink 1s infinite; 92 | animation: blink 1s infinite; 93 | } 94 | 95 | /* Cursor animation */ 96 | 97 | @-webkit-keyframes blink { 98 | 50% { 99 | opacity: 0; 100 | } 101 | } 102 | 103 | @keyframes blink { 104 | 50% { 105 | opacity: 0; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/docs/img/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![logo](./img/logo.png) 2 | 3 | [![License](https://img.shields.io/github/license/FollowTheProcess/pytoil)](https://github.com/FollowTheProcess/pytoil) 4 | [![PyPI](https://img.shields.io/pypi/v/pytoil.svg?logo=python)](https://pypi.python.org/pypi/pytoil) 5 | [![GitHub](https://img.shields.io/github/v/release/FollowTheProcess/pytoil?logo=github&sort=semver)](https://github.com/FollowTheProcess/pytoil) 6 | [![Code Style](https://img.shields.io/badge/code%20style-black-black)](https://github.com/FollowTheProcess/pytoil) 7 | [![CI](https://github.com/FollowTheProcess/pytoil/workflows/CI/badge.svg)](https://github.com/FollowTheProcess/pytoil/actions?query=workflow%3ACI) 8 | [![codecov](https://codecov.io/gh/FollowTheProcess/pytoil/branch/main/graph/badge.svg?token=OLMR2P3J6N)](https://codecov.io/gh/FollowTheProcess/pytoil) 9 | [![Downloads](https://static.pepy.tech/personalized-badge/pytoil?period=total&units=international_system&left_color=grey&right_color=green&left_text=Downloads)](https://pepy.tech/project/pytoil) 10 | 11 | > ***toil*** 12 | <br/> 13 | > *"Long, strenuous or fatiguing labour"* 14 | 15 | * **Source Code**: [https://github.com/FollowTheProcess/pytoil](https://github.com/FollowTheProcess/pytoil) 16 | 17 | * **Documentation**: [https://FollowTheProcess.github.io/pytoil/](https://FollowTheProcess.github.io/pytoil/) 18 | 19 | ![help](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/help.svg) 20 | 21 | ## What is it? 22 | 23 | *pytoil is a small, helpful CLI to take the toil out of software development!* 24 | 25 | `pytoil` is a handy tool that helps you stay on top of all your projects, remote or local. It's primarily aimed at python developers but you could easily use it to manage any project! 26 | 27 | pytoil is: 28 | 29 | * Easy to use ✅ 30 | * Easy to configure ✅ 31 | * Safe (it won't edit your repos at all) ✅ 32 | * Snappy (it's asynchronous from the ground up and as much as possible is done concurrently, clone all your repos in seconds!) 💨 33 | * Useful! (I hope 😃) 34 | 35 | Say goodbye to janky bash scripts 👋🏻 36 | 37 | ## Background 38 | 39 | Like many developers I suspect, I quickly became bored of typing repeated commands to manage my projects, create virtual environments, install packages, fire off `cURL` snippets to check if I had a certain repo etc. 40 | 41 | So I wrote some shell functions to do some of this for me... 42 | 43 | And these shell functions grew and grew and grew. 44 | 45 | Until one day I saw that the file I kept these functions in was over 1000 lines of bash (a lot of `printf`'s so it wasn't all logic but still). And 1000 lines of bash is *waaaay* too much! 46 | 47 | And because I'd basically hacked it all together, it was **very** fragile. If a part of a function failed, it would just carry on and wreak havoc! I'd have to do `rm -rf all_my_projects`... I mean careful forensic investigation to fix it. 48 | 49 | So I decided to make a robust CLI with the proper error handling and testability of python, and here it is! 🎉 50 | 51 | ## Installation 52 | 53 | As pytoil is a CLI program, I'd recommend installing with [pipx]. 54 | 55 | ![pipx-install](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/pipx_install.svg) 56 | 57 | You can always fall back to pip 58 | 59 | ![pip-install](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/pip_install.svg) 60 | 61 | pytoil will install everything it needs *in python* to work. However, it's full feature set can only be accessed if you have the following external dependencies: 62 | 63 | * [git] 64 | * [conda] (if you work with conda environments) 65 | * A directory-aware editor e.g. [VSCode] etc. (if you want to use pytoil to automatically open your projects for you) 66 | * [poetry] (if you want to create poetry environments) 67 | * [flit] (if you want to create flit environments) 68 | 69 | ## Quickstart 70 | 71 | `pytoil` is super easy to get started with. 72 | 73 | After you install pytoil, the first time you run it you'll get something like this. 74 | 75 | ![setup](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/setup.svg) 76 | 77 | If you say yes, pytoil will walk you through a few questions and fill out your config file with the values you enter. If you'd rather not do this interactively, just say no and it will instead put a default config file in the right place for you to edit later. 78 | 79 | Once you've configured it properly, you can do things like... 80 | 81 | #### See your local and remote projects 82 | 83 | ![show-local](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/show_local.svg) 84 | 85 | #### See which ones you have on GitHub, but not on your computer 86 | 87 | ![show-diff](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/show_diff.svg) 88 | 89 | #### Easily grab a project, regardless of where it is 90 | 91 | This project is available on your local machine... 92 | 93 | ![checkout-local](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/checkout_local.svg) 94 | 95 | This one is on GitHub... 96 | 97 | ![checkout-remote](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/checkout_remote.svg) 98 | 99 | #### Create a new project and virtual environment in one go 100 | 101 | ![new-venv](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/new_venv.svg) 102 | 103 | (And include custom packages, see the [docs]) 104 | 105 | #### And even do this from a [cookiecutter] template 106 | 107 | ![new-cookie](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/new_cookie.svg) 108 | 109 | And loads more! 110 | 111 | pytoil's CLI is designed such that if you don't specify any arguments, it won't do anything! just show you the `--help`. This is called being a 'well behaved' unix command line tool. 112 | 113 | This is true for any subcommand of pytoil so you won't accidentally break anything if you don't specify arguments 🎉 114 | 115 | And if you get truly stuck, you can quickly open pytoil's documentation with: 116 | 117 | ![docs](https://github.com/FollowTheProcess/pytoil/raw/main/docs/img/docs.svg) 118 | 119 | Check out the [docs] for more 💥 120 | 121 | ## Contributing 122 | 123 | `pytoil` is an open source project and, as such, welcomes contributions of all kinds 😃 124 | 125 | Your best bet is to check out the [contributing guide] in the docs! 126 | 127 | [pipx]: https://pipxproject.github.io/pipx/ 128 | [docs]: https://FollowTheProcess.github.io/pytoil/ 129 | [FollowTheProcess/poetry_pypackage]: https://github.com/FollowTheProcess/poetry_pypackage 130 | [wasabi]: https://github.com/ines/wasabi 131 | [httpx]: https://www.python-httpx.org 132 | [click]: https://click.palletsprojects.com/en/8.1.x/ 133 | [contributing guide]: https://followtheprocess.github.io/pytoil/contributing/contributing.html 134 | [git]: https://git-scm.com 135 | [conda]: https://docs.conda.io/en/latest/ 136 | [VSCode]: https://code.visualstudio.com 137 | [config]: config.md 138 | [cookiecutter]: https://github.com/cookiecutter/cookiecutter 139 | [poetry]: https://python-poetry.org 140 | [flit]: https://flit.readthedocs.io 141 | -------------------------------------------------------------------------------- /docs/js/custom.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll(".use-termynal").forEach(node => { 2 | node.style.display = "block"; 3 | new Termynal(node, { 4 | lineDelay: 500 5 | }); 6 | }); 7 | const progressLiteralStart = "---> 100%"; 8 | const promptLiteralStart = "$ "; 9 | const customPromptLiteralStart = "# "; 10 | const termynalActivateClass = "termy"; 11 | let termynals = []; 12 | 13 | function createTermynals() { 14 | document 15 | .querySelectorAll(`.${termynalActivateClass} .highlight`) 16 | .forEach(node => { 17 | const text = node.textContent; 18 | const lines = text.split("\n"); 19 | const useLines = []; 20 | let buffer = []; 21 | function saveBuffer() { 22 | if (buffer.length) { 23 | let isBlankSpace = true; 24 | buffer.forEach(line => { 25 | if (line) { 26 | isBlankSpace = false; 27 | } 28 | }); 29 | dataValue = {}; 30 | if (isBlankSpace) { 31 | dataValue["delay"] = 0; 32 | } 33 | if (buffer[buffer.length - 1] === "") { 34 | // A last single <br> won't have effect 35 | // so put an additional one 36 | buffer.push(""); 37 | } 38 | const bufferValue = buffer.join("<br>"); 39 | dataValue["value"] = bufferValue; 40 | useLines.push(dataValue); 41 | buffer = []; 42 | } 43 | } 44 | for (let line of lines) { 45 | if (line === progressLiteralStart) { 46 | saveBuffer(); 47 | useLines.push({ 48 | type: "progress" 49 | }); 50 | } else if (line.startsWith(promptLiteralStart)) { 51 | saveBuffer(); 52 | const value = line.replace(promptLiteralStart, "").trimEnd(); 53 | useLines.push({ 54 | type: "input", 55 | value: value 56 | }); 57 | } else if (line.startsWith("// ")) { 58 | saveBuffer(); 59 | const value = "💬 " + line.replace("// ", "").trimEnd(); 60 | useLines.push({ 61 | value: value, 62 | class: "termynal-comment", 63 | delay: 0 64 | }); 65 | } else if (line.startsWith(customPromptLiteralStart)) { 66 | saveBuffer(); 67 | const promptStart = line.indexOf(promptLiteralStart); 68 | if (promptStart === -1) { 69 | console.error("Custom prompt found but no end delimiter", line) 70 | } 71 | const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") 72 | let value = line.slice(promptStart + promptLiteralStart.length); 73 | useLines.push({ 74 | type: "input", 75 | value: value, 76 | prompt: prompt 77 | }); 78 | } else { 79 | buffer.push(line); 80 | } 81 | } 82 | saveBuffer(); 83 | const div = document.createElement("div"); 84 | node.replaceWith(div); 85 | const termynal = new Termynal(div, { 86 | lineData: useLines, 87 | noInit: true, 88 | lineDelay: 500 89 | }); 90 | termynals.push(termynal); 91 | }); 92 | } 93 | 94 | function loadVisibleTermynals() { 95 | termynals = termynals.filter(termynal => { 96 | if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { 97 | termynal.init(); 98 | return false; 99 | } 100 | return true; 101 | }); 102 | } 103 | window.addEventListener("scroll", loadVisibleTermynals); 104 | createTermynals(); 105 | loadVisibleTermynals(); 106 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pytoil 2 | repo_url: https://github.com/FollowTheProcess/pytoil 3 | site_url: https://FollowTheProcess.github.io/pytoil/ 4 | site_description: CLI to automate the development workflow. 5 | site_author: Tom Fleet 6 | use_directory_urls: false 7 | strict: true 8 | nav: 9 | - Home: index.md 10 | - Config: config.md 11 | - Commands: 12 | - Show: commands/show.md 13 | - New: commands/new.md 14 | - Checkout: commands/checkout.md 15 | - Remove: commands/remove.md 16 | - Keep: commands/keep.md 17 | - Info: commands/info.md 18 | - Find: commands/find.md 19 | - GH: commands/gh.md 20 | - Pull: commands/pull.md 21 | - Config: commands/config.md 22 | - Bug: commands/bug.md 23 | - Contributing: 24 | - Guide: contributing/contributing.md 25 | - Code of Conduct: contributing/code_of_conduct.md 26 | plugins: 27 | - search 28 | theme: 29 | name: material 30 | font: 31 | text: Roboto 32 | code: SF Mono 33 | feature: 34 | tabs: true 35 | palette: 36 | - scheme: default 37 | primary: blue grey 38 | accent: blue 39 | toggle: 40 | icon: material/lightbulb-outline 41 | name: Dark mode 42 | - scheme: slate 43 | primary: blue grey 44 | accent: blue 45 | toggle: 46 | icon: material/lightbulb-outline 47 | name: Light mode 48 | markdown_extensions: 49 | - codehilite 50 | - pymdownx.highlight: 51 | use_pygments: true 52 | - pymdownx.emoji: 53 | emoji_index: !!python/name:material.extensions.emoji.twemoji 54 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 55 | - pymdownx.inlinehilite 56 | - admonition 57 | - extra 58 | - pymdownx.superfences: 59 | custom_fences: 60 | - name: mermaid 61 | class: mermaid 62 | format: !!python/name:pymdownx.superfences.fence_div_format 63 | - pymdownx.details 64 | - pymdownx.tabbed 65 | - toc: 66 | permalink: true 67 | 68 | extra_css: 69 | - "css/termynal.css" 70 | - "css/custom.css" 71 | 72 | extra_javascript: 73 | - "https://unpkg.com/mermaid@8.4.6/dist/mermaid.min.js" 74 | - "js/termynal.js" 75 | - "js/custom.js" 76 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | Maintenance tasks, driven by Nox! 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from pathlib import Path 8 | 9 | import nox 10 | 11 | nox.options.default_venv_backend = "uv" 12 | 13 | ROOT = Path(__file__).parent.resolve() 14 | SRC = ROOT / "src" 15 | TESTS = ROOT / "tests" 16 | 17 | 18 | @nox.session(tags=["check"]) 19 | def test(session: nox.Session) -> None: 20 | """ 21 | Run the test suite 22 | """ 23 | session.install(".") 24 | session.install( 25 | "pytest", 26 | "pytest-cov", 27 | "pytest-mock", 28 | "pytest-httpx", 29 | "pytest-randomly", 30 | "covdefaults", 31 | "coverage[toml]", 32 | "freezegun", 33 | ) 34 | session.run("pytest", "--cov", f"{SRC}", f"{TESTS}") 35 | 36 | if "cover" in session.posargs: 37 | session.run("coverage", "xml") 38 | 39 | 40 | @nox.session(tags=["check"]) 41 | def lint(session: nox.Session) -> None: 42 | """ 43 | Lint the project 44 | """ 45 | session.install("pre-commit") 46 | session.run("pre-commit", "run", "--all-files") 47 | 48 | 49 | @nox.session 50 | def docs(session: nox.Session) -> None: 51 | """ 52 | Build the documentation 53 | """ 54 | session.install("mkdocs", "mkdocs-material") 55 | session.run("mkdocs", "build", "--clean") 56 | 57 | if "serve" in session.posargs: 58 | session.run("mkdocs", "serve") 59 | elif "deploy" in session.posargs: 60 | session.run("mkdocs", "gh-deploy", "--force") 61 | 62 | 63 | @nox.session 64 | def build(session: nox.Session) -> None: 65 | """ 66 | Build the sdist and wheel 67 | """ 68 | session.install("build") 69 | session.run("python", "-m", "build", ".") 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatchling", 5 | ] 6 | 7 | [project] 8 | name = "pytoil" 9 | version = "0.41.0" 10 | description = "CLI to automate the development workflow." 11 | readme = "README.md" 12 | keywords = [ 13 | "automation", 14 | "cli", 15 | "developer-tools", 16 | "python", 17 | ] 18 | license = { text = "Apache Software License 2.0" } 19 | maintainers = [ 20 | { name = "Tom Fleet" }, 21 | { email = "tomfleet2018@gmail.com" }, 22 | ] 23 | authors = [ 24 | { name = "Tom Fleet" }, 25 | { email = "tomfleet2018@gmail.com" }, 26 | ] 27 | requires-python = ">=3.9" 28 | classifiers = [ 29 | "Development Status :: 3 - Alpha", 30 | "Environment :: Console", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: Apache Software License", 33 | "Natural Language :: English", 34 | "Operating System :: MacOS :: MacOS X", 35 | "Operating System :: Microsoft :: Windows", 36 | "Operating System :: OS Independent", 37 | "Operating System :: POSIX :: Linux", 38 | "Programming Language :: Python :: 3 :: Only", 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "Programming Language :: Python :: 3.13", 44 | "Topic :: Software Development", 45 | "Topic :: Utilities", 46 | "Typing :: Typed", 47 | ] 48 | dependencies = [ 49 | "click==8.1.8", 50 | "cookiecutter==2.6", 51 | "copier==9.4.1", 52 | "httpx==0.28.1", 53 | "humanize==4.11", 54 | "pydantic==2.10.4", 55 | "pyyaml==6.0.2", 56 | "questionary==2.0.1", 57 | "rich==13.9.4", 58 | "rtoml==0.12", 59 | "thefuzz[speedup]==0.22.1", 60 | "virtualenv==20.28.1", 61 | ] 62 | 63 | urls.Documentation = "https://FollowTheProcess.github.io/pytoil/" 64 | urls.Homepage = "https://github.com/FollowTheProcess/pytoil" 65 | urls.Source = "https://github.com/FollowTheProcess/pytoil" 66 | scripts.pytoil = "pytoil.cli.root:main" 67 | 68 | [tool.ruff] 69 | line-length = 120 70 | 71 | lint.select = [ 72 | "A", # Don't shadow builtins 73 | "ANN", # Type annotations 74 | "ARG", # Unused arguments 75 | "B", # Flake8 bugbear 76 | "BLE", # No blind excepts 77 | "C4", # Flake8 comprehensions 78 | "C90", # Complexity 79 | # https://github.com/charliermarsh/ruff#supported-rules 80 | "E", # Pycodestyle errors 81 | "ERA", # Commented out code 82 | "F", # Pyflakes errors 83 | "I", # Isort 84 | "INP", # No implicit namespace packages (causes import errors) 85 | "N", # PEP8 naming 86 | "PGH", # Pygrep hooks 87 | "PIE", # Flake8 pie 88 | "PT", # Pytest style 89 | "PTH", # Use pathlib over os.path 90 | "RET", # Function returns 91 | "RSE", # When raising an exception chain, use from 92 | "RUF", # Ruff specific rules 93 | "S", # Bandit (security) 94 | "SIM", # Simplify 95 | "SLF", # Flake8-self, private member access 96 | "T20", # No print statements 97 | "TCH", # Stuff for typing is behind an if TYPE_CHECKING block 98 | "UP", # All pyupgrade rules 99 | "W", # Pycodestyle warnings 100 | "YTT", # Flake8 2020 101 | ] 102 | lint.ignore = [ 103 | "ANN101", # Missing type annotation for self in method 104 | "S105", # Hardcoded passwords (lots of false positives) 105 | "S106", # Hardcoded passwords (again?) 106 | "S603", # Subprocess calls 107 | ] 108 | 109 | lint.per-file-ignores."conftest.py" = [ 110 | "TCH", # Conftest is only run for tests (with dev dependencies) 111 | ] 112 | lint.per-file-ignores."tests/**/*.py" = [ 113 | "ARG001", # Thinks pytest fixtures are unused arguments 114 | "D104", # Missing docstring in __init__.py in tests (which is fine) 115 | "FBT001", # Tests are allowed positional bools (fixtures etc.) 116 | "S", # Security stuff in tests is fine 117 | "S101", # Assert is allowed in tests (obviously) 118 | "SLF001", # Private member access in tests is fine 119 | "TCH", # Tests will be run with dev dependencies so we don't care 120 | ] 121 | lint.isort.required-imports = [ 122 | "from __future__ import annotations", 123 | ] 124 | lint.mccabe.max-complexity = 15 125 | 126 | [tool.codespell] 127 | skip = "*.svg" 128 | ignore-words-list = "ines,Ines" 129 | 130 | [tool.pytest.ini_options] 131 | minversion = "7.0" 132 | addopts = [ 133 | "-ra", 134 | "--strict-markers", 135 | "--strict-config", 136 | ] 137 | xfail_strict = true 138 | filterwarnings = [ 139 | "error", 140 | "ignore::DeprecationWarning", # DeprecationWarning: read_binary is deprecated. Use files() instead. Comes from virtualenv 141 | ] 142 | log_cli_level = "info" 143 | pythonpath = [ 144 | "src", 145 | ] 146 | testpaths = [ 147 | "tests", 148 | ] 149 | 150 | [tool.coverage.run] 151 | plugins = [ 152 | "covdefaults", 153 | ] 154 | omit = [ 155 | "src/pytoil/cli/*.py", 156 | "src/pytoil/starters/base.py", 157 | "src/pytoil/exceptions.py", 158 | ] 159 | 160 | [tool.coverage.report] 161 | fail_under = 95 162 | exclude_lines = [ 163 | "def __repr__", 164 | "except ImportError", 165 | ] 166 | 167 | [tool.mypy] 168 | files = [ 169 | "**/*.py", 170 | ] 171 | python_version = "3.9" 172 | ignore_missing_imports = true 173 | strict = true 174 | pretty = true 175 | disallow_untyped_decorators = false 176 | plugins = "pydantic.mypy" 177 | show_error_codes = true 178 | warn_unreachable = true 179 | enable_error_code = [ 180 | "ignore-without-code", 181 | "redundant-expr", 182 | "truthy-bool", 183 | ] 184 | 185 | [tool.uv] 186 | dev-dependencies = [ 187 | "covdefaults", 188 | "coverage[toml]", 189 | "freezegun", 190 | "mkdocs", 191 | "mkdocs-material", 192 | "mypy", 193 | "nox", 194 | "pre-commit", 195 | "pytest", 196 | "pytest-clarity", 197 | "pytest-cov", 198 | "pytest-httpx", 199 | "pytest-mock", 200 | "pytest-randomly", 201 | "ruff", 202 | "types-click", 203 | "types-pyyaml", 204 | ] 205 | -------------------------------------------------------------------------------- /src/pytoil/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Helpful CLI to automate the development workflow. 4 | 5 | - Create and manage your local and remote projects 6 | 7 | - Build projects from cookiecutter templates. 8 | 9 | - Easily create/manage virtual environments. 10 | 11 | - Minimal configuration required. 12 | """ 13 | 14 | from __future__ import annotations 15 | 16 | __version__ = "0.41.0" 17 | -------------------------------------------------------------------------------- /src/pytoil/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry point for pytoil, simply passes control up 3 | to the root click command. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from pytoil.cli.root import main 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /src/pytoil/api/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.api.api import API 4 | 5 | __all__ = ("API",) 6 | -------------------------------------------------------------------------------- /src/pytoil/api/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling pytoil's interface with the 3 | GitHub GraphQL v4 API. 4 | 5 | 6 | Author: Tom Fleet 7 | Created: 21/12/2021 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | from datetime import datetime 13 | from typing import Any 14 | 15 | import httpx 16 | import humanize 17 | 18 | from pytoil import __version__ 19 | from pytoil.api import queries 20 | 21 | URL = "https://api.github.com/graphql" 22 | GITHUB_TIME_FORMAT = r"%Y-%m-%dT%H:%M:%SZ" 23 | DEFAULT_REPO_LIMIT = 50 24 | 25 | 26 | class API: 27 | def __init__(self, username: str, token: str, url: str = URL) -> None: 28 | """ 29 | Container for methods and data for hitting the GitHub v4 30 | GraphQL API. 31 | 32 | Args: 33 | username (str): User's GitHub username. 34 | token (str): User's personal access token. 35 | url (str, optional): GraphQL URL 36 | defaults to https://api.github.com/graphql 37 | """ 38 | self.username = username 39 | self.token = token 40 | self.url = url 41 | 42 | def __repr__(self) -> str: 43 | return self.__class__.__qualname__ + f"(username={self.username}, token={self.token}, url={self.url})" 44 | 45 | __slots__ = ("token", "url", "username") 46 | 47 | @property 48 | def headers(self) -> dict[str, str]: 49 | return { 50 | "Authorization": f"token {self.token}", 51 | "User-Agent": f"pytoil/{__version__}", 52 | "Accept": "application/vnd.github.v4+json", 53 | } 54 | 55 | def get_repos(self, limit: int = DEFAULT_REPO_LIMIT) -> list[dict[str, Any]] | None: 56 | """ 57 | Gets some summary info for all the users repos. 58 | 59 | Args: 60 | limit (int, optional): Maximum number of repos to return. 61 | Defaults to DEFAULT_REPO_LIMIT. 62 | 63 | Returns: 64 | list[dict[str, Any]]: The repos info. 65 | """ 66 | r = httpx.post( 67 | self.url, 68 | json={ 69 | "query": queries.GET_REPOS, 70 | "variables": {"username": self.username, "limit": limit}, 71 | }, 72 | headers=self.headers, 73 | ) 74 | 75 | r.raise_for_status() 76 | raw: dict[str, Any] = r.json() 77 | 78 | if data := raw.get("data"): 79 | return list(data["user"]["repositories"]["nodes"]) 80 | 81 | return None # pragma: no cover 82 | 83 | def get_repo_names(self, limit: int = DEFAULT_REPO_LIMIT) -> set[str]: 84 | """ 85 | Gets the names of all repos owned by the authenticated user. 86 | 87 | Args: 88 | limit (int, optional): Maximum number of repos to return. 89 | Defaults to DEFAULT_REPO_LIMIT. 90 | 91 | Raises: 92 | ValueError: If the GraphQL query is malformed. 93 | 94 | Returns: 95 | Set[str]: The names of the user's repos. 96 | """ 97 | r = httpx.post( 98 | self.url, 99 | json={ 100 | "query": queries.GET_REPO_NAMES, 101 | "variables": {"username": self.username, "limit": limit}, 102 | }, 103 | headers=self.headers, 104 | ) 105 | 106 | r.raise_for_status() 107 | raw: dict[str, Any] = r.json() 108 | 109 | # TODO: I don't like the indexing here, must be a more type safe way of doing this 110 | # What happens when there are no nodes? e.g. user has no forks 111 | if data := raw.get("data"): 112 | return {node["name"] for node in data["user"]["repositories"]["nodes"]} 113 | 114 | raise ValueError(f"Bad GraphQL: {raw}") # pragma: no cover 115 | 116 | def get_forks(self, limit: int = DEFAULT_REPO_LIMIT) -> list[dict[str, Any]] | None: 117 | """ 118 | Gets info for all users forks. 119 | 120 | Args: 121 | limit: (int, optional): Maximum number of repos to return. 122 | Defaults to DEFAULT_REPO_LIMIT. 123 | 124 | Returns: 125 | list[dict[str, Any]]: The JSON info for all forks. 126 | """ 127 | r = httpx.post( 128 | self.url, 129 | json={ 130 | "query": queries.GET_FORKS, 131 | "variables": {"username": self.username, "limit": limit}, 132 | }, 133 | headers=self.headers, 134 | ) 135 | 136 | r.raise_for_status() 137 | raw: dict[str, Any] = r.json() 138 | 139 | if data := raw.get("data"): 140 | return list(data["user"]["repositories"]["nodes"]) 141 | 142 | return None # pragma: no cover 143 | 144 | def check_repo_exists(self, owner: str, name: str) -> bool: 145 | """ 146 | Checks whether or not a repo given by `name` exists 147 | under the current user. 148 | 149 | Args: 150 | name (str): Repo name to check for 151 | 152 | Returns: 153 | bool: True if repo exists on GitHub, else False. 154 | """ 155 | r = httpx.post( 156 | self.url, 157 | json={ 158 | "query": queries.CHECK_REPO_EXISTS, 159 | "variables": {"username": owner, "name": name}, 160 | }, 161 | headers=self.headers, 162 | ) 163 | 164 | r.raise_for_status() 165 | raw: dict[str, Any] = r.json() 166 | 167 | if data := raw.get("data"): 168 | return data["repository"] is not None 169 | 170 | raise ValueError(f"Bad GraphQL: {raw}") # pragma: no cover 171 | 172 | def create_fork(self, owner: str, repo: str) -> None: 173 | """ 174 | Use the v3 REST API to create a fork of the specified repository 175 | under the authenticated user. 176 | 177 | Args: 178 | owner (str): Owner of the original repo. 179 | repo (str): Name of the original repo. 180 | """ 181 | rest_headers = self.headers.copy() 182 | rest_headers["Accept"] = "application/vnd.github.v3+json" 183 | fork_url = f"https://api.github.com/repos/{owner}/{repo}/forks" 184 | 185 | r = httpx.post(fork_url, headers=self.headers) 186 | r.raise_for_status() 187 | 188 | @staticmethod 189 | def _humanize_datetime(dt: str) -> str: 190 | """ 191 | Takes a string datetime of GITHUB_TIME_FORMAT 192 | and converts it to our STR_TIME_FORMAT. 193 | """ 194 | s: str = humanize.naturaltime(datetime.strptime(dt, GITHUB_TIME_FORMAT), when=datetime.utcnow()) 195 | return s 196 | 197 | def get_repo_info(self, name: str) -> dict[str, Any] | None: 198 | """ 199 | Gets some descriptive info for the repo given by 200 | `name` under the current user. 201 | 202 | Args: 203 | name (str): Name of the repo to fetch info for. 204 | 205 | Returns: 206 | Dict[str, Any]: Repository info. 207 | """ 208 | r = httpx.post( 209 | self.url, 210 | json={ 211 | "query": queries.GET_REPO_INFO, 212 | "variables": {"username": self.username, "name": name}, 213 | }, 214 | headers=self.headers, 215 | ) 216 | 217 | r.raise_for_status() 218 | raw: dict[str, Any] = r.json() 219 | 220 | if data := raw.get("data"): 221 | if repo := data.get("repository"): 222 | return { 223 | "Name": repo["name"], 224 | "Description": repo["description"], 225 | "Created": self._humanize_datetime(repo["createdAt"]), 226 | "Updated": self._humanize_datetime(repo["pushedAt"]), 227 | "Size": humanize.naturalsize(int(repo["diskUsage"]) * 1024), # diskUsage is in kB 228 | "License": (repo["licenseInfo"]["name"] if repo.get("licenseInfo") else None), 229 | "Language": repo["primaryLanguage"]["name"], 230 | "Remote": True, 231 | } 232 | return None # pragma: no cover 233 | return None # pragma: no cover 234 | -------------------------------------------------------------------------------- /src/pytoil/api/queries.py: -------------------------------------------------------------------------------- 1 | """ 2 | GraphQL queries needed for pytoil's API calls. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | GET_REPO_NAMES = """ 12 | query ($username: String!, $limit: Int!) { 13 | user(login: $username) { 14 | repositories(first: $limit, ownerAffiliations: OWNER, orderBy: {field: NAME, direction: ASC}) { 15 | nodes { 16 | name 17 | } 18 | } 19 | } 20 | } 21 | """ 22 | 23 | CHECK_REPO_EXISTS = """ 24 | query ($username: String!, $name: String!) { 25 | repository(owner: $username, name: $name) { 26 | name 27 | } 28 | } 29 | """ 30 | 31 | 32 | GET_REPO_INFO = """ 33 | query ($username: String!, $name: String!) { 34 | repository(owner: $username, name: $name) { 35 | name, 36 | description, 37 | createdAt, 38 | pushedAt, 39 | diskUsage, 40 | licenseInfo { 41 | name 42 | } 43 | primaryLanguage { 44 | name 45 | } 46 | } 47 | } 48 | """ 49 | 50 | GET_REPOS = """ 51 | query ($username: String!, $limit: Int!) { 52 | user(login: $username) { 53 | repositories(first: $limit, ownerAffiliations: OWNER, orderBy: {field: NAME, direction: ASC}) { 54 | nodes { 55 | name, 56 | description, 57 | createdAt, 58 | pushedAt, 59 | diskUsage 60 | } 61 | } 62 | } 63 | } 64 | """ 65 | 66 | GET_FORKS = """ 67 | query ($username: String!, $limit: Int!) { 68 | user(login: $username) { 69 | repositories(first: $limit, ownerAffiliations: OWNER, isFork: true, orderBy: {field: NAME, direction: ASC}) { 70 | nodes { 71 | name 72 | diskUsage 73 | createdAt 74 | pushedAt 75 | parent { 76 | nameWithOwner 77 | } 78 | } 79 | } 80 | } 81 | } 82 | """ 83 | -------------------------------------------------------------------------------- /src/pytoil/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/src/pytoil/cli/__init__.py -------------------------------------------------------------------------------- /src/pytoil/cli/bug.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil bug command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 23/02/2022 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import click 12 | 13 | from pytoil.cli.printer import printer 14 | from pytoil.config import defaults 15 | 16 | 17 | @click.command() 18 | def bug() -> None: 19 | """ 20 | Raise an issue about pytoil. 21 | 22 | The bug command let's you easily raise an issue on the pytoil 23 | repo. This can be a bug report, feature request, or a question! 24 | 25 | Examples: 26 | $ pytoil bug 27 | """ 28 | printer.info("Opening pytoil's issues in your browser...") 29 | click.launch(url=defaults.PYTOIL_ISSUES_URL) 30 | -------------------------------------------------------------------------------- /src/pytoil/cli/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil config command group. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import click 12 | from rich import box 13 | from rich.console import Console 14 | from rich.markdown import Markdown 15 | from rich.table import Table 16 | 17 | from pytoil.cli.printer import printer 18 | from pytoil.config import Config, defaults 19 | 20 | 21 | @click.group() 22 | def config() -> None: 23 | """ 24 | Interact with pytoil's configuration. 25 | 26 | The config command group allows you to get, show and explain pytoil's configuration. 27 | """ 28 | 29 | 30 | @config.command() 31 | @click.pass_obj 32 | def show(config: Config) -> None: 33 | """ 34 | Show pytoil's config. 35 | 36 | The show command allows you to easily see pytoil's current config. 37 | 38 | The values are taken directly from the config file where specified or 39 | the defaults otherwise. 40 | 41 | Examples: 42 | $ pytoil config show 43 | """ 44 | table = Table(box=box.SIMPLE) 45 | table.add_column("Key", style="cyan", justify="right") 46 | table.add_column("Value", justify="left") 47 | 48 | for key, val in config.to_dict().items(): 49 | table.add_row(f"{key}:", str(val)) 50 | 51 | console = Console() 52 | console.print(table) 53 | 54 | 55 | @config.command() 56 | @click.argument("key", nargs=1) 57 | @click.pass_obj 58 | def get(config: Config, key: str) -> None: 59 | """ 60 | Get the currently set value for a config key. 61 | 62 | The get command will only allow valid pytoil config keys. 63 | 64 | Examples: 65 | $ pytoil config get editor 66 | """ 67 | if key not in defaults.CONFIG_KEYS: 68 | printer.error(f"{key} is not a valid pytoil config key.", exits=1) 69 | 70 | console = Console() 71 | console.print(f"[cyan]{key}[/]: [default]{config.to_dict().get(key)}[/]") 72 | 73 | 74 | @config.command() 75 | def edit() -> None: 76 | """ 77 | Open pytoil's config file in $EDITOR. 78 | 79 | Examples: 80 | $ pytoil config edit 81 | """ 82 | click.launch(str(defaults.CONFIG_FILE), wait=False) 83 | 84 | 85 | @config.command() 86 | def explain() -> None: 87 | """ 88 | Print a list and description of pytoil config values. 89 | 90 | Examples: 91 | $ pytoil config explain 92 | """ 93 | console = Console() 94 | markdown = Markdown(defaults.CONFIG_SCHEMA, justify="center") 95 | console.print(markdown) 96 | -------------------------------------------------------------------------------- /src/pytoil/cli/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil docs command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import click 12 | 13 | from pytoil.cli.printer import printer 14 | from pytoil.config import defaults 15 | 16 | 17 | @click.command() 18 | def docs() -> None: 19 | """ 20 | Open pytoil's documentation in your browser. 21 | 22 | Examples: 23 | $ pytoil docs 24 | """ 25 | printer.info("Opening pytoil's docs in your browser...") 26 | click.launch(url=defaults.PYTOIL_DOCS_URL) 27 | -------------------------------------------------------------------------------- /src/pytoil/cli/find.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil find command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | import click 14 | from rich import box 15 | from rich.console import Console 16 | from rich.table import Table 17 | from rich.text import Text 18 | from thefuzz import process 19 | 20 | from pytoil.api import API 21 | from pytoil.cli.printer import printer 22 | 23 | if TYPE_CHECKING: 24 | from pytoil.config import Config 25 | 26 | FUZZY_SCORE_CUTOFF = 75 27 | 28 | 29 | @click.command() 30 | @click.argument("project", nargs=1) 31 | @click.option( 32 | "-l", 33 | "--limit", 34 | type=int, 35 | default=5, 36 | help="Limit results to maximum number.", 37 | show_default=True, 38 | ) 39 | @click.pass_obj 40 | def find(config: Config, project: str, limit: int) -> None: 41 | """ 42 | Quickly locate a project. 43 | 44 | The find command provides a fuzzy search for finding a project when you 45 | don't know where it is (local or on GitHub). 46 | 47 | It will perform a fuzzy search through all your local and remote projects, 48 | bring back the best matches and show you where they are. 49 | 50 | Useful if you have a lot of projects and you can't quite remember 51 | what the one you want is called! 52 | 53 | The "-l/--limit" flag can be used to alter the number of returned 54 | search results, but bare in mind that matches with sufficient match score 55 | are returned anyway so the results flag only limits the maximum number 56 | of results shown. 57 | 58 | Examples: 59 | $ pytoil find my 60 | 61 | $ pytoil find proj --limit 3 62 | """ 63 | api = API(username=config.username, token=config.token) 64 | 65 | local_projects: set[str] = { 66 | f.name for f in config.projects_dir.iterdir() if f.is_dir() and not f.name.startswith(".") 67 | } 68 | remote_projects = api.get_repo_names() 69 | 70 | all_projects = local_projects.union(remote_projects) 71 | 72 | matches: list[tuple[str, int]] = process.extractBests( 73 | project, all_projects, limit=limit, score_cutoff=FUZZY_SCORE_CUTOFF 74 | ) 75 | 76 | table = Table(box=box.SIMPLE) 77 | table.add_column("Project", style="bold white") 78 | table.add_column("Similarity") 79 | table.add_column("Where") 80 | 81 | if len(matches) == 0: 82 | printer.error("No matches found!", exits=1) 83 | 84 | for match in matches: 85 | is_local = match[0] in local_projects 86 | table.add_row( 87 | match[0], 88 | str(match[1]), 89 | (Text("Local", style="green") if is_local else Text("Remote", style="dark_orange")), 90 | ) 91 | 92 | console = Console() 93 | console.print(table) 94 | -------------------------------------------------------------------------------- /src/pytoil/cli/gh.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil gh command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | import click 14 | import httpx 15 | 16 | from pytoil.api import API 17 | from pytoil.cli import utils 18 | from pytoil.cli.printer import printer 19 | from pytoil.repo import Repo 20 | 21 | if TYPE_CHECKING: 22 | from pytoil.config import Config 23 | 24 | 25 | @click.command() 26 | @click.argument("project", nargs=1) 27 | @click.option("-i", "--issues", is_flag=True, help="Go to the issues page.") 28 | @click.option("-p", "--prs", is_flag=True, help="Go to the pull requests page.") 29 | @click.pass_obj 30 | def gh(config: Config, project: str, issues: bool, prs: bool) -> None: 31 | """ 32 | Open one of your projects on GitHub. 33 | 34 | Given a project name (must exist on GitHub and be owned by you), 35 | 'gh' will open your browser and navigate to the project on GitHub. 36 | 37 | You can also use the "--issues" or "--prs" flags to immediately 38 | open up the repo's issues or pull requests page. 39 | 40 | Examples: 41 | $ pytoil gh my_project 42 | 43 | $ pytoil gh my_project --issues 44 | 45 | $ pytoil gh my_project --prs 46 | """ 47 | api = API(username=config.username, token=config.token) 48 | repo = Repo( 49 | owner=config.username, 50 | name=project, 51 | local_path=config.projects_dir.joinpath(project), 52 | ) 53 | 54 | try: 55 | exists = repo.exists_remote(api) 56 | except httpx.HTTPStatusError as err: 57 | utils.handle_http_status_error(err) 58 | else: 59 | if not exists: 60 | printer.error(f"Could not find {project!r} on GitHub. Was it a typo?", exits=1) 61 | if issues: 62 | printer.info(f"Opening {project}'s issues on GitHub") 63 | click.launch(url=repo.issues_url) 64 | elif prs: 65 | printer.info(f"Opening {project}'s pull requests on GitHub") 66 | click.launch(url=repo.pulls_url) 67 | else: 68 | printer.info(f"Opening {project} on GitHub") 69 | click.launch(url=repo.html_url) 70 | -------------------------------------------------------------------------------- /src/pytoil/cli/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil info command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | import click 14 | from rich import box 15 | from rich.console import Console 16 | from rich.table import Table 17 | 18 | from pytoil.api import API 19 | from pytoil.cli.printer import printer 20 | from pytoil.exceptions import RepoNotFoundError 21 | from pytoil.repo import Repo 22 | 23 | if TYPE_CHECKING: 24 | from pytoil.config import Config 25 | 26 | 27 | @click.command() 28 | @click.argument("project", nargs=1) 29 | @click.pass_obj 30 | def info(config: Config, project: str) -> None: 31 | """ 32 | Get useful info for a project. 33 | 34 | Given a project name (can be local or remote), 'info' will return a summary 35 | description of the project. 36 | 37 | If the project is on GitHub, info will prefer getting information from the GitHub 38 | API as this is more detailed. 39 | 40 | If the project is local only, some information is extracted from the operating 41 | system about the project. 42 | 43 | Examples: 44 | $ pytoil info my_project 45 | """ 46 | api = API(username=config.username, token=config.token) 47 | repo = Repo( 48 | owner=config.username, 49 | name=project, 50 | local_path=config.projects_dir.joinpath(project), 51 | ) 52 | 53 | try: 54 | info = repo.info(api) 55 | except RepoNotFoundError: 56 | printer.error(f"{project!r} not found locally or on GitHub. Was it a typo?", exits=1) 57 | else: 58 | table = Table(box=box.SIMPLE) 59 | table.add_column("Key", style="cyan", justify="right") 60 | table.add_column("Value", justify="left") 61 | 62 | for key, val in info.items(): 63 | table.add_row(f"{key}:", str(val)) 64 | 65 | console = Console() 66 | console.print(table) 67 | -------------------------------------------------------------------------------- /src/pytoil/cli/keep.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil keep command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 06/02/2022 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | from concurrent.futures import ThreadPoolExecutor 13 | from typing import TYPE_CHECKING 14 | 15 | import click 16 | import questionary 17 | 18 | from pytoil.cli.printer import printer 19 | 20 | if TYPE_CHECKING: 21 | from pytoil.config import Config 22 | 23 | 24 | @click.command() 25 | @click.argument("projects", nargs=-1) 26 | @click.option("-f", "--force", is_flag=True, help="Force delete without confirmation.") 27 | @click.pass_obj 28 | def keep(config: Config, projects: tuple[str, ...], force: bool) -> None: 29 | """ 30 | Remove all but the specified projects. 31 | 32 | The keep command lets you delete all projects from your local 33 | projects directory whilst keeping the specified ones untouched. 34 | 35 | It is effectively the inverse of `pytoil remove`. 36 | 37 | As with most programmatic deleting, the directories are deleted instantly and 38 | not sent to trash. As such, pytoil will prompt you for confirmation before 39 | doing anything. 40 | 41 | The "--force/-f" flag can be used to force deletion without the confirmation 42 | prompt. Use with caution! 43 | 44 | Examples: 45 | $ pytoil keep project1 project2 project3 46 | 47 | $ pytoil keep project1 project2 project3 --force 48 | """ 49 | local_projects: set[str] = { 50 | f.name for f in config.projects_dir.iterdir() if f.is_dir() and not f.name.startswith(".") 51 | } 52 | 53 | if not local_projects: 54 | printer.error("You don't have any local projects to remove", exits=1) 55 | 56 | # If user gives a project that doesn't exist (e.g. typo), abort 57 | for project in projects: 58 | if project not in local_projects: 59 | printer.error( 60 | f"{project!r} not found under {config.projects_dir}. Was it a typo?", 61 | exits=1, 62 | ) 63 | 64 | specified = set(projects) 65 | to_delete = local_projects.difference(specified) 66 | 67 | if not force: 68 | if len(to_delete) <= 3: 69 | # Nice number to show the names 70 | question = questionary.confirm( 71 | f"This will delete {', '.join(to_delete)} from your local" " filesystem. Are you sure?", 72 | default=False, 73 | auto_enter=False, 74 | ) 75 | else: 76 | # Too many to print the names nicely 77 | question = questionary.confirm( 78 | f"This will delete {len(to_delete)} projects from your local" " filesystem. Are you sure?", 79 | default=False, 80 | auto_enter=False, 81 | ) 82 | 83 | confirmed: bool = question.ask() 84 | 85 | if not confirmed: 86 | printer.warn("Aborted", exits=1) 87 | 88 | # If we get here, user has used --force or said yes when prompted 89 | # do the deleting in a threadpool so it's concurrent 90 | with ThreadPoolExecutor() as executor: 91 | for project in to_delete: 92 | executor.submit(remove_and_report, config=config, project=project) 93 | 94 | 95 | def remove_and_report(config: Config, project: str) -> None: 96 | shutil.rmtree(path=config.projects_dir.joinpath(project), ignore_errors=True) 97 | printer.good(f"Deleted {project}") 98 | -------------------------------------------------------------------------------- /src/pytoil/cli/printer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Styles for pytoil's output using rich. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 05/02/2022 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import sys 12 | 13 | from rich.console import Console 14 | from rich.progress import Progress, SpinnerColumn, TextColumn 15 | from rich.style import Style 16 | from rich.theme import Theme 17 | 18 | __all__ = ("Printer", "printer") 19 | 20 | 21 | class Printer: 22 | """ 23 | Pytoil's default CLI output printer, designed for user 24 | friendly, colourful output, not for logging. 25 | """ 26 | 27 | _pytoil_theme = Theme( 28 | styles={ 29 | "title": Style(color="bright_cyan", bold=True), 30 | "info": Style(color="bright_cyan"), 31 | "warning": Style(color="yellow", bold=True), 32 | "error": Style(color="bright_red", bold=True), 33 | "error_message": Style(color="white", bold=True), 34 | "good": Style(color="bright_green"), 35 | "note": Style(color="white", bold=True), 36 | "subtle": Style(color="bright_black", italic=True), 37 | } 38 | ) 39 | 40 | _pytoil_console = Console(theme=_pytoil_theme) 41 | 42 | __slots__ = () 43 | 44 | def title(self, msg: str, spaced: bool = True) -> None: 45 | """ 46 | Print a bold title message or section header. 47 | """ 48 | to_print = f"{msg}" 49 | if spaced: 50 | to_print = f"{msg}\n" 51 | self._pytoil_console.print(to_print, style="title") 52 | 53 | def warn(self, msg: str, exits: int | None = None) -> None: 54 | """ 55 | Print a warning message. 56 | 57 | If `exits` is not None, will call `sys.exit` with given code. 58 | """ 59 | self._pytoil_console.print(f"⚠️ {msg}", style="warning") 60 | if exits is not None: 61 | sys.exit(exits) 62 | 63 | def info(self, msg: str, exits: int | None = None, spaced: bool = False) -> None: 64 | """ 65 | Print an info message. 66 | 67 | If `exits` is not None, will call `sys.exit` with given code. 68 | 69 | If spaced is True, a new line will be printed before and after the message. 70 | """ 71 | to_print = f"💡 {msg}" 72 | if spaced: 73 | to_print = f"\n💡 {msg}\n" 74 | 75 | self._pytoil_console.print(to_print, style="info") 76 | if exits is not None: 77 | sys.exit(exits) 78 | 79 | def sub_info(self, msg: str, exits: int | None = None) -> None: 80 | """ 81 | Print a sub-info message. 82 | 83 | If `exits` is not None, will call `sys.exit` with given code. 84 | """ 85 | self._pytoil_console.print(f" ↪ {msg}") 86 | if exits is not None: 87 | sys.exit(exits) 88 | 89 | def error(self, msg: str, exits: int | None = None) -> None: 90 | """ 91 | Print an error message. 92 | 93 | If `exits` is not None, will call `sys.exit` with given code. 94 | """ 95 | self._pytoil_console.print(f"[error]✘ Error: [/error][error_message]{msg}[/error_message]") 96 | if exits is not None: 97 | sys.exit(exits) 98 | 99 | def good(self, msg: str, exits: int | None = None) -> None: 100 | """ 101 | Print a success message. 102 | 103 | If `exits` is not None, will call `sys.exit` with given code. 104 | """ 105 | self._pytoil_console.print(f"✔ {msg}", style="good") 106 | if exits is not None: 107 | sys.exit(exits) 108 | 109 | def note(self, msg: str, exits: int | None = None) -> None: 110 | """ 111 | Print a note, designed for supplementary info on another 112 | printer method. 113 | 114 | If `exits` is not None, will call `sys.exit` with given code. 115 | """ 116 | self._pytoil_console.print(f"[note]Note:[/note] {msg}") 117 | if exits is not None: 118 | sys.exit(exits) 119 | 120 | def text(self, msg: str, exits: int | None = None) -> None: 121 | """ 122 | Print default text. 123 | 124 | If `exits` is not None, will call `sys.exit` with given code. 125 | """ 126 | self._pytoil_console.print(msg, style="default") 127 | if exits is not None: 128 | sys.exit(exits) 129 | 130 | def progress(self) -> Progress: 131 | """ 132 | Return a pre-configured rich spinner. 133 | """ 134 | text_column = TextColumn("{task.description}") 135 | spinner_column = SpinnerColumn("simpleDotsScrolling", style="bold white") 136 | return Progress(text_column, spinner_column, transient=True) 137 | 138 | def subtle(self, msg: str) -> None: 139 | """ 140 | Print subtle greyed out text. 141 | """ 142 | self._pytoil_console.print(msg, style="subtle", markup=None) 143 | 144 | 145 | # Export a default printer 146 | printer = Printer() 147 | -------------------------------------------------------------------------------- /src/pytoil/cli/pull.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil pull command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from concurrent.futures import ThreadPoolExecutor 12 | from typing import TYPE_CHECKING 13 | 14 | import click 15 | import httpx 16 | import questionary 17 | 18 | from pytoil.api import API 19 | from pytoil.cli import utils 20 | from pytoil.cli.printer import printer 21 | from pytoil.git import Git 22 | from pytoil.repo import Repo 23 | 24 | if TYPE_CHECKING: 25 | from pytoil.config import Config 26 | 27 | 28 | @click.command() 29 | @click.argument("projects", nargs=-1) 30 | @click.option("-f", "--force", is_flag=True, help="Force pull without confirmation.") 31 | @click.option("-a", "--all", "all_", is_flag=True, help="Pull down all your projects.") 32 | @click.pass_obj 33 | def pull(config: Config, projects: tuple[str, ...], force: bool, all_: bool) -> None: 34 | """ 35 | Pull down your remote projects. 36 | 37 | The pull command provides easy methods for pulling down remote projects. 38 | 39 | It is effectively a nice wrapper around git clone but you don't have to 40 | worry about urls or what your cwd is, pull will grab your remote projects 41 | by name and clone them to your configured projects directory. 42 | 43 | You can also use pull to batch clone multiple repos, even all of them ("--all/-a") 44 | if you're into that sorta thing. 45 | 46 | If more than 1 repo is passed (or if "--all/-a" is used) pytoil will pull 47 | the repos concurrently, speeding up the process. 48 | 49 | Any remote project that already exists locally will be skipped and none of 50 | your local projects are changed in any way. pytoil will only pull down 51 | those projects that don't already exist locally. 52 | 53 | It's very possible to accidentally clone a lot of repos when using pull so 54 | you will be prompted for confirmation before pytoil does anything. 55 | 56 | The "--force/-f" flag can be used to override this confirmation prompt if 57 | desired. 58 | 59 | Examples: 60 | $ pytoil pull project1 project2 project3 61 | 62 | $ pytoil pull project1 project2 project3 --force 63 | 64 | $ pytoil pull --all 65 | 66 | $ pytoil pull --all --force 67 | """ 68 | if not projects and not all_: 69 | printer.error("If not using the '--all' flag, you must specify projects to pull.", exits=1) 70 | 71 | api = API(username=config.username, token=config.token) 72 | 73 | local_projects: set[str] = { 74 | f.name for f in config.projects_dir.iterdir() if f.is_dir() and not f.name.startswith(".") 75 | } 76 | 77 | try: 78 | remote_projects = api.get_repo_names() 79 | except httpx.HTTPStatusError as err: 80 | utils.handle_http_status_error(err) 81 | else: 82 | if not remote_projects: 83 | printer.error("You don't have any remote projects to pull.", exits=1) 84 | 85 | specified_remotes = remote_projects if all_ else set(projects) 86 | 87 | # Check for typos 88 | for project in projects: 89 | if project not in remote_projects: 90 | printer.error(f"{project!r} not found on GitHub. Was it a typo?", exits=1) 91 | 92 | diff = specified_remotes.difference(local_projects) 93 | if not diff: 94 | printer.good("Your local and remote projects are in sync!", exits=0) 95 | 96 | if not force: 97 | if len(diff) <= 3: 98 | message = f"This will pull down {', '.join(diff)}. Are you sure?" 99 | else: 100 | # Too many to show nicely 101 | message = f"This will pull down {len(diff)} projects. Are you sure?" 102 | 103 | confirmed: bool = questionary.confirm(message, default=False, auto_enter=False).ask() 104 | 105 | if not confirmed: 106 | printer.warn("Aborted", exits=1) 107 | 108 | # Now we're good to go 109 | to_clone = [ 110 | Repo( 111 | owner=config.username, 112 | name=project, 113 | local_path=config.projects_dir.joinpath(project), 114 | ) 115 | for project in diff 116 | ] 117 | git = Git() 118 | with ThreadPoolExecutor() as executor: 119 | for repo in to_clone: 120 | executor.submit(clone_and_report, repo=repo, git=git, config=config) 121 | 122 | 123 | def clone_and_report(repo: Repo, git: Git, config: Config) -> None: 124 | git.clone(url=repo.clone_url, cwd=config.projects_dir) 125 | printer.good(f"Cloned {repo.name!r}") 126 | -------------------------------------------------------------------------------- /src/pytoil/cli/remove.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytoil remove command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | from concurrent.futures import ThreadPoolExecutor 13 | from typing import TYPE_CHECKING 14 | 15 | import click 16 | import questionary 17 | 18 | from pytoil.cli.printer import printer 19 | 20 | if TYPE_CHECKING: 21 | from pytoil.config import Config 22 | 23 | 24 | @click.command() 25 | @click.argument("projects", nargs=-1) 26 | @click.option("-f", "--force", is_flag=True, help="Force delete without confirmation.") 27 | @click.option( 28 | "-a", 29 | "--all", 30 | "all_", 31 | is_flag=True, 32 | help="Delete all of your local projects.", 33 | ) 34 | @click.pass_obj 35 | def remove(config: Config, projects: tuple[str, ...], force: bool, all_: bool) -> None: 36 | """ 37 | Remove projects from your local filesystem. 38 | 39 | The remove command provides an easy interface for decluttering your local 40 | projects directory. 41 | 42 | You can selectively remove any number of projects by passing them as 43 | arguments or nuke the whole lot with "--all/-a" if you want. 44 | 45 | As with most programmatic deleting, the directories are deleted instantly and 46 | not sent to trash. As such, pytoil will prompt you for confirmation before 47 | doing anything. 48 | 49 | The "--force/-f" flag can be used to force deletion without the confirmation 50 | prompt. Use with caution! 51 | 52 | Examples: 53 | $ pytoil remove project1 project2 project3 54 | 55 | $ pytoil remove project1 project2 project3 --force 56 | 57 | $ pytoil remove --all 58 | 59 | $ pytoil remove --all --force 60 | """ 61 | local_projects: set[str] = { 62 | f.name for f in config.projects_dir.iterdir() if f.is_dir() and not f.name.startswith(".") 63 | } 64 | 65 | if not local_projects: 66 | printer.error("You don't have any local projects to remove", exits=1) 67 | 68 | if not projects and not all_: 69 | printer.error( 70 | "If not using the '--all' flag, you must specify projects to remove.", 71 | exits=1, 72 | ) 73 | 74 | # If user gives a project that doesn't exist (e.g. typo), abort 75 | for project in projects: 76 | if project not in local_projects: 77 | printer.error( 78 | f"{project!r} not found under {config.projects_dir}. Was it a typo?", 79 | exits=1, 80 | ) 81 | 82 | to_delete = local_projects if all_ else projects 83 | 84 | if not force: 85 | if all_: 86 | question = questionary.confirm( 87 | "This will delete ALL of your projects. Are you sure?", 88 | default=False, 89 | auto_enter=False, 90 | ) 91 | elif len(projects) <= 3: 92 | # Nice number to show the names 93 | question = questionary.confirm( 94 | f"This will delete {', '.join(projects)} from your local" " filesystem. Are you sure?", 95 | default=False, 96 | auto_enter=False, 97 | ) 98 | else: 99 | # Too many to print the names nicely 100 | question = questionary.confirm( 101 | f"This will delete {len(projects)} projects from your local" " filesystem. Are you sure?", 102 | default=False, 103 | auto_enter=False, 104 | ) 105 | 106 | confirmed: bool = question.ask() 107 | 108 | if not confirmed: 109 | printer.warn("Aborted", exits=1) 110 | 111 | # If we get here, user has used --force or said yes when prompted 112 | # do the deleting in a threadpool so it's concurrent 113 | with ThreadPoolExecutor() as executor: 114 | for project in to_delete: 115 | executor.submit(remove_and_report, config=config, project=project) 116 | 117 | 118 | def remove_and_report(config: Config, project: str) -> None: 119 | shutil.rmtree(config.projects_dir.joinpath(project), ignore_errors=True) 120 | printer.good(f"Deleted {project}") 121 | -------------------------------------------------------------------------------- /src/pytoil/cli/root.py: -------------------------------------------------------------------------------- 1 | """ 2 | The root CLI command. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from pathlib import Path 12 | 13 | import click 14 | import questionary 15 | import rich.traceback 16 | 17 | from pytoil import __version__ 18 | from pytoil.cli.bug import bug 19 | from pytoil.cli.checkout import checkout 20 | from pytoil.cli.config import config 21 | from pytoil.cli.docs import docs 22 | from pytoil.cli.find import find 23 | from pytoil.cli.gh import gh 24 | from pytoil.cli.info import info 25 | from pytoil.cli.keep import keep 26 | from pytoil.cli.new import new 27 | from pytoil.cli.printer import printer 28 | from pytoil.cli.pull import pull 29 | from pytoil.cli.remove import remove 30 | from pytoil.cli.show import show 31 | from pytoil.config import Config, defaults 32 | 33 | # So that if we do ever get a traceback, it uses rich to show it nicely 34 | rich.traceback.install() 35 | 36 | 37 | @click.group( 38 | commands={ 39 | "checkout": checkout, 40 | "config": config, 41 | "docs": docs, 42 | "find": find, 43 | "gh": gh, 44 | "info": info, 45 | "new": new, 46 | "pull": pull, 47 | "remove": remove, 48 | "show": show, 49 | "keep": keep, 50 | "bug": bug, 51 | } 52 | ) 53 | @click.version_option(version=__version__, prog_name="pytoil") 54 | @click.pass_context 55 | def main(ctx: click.Context) -> None: 56 | """ 57 | Helpful CLI to automate the development workflow. 58 | 59 | - Create and manage your local and remote projects 60 | 61 | - Build projects from cookiecutter templates. 62 | 63 | - Easily create/manage virtual environments. 64 | 65 | - Minimal configuration required. 66 | """ 67 | # Load the config once on launch of the app and pass it down to the child commands 68 | # through click's context 69 | try: 70 | config = Config.load() 71 | except FileNotFoundError: 72 | interactive_config() 73 | else: 74 | if not config.can_use_api(): 75 | printer.error( 76 | "You must set your GitHub username and personal access token to use" " API features.", 77 | exits=1, 78 | ) 79 | # We have a valid config file at the right place so load it into click's 80 | # context and pass it down to all subcommands 81 | ctx.obj = config 82 | 83 | 84 | def interactive_config() -> None: 85 | """ 86 | Prompt the user with a series of questions 87 | to configure pytoil interactively. 88 | """ 89 | printer.warn("No pytoil config file detected!") 90 | interactive: bool = questionary.confirm("Interactively configure pytoil?", default=False, auto_enter=False).ask() 91 | 92 | if not interactive: 93 | # User doesn't want to interactively walk through a config file 94 | # so just make a default and exit cleanly 95 | Config.helper().write() 96 | printer.good("I made a default file for you.") 97 | printer.note( 98 | f"It's here: {defaults.CONFIG_FILE}, you can edit it with `pytoil" " config edit``", 99 | exits=0, 100 | ) 101 | return 102 | 103 | # If we get here, the user wants to interactively make the config 104 | projects_dir: str = questionary.path( 105 | "Where do you keep your projects?", 106 | default=str(defaults.PROJECTS_DIR), 107 | only_directories=True, 108 | ).ask() 109 | 110 | token: str = questionary.text("GitHub personal access token?").ask() 111 | 112 | username: str = questionary.text("What's your GitHub username?").ask() 113 | 114 | use_editor: bool = questionary.confirm("Auto open projects in an editor?", default=False, auto_enter=False).ask() 115 | 116 | if use_editor: 117 | editor: str = questionary.text("Name of the editor binary to use?").ask() 118 | else: 119 | editor = "None" 120 | 121 | git: bool = questionary.confirm("Make git repos when creating new projects?", default=True, auto_enter=False).ask() 122 | 123 | conda_bin: str = questionary.select( 124 | "Use conda or mamba for conda environments?", 125 | choices=("conda", "mamba"), 126 | default="conda", 127 | ).ask() 128 | 129 | config = Config( 130 | projects_dir=Path(projects_dir).resolve(), 131 | token=token, 132 | username=username, 133 | editor=editor, 134 | conda_bin=conda_bin, 135 | git=git, 136 | ) 137 | 138 | config.write() 139 | 140 | printer.good("Config created") 141 | printer.note(f"It's available at {defaults.CONFIG_FILE}.", exits=0) 142 | -------------------------------------------------------------------------------- /src/pytoil/cli/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of useful helpers for the CLI. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 30/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | from pytoil.cli.printer import printer 14 | 15 | if TYPE_CHECKING: 16 | from httpx import HTTPStatusError 17 | 18 | 19 | def handle_http_status_error(error: HTTPStatusError) -> None: 20 | """ 21 | Handles a variety of possible HTTP Status errors, print's nicer output 22 | to the user, and exits the program if necessary. 23 | Call this in an except block on CLI commands accessing the 24 | GitHub API. 25 | 26 | Args: 27 | error (httpx.HTTPStatusError): The error to be handled. 28 | """ 29 | code = error.response.status_code 30 | 31 | if code == 401: 32 | printer.error("HTTP 401 - Unauthorized") 33 | printer.note("This usually means something is wrong with your token!", exits=1) 34 | elif code == 404: 35 | printer.error("HTTP 404 - Not Found") 36 | printer.note("This is a bug we've not handled, please raise an issue!", exits=1) 37 | elif code == 500: 38 | printer.error("HTTP 500 - Server Error") 39 | printer.note("This is very rare but it means GitHub is not happy!", exits=1) 40 | -------------------------------------------------------------------------------- /src/pytoil/config/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.config import defaults 4 | from pytoil.config.config import Config 5 | 6 | __all__ = ( 7 | "Config", 8 | "defaults", 9 | ) 10 | -------------------------------------------------------------------------------- /src/pytoil/config/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling pytoil's programmatic 3 | interaction with its config file. 4 | 5 | Author: Tom Fleet 6 | Created: 21/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from pathlib import Path 12 | from typing import Any 13 | 14 | import rtoml 15 | from pydantic import BaseModel 16 | 17 | from pytoil.config import defaults 18 | 19 | 20 | class Config(BaseModel): 21 | projects_dir: Path = defaults.PROJECTS_DIR 22 | token: str = defaults.TOKEN 23 | username: str = defaults.USERNAME 24 | editor: str = defaults.EDITOR 25 | conda_bin: str = defaults.CONDA_BIN 26 | common_packages: list[str] = defaults.COMMON_PACKAGES 27 | git: bool = defaults.GIT 28 | 29 | @staticmethod 30 | def load(path: Path = defaults.CONFIG_FILE) -> Config: 31 | """ 32 | Reads in the ~/.pytoil.toml config file and returns 33 | a populated `Config` object. 34 | 35 | Args: 36 | path (Path, optional): Path to the config file. 37 | Defaults to defaults.CONFIG_FILE. 38 | 39 | Returns: 40 | Config: Populated `Config` object. 41 | 42 | Raises: 43 | FileNotFoundError: If config file not found. 44 | """ 45 | try: 46 | config_dict: dict[str, Any] = rtoml.loads(path.read_text(encoding="utf-8")).get("pytoil", "") 47 | except FileNotFoundError: 48 | raise 49 | else: 50 | # This is actually covered 51 | if config_dict.get("projects_dir"): # pragma: no cover 52 | config_dict["projects_dir"] = Path(config_dict["projects_dir"]).expanduser().resolve() 53 | return Config(**config_dict) 54 | 55 | @staticmethod 56 | def helper() -> Config: 57 | """ 58 | Returns a friendly placeholder object designed to be 59 | written to a config file as a guide to the user on what 60 | to fill in. 61 | 62 | Most of the fields will be the default but some will have 63 | helpful instructions. 64 | 65 | Returns: 66 | Config: Helper config object. 67 | """ 68 | return Config( 69 | token="Put your GitHub personal access token here", 70 | username="This your GitHub username", 71 | ) 72 | 73 | def to_dict(self) -> dict[str, Any]: 74 | """ 75 | Writes out the attributes from the calling instance 76 | to a dictionary. 77 | """ 78 | return { 79 | "projects_dir": str(self.projects_dir), 80 | "token": self.token, 81 | "username": self.username, 82 | "editor": self.editor, 83 | "conda_bin": self.conda_bin, 84 | "common_packages": self.common_packages, 85 | "git": self.git, 86 | } 87 | 88 | def write(self, path: Path = defaults.CONFIG_FILE) -> None: 89 | """ 90 | Overwrites the config file at `path` with the attributes from 91 | the calling instance. 92 | 93 | Args: 94 | path (Path, optional): Config file to overwrite. 95 | Defaults to defaults.CONFIG_FILE. 96 | """ 97 | path.write_text(rtoml.dumps({"pytoil": self.to_dict()}, pretty=True), encoding="utf-8") 98 | 99 | def can_use_api(self) -> bool: 100 | """ 101 | Helper method to easily determine whether or not 102 | the config instance has the required elements 103 | to use the GitHub API. 104 | 105 | Returns: 106 | bool: True if can use API, else False. 107 | """ 108 | conditions = [ 109 | self.username == "", 110 | self.username == "This your GitHub username", 111 | self.token == "", 112 | self.token == "Put your GitHub personal access token here", 113 | ] 114 | 115 | return not any(conditions) 116 | 117 | def specifies_editor(self) -> bool: 118 | """ 119 | Returns whether the user has set an editor, either directly 120 | or through the $EDITOR env var. 121 | 122 | If a user has no `editor` key in the config file, pytoil will 123 | use $EDITOR. 124 | 125 | If the key is present but is set to the literal "None", pytoil 126 | will not try to open projects. 127 | 128 | Otherwise the value of the key `editor` will be used as the name 129 | of the binary. 130 | 131 | Returns: 132 | bool: True if editor is not literal "None" else False. 133 | """ 134 | return self.editor.lower() != "none" 135 | -------------------------------------------------------------------------------- /src/pytoil/config/defaults.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global defaults for pytoil. 3 | 4 | Author: Tom Fleet 5 | Created: 21/12/2021 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | from pathlib import Path 12 | 13 | # Default path for pytoil's config file 14 | CONFIG_FILE: Path = Path.home().joinpath(".pytoil.toml").resolve() 15 | 16 | # Valid pytoil config keys 17 | CONFIG_KEYS: set[str] = { 18 | "projects_dir", 19 | "token", 20 | "username", 21 | "editor", 22 | "conda_bin", 23 | "common_packages", 24 | "git", 25 | } 26 | 27 | # Pytoil meta stuff 28 | PYTOIL_DOCS_URL: str = "https://followtheprocess.github.io/pytoil/" 29 | PYTOIL_ISSUES_URL: str = "https://github.com/FollowTheProcess/pytoil/issues" 30 | 31 | # Defaults for pytoil config 32 | PROJECTS_DIR: Path = Path.home().joinpath("Development").resolve() 33 | TOKEN: str = os.getenv("GITHUB_TOKEN", "") 34 | USERNAME: str = "" 35 | EDITOR: str = os.getenv("EDITOR", "") 36 | CONDA_BIN: str = "conda" 37 | COMMON_PACKAGES: list[str] = [] 38 | GIT: bool = True 39 | 40 | # Config Schema 41 | CONFIG_SCHEMA = """ 42 | 43 | # The .pytoil.toml config file 44 | 45 | ## projects_dir *(str)* 46 | 47 | The absolute path to where you keep your development projects 48 | (e.g. /Users/you/Projects). 49 | 50 | ## token *(str)* 51 | 52 | Your GitHub personal access token. This must have a minimum of repo read access. See the documentation here: 53 | https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token 54 | 55 | Pytoil will try and get this from the config file initially, then fall back to the $GITHUB_TOKEN environment 56 | variable. If neither of these places are set, you will not be able to use pytoil commands that rely on the 57 | GitHub API. Pytoil will notify you of this when any of these commands are called. 58 | 59 | ## username *(str)* 60 | 61 | Your GitHub username. Pytoil needs this so it can construct urls to your projects. 62 | 63 | ## editor *(str)* 64 | 65 | The name of the editor you'd like pytoil to use to open projects. Note: This editor must 66 | be directory-aware and have a command line binary that can be used to launch projects. 67 | 68 | For example: 69 | `code /path/to/my/project` 70 | `code-insiders /path/to/my/project` 71 | `pycharm /path/to/my/project` 72 | 73 | If the key is not present in the config file, pytoil will fall back to $EDITOR, which may fail 74 | if the configured $EDITOR is not directory-aware e.g. things like vim and nvim. 75 | 76 | If the key is set to the literal string "None" (case-insensitive), pytoil will not attempt to open 77 | projects for you. 78 | 79 | Otherwise, the value of `editor` will be used as the name of the command line binary used to open 80 | projects e.g. `code, `code-insiders`, `pycharm` etc. 81 | 82 | 83 | ## conda_bin *(str)* 84 | 85 | The name of the binary to use when performing conda operations. Either "conda" (default) 86 | or "mamba" 87 | 88 | ## common_packages *(List[str])* 89 | 90 | A list of python packages to inject into every virtual environment pytoil creates 91 | (e.g. linters, formatters and other dev dependencies). 92 | 93 | Any versioning syntax (e.g. mypy>=0.902) will work as expected here as these packages 94 | are passed straight through to installation tools like pip and conda. 95 | 96 | ## git *(bool)* 97 | 98 | Whether or not you want pytoil to create an empty git repo when you make a new project with 99 | 'pytoil new'. This can also be disabled on a per use basis using the '--no-git' flag. 100 | """ 101 | -------------------------------------------------------------------------------- /src/pytoil/editor/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.editor.editor import launch 4 | 5 | __all__ = ("launch",) 6 | -------------------------------------------------------------------------------- /src/pytoil/editor/editor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling launching a directory-aware editor 3 | when opening a project. 4 | 5 | 6 | Author: Tom Fleet 7 | Created: 12/03/2022 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | if TYPE_CHECKING: 17 | from pathlib import Path 18 | 19 | 20 | def launch(path: Path, binary: str) -> None: 21 | """ 22 | Launch a directory-aware editor from the command line binary 23 | `bin` to open a project with root at `path`. 24 | 25 | Launch assumes that the command to open a project is of the 26 | structure `<bin> <path>` e.g. `code ~/myproject`. 27 | 28 | Args: 29 | path (Path): Absolute path to the root of the project to open. 30 | bin (str): Name of the editor binary e.g. `code`. 31 | """ 32 | subprocess.run([binary, path], stdout=sys.stdout, stderr=sys.stderr) 33 | -------------------------------------------------------------------------------- /src/pytoil/environments/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.environments.base import Environment 4 | from pytoil.environments.conda import Conda 5 | from pytoil.environments.flit import Flit 6 | from pytoil.environments.poetry import Poetry 7 | from pytoil.environments.reqs import Requirements 8 | from pytoil.environments.virtualenv import Venv 9 | 10 | __all__ = ( 11 | "Conda", 12 | "Environment", 13 | "Flit", 14 | "Poetry", 15 | "Requirements", 16 | "Venv", 17 | ) 18 | -------------------------------------------------------------------------------- /src/pytoil/environments/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface that all virtual environment classes must 3 | satisfy. 4 | 5 | 6 | Author: Tom Fleet 7 | Created: 24/12/2021 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | from typing import TYPE_CHECKING, Protocol 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Sequence 16 | from pathlib import Path 17 | 18 | 19 | class Environment(Protocol): 20 | @property 21 | def project_path(self) -> Path: 22 | """ 23 | `.project_path` represents the root directory of the 24 | project associated with the virtual environment. 25 | """ 26 | ... 27 | 28 | @property 29 | def executable(self) -> Path: 30 | """ 31 | `.executable` is the absolute path to the virtual environment's 32 | python interpreter. 33 | """ 34 | ... 35 | 36 | @property 37 | def name(self) -> str: 38 | """ 39 | Returns the type of environment implemented by the concrete instance. 40 | Used for logging and debugging. 41 | 42 | Returns: 43 | str: E.g. 'conda', 'venv', 'poetry' etc. 44 | """ 45 | ... 46 | 47 | def exists(self) -> bool: 48 | """ 49 | `.exists()` checks whether the virtual environment exists. 50 | How it does this is up to the concrete implementation, but a good 51 | example might be simply checking if `self.executable` exists. 52 | """ 53 | ... 54 | 55 | def create(self, packages: Sequence[str] | None = None, silent: bool = False) -> None: 56 | """ 57 | Method to create the virtual environment. If packages are specified, 58 | these can be installed during environment creation. 59 | """ 60 | ... 61 | 62 | def install(self, packages: Sequence[str], silent: bool = False) -> None: 63 | """ 64 | Generic install method. 65 | 66 | Installs `packages` into the correct virtual environment. 67 | 68 | Args: 69 | packages (List[str]): List of valid packages to install. 70 | silent (bool, optional): Whether to discard or display output. 71 | """ 72 | ... 73 | 74 | def install_self(self, silent: bool = False) -> None: 75 | """ 76 | Installs the current project. 77 | 78 | For example: `pip install -e .[dev]` or `poetry install` 79 | """ 80 | ... 81 | -------------------------------------------------------------------------------- /src/pytoil/environments/flit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling Flit python environments. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 26/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | from pytoil.environments.virtualenv import Venv 17 | from pytoil.exceptions import FlitNotInstalledError 18 | 19 | if TYPE_CHECKING: 20 | from pathlib import Path 21 | 22 | FLIT = shutil.which("flit") 23 | 24 | 25 | class Flit(Venv): 26 | def __init__(self, root: Path, flit: str | None = FLIT) -> None: 27 | self.root = root 28 | self.flit = flit 29 | super().__init__(root) 30 | 31 | def __repr__(self) -> str: 32 | return self.__class__.__qualname__ + f"(root={self.root!r}, flit={self.flit!r})" 33 | 34 | __slots__ = ("flit", "root") 35 | 36 | @property 37 | def name(self) -> str: 38 | return "flit" 39 | 40 | def install_self(self, silent: bool = False) -> None: 41 | """ 42 | Installs a flit based project. 43 | 44 | Args: 45 | silent (bool, optional): Whether to discard or display output. 46 | Defaults to False. 47 | """ 48 | if not self.flit: 49 | raise FlitNotInstalledError 50 | 51 | # Unlike poetry, conda etc. flit does not make it's own virtual environment 52 | # we must make one here before installing the project 53 | if not self.exists(): 54 | self.create() 55 | 56 | subprocess.run( 57 | [ 58 | self.flit, 59 | "install", 60 | "--deps", 61 | "develop", 62 | "--symlink", 63 | "--python", 64 | f"{self.executable}", 65 | ], 66 | cwd=self.project_path, 67 | stdout=subprocess.DEVNULL if silent else sys.stdout, 68 | stderr=subprocess.DEVNULL if silent else sys.stderr, 69 | ) 70 | -------------------------------------------------------------------------------- /src/pytoil/environments/poetry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling poetry environments. 3 | 4 | Here we take advantage of poetry's new `local` config setting 5 | to enforce the virtual environment being in the project without 6 | altering the user's base config. 7 | 8 | 9 | Author: Tom Fleet 10 | Created: 24/12/2021 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | import shutil 16 | import subprocess 17 | import sys 18 | from typing import TYPE_CHECKING 19 | 20 | from pytoil.exceptions import PoetryNotInstalledError 21 | 22 | if TYPE_CHECKING: 23 | from collections.abc import Sequence 24 | from pathlib import Path 25 | 26 | POETRY = shutil.which("poetry") 27 | 28 | 29 | class Poetry: 30 | def __init__(self, root: Path, poetry: str | None = POETRY) -> None: 31 | self.root = root 32 | self.poetry = poetry 33 | 34 | def __repr__(self) -> str: 35 | return self.__class__.__qualname__ + f"(root={self.root!r}, poetry={self.poetry!r})" 36 | 37 | __slots__ = ("poetry", "root") 38 | 39 | @property 40 | def project_path(self) -> Path: 41 | return self.root.resolve() 42 | 43 | @property 44 | def executable(self) -> Path: 45 | return self.project_path.joinpath(".venv/bin/python") 46 | 47 | @property 48 | def name(self) -> str: 49 | return "poetry" 50 | 51 | def enforce_local_config(self) -> None: 52 | """ 53 | Ensures any changes to poetry's config such as storing the 54 | virtual environment in the project directory as we do here, do not 55 | propagate to the user's global poetry config. 56 | """ 57 | if not self.poetry: 58 | raise PoetryNotInstalledError 59 | 60 | subprocess.run( 61 | [self.poetry, "config", "virtualenvs.in-project", "true", "--local"], 62 | cwd=self.project_path, 63 | ) 64 | 65 | def exists(self) -> bool: 66 | """ 67 | Checks whether the virtual environment exists by a proxy 68 | check if the `executable` exists. 69 | 70 | If this executable exists then both the project and the virtual environment 71 | must also exist and must therefore be valid. 72 | """ 73 | return self.executable.exists() # pragma: no cover 74 | 75 | def create(self, packages: Sequence[str] | None = None, silent: bool = False) -> None: 76 | """ 77 | This method is not implemented for poetry environments. 78 | 79 | Use `install` instead as with poetry, creation and installation 80 | are handled together. 81 | """ 82 | raise NotImplementedError 83 | 84 | def install(self, packages: Sequence[str], silent: bool = False) -> None: 85 | """ 86 | Calls `poetry add` to install packages into the environment. 87 | 88 | Args: 89 | packages (List[str]): List of packages to install. 90 | silent (bool, optional): Whether to discard or display output. 91 | """ 92 | if not self.poetry: 93 | raise PoetryNotInstalledError 94 | 95 | self.enforce_local_config() 96 | 97 | subprocess.run( 98 | [self.poetry, "add", *packages], 99 | cwd=self.project_path, 100 | stdout=subprocess.DEVNULL if silent else sys.stdout, 101 | stderr=subprocess.DEVNULL if silent else sys.stderr, 102 | ) 103 | 104 | def install_self(self, silent: bool = False) -> None: 105 | """ 106 | Calls `poetry install` under the hood to install the current package 107 | and all it's dependencies. 108 | 109 | Args: 110 | silent (bool, optional): Whether to discard or display output. 111 | Defaults to False. 112 | """ 113 | if not self.poetry: 114 | raise PoetryNotInstalledError 115 | 116 | self.enforce_local_config() 117 | 118 | subprocess.run( 119 | [self.poetry, "install"], 120 | cwd=self.project_path, 121 | stdout=subprocess.DEVNULL if silent else sys.stdout, 122 | stderr=subprocess.DEVNULL if silent else sys.stderr, 123 | ) 124 | -------------------------------------------------------------------------------- /src/pytoil/environments/reqs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling python environments 3 | with a `requirements.txt` (or `requirements-dev.txt`). 4 | 5 | 6 | Author: Tom Fleet 7 | Created: 26/12/2021 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | from pytoil.environments.virtualenv import Venv 17 | 18 | if TYPE_CHECKING: 19 | from pathlib import Path 20 | 21 | 22 | class Requirements(Venv): 23 | def __init__(self, root: Path) -> None: 24 | self.root = root 25 | super().__init__(root) 26 | 27 | def __repr__(self) -> str: 28 | return self.__class__.__qualname__ + f"(root={self.root!r})" 29 | 30 | __slots__ = ("root",) 31 | 32 | @property 33 | def name(self) -> str: 34 | return "requirements file" 35 | 36 | def install_self(self, silent: bool = False) -> None: 37 | """ 38 | Installs everything in the requirements file into 39 | a python environment. 40 | """ 41 | if not self.exists(): 42 | self.create(silent=silent) 43 | 44 | requirements_file = "requirements.txt" 45 | 46 | if self.project_path.joinpath("requirements-dev.txt").exists(): 47 | requirements_file = "requirements-dev.txt" 48 | 49 | subprocess.run( 50 | [f"{self.executable}", "-m", "pip", "install", "-r", requirements_file], 51 | cwd=self.project_path, 52 | stdout=subprocess.DEVNULL if silent else sys.stdout, 53 | stderr=subprocess.DEVNULL if silent else sys.stderr, 54 | ) 55 | -------------------------------------------------------------------------------- /src/pytoil/environments/virtualenv.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for handling python virtual environments 3 | through the std lib `venv` module. 4 | 5 | 6 | Author: Tom Fleet 7 | Created: 24/12/2021 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | import virtualenv 17 | 18 | if TYPE_CHECKING: 19 | from collections.abc import Sequence 20 | from pathlib import Path 21 | 22 | 23 | class Venv: 24 | root: Path 25 | 26 | def __init__(self, root: Path) -> None: 27 | self.root = root 28 | 29 | def __repr__(self) -> str: 30 | return self.__class__.__qualname__ + f"(root={self.root!r})" 31 | 32 | __slots__ = ("root",) 33 | 34 | @property 35 | def project_path(self) -> Path: 36 | return self.root.resolve() 37 | 38 | @property 39 | def executable(self) -> Path: 40 | return self.project_path.joinpath(".venv/bin/python") 41 | 42 | @property 43 | def name(self) -> str: 44 | return "venv" 45 | 46 | def exists(self) -> bool: 47 | """ 48 | Checks whether the virtual environment exists by a proxy 49 | check if the `executable` exists. 50 | 51 | If this executable exists then both the project and virtual environment 52 | must also exist and therefore must be valid. 53 | """ 54 | return self.executable.exists() # pragma: no cover 55 | 56 | def create(self, packages: Sequence[str] | None = None, silent: bool = False) -> None: 57 | """ 58 | Create the virtual environment in the project. 59 | 60 | If packages are specified here, these will be installed 61 | once the environment is created. 62 | 63 | Args: 64 | packages (Optional[List[str]], optional): Packages to install immediately 65 | after environment creation. Defaults to None. 66 | silent (bool, optional): Whether to discard or display output. 67 | Defaults to False. 68 | """ 69 | virtualenv.cli_run(args=[str(self.project_path.joinpath(".venv")), "--quiet"]) 70 | 71 | # Install any specified packages 72 | if packages: # pragma: no cover 73 | self.install(packages=packages, silent=silent) 74 | 75 | def install(self, packages: Sequence[str], silent: bool = False) -> None: 76 | """ 77 | Generic `pip install` method. 78 | 79 | Takes a list of packages to install. All packages are passed through to pip 80 | so any versioning syntax will work as expected. 81 | 82 | Args: 83 | packages (List[str]): List of packages to install, if only 1 package 84 | still must be a list e.g. `["black"]`. 85 | silent (bool, optional): Whether to discard or display output. 86 | Defaults to False. 87 | """ 88 | subprocess.run( 89 | [f"{self.executable}", "-m", "pip", "install", *packages], 90 | cwd=self.project_path, 91 | stdout=subprocess.DEVNULL if silent else sys.stdout, 92 | stderr=subprocess.DEVNULL if silent else sys.stderr, 93 | ) 94 | 95 | def install_self(self, silent: bool = False) -> None: 96 | """ 97 | Installs current package. 98 | 99 | We first try the equivalent of `pip install -e .[dev]` as a large 100 | number of packages declare a [dev] extra which contains everything 101 | needed to work on it. 102 | 103 | Pip will automatically fall back to `pip install -e .` in the event 104 | `.[dev]` does not exist and every python package must know how to 105 | install itself this way by definition. 106 | 107 | Args: 108 | silent (bool, optional): Whether to discard or display output. 109 | Defaults to False. 110 | """ 111 | # Before installing the package, ensure a virtualenv exists 112 | if not self.exists(): 113 | self.create(silent=silent) 114 | 115 | # We try .[dev] first as most packages I've seen have this 116 | # and pip will automatically fall back to '.' if not 117 | subprocess.run( 118 | [f"{self.executable}", "-m", "pip", "install", "-e", ".[dev]"], 119 | cwd=self.project_path, 120 | stdout=subprocess.DEVNULL if silent else sys.stdout, 121 | stderr=subprocess.DEVNULL if silent else sys.stderr, 122 | ) 123 | -------------------------------------------------------------------------------- /src/pytoil/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions implemented in pytoil. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | 8 | class PytoilError(Exception): 9 | """ 10 | Base pytoil exception from which all subclasses 11 | must inherit. 12 | """ 13 | 14 | def __init__(self, message: str) -> None: 15 | self.message = message 16 | super().__init__(self.message) 17 | 18 | 19 | class ExternalToolNotInstalledError(PytoilError): 20 | """ 21 | Base exception for any child exception responsible 22 | for raising in the presence of a required external 23 | tool that's not installed. 24 | """ 25 | 26 | def __init__(self, message: str) -> None: 27 | self.message = message 28 | super().__init__(self.message) 29 | 30 | 31 | class GitNotInstalledError(ExternalToolNotInstalledError): 32 | """ 33 | Raise when calling something that needs the user 34 | to have git installed. 35 | """ 36 | 37 | def __init__(self) -> None: 38 | self.message = "'git' executable not found on $PATH. Is git installed?" 39 | super().__init__(self.message) 40 | 41 | 42 | class CondaNotInstalledError(ExternalToolNotInstalledError): 43 | """ 44 | Trying to do something that requires the user to have 45 | the `conda` package manager installed. 46 | """ 47 | 48 | def __init__(self) -> None: 49 | self.message = "Conda not found on $PATH. Is it installed?" 50 | super().__init__(self.message) 51 | 52 | 53 | class EnvironmentAlreadyExistsError(PytoilError): 54 | """ 55 | Trying to overwrite an existing environment, only applicable 56 | to conda environments. 57 | """ 58 | 59 | def __init__(self, message: str) -> None: 60 | self.message = message 61 | super().__init__(self.message) 62 | 63 | 64 | class BadEnvironmentFileError(PytoilError): 65 | """ 66 | The conda environment's `environment.yml` is malformed. 67 | """ 68 | 69 | def __init__(self, message: str) -> None: 70 | self.message = message 71 | super().__init__(self.message) 72 | 73 | 74 | class EnvironmentDoesNotExistError(PytoilError): 75 | """ 76 | Trying to do something to a virtual environment that does not 77 | exist. 78 | """ 79 | 80 | def __init__(self, message: str) -> None: 81 | self.message = message 82 | super().__init__(self.message) 83 | 84 | 85 | class UnsupportedCondaInstallationError(PytoilError): 86 | """ 87 | User's conda installation is not one of the supported 88 | ones for pytoil. See environments/conda.py. 89 | """ 90 | 91 | def __init__(self, message: str) -> None: 92 | self.message = message 93 | super().__init__(self.message) 94 | 95 | 96 | class RepoNotFoundError(PytoilError): 97 | """ 98 | The repo object trying to be operated on does not exist. 99 | """ 100 | 101 | def __init__(self, message: str) -> None: 102 | self.message = message 103 | super().__init__(self.message) 104 | 105 | 106 | class GoNotInstalledError(ExternalToolNotInstalledError): 107 | """ 108 | The user does not have `go` installed. 109 | """ 110 | 111 | def __init__(self) -> None: 112 | self.message = "Go not found on $PATH. Is it installed?" 113 | super().__init__(self.message) 114 | 115 | 116 | class CargoNotInstalledError(ExternalToolNotInstalledError): 117 | """ 118 | The user does not have `cargo` installed. 119 | """ 120 | 121 | def __init__(self) -> None: 122 | self.message = "Cargo not found on $PATH. Is it installed?" 123 | super().__init__(self.message) 124 | 125 | 126 | class FlitNotInstalledError(ExternalToolNotInstalledError): 127 | """ 128 | The user does not have `flit` installed. 129 | """ 130 | 131 | def __init__(self) -> None: 132 | self.message = "Flit not found on $PATH. Is it installed?" 133 | super().__init__(self.message) 134 | 135 | 136 | class PoetryNotInstalledError(ExternalToolNotInstalledError): 137 | """ 138 | The user does not have `poetry` installed. 139 | """ 140 | 141 | def __init__(self) -> None: 142 | self.message = "Poetry not found on $PATH. Is it installed?" 143 | super().__init__(self.message) 144 | -------------------------------------------------------------------------------- /src/pytoil/git/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.git.git import Git 4 | 5 | __all__ = ("Git",) 6 | -------------------------------------------------------------------------------- /src/pytoil/git/git.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for interacting with git via subprocesses. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 22/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | from pytoil.exceptions import GitNotInstalledError 17 | 18 | if TYPE_CHECKING: 19 | from pathlib import Path 20 | 21 | 22 | GIT = shutil.which("git") 23 | 24 | 25 | class Git: 26 | def __init__(self, git: str | None = GIT) -> None: 27 | if git is None: 28 | raise GitNotInstalledError 29 | self.git = git 30 | 31 | def __repr__(self) -> str: 32 | return self.__class__.__qualname__ + f"(git={self.git!r})" 33 | 34 | __slots__ = ("git",) 35 | 36 | def clone(self, url: str, cwd: Path, silent: bool = True) -> None: 37 | """ 38 | Clone a repo. 39 | 40 | Args: 41 | url (str): The clone url of the repo 42 | cwd (Path): The cwd under which to clone 43 | silent (bool, optional): Whether to hook the output 44 | up to stdout and stderr (False) or to discard and keep silent (True). 45 | Defaults to True. 46 | """ 47 | subprocess.run( 48 | [self.git, "clone", url], 49 | cwd=cwd, 50 | stdout=subprocess.DEVNULL if silent else sys.stdout, 51 | stderr=subprocess.DEVNULL if silent else sys.stderr, 52 | ) 53 | 54 | def init(self, cwd: Path, silent: bool = True) -> None: 55 | """ 56 | Initialise a new git repo. 57 | 58 | Args: 59 | cwd (Path): The cwd to initialise the repo in. 60 | silent (bool, optional): Whether to hook the output 61 | up to stdout and stderr (False) or to discard and keep silent (True). 62 | Defaults to True. 63 | """ 64 | subprocess.run( 65 | [self.git, "init"], 66 | cwd=cwd, 67 | stdout=subprocess.DEVNULL if silent else sys.stdout, 68 | stderr=subprocess.DEVNULL if silent else sys.stderr, 69 | ) 70 | 71 | def add(self, cwd: Path, silent: bool = True) -> None: 72 | """ 73 | Stages all files in cwd. 74 | 75 | Args: 76 | cwd (Path): The cwd to stage all child files in. 77 | silent (bool, optional): Whether to hook the output up 78 | to stdout and stderr (False) or to discard and keep silent (True). 79 | Defaults to True. 80 | """ 81 | subprocess.run( 82 | [self.git, "add", "-A"], 83 | cwd=cwd, 84 | stdout=subprocess.DEVNULL if silent else sys.stdout, 85 | stderr=subprocess.DEVNULL if silent else sys.stderr, 86 | ) 87 | 88 | def commit( 89 | self, 90 | cwd: Path, 91 | message: str = "Initial Commit (Automated at Project Creation)", 92 | silent: bool = True, 93 | ) -> None: 94 | """ 95 | Commits the current state. 96 | 97 | Args: 98 | cwd (Path): cwd of the repo to commit. 99 | message (str, optional): Optional commit message. 100 | Defaults to "Initial Commit (Automated at Project Creation)" 101 | silent (bool, optional): Whether to hook the output up 102 | to stdout and stderr (False) or to discard and keep silent (True). 103 | Defaults to True. 104 | """ 105 | subprocess.run( 106 | [self.git, "commit", "-m", message], 107 | cwd=cwd, 108 | stdout=subprocess.DEVNULL if silent else sys.stdout, 109 | stderr=subprocess.DEVNULL if silent else sys.stderr, 110 | ) 111 | 112 | def set_upstream(self, owner: str, repo: str, cwd: Path, silent: bool = True) -> None: 113 | """ 114 | Sets the upstream repo for a local repo, e.g. on a cloned fork. 115 | 116 | Note difference between origin and upstream, origin of a cloned fork 117 | would be user/forked_repo where as upstream would be original_user/forked_repo. 118 | 119 | Args: 120 | owner (str): Owner of the upstream repo. 121 | repo (str): Name of the upstream repo (typically the same as the fork) 122 | cwd (Path): Root of the project where the local git repo is. 123 | silent (bool, optional): Whether to hook the output 124 | up to stdout and stderr (False) or to discard and keep silent (True). 125 | Defaults to True. 126 | """ 127 | base_url = "https://github.com" 128 | constructed_upstream = f"{base_url}/{owner}/{repo}.git" 129 | 130 | subprocess.run( 131 | [self.git, "remote", "add", "upstream", constructed_upstream], 132 | cwd=cwd, 133 | stdout=subprocess.DEVNULL if silent else sys.stdout, 134 | stderr=subprocess.DEVNULL if silent else sys.stderr, 135 | ) 136 | -------------------------------------------------------------------------------- /src/pytoil/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The pytoil package uses inline types. 2 | # See: https://www.python.org/dev/peps/pep-0561/ 3 | -------------------------------------------------------------------------------- /src/pytoil/repo/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.repo.repo import Repo 4 | 5 | __all__ = ("Repo",) 6 | -------------------------------------------------------------------------------- /src/pytoil/starters/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytoil.starters.go import GoStarter 4 | from pytoil.starters.python import PythonStarter 5 | from pytoil.starters.rust import RustStarter 6 | 7 | __all__ = ( 8 | "GoStarter", 9 | "PythonStarter", 10 | "RustStarter", 11 | ) 12 | -------------------------------------------------------------------------------- /src/pytoil/starters/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface that all starter classes must satisfy. 3 | 4 | These templates are not supposed to be exhaustive, for that the user 5 | is better off using pytoil's cookiecutter functionality. 6 | 7 | The templates defined in the `starters` module are exactly that, 8 | a starter. A good analogous reference would be the behaviour of 9 | `cargo new` in rust, which simply sets up a few basic sub directories 10 | and a "hello world" function main.rs. 11 | 12 | 13 | Author: Tom Fleet 14 | Created: 29/12/2021 15 | """ 16 | 17 | from __future__ import annotations 18 | 19 | from typing import Protocol 20 | 21 | 22 | class Starter(Protocol): 23 | def generate(self, username: str | None = None) -> None: 24 | """ 25 | Implements the generation of the project starter template. 26 | """ 27 | ... 28 | -------------------------------------------------------------------------------- /src/pytoil/starters/go.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Go starter template. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 29/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | from pytoil.exceptions import GoNotInstalledError 17 | 18 | if TYPE_CHECKING: 19 | from pathlib import Path 20 | 21 | GO = shutil.which("go") 22 | 23 | 24 | class GoStarter: 25 | def __init__(self, path: Path, name: str, go: str | None = GO) -> None: 26 | self.path = path 27 | self.name = name 28 | self.go = go 29 | self.root = self.path.joinpath(self.name).resolve() 30 | self.files = [self.root.joinpath(filename) for filename in ["README.md", "main.go"]] 31 | 32 | def __repr__(self) -> str: 33 | return self.__class__.__qualname__ + f"(path={self.path!r}, name={self.name!r}, go={self.go!r})" 34 | 35 | __slots__ = ("files", "go", "name", "path", "root") 36 | 37 | def generate(self, username: str | None = None) -> None: 38 | """ 39 | Generate a new Go starter template. 40 | """ 41 | if not self.go: 42 | raise GoNotInstalledError 43 | 44 | self.root.mkdir() 45 | 46 | # Call go mod init 47 | subprocess.run( 48 | [self.go, "mod", "init", f"github.com/{username}/{self.name}"], 49 | cwd=self.root, 50 | stdout=sys.stdout, 51 | stderr=sys.stderr, 52 | ) 53 | 54 | for file in self.files: 55 | file.touch() 56 | 57 | # Put the header in the README 58 | readme = self.root.joinpath("README.md") 59 | 60 | # Populate the go file 61 | main_go = self.root.joinpath("main.go") 62 | 63 | go_text = 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello' ' World")\n}\n' 64 | 65 | readme.write_text(f"# {self.name}\n", encoding="utf-8") 66 | main_go.write_text(go_text, encoding="utf-8") 67 | -------------------------------------------------------------------------------- /src/pytoil/starters/python.py: -------------------------------------------------------------------------------- 1 | """ 2 | The python starter template. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 29/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | if TYPE_CHECKING: 14 | from pathlib import Path 15 | 16 | 17 | class PythonStarter: 18 | def __init__(self, path: Path, name: str) -> None: 19 | self.path = path 20 | self.name = name 21 | self.root = self.path.joinpath(self.name).resolve() 22 | self.files = [self.root.joinpath(filename) for filename in ["README.md", "requirements.txt", f"{self.name}.py"]] 23 | 24 | def __repr__(self) -> str: 25 | return self.__class__.__qualname__ + f"(path={self.path!r}, name={self.name!r})" 26 | 27 | __slots__ = ("files", "name", "path", "root") 28 | 29 | def generate(self, username: str | None = None) -> None: 30 | """ 31 | Generate a new python starter template. 32 | """ 33 | _ = username # not needed for python 34 | self.root.mkdir() 35 | 36 | for file in self.files: 37 | file.touch() 38 | 39 | # Put the header in the README 40 | readme = self.root.joinpath("README.md") 41 | reqs = self.root.joinpath("requirements.txt") 42 | py_file = self.root.joinpath(f"{self.name}.py") 43 | 44 | # Populate the python file 45 | py_text = 'def hello(name: str = "world") -> None:\n print(f"hello {name}")\n' 46 | 47 | readme.write_text(f"# {self.name}\n", encoding="utf-8") 48 | reqs.write_text("# Put your requirements here e.g. flask>=1.0.0\n", encoding="utf-8") 49 | py_file.write_text(py_text, encoding="utf-8") 50 | -------------------------------------------------------------------------------- /src/pytoil/starters/rust.py: -------------------------------------------------------------------------------- 1 | """ 2 | The rust starter template. 3 | 4 | 5 | Author: Tom Fleet 6 | Created: 29/12/2021 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import shutil 12 | import subprocess 13 | import sys 14 | from typing import TYPE_CHECKING 15 | 16 | from pytoil.exceptions import CargoNotInstalledError 17 | 18 | if TYPE_CHECKING: 19 | from pathlib import Path 20 | 21 | CARGO = shutil.which("cargo") 22 | 23 | 24 | class RustStarter: 25 | def __init__(self, path: Path, name: str, cargo: str | None = CARGO) -> None: 26 | self.path = path 27 | self.name = name 28 | self.cargo = cargo 29 | self.root = self.path.joinpath(self.name).resolve() 30 | self.files = [self.root.joinpath(filename) for filename in ["README.md"]] 31 | 32 | def __repr__(self) -> str: 33 | return self.__class__.__qualname__ + f"(path={self.path!r}, name={self.name!r}, cargo={self.cargo!r})" 34 | 35 | __slots__ = ("cargo", "files", "name", "path", "root") 36 | 37 | def generate(self, username: str | None = None) -> None: 38 | """ 39 | Generate a new rust/cargo starter template. 40 | """ 41 | _ = username # not needed for rust 42 | if not self.cargo: 43 | raise CargoNotInstalledError 44 | 45 | self.root.mkdir() 46 | 47 | # Call cargo init 48 | subprocess.run( 49 | [self.cargo, "init", "--vcs", "none"], 50 | cwd=self.root, 51 | stdout=sys.stdout, 52 | stderr=sys.stderr, 53 | ) 54 | 55 | # Create the README 56 | for file in self.files: 57 | file.touch() 58 | 59 | readme = self.root.joinpath("README.md") 60 | readme.write_text(f"# {self.name}\n", encoding="utf-8") 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/test_root.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from click.testing import CliRunner 4 | 5 | from pytoil.cli.root import main 6 | 7 | 8 | def test_cli_doesnt_blow_up() -> None: 9 | runner = CliRunner() 10 | result = runner.invoke(main, ["--help"]) 11 | 12 | assert result.exit_code == 0 13 | assert "Helpful CLI to automate the development workflow" in result.stdout 14 | -------------------------------------------------------------------------------- /tests/environments/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/tests/environments/__init__.py -------------------------------------------------------------------------------- /tests/environments/test_flit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | from typing import TextIO 7 | 8 | import pytest 9 | from pytest_mock import MockerFixture 10 | 11 | from pytoil.environments.flit import Flit 12 | from pytoil.exceptions import FlitNotInstalledError 13 | 14 | 15 | def test_flit() -> None: 16 | flit = Flit(root=Path("somewhere"), flit="notflit") 17 | 18 | assert flit.project_path == Path("somewhere").resolve() 19 | assert flit.name == "flit" 20 | assert flit.executable == Path("somewhere").resolve().joinpath(".venv/bin/python") 21 | assert flit.flit == "notflit" 22 | 23 | 24 | def test_flit_repr() -> None: 25 | flit = Flit(root=Path("somewhere"), flit="notflit") 26 | assert repr(flit) == f"Flit(root={Path('somewhere')!r}, flit='notflit')" 27 | 28 | 29 | def test_raises_if_flit_not_installed() -> None: 30 | flit = Flit(root=Path("somewhere"), flit=None) 31 | 32 | with pytest.raises(FlitNotInstalledError): 33 | flit.install_self() 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ("silent", "stdout", "stderr"), 38 | [ 39 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 40 | (False, sys.stdout, sys.stderr), 41 | ], 42 | ) 43 | def test_install_self_venv_exists( 44 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 45 | ) -> None: 46 | mocker.patch( 47 | "pytoil.environments.flit.Flit.exists", 48 | autospec=True, 49 | return_value=True, 50 | ) 51 | 52 | mock = mocker.patch("pytoil.environments.flit.subprocess.run", autospec=True) 53 | 54 | env = Flit(root=Path("somewhere"), flit="notflit") 55 | 56 | env.install_self(silent=silent) 57 | 58 | mock.assert_called_once_with( 59 | [ 60 | "notflit", 61 | "install", 62 | "--deps", 63 | "develop", 64 | "--symlink", 65 | "--python", 66 | f"{env.executable}", 67 | ], 68 | cwd=env.project_path, 69 | stdout=stdout, 70 | stderr=stderr, 71 | ) 72 | 73 | 74 | @pytest.mark.parametrize( 75 | ("silent", "stdout", "stderr"), 76 | [ 77 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 78 | (False, sys.stdout, sys.stderr), 79 | ], 80 | ) 81 | def test_install_self_venv_doesnt_exist( 82 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 83 | ) -> None: 84 | mocker.patch( 85 | "pytoil.environments.flit.Flit.exists", 86 | autospec=True, 87 | return_value=False, 88 | ) 89 | 90 | mock_create = mocker.patch("pytoil.environments.flit.Flit.create", autospec=True) 91 | 92 | mock = mocker.patch("pytoil.environments.flit.subprocess.run", autospec=True) 93 | 94 | env = Flit(root=Path("somewhere"), flit="notflit") 95 | 96 | env.install_self(silent=silent) 97 | 98 | mock_create.assert_called_once() 99 | 100 | mock.assert_called_once_with( 101 | [ 102 | "notflit", 103 | "install", 104 | "--deps", 105 | "develop", 106 | "--symlink", 107 | "--python", 108 | f"{env.executable}", 109 | ], 110 | cwd=env.project_path, 111 | stdout=stdout, 112 | stderr=stderr, 113 | ) 114 | -------------------------------------------------------------------------------- /tests/environments/test_poetry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | from typing import TextIO 8 | 9 | import pytest 10 | from pytest_mock import MockerFixture 11 | 12 | from pytoil.environments import Poetry 13 | from pytoil.exceptions import PoetryNotInstalledError 14 | 15 | 16 | def test_poetry_instantiation_default() -> None: 17 | poetry = Poetry(root=Path("somewhere")) 18 | 19 | assert poetry.project_path == Path("somewhere").resolve() 20 | assert poetry.poetry == shutil.which("poetry") 21 | assert poetry.name == "poetry" 22 | assert poetry.executable == Path("somewhere").resolve().joinpath(".venv/bin/python") 23 | 24 | 25 | def test_poetry_instanciation_passed() -> None: 26 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 27 | 28 | assert poetry.project_path == Path("somewhere").resolve() 29 | assert poetry.poetry == "notpoetry" 30 | assert poetry.name == "poetry" 31 | assert poetry.executable == Path("somewhere").resolve().joinpath(".venv/bin/python") 32 | 33 | 34 | def test_poetry_repr() -> None: 35 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 36 | assert repr(poetry) == f"Poetry(root={Path('somewhere')!r}, poetry='notpoetry')" 37 | 38 | 39 | def test_create_raises_not_implemented_error() -> None: 40 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 41 | 42 | with pytest.raises(NotImplementedError): 43 | poetry.create() 44 | 45 | 46 | def test_enforce_local_config_correctly_calls_poetry(mocker: MockerFixture) -> None: 47 | mock = mocker.patch("pytoil.environments.poetry.subprocess.run", autospec=True) 48 | 49 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 50 | 51 | poetry.enforce_local_config() 52 | 53 | mock.assert_called_once_with( 54 | ["notpoetry", "config", "virtualenvs.in-project", "true", "--local"], 55 | cwd=poetry.project_path, 56 | ) 57 | 58 | 59 | def test_enforce_local_config_raises_if_poetry_not_installed() -> None: 60 | poetry = Poetry(root=Path("somewhere"), poetry=None) 61 | 62 | with pytest.raises(PoetryNotInstalledError): 63 | poetry.enforce_local_config() 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("silent", "stdout", "stderr"), 68 | [ 69 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 70 | (False, sys.stdout, sys.stderr), 71 | ], 72 | ) 73 | def test_install_correctly_calls_poetry( 74 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 75 | ) -> None: 76 | mock = mocker.patch("pytoil.environments.poetry.subprocess.run", autospec=True) 77 | 78 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 79 | 80 | # Mock out enforce local config as `install` runs this first 81 | mocker.patch("pytoil.environments.poetry.Poetry.enforce_local_config", autospec=True) 82 | 83 | poetry.install(packages=["black", "isort", "flake8", "mypy"], silent=silent) 84 | 85 | mock.assert_called_once_with( 86 | ["notpoetry", "add", "black", "isort", "flake8", "mypy"], 87 | cwd=poetry.project_path, 88 | stdout=stdout, 89 | stderr=stderr, 90 | ) 91 | 92 | 93 | def test_install_raises_if_poetry_not_installed() -> None: 94 | poetry = Poetry(root=Path("somewhere"), poetry=None) 95 | 96 | with pytest.raises(PoetryNotInstalledError): 97 | poetry.install(packages=["something", "doesn't", "matter"]) 98 | 99 | 100 | @pytest.mark.parametrize( 101 | ("silent", "stdout", "stderr"), 102 | [ 103 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 104 | (False, sys.stdout, sys.stderr), 105 | ], 106 | ) 107 | def test_install_self_correctly_calls_poetry( 108 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 109 | ) -> None: 110 | mock = mocker.patch("pytoil.environments.poetry.subprocess.run", autospec=True) 111 | 112 | poetry = Poetry(root=Path("somewhere"), poetry="notpoetry") 113 | 114 | # Mock out enforce local config as `install` runs this first 115 | mocker.patch("pytoil.environments.poetry.Poetry.enforce_local_config", autospec=True) 116 | 117 | poetry.install_self(silent=silent) 118 | 119 | mock.assert_called_once_with( 120 | ["notpoetry", "install"], 121 | cwd=poetry.project_path, 122 | stdout=stdout, 123 | stderr=stderr, 124 | ) 125 | 126 | 127 | def test_install_selfraises_if_poetry_not_installed() -> None: 128 | poetry = Poetry(root=Path("somewhere"), poetry=None) 129 | 130 | with pytest.raises(PoetryNotInstalledError): 131 | poetry.install_self() 132 | -------------------------------------------------------------------------------- /tests/environments/test_requirements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | import sys 5 | import tempfile 6 | from pathlib import Path 7 | from typing import TextIO 8 | 9 | import pytest 10 | from pytest_mock import MockerFixture 11 | 12 | from pytoil.environments import Requirements 13 | 14 | 15 | def test_requirements() -> None: 16 | env = Requirements(root=Path("somewhere")) 17 | 18 | assert env.project_path == Path("somewhere").resolve() 19 | assert env.name == "requirements file" 20 | assert env.executable == Path("somewhere").resolve().joinpath(".venv/bin/python") 21 | 22 | 23 | def test_requirements_repr() -> None: 24 | env = Requirements(root=Path("somewhere")) 25 | assert repr(env) == f"Requirements(root={Path('somewhere')!r})" 26 | 27 | 28 | @pytest.mark.parametrize( 29 | ("silent", "stdout", "stderr"), 30 | [ 31 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 32 | (False, sys.stdout, sys.stderr), 33 | ], 34 | ) 35 | def test_install_self_venv_exists( 36 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 37 | ) -> None: 38 | mocker.patch( 39 | "pytoil.environments.reqs.Requirements.exists", 40 | autospec=True, 41 | return_value=True, 42 | ) 43 | 44 | mock = mocker.patch("pytoil.environments.reqs.subprocess.run", autospec=True) 45 | 46 | env = Requirements(root=Path("somewhere")) 47 | 48 | env.install_self(silent=silent) 49 | 50 | mock.assert_called_once_with( 51 | [f"{env.executable}", "-m", "pip", "install", "-r", "requirements.txt"], 52 | cwd=env.project_path, 53 | stdout=stdout, 54 | stderr=stderr, 55 | ) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | ("silent", "stdout", "stderr"), 60 | [ 61 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 62 | (False, sys.stdout, sys.stderr), 63 | ], 64 | ) 65 | def test_install_self_venv_doesnt_exist( 66 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 67 | ) -> None: 68 | mocker.patch( 69 | "pytoil.environments.reqs.Requirements.exists", 70 | autospec=True, 71 | return_value=False, 72 | ) 73 | 74 | mock = mocker.patch("pytoil.environments.reqs.subprocess.run", autospec=True) 75 | 76 | mock_create = mocker.patch("pytoil.environments.reqs.Requirements.create", autospec=True) 77 | 78 | env = Requirements(root=Path("somewhere")) 79 | 80 | env.install_self(silent=silent) 81 | 82 | mock_create.assert_called_once() 83 | 84 | mock.assert_called_once_with( 85 | [f"{env.executable}", "-m", "pip", "install", "-r", "requirements.txt"], 86 | cwd=env.project_path, 87 | stdout=stdout, 88 | stderr=stderr, 89 | ) 90 | 91 | 92 | @pytest.mark.parametrize( 93 | ("silent", "stdout", "stderr"), 94 | [ 95 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 96 | (False, sys.stdout, sys.stderr), 97 | ], 98 | ) 99 | def test_install_self_requirements_dev( 100 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 101 | ) -> None: 102 | with tempfile.TemporaryDirectory() as tmpdir: 103 | (Path(tmpdir) / "requirements-dev.txt").touch() 104 | 105 | mocker.patch( 106 | "pytoil.environments.reqs.Requirements.exists", 107 | autospec=True, 108 | return_value=True, 109 | ) 110 | 111 | mock = mocker.patch("pytoil.environments.reqs.subprocess.run", autospec=True) 112 | 113 | env = Requirements(root=Path(tmpdir)) 114 | 115 | env.install_self(silent=silent) 116 | 117 | mock.assert_called_once_with( 118 | [f"{env.executable}", "-m", "pip", "install", "-r", "requirements-dev.txt"], 119 | cwd=env.project_path, 120 | stdout=stdout, 121 | stderr=stderr, 122 | ) 123 | -------------------------------------------------------------------------------- /tests/environments/test_virtualenv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | from typing import TextIO 7 | 8 | import pytest 9 | from pytest_mock import MockerFixture 10 | 11 | from pytoil.environments import Venv 12 | 13 | 14 | def test_virtualenv() -> None: 15 | venv = Venv(root=Path("somewhere")) 16 | 17 | assert venv.project_path == Path("somewhere").resolve() 18 | assert venv.executable == Path("somewhere").resolve().joinpath(".venv/bin/python") 19 | assert venv.name == "venv" 20 | 21 | 22 | def test_virtualenv_repr() -> None: 23 | venv = Venv(root=Path("somewhere")) 24 | assert repr(venv) == f"Venv(root={Path('somewhere')!r})" 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ("silent", "stdout", "stderr"), 29 | [ 30 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 31 | (False, sys.stdout, sys.stderr), 32 | ], 33 | ) 34 | def test_install_calls_pip_correctly( 35 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 36 | ) -> None: 37 | mock = mocker.patch( 38 | "pytoil.environments.virtualenv.subprocess.run", 39 | autospec=True, 40 | ) 41 | 42 | venv = Venv(root=Path("somewhere")) 43 | 44 | venv.install(["black", "mypy", "isort", "flake8"], silent=silent) 45 | 46 | mock.assert_called_once_with( 47 | [ 48 | f"{venv.executable}", 49 | "-m", 50 | "pip", 51 | "install", 52 | "black", 53 | "mypy", 54 | "isort", 55 | "flake8", 56 | ], 57 | cwd=venv.project_path, 58 | stdout=stdout, 59 | stderr=stderr, 60 | ) 61 | 62 | 63 | @pytest.mark.parametrize( 64 | ("silent", "stdout", "stderr"), 65 | [ 66 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 67 | (False, sys.stdout, sys.stderr), 68 | ], 69 | ) 70 | def test_install_self_calls_pip_correctly( 71 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 72 | ) -> None: 73 | mock = mocker.patch( 74 | "pytoil.environments.virtualenv.subprocess.run", 75 | autospec=True, 76 | ) 77 | 78 | # Make it think there's already a venv 79 | mocker.patch( 80 | "pytoil.environments.virtualenv.Venv.exists", 81 | autospec=True, 82 | return_value=True, 83 | ) 84 | 85 | venv = Venv(root=Path("somewhere")) 86 | 87 | venv.install_self(silent=silent) 88 | 89 | mock.assert_called_once_with( 90 | [f"{venv.executable}", "-m", "pip", "install", "-e", ".[dev]"], 91 | cwd=venv.project_path, 92 | stdout=stdout, 93 | stderr=stderr, 94 | ) 95 | 96 | 97 | @pytest.mark.parametrize( 98 | ("silent", "stdout", "stderr"), 99 | [ 100 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 101 | (False, sys.stdout, sys.stderr), 102 | ], 103 | ) 104 | def test_install_self_creates_venv_if_not_one_already( 105 | mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int 106 | ) -> None: 107 | mock = mocker.patch( 108 | "pytoil.environments.virtualenv.subprocess.run", 109 | autospec=True, 110 | ) 111 | 112 | # Make it think there isn't a venv 113 | mocker.patch( 114 | "pytoil.environments.virtualenv.Venv.exists", 115 | autospec=True, 116 | return_value=False, 117 | ) 118 | 119 | # Mock out the venv.create method 120 | mock_create = mocker.patch("pytoil.environments.virtualenv.Venv.create", autospec=True) 121 | 122 | venv = Venv(root=Path("somewhere")) 123 | 124 | venv.install_self(silent=silent) 125 | 126 | mock_create.assert_called_once() 127 | 128 | mock.assert_called_once_with( 129 | [f"{venv.executable}", "-m", "pip", "install", "-e", ".[dev]"], 130 | cwd=venv.project_path, 131 | stdout=stdout, 132 | stderr=stderr, 133 | ) 134 | -------------------------------------------------------------------------------- /tests/starters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FollowTheProcess/pytoil/8dd6c58faa7a0eaf5f8de9ca4104e5dec32201c3/tests/starters/__init__.py -------------------------------------------------------------------------------- /tests/starters/test_go.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import tempfile 5 | from pathlib import Path 6 | 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | from pytoil.exceptions import GoNotInstalledError 11 | from pytoil.starters import GoStarter 12 | 13 | 14 | def test_go_starter_init() -> None: 15 | starter = GoStarter(path=Path("somewhere"), name="testygo", go="notgo") 16 | 17 | assert starter.path == Path("somewhere") 18 | assert starter.name == "testygo" 19 | assert starter.root == Path("somewhere").joinpath("testygo").resolve() 20 | assert starter.files == [ 21 | Path("somewhere").joinpath("testygo").resolve().joinpath("README.md"), 22 | Path("somewhere").joinpath("testygo").resolve().joinpath("main.go"), 23 | ] 24 | 25 | 26 | def test_generate_raises_if_go_not_installed() -> None: 27 | starter = GoStarter(path=Path("somewhere"), name="testygo", go=None) 28 | 29 | with pytest.raises(GoNotInstalledError): 30 | starter.generate() 31 | 32 | 33 | def test_go_starter_generate(mocker: MockerFixture) -> None: 34 | with tempfile.TemporaryDirectory() as tmpdir: 35 | starter = GoStarter(path=Path(tmpdir), name="tempgo", go="notgo") 36 | 37 | mock_go_mod_init = mocker.patch("pytoil.starters.go.subprocess.run", autospec=True) 38 | 39 | starter.generate(username="me") 40 | 41 | mock_go_mod_init.assert_called_once_with( 42 | ["notgo", "mod", "init", "github.com/me/tempgo"], 43 | cwd=starter.root, 44 | stdout=sys.stdout, 45 | stderr=sys.stderr, 46 | ) 47 | 48 | for file in starter.files: 49 | assert file.exists() 50 | 51 | readme_content = starter.root.joinpath("README.md").read_text() 52 | main_go_content = starter.root.joinpath("main.go").read_text() 53 | 54 | assert readme_content == "# tempgo\n" 55 | assert main_go_content == 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello' ' World")\n}\n' 56 | -------------------------------------------------------------------------------- /tests/starters/test_python.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import tempfile 4 | from pathlib import Path 5 | 6 | from pytoil.starters import PythonStarter 7 | 8 | 9 | def test_python_starter_init() -> None: 10 | starter = PythonStarter(path=Path("somewhere"), name="testypython") 11 | 12 | assert starter.path == Path("somewhere") 13 | assert starter.name == "testypython" 14 | assert starter.root == Path("somewhere").joinpath("testypython").resolve() 15 | assert starter.files == [ 16 | Path("somewhere").joinpath("testypython").resolve().joinpath("README.md"), 17 | Path("somewhere").joinpath("testypython").resolve().joinpath("requirements.txt"), 18 | Path("somewhere").joinpath("testypython").resolve().joinpath("testypython.py"), 19 | ] 20 | 21 | 22 | def test_python_starter_generate() -> None: 23 | with tempfile.TemporaryDirectory() as tmpdir: 24 | starter = PythonStarter(path=Path(tmpdir), name="temptest") 25 | 26 | starter.generate() 27 | 28 | for file in starter.files: 29 | assert file.exists() 30 | 31 | readme_content = starter.root.joinpath("README.md").read_text() 32 | requirements_content = starter.root.joinpath("requirements.txt").read_text() 33 | python_content = starter.root.joinpath("temptest.py").read_text() 34 | 35 | assert readme_content == "# temptest\n" 36 | assert requirements_content == "# Put your requirements here e.g. flask>=1.0.0\n" 37 | assert python_content == 'def hello(name: str = "world") -> None:\n print(f"hello {name}")\n' 38 | -------------------------------------------------------------------------------- /tests/starters/test_rust.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import tempfile 5 | from pathlib import Path 6 | 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | from pytoil.exceptions import CargoNotInstalledError 11 | from pytoil.starters import RustStarter 12 | 13 | 14 | def test_go_starter_init() -> None: 15 | starter = RustStarter(path=Path("somewhere"), name="testyrust", cargo="notcargo") 16 | 17 | assert starter.path == Path("somewhere") 18 | assert starter.name == "testyrust" 19 | assert starter.root == Path("somewhere").joinpath("testyrust").resolve() 20 | assert starter.files == [ 21 | Path("somewhere").joinpath("testyrust").resolve().joinpath("README.md"), 22 | ] 23 | 24 | 25 | def test_generate_raises_if_cargo_not_installed() -> None: 26 | starter = RustStarter(path=Path("somewhere"), name="testyrust", cargo=None) 27 | 28 | with pytest.raises(CargoNotInstalledError): 29 | starter.generate() 30 | 31 | 32 | def test_rust_starter_generate(mocker: MockerFixture) -> None: 33 | with tempfile.TemporaryDirectory() as tmpdir: 34 | starter = RustStarter(path=Path(tmpdir), name="temprust", cargo="notcargo") 35 | 36 | mock_cargo_init = mocker.patch("pytoil.starters.rust.subprocess.run", autospec=True) 37 | 38 | starter.generate() 39 | 40 | mock_cargo_init.assert_called_once_with( 41 | ["notcargo", "init", "--vcs", "none"], 42 | cwd=starter.root, 43 | stdout=sys.stdout, 44 | stderr=sys.stderr, 45 | ) 46 | 47 | for file in starter.files: 48 | assert file.exists() 49 | 50 | readme_content = starter.root.joinpath("README.md").read_text() 51 | 52 | assert readme_content == "# temprust\n" 53 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from freezegun import freeze_time 6 | from pytest_httpx import HTTPXMock 7 | 8 | from pytoil import __version__ 9 | from pytoil.api import API 10 | 11 | 12 | def test_headers() -> None: 13 | api = API(username="me", token="notatoken") 14 | 15 | assert api.headers == { 16 | "Authorization": "token notatoken", 17 | "User-Agent": f"pytoil/{__version__}", 18 | "Accept": "application/vnd.github.v4+json", 19 | } 20 | 21 | 22 | def test_get_repo_names(httpx_mock: HTTPXMock, fake_get_repo_names_response: dict[str, Any]) -> None: 23 | api = API(username="me", token="definitelynotatoken") 24 | 25 | httpx_mock.add_response(url=api.url, json=fake_get_repo_names_response, status_code=200) 26 | 27 | names = api.get_repo_names() 28 | 29 | assert names == { 30 | "dingle", 31 | "dangle", 32 | "dongle", 33 | "a_cool_project", 34 | "another", 35 | "yetanother", 36 | "hello", 37 | } 38 | 39 | 40 | def test_check_repo_exists_returns_false_if_not_exists( 41 | httpx_mock: HTTPXMock, fake_repo_exists_false_response: dict[str, Any] 42 | ) -> None: 43 | api = API(username="me", token="definitelynotatoken") 44 | 45 | httpx_mock.add_response(url=api.url, json=fake_repo_exists_false_response, status_code=200) 46 | 47 | exists = api.check_repo_exists(owner="me", name="dave") 48 | 49 | assert exists is False 50 | 51 | 52 | def test_check_repo_exists_returns_true_if_exists( 53 | httpx_mock: HTTPXMock, fake_repo_exists_true_response: dict[str, Any] 54 | ) -> None: 55 | api = API(username="me", token="definitelynotatoken") 56 | 57 | httpx_mock.add_response(url=api.url, json=fake_repo_exists_true_response, status_code=200) 58 | 59 | exists = api.check_repo_exists(owner="me", name="pytoil") 60 | 61 | assert exists is True 62 | 63 | 64 | @freeze_time("2022-01-16") 65 | def test_get_repo_info_good_response(httpx_mock: HTTPXMock, fake_repo_info_response: dict[str, Any]) -> None: 66 | api = API(username="me", token="definitelynotatoken") 67 | 68 | httpx_mock.add_response(url=api.url, json=fake_repo_info_response, status_code=200) 69 | 70 | info = api.get_repo_info(name="pytoil") 71 | 72 | assert info == { 73 | "Name": "pytoil", 74 | "Description": "CLI to automate the development workflow :robot:", 75 | "Created": "11 months ago", 76 | "Updated": "19 days ago", 77 | "Size": "3.2 MB", 78 | "License": "Apache License 2.0", 79 | "Remote": True, 80 | "Language": "Python", 81 | } 82 | 83 | 84 | @freeze_time("2022-01-16") 85 | def test_get_repo_info_no_license(httpx_mock: HTTPXMock, fake_repo_info_response_no_license: dict[str, Any]) -> None: 86 | api = API(username="me", token="definitelynotatoken") 87 | 88 | httpx_mock.add_response(url=api.url, json=fake_repo_info_response_no_license, status_code=200) 89 | 90 | info = api.get_repo_info(name="pytoil") 91 | 92 | assert info == { 93 | "Name": "pytoil", 94 | "Description": "CLI to automate the development workflow :robot:", 95 | "Created": "11 months ago", 96 | "Updated": "19 days ago", 97 | "Size": "3.2 MB", 98 | "License": None, 99 | "Remote": True, 100 | "Language": "Python", 101 | } 102 | 103 | 104 | def test_create_fork(httpx_mock: HTTPXMock) -> None: 105 | api = API(username="me", token="definitelynotatoken") 106 | 107 | httpx_mock.add_response(url="https://api.github.com/repos/someoneelse/project/forks", status_code=201) 108 | 109 | api.create_fork(owner="someoneelse", repo="project") 110 | 111 | 112 | def test_get_repos(httpx_mock: HTTPXMock, fake_get_repos_response: dict[str, Any]) -> None: 113 | api = API(username="me", token="definitelynotatoken") 114 | 115 | httpx_mock.add_response(url=api.url, json=fake_get_repos_response, status_code=200) 116 | 117 | data = api.get_repos() 118 | 119 | assert data == [ 120 | { 121 | "name": "advent_of_code_2020", 122 | "description": "Retroactively doing AOC2020 in Go.", 123 | "createdAt": "2022-01-05T16:54:03Z", 124 | "pushedAt": "2022-01-09T06:55:32Z", 125 | "diskUsage": 45, 126 | }, 127 | { 128 | "name": "advent_of_code_2021", 129 | "description": "My code for AOC 2021", 130 | "createdAt": "2021-11-30T12:01:22Z", 131 | "pushedAt": "2021-12-19T15:10:07Z", 132 | "diskUsage": 151, 133 | }, 134 | { 135 | "name": "aircraft_crashes", 136 | "description": "Analysis of aircraft crash data.", 137 | "createdAt": "2021-01-02T19:34:15Z", 138 | "pushedAt": "2021-01-20T10:35:57Z", 139 | "diskUsage": 2062, 140 | }, 141 | { 142 | "name": "cookie_pypackage", 143 | "description": "My own version of the Cookiecutter pypackage template", 144 | "createdAt": "2020-07-04T10:05:36Z", 145 | "pushedAt": "2021-12-03T08:45:49Z", 146 | "diskUsage": 734, 147 | }, 148 | { 149 | "name": "cv", 150 | "description": "Repo for my CV, built with JSON Resume.", 151 | "createdAt": "2021-10-30T15:11:49Z", 152 | "pushedAt": "2022-01-10T19:50:24Z", 153 | "diskUsage": 145, 154 | }, 155 | { 156 | "name": "eu_energy_analysis", 157 | "description": "Analysis of the EU Open Power System Data.", 158 | "createdAt": "2020-12-13T10:50:35Z", 159 | "pushedAt": "2020-12-24T11:12:34Z", 160 | "diskUsage": 1834, 161 | }, 162 | { 163 | "name": "FollowTheProcess", 164 | "description": 'My "About Me" Repo', 165 | "createdAt": "2020-07-14T16:06:52Z", 166 | "pushedAt": "2022-01-10T20:05:47Z", 167 | "diskUsage": 14640, 168 | }, 169 | { 170 | "name": "followtheprocess.github.io", 171 | "description": "Repo for my GitHub pages site.", 172 | "createdAt": "2021-02-19T20:16:05Z", 173 | "pushedAt": "2021-11-18T19:04:06Z", 174 | "diskUsage": 10753, 175 | }, 176 | ] 177 | 178 | 179 | def test_get_forks(httpx_mock: HTTPXMock, fake_get_forks_response: dict[str, Any]) -> None: 180 | api = API(username="me", token="definitelynotatoken") 181 | 182 | httpx_mock.add_response(url=api.url, json=fake_get_forks_response, status_code=200) 183 | 184 | data = api.get_forks() 185 | 186 | assert data == [ 187 | { 188 | "name": "nox", 189 | "diskUsage": 5125, 190 | "createdAt": "2021-07-01T11:43:36Z", 191 | "pushedAt": "2022-01-08T11:00:44Z", 192 | "parent": {"nameWithOwner": "theacodes/nox"}, 193 | }, 194 | { 195 | "name": "python-launcher", 196 | "diskUsage": 824, 197 | "createdAt": "2021-10-25T18:33:11Z", 198 | "pushedAt": "2021-11-09T07:47:23Z", 199 | "parent": {"nameWithOwner": "brettcannon/python-launcher"}, 200 | }, 201 | ] 202 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import tempfile 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from pytoil.config import Config, defaults 11 | 12 | # GitHub Actions 13 | ON_CI = bool(os.getenv("CI")) 14 | ON_WINDOWS = platform.system().lower() == "windows" 15 | 16 | 17 | def test_config_init_defaults() -> None: 18 | config = Config() 19 | 20 | assert config.projects_dir == defaults.PROJECTS_DIR 21 | assert config.token == defaults.TOKEN 22 | assert config.username == defaults.USERNAME 23 | assert config.editor == defaults.EDITOR 24 | assert config.conda_bin == defaults.CONDA_BIN 25 | assert config.common_packages == defaults.COMMON_PACKAGES 26 | assert config.git == defaults.GIT 27 | 28 | 29 | def test_config_init_passed() -> None: 30 | config = Config( 31 | projects_dir=Path("some/dir"), 32 | token="sometoken", 33 | username="me", 34 | editor="fakeedit", 35 | conda_bin="mamba", 36 | common_packages=["black", "mypy", "flake8"], 37 | git=False, 38 | ) 39 | 40 | assert config.projects_dir == Path("some/dir") 41 | assert config.token == "sometoken" 42 | assert config.username == "me" 43 | assert config.editor == "fakeedit" 44 | assert config.conda_bin == "mamba" 45 | assert config.common_packages == ["black", "mypy", "flake8"] 46 | assert config.git is False 47 | 48 | 49 | def test_config_helper() -> None: 50 | config = Config.helper() 51 | 52 | assert config.projects_dir == defaults.PROJECTS_DIR 53 | assert config.token == "Put your GitHub personal access token here" 54 | assert config.username == "This your GitHub username" 55 | assert config.editor == defaults.EDITOR 56 | assert config.conda_bin == defaults.CONDA_BIN 57 | assert config.common_packages == defaults.COMMON_PACKAGES 58 | assert config.git == defaults.GIT 59 | 60 | 61 | def test_config_load() -> None: 62 | with tempfile.NamedTemporaryFile("w", delete=not (ON_CI and ON_WINDOWS)) as file: 63 | # Make a fake config object 64 | config = Config( 65 | projects_dir=Path("~/some/dir"), 66 | token="sometoken", 67 | username="me", 68 | editor="fakeedit", 69 | common_packages=["black", "mypy", "flake8"], 70 | git=False, 71 | ) 72 | 73 | # Write the config 74 | config.write(path=Path(file.name)) 75 | 76 | # Load the config 77 | loaded_config = Config.load(path=Path(file.name)) 78 | 79 | assert loaded_config.projects_dir == Path("~/some/dir").expanduser() 80 | assert loaded_config.token == "sometoken" 81 | assert loaded_config.username == "me" 82 | assert loaded_config.editor == "fakeedit" 83 | assert loaded_config.common_packages == ["black", "mypy", "flake8"] 84 | assert loaded_config.git is False 85 | 86 | 87 | @pytest.mark.parametrize( 88 | ("editor", "want"), 89 | [ 90 | ("code", True), 91 | ("", True), # Because it will default to $EDITOR 92 | ("None", False), 93 | ("none", False), 94 | ], 95 | ) 96 | def test_specifies_editor(editor: str, want: bool) -> None: 97 | config = Config(editor=editor) 98 | assert config.specifies_editor() is want 99 | 100 | 101 | def test_from_file_raises_on_missing_file() -> None: 102 | with pytest.raises(FileNotFoundError): 103 | Config.load(path=Path("not/here.toml")) 104 | 105 | 106 | @pytest.mark.parametrize( 107 | ("username", "token", "expected"), 108 | [ 109 | ("", "", False), 110 | ("", "something", False), 111 | ("something", "", False), 112 | ( 113 | "This your GitHub username", 114 | "Put your GitHub personal access token here", 115 | False, 116 | ), 117 | ("", "Put your GitHub personal access token here", False), 118 | ("This your GitHub username", "", False), 119 | ("something", "something", True), 120 | ], 121 | ) 122 | def test_can_use_api(username: str, token: str, expected: bool) -> None: 123 | config = Config(username=username, token=token) 124 | 125 | assert config.can_use_api() is expected 126 | 127 | 128 | def test_file_write() -> None: 129 | with tempfile.NamedTemporaryFile("w", delete=not (ON_CI and ON_WINDOWS)) as file: 130 | # Make a fake config object 131 | config = Config( 132 | projects_dir=Path("some/dir").expanduser().resolve(), 133 | token="sometoken", 134 | username="me", 135 | editor="fakeedit", 136 | common_packages=["black", "mypy", "flake8"], 137 | git=False, 138 | ) 139 | 140 | # Write the config 141 | config.write(path=Path(file.name)) 142 | 143 | file_config = Config.load(Path(file.name)) 144 | 145 | assert file_config == config 146 | -------------------------------------------------------------------------------- /tests/test_editor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | from pytest_mock import MockerFixture 7 | 8 | from pytoil.editor import launch 9 | 10 | 11 | def test_launch(mocker: MockerFixture) -> None: 12 | mock = mocker.patch("pytoil.editor.editor.subprocess.run", autospec=True) 13 | 14 | launch(path=Path("somewhere"), binary="/path/to/editor") 15 | 16 | mock.assert_called_once_with(["/path/to/editor", Path("somewhere")], stdout=sys.stdout, stderr=sys.stderr) 17 | -------------------------------------------------------------------------------- /tests/test_git.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | from typing import TextIO 8 | 9 | import pytest 10 | from pytest_mock import MockerFixture 11 | 12 | from pytoil.exceptions import GitNotInstalledError 13 | from pytoil.git import Git 14 | 15 | 16 | def test_git_instanciation_default() -> None: 17 | git = Git() 18 | 19 | assert git.git == shutil.which("git") 20 | 21 | 22 | def test_git_instantiation_passed() -> None: 23 | git = Git(git="/some/path/to/git") 24 | 25 | assert git.git == "/some/path/to/git" 26 | 27 | 28 | def test_git_repr() -> None: 29 | git = Git(git="hellogit") 30 | 31 | assert repr(git) == "Git(git='hellogit')" 32 | 33 | 34 | @pytest.mark.parametrize( 35 | ("silent", "stdout", "stderr"), 36 | [ 37 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 38 | (False, sys.stdout, sys.stderr), 39 | ], 40 | ) 41 | def test_git_init(mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int) -> None: 42 | mock = mocker.patch("pytoil.git.git.subprocess.run", autospec=True) 43 | 44 | git = Git(git="notgit") 45 | 46 | git.init(Path("somewhere"), silent=silent) 47 | 48 | mock.assert_called_once_with( 49 | ["notgit", "init"], 50 | cwd=Path("somewhere"), 51 | stdout=stdout, 52 | stderr=stderr, 53 | ) 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("silent", "stdout", "stderr"), 58 | [ 59 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 60 | (False, sys.stdout, sys.stderr), 61 | ], 62 | ) 63 | def test_git_clone(mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int) -> None: 64 | mock = mocker.patch("pytoil.git.git.subprocess.run", autospec=True) 65 | 66 | git = Git(git="notgit") 67 | 68 | git.clone(url="https://nothub.com/some/project.git", cwd=Path("somewhere"), silent=silent) 69 | 70 | mock.assert_called_once_with( 71 | ["notgit", "clone", "https://nothub.com/some/project.git"], 72 | cwd=Path("somewhere"), 73 | stdout=stdout, 74 | stderr=stderr, 75 | ) 76 | 77 | 78 | def test_instantiation_raises_if_git_not_installed() -> None: 79 | with pytest.raises(GitNotInstalledError): 80 | Git(git=None) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ("silent", "stdout", "stderr"), 85 | [ 86 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 87 | (False, sys.stdout, sys.stderr), 88 | ], 89 | ) 90 | def test_git_set_upstream(mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int) -> None: 91 | mock = mocker.patch("pytoil.git.git.subprocess.run", autospec=True) 92 | 93 | git = Git(git="notgit") 94 | 95 | git.set_upstream(owner="me", repo="project", cwd=Path("somewhere"), silent=silent) 96 | 97 | mock.assert_called_once_with( 98 | ["notgit", "remote", "add", "upstream", "https://github.com/me/project.git"], 99 | cwd=Path("somewhere"), 100 | stdout=stdout, 101 | stderr=stderr, 102 | ) 103 | 104 | 105 | @pytest.mark.parametrize( 106 | ("silent", "stdout", "stderr"), 107 | [ 108 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 109 | (False, sys.stdout, sys.stderr), 110 | ], 111 | ) 112 | def test_git_add_all(mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int) -> None: 113 | mock = mocker.patch("pytoil.git.git.subprocess.run", autospec=True) 114 | 115 | git = Git(git="notgit") 116 | 117 | git.add(cwd=Path("somewhere"), silent=silent) 118 | 119 | mock.assert_called_once_with(["notgit", "add", "-A"], cwd=Path("somewhere"), stdout=stdout, stderr=stderr) 120 | 121 | 122 | @pytest.mark.parametrize( 123 | ("silent", "stdout", "stderr"), 124 | [ 125 | (True, subprocess.DEVNULL, subprocess.DEVNULL), 126 | (False, sys.stdout, sys.stderr), 127 | ], 128 | ) 129 | def test_git_commit(mocker: MockerFixture, silent: bool, stdout: TextIO | int, stderr: TextIO | int) -> None: 130 | mock = mocker.patch("pytoil.git.git.subprocess.run", autospec=True) 131 | 132 | git = Git(git="notgit") 133 | 134 | git.commit(message="Commit message", cwd=Path("somewhere"), silent=silent) 135 | 136 | mock.assert_called_once_with( 137 | ["notgit", "commit", "-m", "Commit message"], 138 | cwd=Path("somewhere"), 139 | stdout=stdout, 140 | stderr=stderr, 141 | ) 142 | --------------------------------------------------------------------------------