"
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 |
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 | 
2 |
3 | [](https://github.com/FollowTheProcess/pytoil)
4 | [](https://pypi.python.org/pypi/pytoil)
5 | [](https://github.com/FollowTheProcess/pytoil)
6 | [](https://github.com/pypa/hatch)
7 | [](https://github.com/astral-sh/ruff)
8 | [](https://github.com/FollowTheProcess/pytoil/actions?query=workflow%3ACI)
9 | [](https://codecov.io/gh/FollowTheProcess/pytoil)
10 | [](https://results.pre-commit.ci/latest/github/FollowTheProcess/pytoil/main)
11 | [](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 | 
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 | 
64 |
65 | You can always fall back to pip
66 |
67 | 
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 | 
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 | 
92 |
93 | #### See which ones you have on GitHub, but not on your computer
94 |
95 | 
96 |
97 | #### Easily grab a project, regardless of where it is
98 |
99 | This project is available on your local machine...
100 |
101 | 
102 |
103 | This one is on GitHub...
104 |
105 | 
106 |
107 | #### Create a new project and virtual environment in one go
108 |
109 | 
110 |
111 | (And include custom packages, see the [docs])
112 |
113 | #### And even do this from a [cookiecutter] template
114 |
115 | 
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 | 
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 |
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 |
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 |
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 |
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 |
36 |
37 | ```console
38 | $ pytoil config get editor
39 |
40 | editor: code-insiders
41 | ```
42 |
43 |
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 |
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 |
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 |
73 |
74 | ```console
75 | $pytoil config edit
76 |
77 | Opening ~/.pytoil.toml in your $EDITOR
78 | ```
79 |
80 |
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 |
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 |
42 |
43 | ## Searching for Projects
44 |
45 |
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 |
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 |
76 |
77 | ```console
78 | // Something that won't match
79 | $ pytoil find dingledangledongle
80 |
81 | ⚠ No matches found!
82 | ```
83 |
84 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
62 |
63 | And if you say no...
64 |
65 |
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 |
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 |
82 |
83 | ```console
84 | $ pytoil keep project1 project2 --force
85 |
86 | Removed: remove1.
87 | Removed: remove2.
88 | Removed: remove3.
89 | ```
90 |
91 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
121 |
122 | And just like `--all` you can abort the whole operation by entering `n` when prompted.
123 |
124 |
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 |
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 |
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 |
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 |
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 |
72 |
73 | And if you say no...
74 |
75 |
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 |
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 |
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 |
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 |
115 |
116 | ```console
117 | $ pytoil remove project1 project2 --force
118 |
119 | Removed: 'remove1'.
120 | Removed: 'remove2'.
121 | Removed: 'remove3'.
122 | ```
123 |
124 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
126 |
127 | ## Forks
128 |
129 | You can also see all your forked repos and whether or not they are available locally!
130 |
131 |
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 |
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 |
45 |
46 | ```console
47 | $ pytoil
48 |
49 | No pytoil config file detected!
50 | ? Interactively configure pytoil? [y/n]
51 | ```
52 |
53 |
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//pytoil.git
29 | ```
30 |
31 | **If you use SSH:**
32 |
33 | ```shell
34 | git clone git@github.com:/pytoil.git
35 | ```
36 |
37 | **Or you can be really fancy and use the [GH CLI]**
38 |
39 | ```shell
40 | gh repo clone /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
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
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
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
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 | 
2 |
3 | [](https://github.com/FollowTheProcess/pytoil)
4 | [](https://pypi.python.org/pypi/pytoil)
5 | [](https://github.com/FollowTheProcess/pytoil)
6 | [](https://github.com/FollowTheProcess/pytoil)
7 | [](https://github.com/FollowTheProcess/pytoil/actions?query=workflow%3ACI)
8 | [](https://codecov.io/gh/FollowTheProcess/pytoil)
9 | [](https://pepy.tech/project/pytoil)
10 |
11 | > ***toil***
12 |
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 | 
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 | 
56 |
57 | You can always fall back to pip
58 |
59 | 
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 | 
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 | 
84 |
85 | #### See which ones you have on GitHub, but not on your computer
86 |
87 | 
88 |
89 | #### Easily grab a project, regardless of where it is
90 |
91 | This project is available on your local machine...
92 |
93 | 
94 |
95 | This one is on GitHub...
96 |
97 | 
98 |
99 | #### Create a new project and virtual environment in one go
100 |
101 | 
102 |
103 | (And include custom packages, see the [docs])
104 |
105 | #### And even do this from a [cookiecutter] template
106 |
107 | 
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 | 
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
won't have effect
35 | // so put an additional one
36 | buffer.push("");
37 | }
38 | const bufferValue = buffer.join("
");
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 ` ` 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 |
--------------------------------------------------------------------------------