Compare commits
351 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfc4e5812 | |||
| 33eddecd6e | |||
| ee779cec4d | |||
| df7bd253d4 | |||
| 8c6339493d | |||
| b87b18e9d4 | |||
| e7b88acea2 | |||
| 45c8ad6709 | |||
| 2116dd9582 | |||
| 05d1c36f49 | |||
| 5e57fdefe0 | |||
| af2ef5d90f | |||
| 1f6bb7dab0 | |||
| 2eb3279945 | |||
| 8fd7af98d9 | |||
| 240ccd781a | |||
| af9e8063f6 | |||
| 3054dad82b | |||
| 39a271132b | |||
| c19c21449e | |||
| f245cb7899 | |||
| 0b5866159a | |||
| 0dd2280781 | |||
| c049da0c6f | |||
| 7b69bd6f11 | |||
| c594e5df09 | |||
| 915bb40473 | |||
| af1403fc41 | |||
| 13e5f5c855 | |||
| f393ad0053 | |||
| 947e49a15d | |||
| cd3ed07cd0 | |||
| 558e248e20 | |||
| cd1e98e9d0 | |||
| 8c2ae297ed | |||
| 7be310f364 | |||
| 2316a34ce9 | |||
| 36c23e3de6 | |||
| b6bc144e9e | |||
| 93e629608e | |||
| cacd0fabbc | |||
| d9f9b5a27d | |||
| 4d4892f58a | |||
| ffe20fb63e | |||
| b384f871e8 | |||
| f2b8522c89 | |||
| 75dcb0e475 | |||
| 1e98a2e459 | |||
| c53fb88479 | |||
| fed1e39611 | |||
| ed0d5699ff | |||
| b1542e4a62 | |||
| 9c7c371419 | |||
| ae45bdeb02 | |||
| d20af71118 | |||
| 8f85a8856f | |||
| 87c356773f | |||
| 27a97e4ebf | |||
| 53d8dab86f | |||
| 86a3decd0a | |||
| d3917b10e7 | |||
| 86b3b74b21 | |||
| bb385f7786 | |||
| e07ad4c9ee | |||
| 1e03b45986 | |||
| 7a1b556b25 | |||
| d7911ffaf7 | |||
| daa7f54dd0 | |||
| 753136ade4 | |||
| 579763620d | |||
| cbc22adb76 | |||
| 82bb65bd52 | |||
| 671e553239 | |||
| fa4bcc034d | |||
| 0a048da8a4 | |||
| 7808a1166c | |||
| b9b2ebbe13 | |||
| b4b53e80eb | |||
| 2212fdabf7 | |||
| 1e8a174e0c | |||
| 6868d5ba6f | |||
| e203006537 | |||
| 1c72e1e564 | |||
| aecd02e850 | |||
| 5019970d01 | |||
| 446547df04 | |||
| dea98e71b8 | |||
| ea62fa1cec | |||
| 32129ee5a2 | |||
| 6638c13c36 | |||
| 98ab6a52f6 | |||
| f9d3020616 | |||
| 1c98cf7053 | |||
| 6b98cb311f | |||
| 8a4bf2d437 | |||
| db645b7db8 | |||
| 3f4c491202 | |||
| 39d3adddff | |||
| d557058db7 | |||
| 3126bdb90a | |||
| d2f73683f8 | |||
| 83978d4c68 | |||
| bb00940631 | |||
| 29bcd3aa01 | |||
| 42481f2db3 | |||
| 928d22d370 | |||
| 169e12a4c6 | |||
| 26ad0cd2b9 | |||
| 63fc8ff722 | |||
| bb912d3473 | |||
| 7cb41c2daa | |||
| c7af299b48 | |||
| c0698e953b | |||
| 54bf8328a1 | |||
| ace9daea3e | |||
| 255990f45b | |||
| 1742e84da5 | |||
| 2b4021eaa2 | |||
| 670ead38ad | |||
| c12c4a6e0d | |||
| bd5e3b5cb4 | |||
| 82fede52ac | |||
| 76fa43cf78 | |||
| 7ec12a8b9d | |||
| b714ea60c3 | |||
| 1797e83de8 | |||
| 593ae652e6 | |||
| 963818be15 | |||
| 47b977f9b2 | |||
| 8c0f655483 | |||
| e0aacc9dc7 | |||
| 24472967eb | |||
| 1a2e43aa58 | |||
| ffed902922 | |||
| 325868514b | |||
| dbfdd2dec5 | |||
| de7f8f6b70 | |||
| 552fe67430 | |||
| de28654c6d | |||
| 02c190894a | |||
| 7df9a61f89 | |||
| dad8b3c15a | |||
| e24448ed18 | |||
| 9befc84294 | |||
| bb489e8ec2 | |||
| f208f0228c | |||
| e4e8f987e1 | |||
| 9f74f1ca9c | |||
| bb1a9e43cf | |||
| 501182017e | |||
| 710dc3e951 | |||
| 12242d94c2 | |||
| 19336ff9ee | |||
| 8e1de74918 | |||
| 137ed60056 | |||
| d320265936 | |||
| 97346a44bc | |||
| 1e0b08250d | |||
| 6a928e9322 | |||
| 5315431058 | |||
| aa11928c03 | |||
| 369c3e150a | |||
| c4e82e0740 | |||
| 95152b6f7f | |||
| 3bd1a79bfa | |||
| 42fcfc3930 | |||
| 60f2bb743d | |||
| f8902bd258 | |||
| f7125127f1 | |||
| f8fe363999 | |||
| bbd667e30c | |||
| 32506bf1bb | |||
| 45a231af0c | |||
| 1f7ad8a73e | |||
| 2faca28631 | |||
| d99eed2ed0 | |||
| a5a481e322 | |||
| 21daea7028 | |||
| 51e80c2f52 | |||
| 567ef9637e | |||
| e8818c9d23 | |||
| 2710813c51 | |||
| fb69e6d50a | |||
| 135d9f63a1 | |||
| 695cd7eb4d | |||
| 61ca025afd | |||
| 2bd1630621 | |||
| 34517d01b7 | |||
| 0a4d66e13a | |||
| fe0a3af05f | |||
| 4508b8548f | |||
| 2ea90a0893 | |||
| 9fccc990be | |||
| 6d99eacdf8 | |||
| dbd731585a | |||
| 9d480bd287 | |||
| 709f416e52 | |||
| bcdac9a80e | |||
| 4772e68828 | |||
| 24741c7d2b | |||
| b756421528 | |||
| 3d96bcdc14 | |||
| 6e6b6bcde6 | |||
| 1df06eed72 | |||
| 60a56b6976 | |||
| 1e3605feb3 | |||
| 48ddcefea1 | |||
| 2f6f38cc33 | |||
| c73ae7e97c | |||
| 0475193870 | |||
| 8d9b3b2880 | |||
| 0dbe3dc448 | |||
| c41fd5e940 | |||
| b70736a876 | |||
| 69b3b890d2 | |||
| 8b30a9766d | |||
| 982349a450 | |||
| 4d161c9ec4 | |||
| 047eb00594 | |||
| 558251860a | |||
| b685fe5d3f | |||
| e447fb4252 | |||
| 066a865084 | |||
| a1276f8d58 | |||
| 3555e34ae9 | |||
| b36cf144e3 | |||
| fb05b69adc | |||
| 4283c258d9 | |||
| b5e8aa4cac | |||
| 1c6d81fc3b | |||
| 0ffae74377 | |||
| cbe3ad0248 | |||
| 97689b53d7 | |||
| 054e071787 | |||
| 78e0fda2f0 | |||
| 9c6123483b | |||
| e851c8fcb5 | |||
| 92168e1ef0 | |||
| fd744ac24c | |||
| 840c791cc8 | |||
| 8f5b8e83c0 | |||
| 1da98510d3 | |||
| 6ec242b133 | |||
| 6433cbc0f4 | |||
| aedad7e602 | |||
| 504291bbf5 | |||
| c6e736adc7 | |||
| fe42ab3534 | |||
| d805fe2b6e | |||
| 5321a121fc | |||
| 3c31d41bf3 | |||
| c6977fa851 | |||
| 3b6c0fd058 | |||
| 216f5d44c1 | |||
| 0bf0b5a503 | |||
| 4b11e04672 | |||
| aba043abdc | |||
| efd9f68243 | |||
| e3e0856f96 | |||
| 670a07ff0b | |||
| c4c76e3534 | |||
| 9ad519d4a2 | |||
| 655979cef5 | |||
| 6b0d5fe85b | |||
| d163222123 | |||
| 5342ab3e10 | |||
| 21ee8ce228 | |||
| 111ebe2a34 | |||
| 9fdee68e7f | |||
| b36c53a249 | |||
| 07c38a36d5 | |||
| 7605ab1007 | |||
| 315e863b21 | |||
| 8fc7dccd13 | |||
| 8a531f7b7b | |||
| 12c415f09c | |||
| 5a777c5de9 | |||
| c51c898562 | |||
| 08ea23b087 | |||
| 0105405d83 | |||
| 92c57e2a46 | |||
| 30c89ec8e0 | |||
| a7a7bd9f08 | |||
| 5229ce14e3 | |||
| 8fa960f25e | |||
| 1481dfe157 | |||
| ac39b9bb0f | |||
| 44a446308f | |||
| 90bdf52416 | |||
| 191980052e | |||
| 5fbdd95c4b | |||
| 1002360e20 | |||
| a03aa1cbba | |||
| a0b692d718 | |||
| ab7d0c6e46 | |||
| 73c903b8b6 | |||
| 8f2185af32 | |||
| bae2f1f1ee | |||
| 120ce557f1 | |||
| a15b0d6460 | |||
| f0dafaf068 | |||
| 3414bdcaa3 | |||
| c8567d8098 | |||
| dc3ca88deb | |||
| a4e9c34387 | |||
| 742e1ce0e1 | |||
| 39df759f57 | |||
| b694c4284d | |||
| e184710036 | |||
| d67addde2d | |||
| 29541559d0 | |||
| db0b8cb9a0 | |||
| 6a0e62a078 | |||
| bbc5ffd6c9 | |||
| e0adba4e68 | |||
| 56bcb12ed6 | |||
| 897fe121e2 | |||
| 1a723e488f | |||
| bf502d26fa | |||
| ebea7f7ea0 | |||
| 49b288786a | |||
| e75b4d4ed1 | |||
| 7f56cfd781 | |||
| b04bf7a4da | |||
| fb260f29e1 | |||
| e8e98ca15a | |||
| 4d1302696e | |||
| 4d78df2cbc | |||
| 4a45081412 | |||
| 9cab9ea610 | |||
| 177fa55bce | |||
| 0783864629 | |||
| 8ec66dd599 | |||
| 790536eca4 | |||
| 6f1ec49a9a | |||
| c7f37a0e1b | |||
| 5001c627ef | |||
| 490c94e282 | |||
| 212e185b62 | |||
| b7d0a65bdf | |||
| 221d42fec1 | |||
| f3e83794c9 | |||
| 73628cc6b0 | |||
| e077319a16 | |||
| 5b0ba9a25d | |||
| bd4e9bf9cf | |||
| b2974b4219 | |||
| be0dd95e46 | |||
| 30031a8cd7 | |||
| 94adba52c6 | |||
| 38aefc9716 |
@@ -0,0 +1,12 @@
|
||||
[bumpversion]
|
||||
current_version = 1.2.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = {new_version}
|
||||
message = New release: {current_version} → {new_version}
|
||||
|
||||
[bumpversion:file:.bumpversion.cfg]
|
||||
|
||||
[bumpversion:file:qmk_cli/__init__.py]
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "qmk/collaborators"
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "qmk/collaborators"
|
||||
@@ -0,0 +1,41 @@
|
||||
name: CLI Lint
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
paths:
|
||||
- 'qmk_cli/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/cli_lint.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
steps:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Activate virtualenv
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv .ci_venv
|
||||
source .ci_venv/bin/activate
|
||||
echo PATH=$PATH >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install --group dev
|
||||
|
||||
- name: Run lint
|
||||
run: ruff check --no-cache --output-format=github
|
||||
|
||||
- name: Run format
|
||||
run: yapf -r --diff qmk_cli test
|
||||
@@ -0,0 +1,125 @@
|
||||
name: CLI Setup
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test_cli_base_container:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/qmk/qmk_base_container
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: apt-get update && apt-get install -y python3-venv
|
||||
|
||||
- name: Run unit test
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m venv .ci_venv
|
||||
source .ci_venv/bin/activate
|
||||
python3 -m pip install -r requirements.txt
|
||||
python3 -m pip install --group dev
|
||||
pytest
|
||||
|
||||
- name: Run ci_tests
|
||||
run: ./ci_tests
|
||||
|
||||
test_cli_linux_macos:
|
||||
needs: test_cli_base_container
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run ci_tests
|
||||
run: ./ci_tests
|
||||
|
||||
test_cli_win:
|
||||
needs: test_cli_base_container
|
||||
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: (MSYS2) Setup and install dependencies
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
update: true
|
||||
install: git mingw-w64-x86_64-toolchain mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-build mingw-w64-x86_64-python-pillow mingw-w64-x86_64-rust mingw-w64-x86_64-python-halo mingw-w64-x86_64-python-nh3 mingw-w64-x86_64-python-rpds-py mingw-w64-x86_64-python-ruff
|
||||
|
||||
# Upgrade pip due to msys packaging + pypa/build/pull/736 issues
|
||||
- name: (MSYS2) Install Python dependencies
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages --force-reinstall --upgrade pip
|
||||
|
||||
- name: Run ci_tests
|
||||
shell: msys2 {0}
|
||||
run: ./ci_tests
|
||||
|
||||
test_docker_container:
|
||||
needs: test_cli_base_container
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
TEST_TAG: qmkfm/qmk_cli:test
|
||||
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip install setuptools wheel
|
||||
pip install --group dev
|
||||
|
||||
- name: Build Python
|
||||
run: |
|
||||
python3 -m build
|
||||
|
||||
- name: Build Test Container
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ${{ env.TEST_TAG }}
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
docker run --rm -v ${{ github.workspace }}:/qmk_cli -w /qmk_cli ${{ env.TEST_TAG }} /qmk_cli/ci_tests
|
||||
|
||||
- name: Build All Containers
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: full
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '22 18 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Rebuild Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
redeploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Previous tag
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
||||
|
||||
- name: Download previous artifact
|
||||
run: |
|
||||
pip download -d "${GITHUB_WORKSPACE}/dist" qmk=="${{ steps.previoustag.outputs.tag }}"
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and Push slim to Docker Hub
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/qmk/qmk_cli:slim
|
||||
qmkfm/qmk_cli:slim
|
||||
ghcr.io/qmk/qmk_cli:${{ steps.previoustag.outputs.tag }}-slim
|
||||
qmkfm/qmk_cli:${{ steps.previoustag.outputs.tag }}-slim
|
||||
|
||||
- name: Build and Push full to Docker Hub
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: full
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/qmk/qmk_cli:latest
|
||||
qmkfm/qmk_cli:latest
|
||||
ghcr.io/qmk/qmk_cli:${{ github.sha }}
|
||||
qmkfm/qmk_cli:${{ github.sha }}
|
||||
ghcr.io/qmk/qmk_cli:${{ steps.previoustag.outputs.tag }}
|
||||
qmkfm/qmk_cli:${{ steps.previoustag.outputs.tag }}
|
||||
@@ -0,0 +1,101 @@
|
||||
# This workflow will upload a Python Package using Twine when a release is created
|
||||
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_part:
|
||||
description: 'Which part of the version to increment (patch, minor, major)'
|
||||
required: true
|
||||
default: 'patch'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip install setuptools wheel
|
||||
pip install --group dev
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
git config --local user.email "hello@qmk.fm"
|
||||
git config --local user.name "QMK Bot"
|
||||
bumpversion --dry-run --list ${{ github.event.inputs.version_part }} | grep new_version= | sed "s/new_version/NEW_CLI_VERSION/" >> "$GITHUB_ENV"
|
||||
bumpversion ${{ github.event.inputs.version_part }}
|
||||
|
||||
- name: Push changes
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: master
|
||||
tags: true
|
||||
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_APITOKEN }}
|
||||
run: |
|
||||
python3 -m build
|
||||
twine upload dist/*
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and Push slim to Docker Hub
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/qmk/qmk_cli:slim
|
||||
qmkfm/qmk_cli:slim
|
||||
ghcr.io/qmk/qmk_cli:${{ env.NEW_CLI_VERSION }}-slim
|
||||
qmkfm/qmk_cli:${{ env.NEW_CLI_VERSION }}-slim
|
||||
|
||||
- name: Build and Push full to Docker Hub
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: full
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/qmk/qmk_cli:latest
|
||||
qmkfm/qmk_cli:latest
|
||||
ghcr.io/qmk/qmk_cli:${{ github.sha }}
|
||||
qmkfm/qmk_cli:${{ github.sha }}
|
||||
ghcr.io/qmk/qmk_cli:${{ env.NEW_CLI_VERSION }}
|
||||
qmkfm/qmk_cli:${{ env.NEW_CLI_VERSION }}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.formatting.provider": "yapf"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# Code Of Conduct
|
||||
|
||||
QMK strives to be an inclusive, tolerant, and welcoming community. We encourage participation from anyone regardless of age, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, political belief, race, religion, or sexual identity and orientation.
|
||||
|
||||
> “A gentle word turns away wrath, but a harsh word stirs up anger."
|
||||
|
||||
Our users, contributors, and collaborators are expected to treat each other with kindness and respect, to assume good intentions, and to gently correct, where possible, rather than react with escalation. While our goal is to be as accurate as possible, kindness and understanding are more valuable than correctness. Some examples of behavior we will not tolerate include, but is not limited to:
|
||||
|
||||
* The use of sexualized language or imagery
|
||||
* Unwelcome advances, sexual or otherwise
|
||||
* Insults or derogatory comments, or personal or political attacks
|
||||
* Publishing others’ private information without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
If someone is violating this Code of Conduct you may email hello@qmk.fm to bring your concern to the Members. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
FROM ghcr.io/qmk/qmk_base_container:latest AS builder
|
||||
|
||||
# Copy package in
|
||||
ADD dist /tmp/dist
|
||||
|
||||
# Install QMK CLI via bootstrap script
|
||||
RUN mkdir -p /opt/uv/bin \
|
||||
&& export UV_PYTHON_INSTALL_DIR=/opt/uv/bin \
|
||||
&& /bin/bash -c "curl -fsSL https://install.qmk.fm | sh -s -- --confirm --uv-install-dir=/usr/local/bin --uv-tool-dir=/opt/uv/tools --qmk-distrib-dir=/opt/qmk"
|
||||
|
||||
# Do the equivalent of entering the virtual environment
|
||||
ENV PATH=/opt/qmk/bin:/opt/uv/tools/qmk/bin:/usr/local/bin:$PATH \
|
||||
VIRTUAL_ENV=/opt/uv/tools/qmk
|
||||
|
||||
# Install python packages
|
||||
RUN python3 -m pip uninstall -y qmk || true
|
||||
RUN python3 -m pip install --upgrade pip setuptools wheel nose2 && \
|
||||
python3 -m pip install /tmp/dist/qmk-*.whl
|
||||
|
||||
# Allow the uv-installed python to be usable by others
|
||||
RUN chmod -R go=u,go-w /opt/uv /opt/qmk
|
||||
|
||||
# 2nd stage so we don't have /tmp/dist in the final image
|
||||
FROM ghcr.io/qmk/qmk_base_container:latest AS slim
|
||||
|
||||
COPY --from=builder /opt/uv /opt/uv
|
||||
|
||||
# Do the equivalent of entering the virtual environment
|
||||
ENV PATH=/opt/uv/tools/qmk/bin:$PATH \
|
||||
VIRTUAL_ENV=/opt/uv/tools/qmk \
|
||||
QMK_HOME=/qmk_firmware \
|
||||
QMK_USERSPACE=/qmk_userspace
|
||||
|
||||
# 2nd stage so we don't have /tmp/dist in the final image
|
||||
FROM ghcr.io/qmk/qmk_base_container:latest AS full
|
||||
|
||||
COPY --from=builder /opt/uv /opt/uv
|
||||
COPY --from=builder /opt/qmk /opt/qmk
|
||||
|
||||
# Do the equivalent of entering the virtual environment
|
||||
ENV PATH=/opt/qmk/bin:/opt/uv/tools/qmk/bin:$PATH \
|
||||
VIRTUAL_ENV=/opt/uv/tools/qmk \
|
||||
QMK_DISTRIB_DIR=/opt/qmk \
|
||||
QMK_HOME=/qmk_firmware \
|
||||
QMK_USERSPACE=/qmk_userspace
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
# QMK CLI
|
||||
|
||||
A program to help users work with [QMK Firmware](https://qmk.fm/).
|
||||
> A program to help users work with [QMK Firmware](https://qmk.fm/).
|
||||
|
||||
# Features
|
||||
[](https://github.com/qmk/qmk_cli/tags)
|
||||
[](https://github.com/qmk/qmk_cli/actions?query=workflow%3ACLI+branch%3Amaster)
|
||||
[](https://discord.gg/qmk)
|
||||
|
||||
## Features
|
||||
|
||||
* Interact with your qmk_firmware tree from any location
|
||||
* Use `qmk clone` to pull down anyone's `qmk_firmware` fork
|
||||
* Setup your build environment with `qmk setup`
|
||||
* Use `qmk console` to get debugging information from your keyboard(s)
|
||||
* Check that your environment is correctly setup with `qmk doctor`
|
||||
* Dispatches to `qmk_firmware/bin/qmk` for additional functionality:
|
||||
* `qmk compile-json`
|
||||
* More to come
|
||||
* Integrates with qmk_firmware for additional functionality:
|
||||
* `qmk compile`
|
||||
* `qmk info`
|
||||
* `qmk flash`
|
||||
* `qmk lint`
|
||||
* ...and many more!
|
||||
|
||||
# Quickstart
|
||||
## Quickstart
|
||||
|
||||
To be written.
|
||||
Follow our [documentation to get started](https://docs.qmk.fm/newbs_getting_started).
|
||||
|
||||
## Packages
|
||||
|
||||
We provide "install and go" packages for many Operating Systems.
|
||||
|
||||
### macOS
|
||||
|
||||
Using [Homebrew](https://brew.sh):
|
||||
|
||||
brew install qmk/qmk/qmk
|
||||
|
||||
### Windows
|
||||
|
||||
Download our custom MSYS2 installer here: https://msys.qmk.fm/
|
||||
|
||||
## Building
|
||||
|
||||
We follow PEP517, you can install using [build](https://pypi.org/project/build/):
|
||||
|
||||
Setup:
|
||||
|
||||
python3 -m pip install --group dev
|
||||
|
||||
Build:
|
||||
|
||||
python3 -m build
|
||||
|
||||
You can read more about working with PEP517 packages in the [Python Packaging User Guide](https://packaging.python.org/guides/distributing-packages-using-setuptools/#packaging-your-project).
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: <https://docs.qmk.fm/#/tutorial>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.0.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
* [Open an Issue](https://github.com/qmk/qmk_cli/issues/new?assignees=&labels=bug&template=bug_report.md&title=%5BBug%5D+)
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
#set -x
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
CI_VENV="$TMPDIR/.ci_venv"
|
||||
|
||||
# Avoid issues with building pillow as we install mingw-w64-x86_64-python-pillow
|
||||
if [[ "$(uname -s)" =~ ^MINGW64_NT.* ]]; then
|
||||
CI_VENV_ARGS="--system-site-packages"
|
||||
fi
|
||||
|
||||
python3 -m venv $CI_VENV_ARGS $CI_VENV
|
||||
source $CI_VENV/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
python3 -m pip install -U pip wheel
|
||||
python3 -m pip install .
|
||||
python3 -m pip install --group dev
|
||||
|
||||
pytest -vv -m system --qmk=real
|
||||
@@ -1,742 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""MILC - A CLI Framework
|
||||
|
||||
PYTHON_ARGCOMPLETE_OK
|
||||
|
||||
MILC is an opinionated framework for writing CLI apps. It optimizes for the
|
||||
most common unix tool pattern- small tools that are run from the command
|
||||
line but generally do not feature any user interaction while they run.
|
||||
|
||||
For more details see the MILC documentation:
|
||||
|
||||
<https://github.com/clueboard/milc/tree/master/docs>
|
||||
"""
|
||||
from __future__ import division, print_function, unicode_literals
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from tempfile import NamedTemporaryFile
|
||||
from time import sleep
|
||||
|
||||
try:
|
||||
from ConfigParser import RawConfigParser
|
||||
except ImportError:
|
||||
from configparser import RawConfigParser
|
||||
|
||||
try:
|
||||
import thread
|
||||
import threading
|
||||
except ImportError:
|
||||
thread = None
|
||||
|
||||
import argcomplete
|
||||
import colorama
|
||||
from appdirs import user_config_dir
|
||||
|
||||
# Disable logging until we can configure it how the user wants
|
||||
logging.basicConfig(filename='/dev/null')
|
||||
|
||||
# Log Level Representations
|
||||
EMOJI_LOGLEVELS = {
|
||||
'CRITICAL': '{bg_red}{fg_white}¬_¬{style_reset_all}',
|
||||
'ERROR': '{fg_red}☒{style_reset_all}',
|
||||
'WARNING': '{fg_yellow}⚠{style_reset_all}',
|
||||
'INFO': '{fg_blue}ℹ{style_reset_all}',
|
||||
'DEBUG': '{fg_cyan}☐{style_reset_all}',
|
||||
'NOTSET': '{style_reset_all}¯\\_(o_o)_/¯'
|
||||
}
|
||||
EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL']
|
||||
EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING']
|
||||
UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf')
|
||||
|
||||
# ANSI Color setup
|
||||
# Regex was gratefully borrowed from kfir on stackoverflow:
|
||||
# https://stackoverflow.com/a/45448194
|
||||
ansi_regex = r'\x1b(' \
|
||||
r'(\[\??\d+[hl])|' \
|
||||
r'([=<>a-kzNM78])|' \
|
||||
r'([\(\)][a-b0-2])|' \
|
||||
r'(\[\d{0,2}[ma-dgkjqi])|' \
|
||||
r'(\[\d+;\d+[hfy]?)|' \
|
||||
r'(\[;?[hf])|' \
|
||||
r'(#[3-68])|' \
|
||||
r'([01356]n)|' \
|
||||
r'(O[mlnp-z]?)|' \
|
||||
r'(/Z)|' \
|
||||
r'(\d+)|' \
|
||||
r'(\[\?\d;\d0c)|' \
|
||||
r'(\d;\dR))'
|
||||
ansi_escape = re.compile(ansi_regex, flags=re.IGNORECASE)
|
||||
ansi_styles = (
|
||||
('fg', colorama.ansi.AnsiFore()),
|
||||
('bg', colorama.ansi.AnsiBack()),
|
||||
('style', colorama.ansi.AnsiStyle()),
|
||||
)
|
||||
ansi_colors = {}
|
||||
|
||||
for prefix, obj in ansi_styles:
|
||||
for color in [x for x in obj.__dict__ if not x.startswith('_')]:
|
||||
ansi_colors[prefix + '_' + color.lower()] = getattr(obj, color)
|
||||
|
||||
|
||||
def format_ansi(text):
|
||||
"""Return a copy of text with certain strings replaced with ansi.
|
||||
"""
|
||||
# Avoid .format() so we don't have to worry about the log content
|
||||
for color in ansi_colors:
|
||||
text = text.replace('{%s}' % color, ansi_colors[color])
|
||||
return text + ansi_colors['style_reset_all']
|
||||
|
||||
|
||||
class ANSIFormatter(logging.Formatter):
|
||||
"""A log formatter that inserts ANSI color.
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
msg = super(ANSIFormatter, self).format(record)
|
||||
return format_ansi(msg)
|
||||
|
||||
|
||||
class ANSIEmojiLoglevelFormatter(ANSIFormatter):
|
||||
"""A log formatter that makes the loglevel an emoji on UTF capable terminals.
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
if UNICODE_SUPPORT:
|
||||
record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors)
|
||||
return super(ANSIEmojiLoglevelFormatter, self).format(record)
|
||||
|
||||
|
||||
class ANSIStrippingFormatter(ANSIFormatter):
|
||||
"""A log formatter that strips ANSI.
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
msg = super(ANSIStrippingFormatter, self).format(record)
|
||||
return ansi_escape.sub('', msg)
|
||||
|
||||
|
||||
class Configuration(object):
|
||||
"""Represents the running configuration.
|
||||
|
||||
This class never raises IndexError, instead it will return None if a
|
||||
section or option does not yet exist.
|
||||
"""
|
||||
|
||||
def __contains__(self, key):
|
||||
return self._config.__contains__(key)
|
||||
|
||||
def __iter__(self):
|
||||
return self._config.__iter__()
|
||||
|
||||
def __len__(self):
|
||||
return self._config.__len__()
|
||||
|
||||
def __repr__(self):
|
||||
return self._config.__repr__()
|
||||
|
||||
def keys(self):
|
||||
return self._config.keys()
|
||||
|
||||
def items(self):
|
||||
return self._config.items()
|
||||
|
||||
def values(self):
|
||||
return self._config.values()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._config = {}
|
||||
|
||||
def __getattr__(self, key):
|
||||
return self.__getitem__(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Returns a config section, creating it if it doesn't exist yet.
|
||||
"""
|
||||
if key not in self._config:
|
||||
self.__dict__[key] = self._config[key] = ConfigurationSection(self)
|
||||
|
||||
return self._config[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__dict__[key] = value
|
||||
self._config[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
if key in self.__dict__ and key[0] != '_':
|
||||
del self.__dict__[key]
|
||||
if key in self._config:
|
||||
del self._config[key]
|
||||
|
||||
|
||||
class ConfigurationSection(Configuration):
|
||||
def __init__(self, parent, *args, **kwargs):
|
||||
super(ConfigurationSection, self).__init__(*args, **kwargs)
|
||||
self.parent = parent
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Returns a config value, pulling from the `default` section as a fallback.
|
||||
"""
|
||||
if key in self._config:
|
||||
return self._config[key]
|
||||
|
||||
elif key in self.parent.default:
|
||||
return self.parent.default[key]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def handle_store_boolean(self, *args, **kwargs):
|
||||
"""Does the add_argument for action='store_boolean'.
|
||||
"""
|
||||
disabled_args = None
|
||||
disabled_kwargs = kwargs.copy()
|
||||
disabled_kwargs['action'] = 'store_false'
|
||||
disabled_kwargs['dest'] = self.get_argument_name(*args, **kwargs)
|
||||
disabled_kwargs['help'] = 'Disable ' + kwargs['help']
|
||||
kwargs['action'] = 'store_true'
|
||||
kwargs['help'] = 'Enable ' + kwargs['help']
|
||||
|
||||
for flag in args:
|
||||
if flag[:2] == '--':
|
||||
disabled_args = ('--no-' + flag[2:],)
|
||||
break
|
||||
|
||||
self.add_argument(*args, **kwargs)
|
||||
self.add_argument(*disabled_args, **disabled_kwargs)
|
||||
|
||||
return (args, kwargs, disabled_args, disabled_kwargs)
|
||||
|
||||
|
||||
class SubparserWrapper(object):
|
||||
"""Wrap subparsers so we can populate the normal and the shadow parser.
|
||||
"""
|
||||
|
||||
def __init__(self, cli, submodule, subparser):
|
||||
self.cli = cli
|
||||
self.submodule = submodule
|
||||
self.subparser = subparser
|
||||
|
||||
for attr in dir(subparser):
|
||||
if not hasattr(self, attr):
|
||||
setattr(self, attr, getattr(subparser, attr))
|
||||
|
||||
def completer(self, completer):
|
||||
"""Add an arpcomplete completer to this subcommand.
|
||||
"""
|
||||
self.subparser.completer = completer
|
||||
|
||||
def add_argument(self, *args, **kwargs):
|
||||
if 'action' in kwargs and kwargs['action'] == 'store_boolean':
|
||||
return handle_store_boolean(self, *args, **kwargs)
|
||||
|
||||
self.cli.acquire_lock()
|
||||
self.subparser.add_argument(*args, **kwargs)
|
||||
|
||||
if 'default' in kwargs:
|
||||
del kwargs['default']
|
||||
if 'action' in kwargs and kwargs['action'] == 'store_false':
|
||||
kwargs['action'] == 'store_true'
|
||||
self.cli.subcommands_default[self.submodule].add_argument(*args, **kwargs)
|
||||
self.cli.release_lock()
|
||||
|
||||
|
||||
class MILC(object):
|
||||
"""MILC - An Opinionated Batteries Included Framework
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the MILC object.
|
||||
"""
|
||||
# Setup a lock for thread safety
|
||||
self._lock = threading.RLock() if thread else None
|
||||
|
||||
# Define some basic info
|
||||
self.acquire_lock()
|
||||
self._description = None
|
||||
self._entrypoint = None
|
||||
self._inside_context_manager = False
|
||||
self.ansi = ansi_colors
|
||||
self.arg_only = []
|
||||
self.config = Configuration()
|
||||
self.config_file = None
|
||||
self.version = os.environ.get('QMK_VERSION', 'unknown')
|
||||
self.release_lock()
|
||||
|
||||
# Figure out our program name
|
||||
self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0]
|
||||
self.prog_name = self.prog_name.split('/')[-1]
|
||||
|
||||
# Initialize all the things
|
||||
self.initialize_argparse()
|
||||
self.initialize_logging()
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value):
|
||||
self._description = self._arg_parser.description = self._arg_defaults.description = value
|
||||
|
||||
def echo(self, text, *args, **kwargs):
|
||||
"""Print colorized text to stdout.
|
||||
|
||||
ANSI color strings (such as {fg-blue}) will be converted into ANSI
|
||||
escape sequences, and the ANSI reset sequence will be added to all
|
||||
strings.
|
||||
|
||||
If *args or **kwargs are passed they will be used to %-format the strings.
|
||||
"""
|
||||
if args and kwargs:
|
||||
raise RuntimeError('You can only specify *args or **kwargs, not both!')
|
||||
|
||||
args = args or kwargs
|
||||
text = format_ansi(text)
|
||||
|
||||
print(text % args)
|
||||
|
||||
def initialize_argparse(self):
|
||||
"""Prepare to process arguments from sys.argv.
|
||||
"""
|
||||
kwargs = {
|
||||
'fromfile_prefix_chars': '@',
|
||||
'conflict_handler': 'resolve',
|
||||
}
|
||||
|
||||
self.acquire_lock()
|
||||
self.subcommands = {}
|
||||
self.subcommands_default = {}
|
||||
self._subparsers = None
|
||||
self._subparsers_default = None
|
||||
self.argwarn = argcomplete.warn
|
||||
self.args = None
|
||||
self._arg_defaults = argparse.ArgumentParser(**kwargs)
|
||||
self._arg_parser = argparse.ArgumentParser(**kwargs)
|
||||
self.set_defaults = self._arg_parser.set_defaults
|
||||
self.print_usage = self._arg_parser.print_usage
|
||||
self.print_help = self._arg_parser.print_help
|
||||
self.release_lock()
|
||||
|
||||
def completer(self, completer):
|
||||
"""Add an argcomplete completer to this subcommand.
|
||||
"""
|
||||
self._arg_parser.completer = completer
|
||||
|
||||
def add_argument(self, *args, **kwargs):
|
||||
"""Wrapper to add arguments to both the main and the shadow argparser.
|
||||
"""
|
||||
if 'action' in kwargs and kwargs['action'] == 'store_boolean':
|
||||
return handle_store_boolean(self, *args, **kwargs)
|
||||
|
||||
if kwargs.get('add_dest', True) and args[0][0] == '-':
|
||||
kwargs['dest'] = 'general_' + self.get_argument_name(*args, **kwargs)
|
||||
if 'add_dest' in kwargs:
|
||||
del kwargs['add_dest']
|
||||
|
||||
self.acquire_lock()
|
||||
self._arg_parser.add_argument(*args, **kwargs)
|
||||
|
||||
# Populate the shadow parser
|
||||
if 'default' in kwargs:
|
||||
del kwargs['default']
|
||||
if 'action' in kwargs and kwargs['action'] == 'store_false':
|
||||
kwargs['action'] == 'store_true'
|
||||
self._arg_defaults.add_argument(*args, **kwargs)
|
||||
self.release_lock()
|
||||
|
||||
def initialize_logging(self):
|
||||
"""Prepare the defaults for the logging infrastructure.
|
||||
"""
|
||||
self.acquire_lock()
|
||||
self.log_file = None
|
||||
self.log_file_mode = 'a'
|
||||
self.log_file_handler = None
|
||||
self.log_print = True
|
||||
self.log_print_to = sys.stderr
|
||||
self.log_print_level = logging.INFO
|
||||
self.log_file_level = logging.DEBUG
|
||||
self.log_level = logging.INFO
|
||||
self.log = logging.getLogger(self.__class__.__name__)
|
||||
self.log.setLevel(logging.DEBUG)
|
||||
logging.root.setLevel(logging.DEBUG)
|
||||
self.release_lock()
|
||||
|
||||
self.add_argument('-V', '--version', version=self.version, action='version', help='Display the version and exit')
|
||||
self.add_argument('-v', '--verbose', action='store_true', help='Make the logging more verbose')
|
||||
self.add_argument('--datetime-fmt', default='%Y-%m-%d %H:%M:%S', help='Format string for datetimes')
|
||||
self.add_argument('--log-fmt', default='%(levelname)s %(message)s', help='Format string for printed log output')
|
||||
self.add_argument('--log-file-fmt', default='[%(levelname)s] [%(asctime)s] [file:%(pathname)s] [line:%(lineno)d] %(message)s', help='Format string for log file.')
|
||||
self.add_argument('--log-file', help='File to write log messages to')
|
||||
self.add_argument('--color', action='store_boolean', default=True, help='color in output')
|
||||
self.add_argument('-c', '--config-file', help='The config file to read and/or write')
|
||||
self.add_argument('--save-config', action='store_true', help='Save the running configuration to the config file')
|
||||
|
||||
def add_subparsers(self, title='Sub-commands', **kwargs):
|
||||
if self._inside_context_manager:
|
||||
raise RuntimeError('You must run this before the with statement!')
|
||||
|
||||
self.acquire_lock()
|
||||
self._subparsers_default = self._arg_defaults.add_subparsers(title=title, dest='subparsers', **kwargs)
|
||||
self._subparsers = self._arg_parser.add_subparsers(title=title, dest='subparsers', **kwargs)
|
||||
self.release_lock()
|
||||
|
||||
def acquire_lock(self):
|
||||
"""Acquire the MILC lock for exclusive access to properties.
|
||||
"""
|
||||
if self._lock:
|
||||
self._lock.acquire()
|
||||
|
||||
def release_lock(self):
|
||||
"""Release the MILC lock.
|
||||
"""
|
||||
if self._lock:
|
||||
self._lock.release()
|
||||
|
||||
def find_config_file(self):
|
||||
"""Locate the config file.
|
||||
"""
|
||||
if self.config_file:
|
||||
return self.config_file
|
||||
|
||||
if self.args and self.args.general_config_file:
|
||||
return self.args.general_config_file
|
||||
|
||||
return os.path.join(user_config_dir(appname='qmk', appauthor='QMK'), '%s.ini' % self.prog_name)
|
||||
|
||||
def get_argument_name(self, *args, **kwargs):
|
||||
"""Takes argparse arguments and returns the dest name.
|
||||
"""
|
||||
try:
|
||||
return self._arg_parser._get_optional_kwargs(*args, **kwargs)['dest']
|
||||
except ValueError:
|
||||
return self._arg_parser._get_positional_kwargs(*args, **kwargs)['dest']
|
||||
|
||||
def argument(self, *args, **kwargs):
|
||||
"""Decorator to call self.add_argument or self.<subcommand>.add_argument.
|
||||
"""
|
||||
if self._inside_context_manager:
|
||||
raise RuntimeError('You must run this before the with statement!')
|
||||
|
||||
def argument_function(handler):
|
||||
if 'arg_only' in kwargs and kwargs['arg_only']:
|
||||
arg_name = self.get_argument_name(*args, **kwargs)
|
||||
self.arg_only.append(arg_name)
|
||||
del kwargs['arg_only']
|
||||
|
||||
if handler is self._entrypoint:
|
||||
self.add_argument(*args, **kwargs)
|
||||
|
||||
elif handler.__name__ in self.subcommands:
|
||||
self.subcommands[handler.__name__].add_argument(*args, **kwargs)
|
||||
|
||||
else:
|
||||
raise RuntimeError('Decorated function is not entrypoint or subcommand!')
|
||||
|
||||
return handler
|
||||
|
||||
return argument_function
|
||||
|
||||
def arg_passed(self, arg):
|
||||
"""Returns True if arg was passed on the command line.
|
||||
"""
|
||||
return self.args_passed[arg] in (None, False)
|
||||
|
||||
def parse_args(self):
|
||||
"""Parse the CLI args.
|
||||
"""
|
||||
if self.args:
|
||||
self.log.debug('Warning: Arguments have already been parsed, ignoring duplicate attempt!')
|
||||
return
|
||||
|
||||
argcomplete.autocomplete(self._arg_parser)
|
||||
|
||||
self.acquire_lock()
|
||||
self.args = self._arg_parser.parse_args()
|
||||
self.args_passed = self._arg_defaults.parse_args()
|
||||
|
||||
if 'entrypoint' in self.args:
|
||||
self._entrypoint = self.args.entrypoint
|
||||
|
||||
if self.args.general_config_file:
|
||||
self.config_file = self.args.general_config_file
|
||||
|
||||
self.release_lock()
|
||||
|
||||
def read_config(self):
|
||||
"""Parse the configuration file and determine the runtime configuration.
|
||||
"""
|
||||
self.acquire_lock()
|
||||
self.config_file = self.find_config_file()
|
||||
|
||||
if self.config_file and os.path.exists(self.config_file):
|
||||
config = RawConfigParser(self.config)
|
||||
config.read(self.config_file)
|
||||
|
||||
# Iterate over the config file options and write them into self.config
|
||||
for section in config.sections():
|
||||
for option in config.options(section):
|
||||
value = config.get(section, option)
|
||||
|
||||
# Coerce values into useful datatypes
|
||||
if value.lower() in ['1', 'yes', 'true', 'on']:
|
||||
value = True
|
||||
elif value.lower() in ['0', 'no', 'false', 'none', 'off']:
|
||||
value = False
|
||||
elif value.replace('.', '').isdigit():
|
||||
if '.' in value:
|
||||
value = Decimal(value)
|
||||
else:
|
||||
value = int(value)
|
||||
|
||||
self.config[section][option] = value
|
||||
|
||||
# Fold the CLI args into self.config
|
||||
for argument in vars(self.args):
|
||||
if argument in ('subparsers', 'entrypoint'):
|
||||
continue
|
||||
|
||||
if '_' in argument:
|
||||
section, option = argument.split('_', 1)
|
||||
else:
|
||||
section = self._entrypoint.__name__
|
||||
option = argument
|
||||
|
||||
if option not in self.arg_only:
|
||||
if hasattr(self.args_passed, argument):
|
||||
arg_value = getattr(self.args, argument)
|
||||
if arg_value:
|
||||
self.config[section][option] = arg_value
|
||||
else:
|
||||
if option not in self.config[section]:
|
||||
self.config[section][option] = getattr(self.args, argument)
|
||||
|
||||
self.release_lock()
|
||||
|
||||
def save_config(self):
|
||||
"""Save the current configuration to the config file.
|
||||
"""
|
||||
self.log.debug("Saving config file to '%s'", self.config_file)
|
||||
|
||||
if not self.config_file:
|
||||
self.log.warning('%s.config_file file not set, not saving config!', self.__class__.__name__)
|
||||
return
|
||||
|
||||
self.acquire_lock()
|
||||
|
||||
config = RawConfigParser()
|
||||
config_dir = os.path.dirname(self.config_file)
|
||||
|
||||
for section_name, section in self.config._config.items():
|
||||
config.add_section(section_name)
|
||||
for option_name, value in section.items():
|
||||
if section_name == 'general':
|
||||
if option_name in ['save_config']:
|
||||
continue
|
||||
config.set(section_name, option_name, str(value))
|
||||
|
||||
if not os.path.exists(config_dir):
|
||||
os.makedirs(config_dir)
|
||||
|
||||
with NamedTemporaryFile(mode='w', dir=config_dir, delete=False) as tmpfile:
|
||||
config.write(tmpfile)
|
||||
|
||||
# Move the new config file into place atomically
|
||||
if os.path.getsize(tmpfile.name) > 0:
|
||||
os.rename(tmpfile.name, self.config_file)
|
||||
else:
|
||||
self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name)
|
||||
|
||||
self.release_lock()
|
||||
cli.log.info('Wrote configuration to %s', shlex.quote(self.config_file))
|
||||
|
||||
def __call__(self):
|
||||
"""Execute the entrypoint function.
|
||||
"""
|
||||
if not self._inside_context_manager:
|
||||
# If they didn't use the context manager use it ourselves
|
||||
with self:
|
||||
return self.__call__()
|
||||
|
||||
if not self._entrypoint:
|
||||
raise RuntimeError('No entrypoint provided!')
|
||||
|
||||
return self._entrypoint(self)
|
||||
|
||||
def entrypoint(self, description):
|
||||
"""Set the entrypoint for when no subcommand is provided.
|
||||
"""
|
||||
if self._inside_context_manager:
|
||||
raise RuntimeError('You must run this before cli()!')
|
||||
|
||||
self.acquire_lock()
|
||||
self.description = description
|
||||
self.release_lock()
|
||||
|
||||
def entrypoint_func(handler):
|
||||
self.acquire_lock()
|
||||
self._entrypoint = handler
|
||||
self.release_lock()
|
||||
|
||||
return handler
|
||||
|
||||
return entrypoint_func
|
||||
|
||||
def add_subcommand(self, handler, description, name=None, **kwargs):
|
||||
"""Register a subcommand.
|
||||
|
||||
If name is not provided we use `handler.__name__`.
|
||||
"""
|
||||
if self._inside_context_manager:
|
||||
raise RuntimeError('You must run this before the with statement!')
|
||||
|
||||
if self._subparsers is None:
|
||||
self.add_subparsers()
|
||||
|
||||
if not name:
|
||||
name = handler.__name__
|
||||
|
||||
self.acquire_lock()
|
||||
kwargs['help'] = description
|
||||
self.subcommands_default[name] = self._subparsers_default.add_parser(name, **kwargs)
|
||||
self.subcommands[name] = SubparserWrapper(self, name, self._subparsers.add_parser(name, **kwargs))
|
||||
self.subcommands[name].set_defaults(entrypoint=handler)
|
||||
|
||||
if name not in self.__dict__:
|
||||
self.__dict__[name] = self.subcommands[name]
|
||||
else:
|
||||
self.log.debug("Could not add subcommand '%s' to attributes, key already exists!", name)
|
||||
|
||||
self.release_lock()
|
||||
|
||||
return handler
|
||||
|
||||
def subcommand(self, description, **kwargs):
|
||||
"""Decorator to register a subcommand.
|
||||
"""
|
||||
|
||||
def subcommand_function(handler):
|
||||
return self.add_subcommand(handler, description, **kwargs)
|
||||
|
||||
return subcommand_function
|
||||
|
||||
def setup_logging(self):
|
||||
"""Called by __enter__() to setup the logging configuration.
|
||||
"""
|
||||
if len(logging.root.handlers) != 0:
|
||||
# MILC is the only thing that should have root log handlers
|
||||
logging.root.handlers = []
|
||||
|
||||
self.acquire_lock()
|
||||
|
||||
if self.config['general']['verbose']:
|
||||
self.log_print_level = logging.DEBUG
|
||||
|
||||
self.log_file = self.config['general']['log_file'] or self.log_file
|
||||
self.log_file_format = self.config['general']['log_file_fmt']
|
||||
self.log_file_format = ANSIStrippingFormatter(self.config['general']['log_file_fmt'], self.config['general']['datetime_fmt'])
|
||||
self.log_format = self.config['general']['log_fmt']
|
||||
|
||||
if self.config.general.color:
|
||||
self.log_format = ANSIEmojiLoglevelFormatter(self.args.general_log_fmt, self.config.general.datetime_fmt)
|
||||
else:
|
||||
self.log_format = ANSIStrippingFormatter(self.args.general_log_fmt, self.config.general.datetime_fmt)
|
||||
|
||||
if self.log_file:
|
||||
self.log_file_handler = logging.FileHandler(self.log_file, self.log_file_mode)
|
||||
self.log_file_handler.setLevel(self.log_file_level)
|
||||
self.log_file_handler.setFormatter(self.log_file_format)
|
||||
logging.root.addHandler(self.log_file_handler)
|
||||
|
||||
if self.log_print:
|
||||
self.log_print_handler = logging.StreamHandler(self.log_print_to)
|
||||
self.log_print_handler.setLevel(self.log_print_level)
|
||||
self.log_print_handler.setFormatter(self.log_format)
|
||||
logging.root.addHandler(self.log_print_handler)
|
||||
|
||||
self.release_lock()
|
||||
|
||||
def __enter__(self):
|
||||
if self._inside_context_manager:
|
||||
self.log.debug('Warning: context manager was entered again. This usually means that self.__call__() was called before the with statement. You probably do not want to do that.')
|
||||
return
|
||||
|
||||
self.acquire_lock()
|
||||
self._inside_context_manager = True
|
||||
self.release_lock()
|
||||
|
||||
colorama.init()
|
||||
self.parse_args()
|
||||
self.read_config()
|
||||
self.setup_logging()
|
||||
|
||||
if 'save_config' in self.config.general and self.config.general.save_config:
|
||||
self.save_config()
|
||||
exit(0)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.acquire_lock()
|
||||
self._inside_context_manager = False
|
||||
self.release_lock()
|
||||
|
||||
if exc_type is not None and not isinstance(SystemExit(), exc_type):
|
||||
print(exc_type)
|
||||
logging.exception(exc_val)
|
||||
exit(255)
|
||||
|
||||
|
||||
cli = MILC()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@cli.argument('-c', '--comma', help='comma in output', default=True, action='store_boolean')
|
||||
@cli.entrypoint('My useful CLI tool with subcommands.')
|
||||
def main(cli):
|
||||
comma = ',' if cli.config.general.comma else ''
|
||||
cli.log.info('{bg_green}{fg_red}Hello%s World!', comma)
|
||||
|
||||
@cli.argument('-n', '--name', help='Name to greet', default='World')
|
||||
@cli.subcommand('Description of hello subcommand here.')
|
||||
def hello(cli):
|
||||
comma = ',' if cli.config.general.comma else ''
|
||||
cli.log.info('{fg_blue}Hello%s %s!', comma, cli.config.hello.name)
|
||||
|
||||
def goodbye(cli):
|
||||
comma = ',' if cli.config.general.comma else ''
|
||||
cli.log.info('{bg_red}Goodbye%s %s!', comma, cli.config.goodbye.name)
|
||||
|
||||
@cli.argument('-n', '--name', help='Name to greet', default='World')
|
||||
@cli.subcommand('Think a bit before greeting the user.')
|
||||
def thinking(cli):
|
||||
comma = ',' if cli.config.general.comma else ''
|
||||
spinner = cli.spinner(text='Just a moment...', spinner='earth')
|
||||
spinner.start()
|
||||
sleep(2)
|
||||
spinner.stop()
|
||||
|
||||
with cli.spinner(text='Almost there!', spinner='moon'):
|
||||
sleep(2)
|
||||
|
||||
cli.log.info('{fg_cyan}Hello%s %s!', comma, cli.config.thinking.name)
|
||||
|
||||
@cli.subcommand('Show off our ANSI colors.')
|
||||
def pride(cli):
|
||||
cli.echo('{bg_red} ')
|
||||
cli.echo('{bg_lightred_ex} ')
|
||||
cli.echo('{bg_lightyellow_ex} ')
|
||||
cli.echo('{bg_green} ')
|
||||
cli.echo('{bg_blue} ')
|
||||
cli.echo('{bg_magenta} ')
|
||||
|
||||
# You can register subcommands using decorators as seen above, or using functions like like this:
|
||||
cli.add_subcommand(goodbye, 'This will show up in --help output.')
|
||||
cli.goodbye.add_argument('-n', '--name', help='Name to bid farewell to', default='World')
|
||||
|
||||
cli() # Automatically picks between main(), hello() and goodbye()
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=82.0.1"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "qmk"
|
||||
version = "1.2.0"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
authors = [{name = "skullydazed", email = "skullydazed@gmail.com"}]
|
||||
description = "A program to help users work with QMK Firmware."
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Software Development",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"hid",
|
||||
"milc>=1.9.0",
|
||||
"platformdirs",
|
||||
"pyusb",
|
||||
# qmk_firmware packages
|
||||
"dotty-dict",
|
||||
"hjson",
|
||||
"jsonschema>=4",
|
||||
"pillow",
|
||||
"pygments",
|
||||
"pyserial",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"build",
|
||||
"bumpversion",
|
||||
"pytest",
|
||||
"ruff",
|
||||
"twine",
|
||||
"yapf",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Bug Tracker" = "https://github.com/qmk/qmk_cli/issues"
|
||||
Documentation = "https://docs.qmk.fm/cli"
|
||||
Homepage = "https://qmk.fm/"
|
||||
Source = "https://github.com/qmk/qmk_cli/"
|
||||
|
||||
[project.scripts]
|
||||
qmk = "qmk_cli.script_qmk:main"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = false
|
||||
|
||||
[tool.setuptools.packages]
|
||||
find = {namespaces = false}
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py39"
|
||||
|
||||
include = [
|
||||
"qmk_cli/**/*.py",
|
||||
"test/**/*.py",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "B", "N", "W", "COM", "C901", "PGH004"]
|
||||
ignore = [
|
||||
"E501", # QMK is ok with long lines.
|
||||
"E231", # Conflicts with our yapf config
|
||||
]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 14 # Let's slowly crank this down
|
||||
|
||||
[tool.yapfignore]
|
||||
ignore_patterns = [
|
||||
".direnv/**/*",
|
||||
".env/**/*",
|
||||
]
|
||||
|
||||
[tool.yapf]
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
allow_multiline_dictionary_keys = false
|
||||
allow_multiline_lambdas = false
|
||||
allow_split_before_default_or_named_assigns = true
|
||||
allow_split_before_dict_value = true
|
||||
arithmetic_precedence_indication = true
|
||||
blank_lines_around_top_level_definition = 2
|
||||
blank_line_before_class_docstring = false
|
||||
blank_line_before_module_docstring = false
|
||||
blank_line_before_nested_class_or_def = false
|
||||
coalesce_brackets = true
|
||||
column_limit = 256
|
||||
continuation_align_style = "SPACE"
|
||||
continuation_indent_width = 4
|
||||
dedent_closing_brackets = true
|
||||
disable_ending_comma_heuristic = false
|
||||
each_dict_entry_on_separate_line = true
|
||||
i18n_comment = ""
|
||||
i18n_function_call = ""
|
||||
indent_blank_lines = false
|
||||
indent_dictionary_value = true
|
||||
indent_width = 4
|
||||
join_multiple_lines = false
|
||||
no_spaces_around_selected_binary_operators = ""
|
||||
spaces_around_default_or_named_assign = false
|
||||
spaces_around_power_operator = false
|
||||
spaces_before_comment = 2
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
split_all_comma_separated_values = false
|
||||
split_arguments_when_comma_terminated = true
|
||||
split_before_arithmetic_operator = false
|
||||
split_before_bitwise_operator = true
|
||||
split_before_closing_bracket = true
|
||||
split_before_dict_set_generator = true
|
||||
split_before_dot = false
|
||||
split_before_expression_after_opening_paren = false
|
||||
split_before_first_argument = false
|
||||
split_before_logical_operator = false
|
||||
split_before_named_assigns = true
|
||||
split_complex_comprehension = true
|
||||
split_penalty_after_opening_bracket = 300
|
||||
split_penalty_after_unary_operator = 10000
|
||||
split_penalty_arithmetic_operator = 300
|
||||
split_penalty_before_if_expr = 0
|
||||
split_penalty_bitwise_operator = 300
|
||||
split_penalty_comprehension = 80
|
||||
split_penalty_excess_character = 7000
|
||||
split_penalty_for_added_line_split = 30
|
||||
split_penalty_import_names = 0
|
||||
split_penalty_logical_operator = 300
|
||||
use_tabs = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"integration",
|
||||
"system",
|
||||
]
|
||||
addopts = "-m 'not (integration or system)'"
|
||||
@@ -2,4 +2,5 @@
|
||||
"""Wrapper to call the qmk cli script entrypoint.
|
||||
"""
|
||||
import qmk_cli.script_qmk
|
||||
|
||||
qmk_cli.script_qmk.main()
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""A program to help you work with qmk_firmware."""
|
||||
|
||||
__version__ = '0.0.12'
|
||||
__version__ = '1.2.0'
|
||||
|
||||
+68
-16
@@ -1,32 +1,84 @@
|
||||
"""Helpers for working with git.
|
||||
"""
|
||||
"""Helpers for working with git."""
|
||||
|
||||
import subprocess
|
||||
from .helpers import rmtree
|
||||
|
||||
from milc import cli
|
||||
|
||||
default_repo = 'qmk_firmware'
|
||||
default_fork = 'qmk/' + default_repo
|
||||
default_branch = 'master'
|
||||
DEFAULT_UPSTREAM = 'https://github.com/qmk/qmk_firmware'
|
||||
|
||||
|
||||
def clone(url, destination, branch):
|
||||
def git_clone(destination, url, branch):
|
||||
"""Add the qmk/qmk_firmware upstream to a qmk_firmware clone."""
|
||||
git_clone = [
|
||||
'git',
|
||||
'clone',
|
||||
'--recurse-submodules',
|
||||
'--branch=' + branch,
|
||||
f'--branch={branch}',
|
||||
url,
|
||||
destination,
|
||||
str(destination),
|
||||
]
|
||||
cli.log.debug('Git clone command: %s', git_clone)
|
||||
cli.log.debug(f'Git clone command: {git_clone}')
|
||||
|
||||
with subprocess.Popen(git_clone, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True) as p:
|
||||
for line in p.stdout:
|
||||
print(line, end='')
|
||||
try:
|
||||
with subprocess.Popen(git_clone, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, encoding='utf-8') as p:
|
||||
for line in p.stdout:
|
||||
print(line, end='')
|
||||
|
||||
except Exception as e:
|
||||
git_cmd = ' '.join([s.replace(' ', r'\ ') for s in git_clone])
|
||||
|
||||
cli.log.error(f"Could not run '{git_cmd}': {e.__class__.__name__}:{e}")
|
||||
return False
|
||||
|
||||
if p.returncode != 0:
|
||||
cli.log.error('git clone exited %d', p.returncode)
|
||||
cli.log.error(f'git clone exited {p.returncode}')
|
||||
return False
|
||||
else:
|
||||
cli.log.info('Successfully cloned %s to %s!', cli.args.fork, cli.args.destination)
|
||||
return True
|
||||
|
||||
cli.log.info(f'Successfully cloned {url} to {destination}!')
|
||||
return True
|
||||
|
||||
|
||||
def git_set_upstream(destination):
|
||||
"""Add the qmk/qmk_firmware upstream to a qmk_firmware clone."""
|
||||
git_remote = [
|
||||
'git',
|
||||
'-C',
|
||||
str(destination),
|
||||
'remote',
|
||||
'add',
|
||||
'upstream',
|
||||
DEFAULT_UPSTREAM,
|
||||
]
|
||||
cli.log.debug(f'Git remote command: {git_remote}')
|
||||
|
||||
try:
|
||||
with subprocess.Popen(git_remote, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, encoding='utf-8') as p:
|
||||
for line in p.stdout:
|
||||
print(line, end='')
|
||||
|
||||
except Exception as e:
|
||||
git_cmd = ' '.join([s.replace(' ', r'\ ') for s in git_remote])
|
||||
|
||||
cli.log.error(f"Could not run '{git_cmd}': {e.__class__.__name__}:{e}")
|
||||
return False
|
||||
|
||||
if p.returncode != 0:
|
||||
cli.log.error(f'git remote add exited {p.returncode}')
|
||||
return False
|
||||
|
||||
cli.log.info(f'Added {DEFAULT_UPSTREAM} as remote upstream.')
|
||||
return True
|
||||
|
||||
|
||||
def git_clone_fork(destination, baseurl, fork, branch, force=False):
|
||||
"""Clone fork and configure upstream."""
|
||||
url = '/'.join((baseurl, fork))
|
||||
|
||||
if force:
|
||||
rmtree(destination)
|
||||
|
||||
if not git_clone(destination, url, branch):
|
||||
return False
|
||||
|
||||
return git_set_upstream(destination)
|
||||
|
||||
+122
-23
@@ -1,35 +1,134 @@
|
||||
"""Useful helper functions.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import stat
|
||||
import json
|
||||
import shutil
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from milc import cli
|
||||
|
||||
|
||||
def question(question, boolean=True, default=''):
|
||||
"""Asks the user to answer a question.
|
||||
|
||||
This keeps re-asking until it gets acceptible input.
|
||||
def AbsPath(arg): # noqa: N802
|
||||
"""Resolve relative paths to the original working directory.
|
||||
"""
|
||||
if cli.args.yes:
|
||||
return True
|
||||
arg = Path(arg)
|
||||
if not arg.is_absolute():
|
||||
return (Path(os.environ['ORIG_CWD']) / arg).absolute()
|
||||
|
||||
if default and default.lower() == 'y':
|
||||
answer_key = 'Y/n'
|
||||
elif default and default.lower() == 'n':
|
||||
answer_key = 'y/N'
|
||||
return arg
|
||||
|
||||
|
||||
def rmtree(path):
|
||||
"""Safe version of shutil.rmtree which handles errors on Windows where some of the files have their read-only bit set."""
|
||||
def remove_readonly(func, path, _):
|
||||
"""Clear the readonly bit and reattempt the removal."""
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
# See https://docs.python.org/3.12/whatsnew/3.12.html#shutil
|
||||
return shutil.rmtree(path, onexc=remove_readonly)
|
||||
else:
|
||||
answer_key = 'y/n'
|
||||
return shutil.rmtree(path, onerror=remove_readonly)
|
||||
|
||||
prompt = '*** %s [%s] ' % (question, answer_key)
|
||||
|
||||
while True:
|
||||
answer = input(prompt)
|
||||
if answer == '' and default.lower() == 'y':
|
||||
answer = 'y'
|
||||
elif answer == '' and default.lower() == 'n':
|
||||
answer = 'n'
|
||||
def is_qmk_firmware(qmk_firmware):
|
||||
"""Returns True if the given Path() is a qmk_firmware clone.
|
||||
"""
|
||||
paths = [
|
||||
qmk_firmware,
|
||||
qmk_firmware / 'quantum',
|
||||
qmk_firmware / 'requirements.txt',
|
||||
qmk_firmware / 'requirements-dev.txt',
|
||||
qmk_firmware / 'lib/python/qmk/cli/__init__.py',
|
||||
]
|
||||
|
||||
if answer.lower() in ['y', 'yes']:
|
||||
return True
|
||||
elif answer.lower() in ['n', 'no']:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
return False
|
||||
else:
|
||||
cli.args.echo('Invalid answer!')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def find_qmk_firmware():
|
||||
"""Look for qmk_firmware in the usual places.
|
||||
|
||||
This function returns the path to qmk_firmware, or the default location if one does not exist.
|
||||
"""
|
||||
if in_qmk_firmware():
|
||||
return in_qmk_firmware()
|
||||
|
||||
if cli.config.user.qmk_home:
|
||||
return Path(cli.config.user.qmk_home).expanduser().resolve()
|
||||
|
||||
if 'QMK_HOME' in os.environ:
|
||||
path = Path(os.environ['QMK_HOME']).expanduser()
|
||||
if path.exists():
|
||||
return path.resolve()
|
||||
return path
|
||||
|
||||
return Path.home() / 'qmk_firmware'
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def in_qmk_firmware():
|
||||
"""Returns the path to the qmk_firmware we are currently in, or None if we are not inside qmk_firmware.
|
||||
"""
|
||||
cur_dir = Path.cwd()
|
||||
while len(cur_dir.parents) > 0:
|
||||
if is_qmk_firmware(cur_dir):
|
||||
return cur_dir
|
||||
|
||||
# Move up a directory before the next iteration
|
||||
cur_dir = cur_dir / '..'
|
||||
cur_dir = cur_dir.resolve()
|
||||
|
||||
|
||||
def is_qmk_userspace(qmk_userspace):
|
||||
"""Returns True if the given Path() is a qmk_userspace clone.
|
||||
"""
|
||||
path = qmk_userspace / 'qmk.json'
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
return 'userspace_version' in json.loads(path.read_text(encoding="UTF-8"))
|
||||
except json.decoder.JSONDecodeError:
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def find_qmk_userspace():
|
||||
"""Look for qmk_userspace in the usual places.
|
||||
"""
|
||||
if in_qmk_userspace():
|
||||
return in_qmk_userspace()
|
||||
|
||||
if 'QMK_USERSPACE' in os.environ:
|
||||
path = Path(os.environ['QMK_USERSPACE']).expanduser()
|
||||
if path.exists():
|
||||
return path.resolve()
|
||||
return path
|
||||
|
||||
if cli.config.user.overlay_dir:
|
||||
return Path(cli.config.user.overlay_dir).expanduser().resolve()
|
||||
|
||||
return Path.home() / 'qmk_userspace'
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def in_qmk_userspace():
|
||||
"""Returns the path to the qmk_userspace we are currently in, or None if we are not inside qmk_userspace.
|
||||
"""
|
||||
cur_dir = Path.cwd()
|
||||
while len(cur_dir.parents) > 0:
|
||||
if is_qmk_userspace(cur_dir):
|
||||
return cur_dir
|
||||
|
||||
# Move up a directory before the next iteration
|
||||
cur_dir = cur_dir / '..'
|
||||
cur_dir = cur_dir.resolve()
|
||||
|
||||
+85
-49
@@ -1,22 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI wrapper for running QMK commands.
|
||||
|
||||
This program can be run from anywhere, with or without a qmk_firmware checkout. It provides a small set of subcommands for working with QMK and otherwise dispatches to the `qmk_firmware/bin/qmk` script for the repo you are currently in, or your default repo if you are not currently in a qmk_firmware checkout.
|
||||
|
||||
FIXME(skullydazed/anyone): --help shows underscores where we want dashes in subcommands (EG json_keymap instead of json-keymap)
|
||||
TODO(skullydazed/anyone): Need a way to filter some subcommands from --help (EG `qmk subcommands`)
|
||||
This program can be run from anywhere, with or without a qmk_firmware repository. If a qmk_firmware repository can be located we will use that to augment our available subcommands.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from platform import platform
|
||||
from traceback import print_exc
|
||||
|
||||
import milc
|
||||
import platformdirs
|
||||
|
||||
from . import __version__
|
||||
from .helpers import find_qmk_firmware, is_qmk_firmware, find_qmk_userspace, is_qmk_userspace
|
||||
|
||||
|
||||
def _get_default_distrib_path():
|
||||
if 'windows' in platform().lower():
|
||||
try:
|
||||
result = milc.cli.run(['cygpath', '-w', '/opt/qmk'])
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return platformdirs.user_data_dir('qmk')
|
||||
|
||||
|
||||
# Ensure the QMK distribution is on the `$PATH` if present. This must be kept in sync with qmk/qmk_firmware.
|
||||
QMK_DISTRIB_DIR = Path(os.environ.get('QMK_DISTRIB_DIR', _get_default_distrib_path()))
|
||||
if QMK_DISTRIB_DIR.exists():
|
||||
os.environ['PATH'] = str(QMK_DISTRIB_DIR / 'bin') + os.pathsep + os.environ['PATH']
|
||||
|
||||
# Prepend any user-defined path prefix
|
||||
if 'QMK_PATH_PREFIX' in os.environ:
|
||||
os.environ['PATH'] = os.environ['QMK_PATH_PREFIX'] + os.pathsep + os.environ['PATH']
|
||||
|
||||
milc.cli.milc_options(version=__version__)
|
||||
milc.EMOJI_LOGLEVELS['INFO'] = '{fg_blue}Ψ{style_reset_all}'
|
||||
|
||||
# These must happen after the milc.milc_options() call
|
||||
import milc.subcommand.config # noqa: E402, must come after milc.milc_options()
|
||||
|
||||
|
||||
@milc.cli.entrypoint('CLI wrapper for running QMK commands.')
|
||||
def qmk_main(cli):
|
||||
@@ -25,58 +53,66 @@ def qmk_main(cli):
|
||||
cli.print_help()
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def in_qmk_firmware():
|
||||
"""Returns the path to the qmk_firmware we are currently in, or None if we are not inside qmk_firmware.
|
||||
def run_cmd(*command):
|
||||
"""Run a command in a subshell.
|
||||
"""
|
||||
cur_dir = Path.cwd()
|
||||
while len(cur_dir.parents) > 0:
|
||||
found_bin = cur_dir / 'bin' / 'qmk'
|
||||
if found_bin.is_file():
|
||||
command = [found_bin, '--version']
|
||||
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if 'windows' in milc.cli.platform.lower():
|
||||
safecmd = map(shlex.quote, command)
|
||||
safecmd = ' '.join(safecmd)
|
||||
command = [os.environ['SHELL'], '-c', safecmd]
|
||||
|
||||
if result.returncode == 0:
|
||||
return cur_dir
|
||||
|
||||
# Move up a directory before the next iteration
|
||||
cur_dir = cur_dir / '..'
|
||||
cur_dir = cur_dir.resolve()
|
||||
|
||||
|
||||
def find_qmk_firmware():
|
||||
"""Look for qmk_firmware in the usual places.
|
||||
|
||||
This function returns the path to qmk_firmware, or the default location if one does not exist.
|
||||
|
||||
FIXME(skullydazed): add config file support
|
||||
"""
|
||||
if in_qmk_firmware():
|
||||
return in_qmk_firmware()
|
||||
|
||||
if 'QMK_HOME' in os.environ:
|
||||
return Path(os.environ['QMK_HOME'])
|
||||
|
||||
return Path.home() / 'qmk_firmware'
|
||||
return subprocess.run(command)
|
||||
|
||||
|
||||
# Python setuptools entrypoint
|
||||
def main():
|
||||
"""Dispatch the CLI subcommand to the proper place.
|
||||
|
||||
We first check to see if the subcommand was provided by the global `qmk`. If it was we import that module and hand control over to the entrypoint.
|
||||
|
||||
All other subcommands are dispatched to the local `qmk`, either the one we are currently in or whatever the user's default qmk_firmware is.
|
||||
"""Setup the environment before dispatching to the entrypoint.
|
||||
"""
|
||||
# Warn if they use an outdated python version
|
||||
if sys.version_info < (3, 9):
|
||||
print('Warning: Your Python version is out of date! Some subcommands may not work!')
|
||||
print('Please upgrade to Python 3.9 or later.')
|
||||
|
||||
# Environment setup
|
||||
qmk_userspace = find_qmk_userspace()
|
||||
qmk_firmware = find_qmk_firmware()
|
||||
if is_qmk_userspace(qmk_userspace):
|
||||
os.environ['QMK_USERSPACE'] = str(qmk_userspace)
|
||||
elif 'QMK_USERSPACE' in os.environ: # Failed to find valid userspace, including what was in the environment if specified -- wipe any environment variable if present as it's clearly invalid.
|
||||
os.environ.pop('QMK_USERSPACE')
|
||||
os.environ['QMK_HOME'] = str(qmk_firmware)
|
||||
os.environ['ORIG_CWD'] = os.getcwd()
|
||||
|
||||
# Import the subcommand modules
|
||||
import qmk_cli.subcommands
|
||||
import qmk_cli.subcommands # noqa: F401
|
||||
|
||||
if qmk_firmware.exists():
|
||||
sys.path.append(str(qmk_firmware / 'lib' / 'python'))
|
||||
import qmk.cli
|
||||
# Check out and initialize the qmk_firmware environment
|
||||
if is_qmk_firmware(qmk_firmware):
|
||||
# All subcommands are run relative to the qmk_firmware root to make it easier to use the right copy of qmk_firmware.
|
||||
os.chdir(str(qmk_firmware))
|
||||
sys.path.append(str(qmk_firmware / 'lib/python'))
|
||||
|
||||
try:
|
||||
import qmk.cli # noqa: F401
|
||||
|
||||
except ImportError as e:
|
||||
if qmk_firmware.name != 'qmk_firmware':
|
||||
print('Warning: %s does not end in "qmk_firmware". Do you need to set QMK_HOME to "%s/qmk_firmware"?' % (qmk_firmware, qmk_firmware))
|
||||
|
||||
print('Error: %s: %s', (e.__class__.__name__, e))
|
||||
print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# Call the entrypoint
|
||||
milc.cli()
|
||||
return_code = milc.cli()
|
||||
|
||||
if return_code is False:
|
||||
exit(1)
|
||||
|
||||
elif return_code is not True and isinstance(return_code, int):
|
||||
if return_code < 0 or return_code > 255:
|
||||
milc.cli.log.error('Invalid return_code: %d', return_code)
|
||||
exit(255)
|
||||
|
||||
exit(return_code)
|
||||
|
||||
exit(0)
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
|
||||
We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
|
||||
"""
|
||||
from . import clone
|
||||
from . import setup
|
||||
from . import clone # noqa: F401
|
||||
from . import console # noqa: F401
|
||||
from . import env # noqa: F401
|
||||
from . import setup # noqa: F401
|
||||
|
||||
@@ -4,25 +4,25 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from milc import cli
|
||||
from qmk_cli.git import clone
|
||||
from qmk_cli.git import git_clone
|
||||
from qmk_cli.helpers import AbsPath
|
||||
|
||||
default_repo = 'qmk_firmware'
|
||||
default_fork = 'qmk/' + default_repo
|
||||
default_branch = 'master'
|
||||
|
||||
|
||||
@cli.argument('--baseurl', default='https://github.com', help='The URL all git operations start from.')
|
||||
@cli.argument('-b', '--branch', default=default_branch, help='The branch to clone.')
|
||||
@cli.argument('destination', default=os.environ['QMK_HOME'], nargs='?', help='The directory to clone to.')
|
||||
@cli.argument('fork', default=default_fork, nargs='?', help='The qmk_firmware fork to clone')
|
||||
@cli.argument('--baseurl', arg_only=True, default='https://github.com', help='The URL all git operations start from (Default: https://github.com)')
|
||||
@cli.argument('-b', '--branch', arg_only=True, default=default_branch, help='The branch to clone. Default: %s' % default_branch)
|
||||
@cli.argument('destination', arg_only=True, default=Path(os.environ['ORIG_CWD']) / default_repo, type=AbsPath, nargs='?', help='The directory to clone to. Default: (current directory)')
|
||||
@cli.argument('fork', arg_only=True, default=default_fork, nargs='?', help='The qmk_firmware fork to clone. Default: %s' % default_fork)
|
||||
@cli.subcommand('Clone a qmk_firmware fork.')
|
||||
def clone(cli):
|
||||
qmk_firmware = Path(cli.args.destination)
|
||||
git_url = '/'.join((cli.config.clone.baseurl, cli.args.fork))
|
||||
git_url = '/'.join((cli.args.baseurl, cli.args.fork))
|
||||
|
||||
if qmk_firmware.exists():
|
||||
# Exists (but not an empty dir)
|
||||
if cli.args.destination.exists() and any(cli.args.destination.iterdir()):
|
||||
cli.log.error('Destination already exists: %s', cli.args.destination)
|
||||
exit(1)
|
||||
return False
|
||||
|
||||
success = clone(git_url, cli.args.destination, cli.config.clone.branch)
|
||||
exit(0 if success else 1)
|
||||
return git_clone(cli.args.destination, git_url, cli.args.branch)
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Acquire debugging information from usb hid devices
|
||||
|
||||
cli implementation of https://www.pjrc.com/teensy/hid_listen.html
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from platform import platform
|
||||
from threading import Thread
|
||||
from time import sleep, strftime
|
||||
|
||||
from milc import cli
|
||||
from milc.questions import yesno
|
||||
|
||||
LOG_COLOR = {
|
||||
'next': 0,
|
||||
'colors': [
|
||||
'{fg_blue}',
|
||||
'{fg_cyan}',
|
||||
'{fg_green}',
|
||||
'{fg_magenta}',
|
||||
'{fg_red}',
|
||||
'{fg_yellow}',
|
||||
],
|
||||
}
|
||||
|
||||
KNOWN_BOOTLOADERS = {
|
||||
# VID , PID
|
||||
('03EB', '2045'): 'lufa-ms: LUFA Mass Storage Bootloader',
|
||||
('03EB', '2067'): 'qmk-hid: HID Bootloader',
|
||||
('03EB', '2FEF'): 'atmel-dfu: ATmega16U2',
|
||||
('03EB', '2FF0'): 'atmel-dfu: ATmega32U2',
|
||||
('03EB', '2FF3'): 'atmel-dfu: ATmega16U4',
|
||||
('03EB', '2FF4'): 'atmel-dfu: ATmega32U4',
|
||||
('03EB', '2FF9'): 'atmel-dfu: AT90USB64',
|
||||
('03EB', '2FFA'): 'atmel-dfu: AT90USB162',
|
||||
('03EB', '2FFB'): 'atmel-dfu: AT90USB128',
|
||||
('03EB', '6124'): 'Microchip SAM-BA',
|
||||
('0483', 'DF11'): 'stm32-dfu: STM32 BOOTLOADER',
|
||||
('16C0', '05DC'): 'usbasploader: USBaspLoader',
|
||||
('16C0', '05DF'): 'bootloadhid: HIDBoot',
|
||||
('16C0', '0478'): 'halfkay: Teensy Halfkay',
|
||||
('1B4F', '9203'): 'caterina: Pro Micro 3.3V',
|
||||
('1B4F', '9205'): 'caterina: Pro Micro 5V',
|
||||
('1B4F', '9207'): 'caterina: LilyPadUSB',
|
||||
('1C11', 'B007'): 'kiibohd: Kiibohd DFU Bootloader',
|
||||
('1EAF', '0003'): 'stm32duino: Maple 003',
|
||||
('1FFB', '0101'): 'caterina: Pololu A-Star 32U4 Bootloader',
|
||||
('2341', '0036'): 'caterina: Arduino Leonardo',
|
||||
('2341', '0037'): 'caterina: Arduino Micro',
|
||||
('239A', '000C'): 'caterina: Adafruit Feather 32U4',
|
||||
('239A', '000D'): 'caterina: Adafruit ItsyBitsy 32U4 3v',
|
||||
('239A', '000E'): 'caterina: Adafruit ItsyBitsy 32U4 5v',
|
||||
('2A03', '0036'): 'caterina: Arduino Leonardo',
|
||||
('2A03', '0037'): 'caterina: Arduino Micro',
|
||||
('314B', '0106'): 'apm32-dfu: APM32 DFU ISP Mode',
|
||||
('342D', 'DFA0'): 'wb32-dfu: WB32 Device in DFU Mode',
|
||||
}
|
||||
|
||||
|
||||
def install_deps():
|
||||
"""Install the necessary dependencies for qmk console.
|
||||
"""
|
||||
this_platform = platform().lower()
|
||||
|
||||
if 'darwin' in this_platform or 'macos' in this_platform:
|
||||
command = ['brew', 'install', 'hidapi']
|
||||
elif 'linux' in this_platform:
|
||||
command = ['sudo', 'apt', 'install', '-y', 'libhidapi-hidraw0', 'libusb-dev']
|
||||
elif 'windows' in this_platform:
|
||||
command = ['pacboy', 'sync', '--needed', '--noconfirm', '--disable-download-timeout', 'hidapi:x']
|
||||
else:
|
||||
cli.log.error('Unsupported platform: %s', this_platform)
|
||||
|
||||
if yesno("Would you like to run `%s` to install the necessary package?", ' '.join(command)):
|
||||
cli.run(command, capture_output=False)
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=0)
|
||||
def import_usb_core():
|
||||
"""Attempts to import the usb.core module.
|
||||
"""
|
||||
try:
|
||||
import usb.core
|
||||
return usb.core
|
||||
|
||||
except ImportError as e:
|
||||
cli.log.error('Could not import usb.core: %s', e)
|
||||
|
||||
if install_deps():
|
||||
return import_usb_core()
|
||||
|
||||
raise
|
||||
|
||||
|
||||
def import_hid():
|
||||
"""Attempts to import the hid module.
|
||||
"""
|
||||
import os
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
# macOS library search paths don't include homebrew by default when launched via a `uv`-based deployment.
|
||||
# The `hid` python module does a search and attempts to load a set of known library names, but doesn't qualify the path, nor gives the option to do so.
|
||||
# To work around this, we chdir to the homebrew lib directory before importing hid, then chdir back to where we were.
|
||||
if 'darwin' in platform().lower() or 'macos' in platform().lower():
|
||||
for path in ('/opt/homebrew/lib', '/usr/local/lib'):
|
||||
lib_search = Path(path) / 'libhidapi.dylib'
|
||||
if lib_search.exists():
|
||||
os.chdir(path)
|
||||
break
|
||||
|
||||
import hid
|
||||
return hid
|
||||
|
||||
except ImportError as e:
|
||||
cli.log.error('Could not import hid: %s', e)
|
||||
|
||||
if install_deps():
|
||||
return import_hid()
|
||||
|
||||
raise
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
class MonitorDevice(object):
|
||||
def __init__(self, hid_device, numeric):
|
||||
self.hid = import_hid()
|
||||
self.hid_device = hid_device
|
||||
self.numeric = numeric
|
||||
self.device = self.hid.Device(path=hid_device['path'])
|
||||
self.current_line = ''
|
||||
|
||||
cli.log.info('Console Connected: %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s%(vendor_id)04X:%(product_id)04X:%(index)d{style_reset_all})', hid_device)
|
||||
|
||||
def read(self, size, encoding='ascii', timeout=1):
|
||||
"""Read size bytes from the device.
|
||||
"""
|
||||
return self.device.read(size, timeout).decode(encoding)
|
||||
|
||||
def read_line(self):
|
||||
"""Read from the device's console until we get a \n.
|
||||
"""
|
||||
while '\n' not in self.current_line:
|
||||
self.current_line += self.read(32).replace('\x00', '')
|
||||
|
||||
lines = self.current_line.split('\n', 1)
|
||||
self.current_line = lines[1]
|
||||
|
||||
return lines[0]
|
||||
|
||||
def run_forever(self):
|
||||
while True:
|
||||
try:
|
||||
message = {**self.hid_device, 'text': self.read_line()}
|
||||
identifier = (int2hex(message['vendor_id']), int2hex(message['product_id'])) if self.numeric else (message['manufacturer_string'], message['product_string'])
|
||||
message['identifier'] = ':'.join(identifier)
|
||||
message['ts'] = '{style_dim}{fg_green}%s{style_reset_all} ' % (strftime(cli.config.general.datetime_fmt),) if cli.args.timestamp else ''
|
||||
|
||||
cli.echo('%s', '%(ts)s%(color)s%(identifier)s:%(index)d{style_reset_all}: %(text)s' % message)
|
||||
|
||||
except self.hid.HIDException:
|
||||
break
|
||||
|
||||
|
||||
class FindDevices(object):
|
||||
def __init__(self, vid, pid, index, numeric):
|
||||
self.hid = import_hid()
|
||||
self.vid = vid
|
||||
self.pid = pid
|
||||
self.index = index
|
||||
self.numeric = numeric
|
||||
|
||||
def run_forever(self):
|
||||
"""Process messages from our queue in a loop.
|
||||
"""
|
||||
live_devices = {}
|
||||
live_bootloaders = {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
for device in list(live_devices):
|
||||
if not live_devices[device]['thread'].is_alive():
|
||||
cli.log.info('Console Disconnected: %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s%(vendor_id)04X:%(product_id)04X:%(index)d{style_reset_all})', live_devices[device])
|
||||
del live_devices[device]
|
||||
|
||||
for device in self.find_devices():
|
||||
if device['path'] not in live_devices:
|
||||
device['color'] = LOG_COLOR['colors'][LOG_COLOR['next']]
|
||||
LOG_COLOR['next'] = (LOG_COLOR['next'] + 1) % len(LOG_COLOR['colors'])
|
||||
live_devices[device['path']] = device
|
||||
|
||||
try:
|
||||
monitor = MonitorDevice(device, self.numeric)
|
||||
device['thread'] = Thread(target=monitor.run_forever, daemon=True)
|
||||
|
||||
device['thread'].start()
|
||||
except Exception as e:
|
||||
device['e'] = e
|
||||
device['e_name'] = e.__class__.__name__
|
||||
cli.log.error("Could not connect to %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s%(vendor_id)04X:%(product_id)04X:%(index)d{style_reset_all}): %(e_name)s: %(e)s", device)
|
||||
if cli.config.general.verbose:
|
||||
cli.log.exception(e)
|
||||
del live_devices[device['path']]
|
||||
|
||||
if cli.args.bootloaders:
|
||||
for device in self.find_bootloaders():
|
||||
if device.address in live_bootloaders:
|
||||
live_bootloaders[device.address]._qmk_found = True
|
||||
else:
|
||||
name = KNOWN_BOOTLOADERS[(int2hex(device.idVendor), int2hex(device.idProduct))]
|
||||
cli.log.info('Bootloader Connected: {style_bright}{fg_magenta}%s', name)
|
||||
device._qmk_found = True
|
||||
live_bootloaders[device.address] = device
|
||||
|
||||
for device in list(live_bootloaders):
|
||||
if live_bootloaders[device]._qmk_found:
|
||||
live_bootloaders[device]._qmk_found = False
|
||||
else:
|
||||
name = KNOWN_BOOTLOADERS[(int2hex(live_bootloaders[device].idVendor), int2hex(live_bootloaders[device].idProduct))]
|
||||
cli.log.info('Bootloader Disconnected: {style_bright}{fg_magenta}%s', name)
|
||||
del live_bootloaders[device]
|
||||
|
||||
sleep(.1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
def is_bootloader(self, hid_device):
|
||||
"""Returns true if the device in question matches a known bootloader vid/pid.
|
||||
"""
|
||||
return (int2hex(hid_device.idVendor), int2hex(hid_device.idProduct)) in KNOWN_BOOTLOADERS
|
||||
|
||||
def is_console_hid(self, hid_device):
|
||||
"""Returns true when the usage page indicates it's a teensy-style console.
|
||||
"""
|
||||
return hid_device['usage_page'] == 0xFF31 and hid_device['usage'] == 0x0074
|
||||
|
||||
def is_filtered_device(self, hid_device):
|
||||
"""Returns True if the device should be included in the list of available consoles.
|
||||
"""
|
||||
return int2hex(hid_device['vendor_id']) == self.vid and int2hex(hid_device['product_id']) == self.pid
|
||||
|
||||
def find_devices_by_report(self, hid_devices):
|
||||
"""Returns a list of available teensy-style consoles by doing a brute-force search.
|
||||
|
||||
Some versions of linux don't report usage and usage_page. In that case we fallback to reading the report (possibly inaccurately) ourselves.
|
||||
"""
|
||||
devices = []
|
||||
|
||||
for device in hid_devices:
|
||||
path = device['path'].decode('utf-8')
|
||||
|
||||
if path.startswith('/dev/hidraw'):
|
||||
number = path[11:]
|
||||
report = Path(f'/sys/class/hidraw/hidraw{number}/device/report_descriptor')
|
||||
|
||||
if report.exists():
|
||||
rp = report.read_bytes()
|
||||
|
||||
if rp[1] == 0x31 and rp[3] == 0x09:
|
||||
devices.append(device)
|
||||
|
||||
return devices
|
||||
|
||||
def find_bootloaders(self):
|
||||
"""Returns a list of available bootloader devices.
|
||||
"""
|
||||
import usb.core
|
||||
|
||||
return list(filter(self.is_bootloader, usb.core.find(find_all=True)))
|
||||
|
||||
def find_devices(self):
|
||||
"""Returns a list of available teensy-style consoles.
|
||||
"""
|
||||
hid_devices = self.hid.enumerate()
|
||||
devices = list(filter(self.is_console_hid, hid_devices))
|
||||
|
||||
if not devices:
|
||||
devices = self.find_devices_by_report(hid_devices)
|
||||
|
||||
if self.vid and self.pid:
|
||||
devices = list(filter(self.is_filtered_device, devices))
|
||||
|
||||
# Add index numbers
|
||||
device_index = {}
|
||||
for device in devices:
|
||||
id = ':'.join((int2hex(device['vendor_id']), int2hex(device['product_id'])))
|
||||
|
||||
if id not in device_index:
|
||||
device_index[id] = 0
|
||||
|
||||
device_index[id] += 1
|
||||
device['index'] = device_index[id]
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def int2hex(number):
|
||||
"""Returns a string representation of the number as hex.
|
||||
"""
|
||||
return "%04X" % number
|
||||
|
||||
|
||||
def list_devices(device_finder):
|
||||
"""Show the user a nicely formatted list of devices.
|
||||
"""
|
||||
devices = device_finder.find_devices()
|
||||
|
||||
if devices:
|
||||
cli.log.info('Available devices:')
|
||||
for dev in devices:
|
||||
color = LOG_COLOR['colors'][LOG_COLOR['next']]
|
||||
LOG_COLOR['next'] = (LOG_COLOR['next'] + 1) % len(LOG_COLOR['colors'])
|
||||
cli.log.info("\t%s%s:%s:%d{style_reset_all}\t%s %s", color, int2hex(dev['vendor_id']), int2hex(dev['product_id']), dev['index'], dev['manufacturer_string'], dev['product_string'])
|
||||
|
||||
if cli.args.bootloaders:
|
||||
bootloaders = device_finder.find_bootloaders()
|
||||
|
||||
if bootloaders:
|
||||
cli.log.info('Available Bootloaders:')
|
||||
|
||||
for dev in bootloaders:
|
||||
cli.log.info("\t%s:%s\t%s", int2hex(dev.idVendor), int2hex(dev.idProduct), KNOWN_BOOTLOADERS[(int2hex(dev.idVendor), int2hex(dev.idProduct))])
|
||||
|
||||
|
||||
@cli.argument('--bootloaders', arg_only=True, default=True, action='store_boolean', help='displaying bootloaders.')
|
||||
@cli.argument('-d', '--device', help='Device to select - uses format <pid>:<vid>[:<index>].')
|
||||
@cli.argument('-l', '--list', arg_only=True, action='store_true', help='List available hid_listen devices.')
|
||||
@cli.argument('-n', '--numeric', arg_only=True, action='store_true', help='Show VID/PID instead of names.')
|
||||
@cli.argument('-t', '--timestamp', arg_only=True, action='store_true', help='Print the timestamp for received messages as well.')
|
||||
@cli.argument('-w', '--wait', type=int, default=1, help="How many seconds to wait between checks (Default: 1)")
|
||||
@cli.subcommand('Acquire debugging information from usb hid devices.')
|
||||
def console(cli):
|
||||
"""Acquire debugging information from usb hid devices
|
||||
"""
|
||||
vid = None
|
||||
pid = None
|
||||
index = 1
|
||||
|
||||
if cli.config.console.device:
|
||||
device = cli.config.console.device.split(':')
|
||||
|
||||
if len(device) == 2:
|
||||
vid, pid = device
|
||||
|
||||
elif len(device) == 3:
|
||||
vid, pid, index = device
|
||||
|
||||
if not index.isdigit():
|
||||
cli.log.error('Device index must be a number! Got "%s" instead.', index)
|
||||
exit(1)
|
||||
|
||||
index = int(index)
|
||||
|
||||
if index < 1:
|
||||
cli.log.error('Device index must be greater than 0! Got %s', index)
|
||||
exit(1)
|
||||
|
||||
else:
|
||||
cli.log.error('Invalid format for device, expected "<pid>:<vid>[:<index>]" but got "%s".', cli.config.console.device)
|
||||
cli.print_help()
|
||||
exit(1)
|
||||
|
||||
vid = vid.upper()
|
||||
pid = pid.upper()
|
||||
|
||||
device_finder = FindDevices(vid, pid, index, cli.args.numeric)
|
||||
|
||||
if cli.args.list:
|
||||
return list_devices(device_finder)
|
||||
|
||||
print('Looking for devices...', flush=True)
|
||||
device_finder.run_forever()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Prints environment information.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from milc import cli
|
||||
from qmk_cli.helpers import is_qmk_firmware, is_qmk_userspace
|
||||
|
||||
|
||||
@cli.argument('var', arg_only=True, default=None, nargs='?', help='Optional variable to query')
|
||||
@cli.subcommand('Prints environment information.')
|
||||
def env(cli):
|
||||
home = os.environ.get('QMK_HOME', "")
|
||||
userspace = os.environ.get('QMK_USERSPACE', "")
|
||||
data = {
|
||||
'QMK_HOME': home,
|
||||
'QMK_FIRMWARE': home if is_qmk_firmware(Path(home)) else "",
|
||||
'QMK_USERSPACE': userspace if is_qmk_userspace(Path(userspace)) else "",
|
||||
'QMK_DISTRIB_DIR': os.environ.get('QMK_DISTRIB_DIR', ""),
|
||||
'QMK_PATH_PREFIX': os.environ.get('QMK_PATH_PREFIX', ""),
|
||||
}
|
||||
|
||||
# Now munge the current cli config
|
||||
for key, val in cli.config.general.items():
|
||||
converted_key = 'QMK_' + key.upper()
|
||||
data[converted_key] = val
|
||||
|
||||
if cli.args.var:
|
||||
if cli.args.var not in data:
|
||||
print(f'Variable "{cli.args.var}" does not exist!', file=sys.stderr)
|
||||
return False
|
||||
|
||||
# dump out requested arg
|
||||
print(data[cli.args.var])
|
||||
else:
|
||||
# dump out everything
|
||||
for key, val in data.items():
|
||||
print(f'{key}="{val}"')
|
||||
@@ -1,48 +1,104 @@
|
||||
"""Setup qmk_firmware on your computer.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from milc import cli
|
||||
from qmk_cli.git import clone
|
||||
from qmk_cli.helpers import question
|
||||
from milc.questions import choice, question, yesno
|
||||
from qmk_cli.git import git_clone_fork
|
||||
from qmk_cli.helpers import AbsPath, is_qmk_firmware
|
||||
|
||||
default_repo = 'qmk_firmware'
|
||||
default_fork = 'qmk/' + default_repo
|
||||
default_branch = 'master'
|
||||
DEFAULT_BASE = 'https://github.com'
|
||||
DEFAULT_REPO = 'qmk_firmware'
|
||||
DEFAULT_FORK = 'qmk/' + DEFAULT_REPO
|
||||
DEFAULT_BRANCH = 'master'
|
||||
|
||||
|
||||
@cli.argument('-y', '--yes', action='store_true', help='Answer yes to all questions')
|
||||
@cli.argument('--baseurl', default='https://github.com', help='The URL all git operations start from')
|
||||
@cli.argument('-b', '--branch', default=default_branch, help='The branch to clone')
|
||||
@cli.argument('destination', default=os.environ['QMK_HOME'], nargs='?', help='The directory to clone to')
|
||||
@cli.argument('fork', default=default_fork, nargs='?', help='The qmk_firmware fork to clone')
|
||||
@cli.argument('-n', '--no', arg_only=True, action='store_true', help='Answer no to all questions')
|
||||
@cli.argument('-y', '--yes', arg_only=True, action='store_true', help='Answer yes to all questions')
|
||||
@cli.argument('--baseurl', arg_only=True, default=DEFAULT_BASE, help='The URL all git operations start from. Default: %s' % DEFAULT_BASE)
|
||||
@cli.argument('-b', '--branch', arg_only=True, default=DEFAULT_BRANCH, help='The branch to clone. Default: %s' % DEFAULT_BRANCH)
|
||||
@cli.argument('-H', '--home', arg_only=True, default=Path(os.environ['QMK_HOME']), type=AbsPath, help='The location for QMK Firmware. Default: %s' % os.environ['QMK_HOME'])
|
||||
@cli.argument('fork', arg_only=True, default=DEFAULT_FORK, nargs='?', help='The qmk_firmware fork to clone. Default: %s' % DEFAULT_FORK)
|
||||
@cli.subcommand('Setup your computer for qmk_firmware.')
|
||||
def setup(cli):
|
||||
qmk_firmware = Path(cli.args.destination)
|
||||
def setup(cli): # noqa: C901
|
||||
"""Guide the user through setting up their QMK environment.
|
||||
"""
|
||||
clone_prompt = 'Would you like to clone {fg_cyan}%s{fg_reset} to {fg_cyan}%s{fg_reset}?' % (cli.args.fork, shlex.quote(str(cli.args.home)))
|
||||
home_prompt = 'Would you like to set {fg_cyan}%s{fg_reset} as your QMK home?' % (shlex.quote(str(cli.args.home)),)
|
||||
|
||||
# Sanity checks
|
||||
if cli.args.yes and cli.args.no:
|
||||
cli.log.error("Can't use both --yes and --no at the same time.")
|
||||
return False
|
||||
|
||||
# Check on qmk_firmware
|
||||
# If it exists, ask the user what to do with it
|
||||
# If it doesn't exist, offer to check it out
|
||||
if is_qmk_firmware(cli.args.home):
|
||||
cli.log.info('Found qmk_firmware at %s.', str(cli.args.home))
|
||||
found_prompt = "What do you want to do?"
|
||||
found_options = [
|
||||
f"Delete and reclone {cli.args.fork}",
|
||||
"Delete and clone a different fork",
|
||||
"Keep it and continue",
|
||||
]
|
||||
delete_confirm = "WARNING: This will delete your current qmk_firmware directory. Proceed?"
|
||||
|
||||
found_action = choice(found_prompt, options=found_options, default=2)
|
||||
if found_action == f"Delete and reclone {cli.args.fork}":
|
||||
if not yesno(delete_confirm, default=False):
|
||||
return False
|
||||
|
||||
if not git_clone_fork(cli.args.home, cli.args.baseurl, cli.args.fork, cli.args.branch, force=True):
|
||||
return False
|
||||
|
||||
elif found_action == "Delete and clone a different fork":
|
||||
fork_name = question("Enter the name of the fork:", default=cli.args.fork)
|
||||
branch_name = question("Enter the branch name to clone:", default=cli.args.branch)
|
||||
|
||||
if not yesno(delete_confirm, default=False):
|
||||
return False
|
||||
|
||||
if not git_clone_fork(cli.args.home, cli.args.baseurl, fork_name, branch_name, force=True):
|
||||
return False
|
||||
|
||||
# Exists (but not an empty dir)
|
||||
elif cli.args.home.exists() and any(cli.args.home.iterdir()):
|
||||
path_str = str(cli.args.home)
|
||||
|
||||
if cli.args.home.name != 'qmk_firmware':
|
||||
cli.log.warning('Warning: %s does not end in "qmk_firmware". Did you mean to use "--home %s/qmk_firmware"?' % (path_str, path_str))
|
||||
|
||||
cli.log.error("Path '%s' exists but is not a qmk_firmware clone!", path_str)
|
||||
return False
|
||||
|
||||
# Check on qmk_firmware, and if it doesn't exist offer to check it out.
|
||||
if qmk_firmware.exists():
|
||||
cli.log.info('Found qmk_firmware at %s.', str(qmk_firmware))
|
||||
else:
|
||||
cli.log.error('qmk_firmware not found!')
|
||||
if question('Would you like to clone %s?' % cli.args.fork):
|
||||
git_url = '/'.join((cli.config.general.baseurl, cli.args.fork))
|
||||
clone(git_url, cli.args.destination, cli.config.general.branch)
|
||||
cli.log.error('Could not find qmk_firmware!')
|
||||
if yesno(clone_prompt):
|
||||
if not git_clone_fork(cli.args.home, cli.args.baseurl, cli.args.fork, cli.args.branch):
|
||||
return False
|
||||
else:
|
||||
cli.log.warning('Not cloning qmk_firmware due to user input or --no flag.')
|
||||
|
||||
# Run `qmk_firmware/bin/qmk doctor` to check the rest of the environment out
|
||||
if qmk_firmware.exists():
|
||||
qmk_bin = qmk_firmware / 'bin' / 'qmk'
|
||||
doctor = subprocess.run([sys.executable, qmk_bin, 'doctor'])
|
||||
if doctor.returncode != 0:
|
||||
cli.log.error('Your build environment is not setup completely.')
|
||||
# Offer to set `user.qmk_home` for them.
|
||||
if str(cli.args.home) != os.environ['QMK_HOME'] and yesno(home_prompt):
|
||||
cli.config['user']['qmk_home'] = str(cli.args.home.absolute())
|
||||
cli.config_source['user']['qmk_home'] = 'config_file'
|
||||
cli.write_config_option('user', 'qmk_home')
|
||||
|
||||
if question('Would you like to run util/qmk_install?'):
|
||||
curdir = os.getcwd()
|
||||
os.chdir(str(qmk_firmware))
|
||||
process = subprocess.run(['util/qmk_install.sh'])
|
||||
os.chdir(curdir)
|
||||
if process.returncode == 0:
|
||||
cli.log.info('QMK setup complete!')
|
||||
# Run `qmk doctor` to check the rest of the environment out
|
||||
if cli.args.home.exists():
|
||||
color = '--color' if cli.config.general.color else '--no-color'
|
||||
unicode = '--unicode' if cli.config.general.unicode else '--no-unicode'
|
||||
doctor_command = [Path(sys.argv[0]).as_posix(), color, unicode, 'doctor']
|
||||
|
||||
if cli.args.no:
|
||||
doctor_command.append('-n')
|
||||
|
||||
if cli.args.yes:
|
||||
doctor_command.append('-y')
|
||||
|
||||
cli.run(doctor_command, stdin=None, capture_output=False, cwd=cli.args.home)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Increment the verison number and release a new version of qmk_cli.
|
||||
set -e
|
||||
set -x
|
||||
|
||||
PYPI_USERNAME=${PYPI_USERNAME:=skully}
|
||||
FLIT_USERNAME=$PYPI_USERNAME
|
||||
TWINE_USERNAME=$PYPI_USERNAME
|
||||
|
||||
export FLIT_USERNAME TWINE_USERNAME
|
||||
|
||||
rm dist/*
|
||||
bumpversion patch
|
||||
git push origin master --tags
|
||||
|
||||
# For now we don't use flit
|
||||
#flit publish
|
||||
|
||||
# Use setuptools until flit works with homebrew
|
||||
python3 setup.py sdist bdist_wheel
|
||||
twine upload dist/qmk-*
|
||||
@@ -1,83 +0,0 @@
|
||||
[bumpversion]
|
||||
current_version = 0.0.12
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = {new_version}
|
||||
message = New release: {current_version} → {new_version}
|
||||
|
||||
[bumpversion:file:qmk_cli/__init__.py]
|
||||
|
||||
[bumpversion:file:setup.cfg]
|
||||
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[flake8]
|
||||
ignore = E501,E226
|
||||
|
||||
[metadata]
|
||||
author = skullydazed
|
||||
author-email = skullydazed@gmail.com
|
||||
description-file = README.md
|
||||
dist-name = qmk
|
||||
license_file = LICENSE
|
||||
module = qmk_cli
|
||||
home-page = https://github.com/qmk/qmk_cli
|
||||
requires-python = >=3.5
|
||||
|
||||
[entry_points]
|
||||
qmk = qmk_cli.script_qmk:main
|
||||
|
||||
[yapf]
|
||||
align_closing_bracket_with_visual_indent = True
|
||||
allow_multiline_dictionary_keys = False
|
||||
allow_multiline_lambdas = False
|
||||
allow_split_before_default_or_named_assigns = True
|
||||
allow_split_before_dict_value = True
|
||||
arithmetic_precedence_indication = True
|
||||
blank_lines_around_top_level_definition = 2
|
||||
blank_line_before_class_docstring = False
|
||||
blank_line_before_module_docstring = False
|
||||
blank_line_before_nested_class_or_def = False
|
||||
coalesce_brackets = True
|
||||
column_limit = 256
|
||||
continuation_align_style = SPACE
|
||||
continuation_indent_width = 4
|
||||
dedent_closing_brackets = True
|
||||
disable_ending_comma_heuristic = False
|
||||
each_dict_entry_on_separate_line = True
|
||||
i18n_comment =
|
||||
i18n_function_call =
|
||||
indent_blank_lines = False
|
||||
indent_dictionary_value = True
|
||||
indent_width = 4
|
||||
join_multiple_lines = False
|
||||
no_spaces_around_selected_binary_operators =
|
||||
spaces_around_default_or_named_assign = False
|
||||
spaces_around_power_operator = False
|
||||
spaces_before_comment = 2
|
||||
space_between_ending_comma_and_closing_bracket = False
|
||||
split_all_comma_separated_values = False
|
||||
split_arguments_when_comma_terminated = True
|
||||
split_before_arithmetic_operator = False
|
||||
split_before_bitwise_operator = True
|
||||
split_before_closing_bracket = True
|
||||
split_before_dict_set_generator = True
|
||||
split_before_dot = False
|
||||
split_before_expression_after_opening_paren = False
|
||||
split_before_first_argument = False
|
||||
split_before_logical_operator = False
|
||||
split_before_named_assigns = True
|
||||
split_complex_comprehension = True
|
||||
split_penalty_after_opening_bracket = 300
|
||||
split_penalty_after_unary_operator = 10000
|
||||
split_penalty_arithmetic_operator = 300
|
||||
split_penalty_before_if_expr = 0
|
||||
split_penalty_bitwise_operator = 300
|
||||
split_penalty_comprehension = 80
|
||||
split_penalty_excess_character = 7000
|
||||
split_penalty_for_added_line_split = 30
|
||||
split_penalty_import_names = 0
|
||||
split_penalty_logical_operator = 300
|
||||
use_tabs = False
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
from configparser import ConfigParser
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup_cfg = ConfigParser()
|
||||
setup_cfg.read('setup.cfg')
|
||||
metadata = setup_cfg['metadata']
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup(
|
||||
name=metadata['dist-name'],
|
||||
description='A program to help users work with QMK Firmware.',
|
||||
entry_points={
|
||||
'console_scripts': ['%s = %s' % l for l in setup_cfg['entry_points'].items()],
|
||||
},
|
||||
license='MIT License',
|
||||
url=metadata['home-page'],
|
||||
version=setup_cfg['bumpversion']['current_version'],
|
||||
author=metadata['author'],
|
||||
author_email=metadata['author-email'],
|
||||
maintainer=metadata['author'],
|
||||
maintainer_email=metadata['author-email'],
|
||||
long_description=open('README.md').read(),
|
||||
long_description_content_type="text/markdown",
|
||||
packages=find_packages(),
|
||||
py_modules = ['milc'],
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Environment :: Console',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: System Administrators',
|
||||
'Intended Audience :: End Users/Desktop',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Natural Language :: English',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: Scientific/Engineering',
|
||||
'Topic :: Software Development',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
requires_python=metadata['requires-python'],
|
||||
install_requires=[
|
||||
#"milc", #FIXME(skullydazed): Included in the repo for now.
|
||||
"appdirs",
|
||||
"argcomplete",
|
||||
"colorama"
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import create_autospec, PropertyMock, MagicMock
|
||||
|
||||
from milc import MILCInterface
|
||||
from milc.configuration import Configuration
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
'--qmk',
|
||||
action='store',
|
||||
default='wrapper',
|
||||
help='options: ./qmk wrapper or real qmk from path',
|
||||
choices=('wrapper', 'real'),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_directory():
|
||||
"""Similar behavior to the default tmpdir fixture except the folder is cleaned up."""
|
||||
with tempfile.TemporaryDirectory() as tmp_path:
|
||||
yield Path(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cli():
|
||||
"""Mock cli to pass to cli subcommands,"""
|
||||
ret = create_autospec(MILCInterface)
|
||||
|
||||
# default behavior
|
||||
type(ret).config = Configuration()
|
||||
ret.config.general.unicode = True
|
||||
ret.config.general.color = True
|
||||
type(ret).write_config_option = MagicMock()
|
||||
type(ret.args).var = PropertyMock(return_value=None)
|
||||
|
||||
return ret
|
||||
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subcommand(temp_directory):
|
||||
tmp = temp_directory.as_posix()
|
||||
with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True):
|
||||
import qmk_cli.subcommands.clone as clone
|
||||
|
||||
yield clone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_clone(subcommand):
|
||||
with patch.object(subcommand, 'git_clone') as git_clone:
|
||||
yield git_clone
|
||||
|
||||
|
||||
def test_clone(subcommand, mock_cli, git_clone):
|
||||
type(mock_cli.args).baseurl = PropertyMock(return_value='asdf')
|
||||
type(mock_cli.args).fork = PropertyMock(return_value='fork')
|
||||
git_clone.return_value = True
|
||||
|
||||
ret = subcommand.clone(mock_cli)
|
||||
|
||||
git_clone.assert_called_once()
|
||||
assert ret is True
|
||||
|
||||
|
||||
def test_clone_not_empty(subcommand, mock_cli, temp_directory, git_clone):
|
||||
type(mock_cli.args).baseurl = PropertyMock(return_value='asdf')
|
||||
type(mock_cli.args).fork = PropertyMock(return_value='fork')
|
||||
type(mock_cli.args).destination = PropertyMock(return_value=temp_directory)
|
||||
(temp_directory / 'asdf').touch()
|
||||
|
||||
ret = subcommand.clone(mock_cli)
|
||||
|
||||
git_clone.assert_not_called()
|
||||
assert ret is False
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subcommand(temp_directory):
|
||||
tmp = temp_directory.as_posix()
|
||||
with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True):
|
||||
import qmk_cli.subcommands.env as env
|
||||
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def is_qmk_firmware(subcommand):
|
||||
with patch.object(subcommand, 'is_qmk_firmware') as is_qmk_firmware:
|
||||
is_qmk_firmware.return_value = True
|
||||
|
||||
yield is_qmk_firmware
|
||||
|
||||
|
||||
def test_request_qmk_firmware_exists(subcommand, mock_cli, capsys, temp_directory, is_qmk_firmware):
|
||||
subcommand.env(mock_cli)
|
||||
captured = capsys.readouterr().out
|
||||
assert f'QMK_HOME="{temp_directory}"' in captured
|
||||
assert f'QMK_FIRMWARE="{temp_directory}"' in captured
|
||||
|
||||
|
||||
def test_request_all_variables(subcommand, mock_cli, capsys, temp_directory):
|
||||
subcommand.env(mock_cli)
|
||||
captured = capsys.readouterr().out
|
||||
assert f'QMK_HOME="{temp_directory}"' in captured
|
||||
assert 'QMK_FIRMWARE=""' in captured
|
||||
assert 'QMK_UNICODE="True"' in captured
|
||||
|
||||
|
||||
def test_request_single_variable(subcommand, mock_cli, capsys, temp_directory):
|
||||
type(mock_cli.args).var = PropertyMock(return_value='QMK_HOME')
|
||||
|
||||
subcommand.env(mock_cli)
|
||||
captured = capsys.readouterr().out
|
||||
assert captured == f'{temp_directory}\n'
|
||||
|
||||
|
||||
def test_request_single_variable_no_exits(subcommand, mock_cli, capsys):
|
||||
type(mock_cli.args).var = PropertyMock(return_value='ASDF')
|
||||
|
||||
ret = subcommand.env(mock_cli)
|
||||
captured = capsys.readouterr().err
|
||||
assert captured == 'Variable "ASDF" does not exist!\n'
|
||||
assert ret is False
|
||||
@@ -0,0 +1,221 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, PropertyMock, ANY
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subcommand(temp_directory):
|
||||
tmp = temp_directory.as_posix()
|
||||
with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True):
|
||||
import qmk_cli.subcommands.setup as setup
|
||||
|
||||
yield setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def is_qmk_firmware(subcommand):
|
||||
with patch.object(subcommand, 'is_qmk_firmware') as is_qmk_firmware:
|
||||
is_qmk_firmware.return_value = True
|
||||
|
||||
yield is_qmk_firmware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def yesno(subcommand):
|
||||
with patch.object(subcommand, 'yesno') as yesno:
|
||||
yesno.return_value = True
|
||||
|
||||
yield yesno
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def choice(subcommand):
|
||||
with patch.object(subcommand, 'choice') as choice:
|
||||
yield choice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def question(subcommand):
|
||||
with patch.object(subcommand, 'question') as question:
|
||||
yield question
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_clone_fork(subcommand):
|
||||
with patch.object(subcommand, 'git_clone_fork') as git_clone_fork:
|
||||
yield git_clone_fork
|
||||
|
||||
|
||||
def test_setup_both_yes_no_invalid(subcommand, mock_cli):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=True)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=True)
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
assert ret is False
|
||||
|
||||
|
||||
def test_setup_reclone(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
type(mock_cli.args).fork = 'asdf'
|
||||
choice.return_value = 'Delete and reclone asdf'
|
||||
yesno.return_value = True
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
|
||||
|
||||
def test_setup_reclone_failed(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
type(mock_cli.args).fork = 'asdf'
|
||||
choice.return_value = 'Delete and reclone asdf'
|
||||
yesno.return_value = True
|
||||
git_clone_fork.return_value = False
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
assert ret is False
|
||||
|
||||
|
||||
def test_setup_reclone_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
type(mock_cli.args).fork = 'asdf'
|
||||
choice.return_value = 'Delete and reclone asdf'
|
||||
yesno.return_value = False
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
assert ret is False
|
||||
git_clone_fork.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_clone_diff_fork(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, question, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
choice.return_value = 'Delete and clone a different fork'
|
||||
yesno.return_value = True
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
|
||||
|
||||
def test_setup_clone_diff_fork_failed(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, question, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
choice.return_value = 'Delete and clone a different fork'
|
||||
yesno.return_value = True
|
||||
git_clone_fork.return_value = False
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
assert ret is False
|
||||
|
||||
|
||||
def test_setup_clone_diff_fork_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, question, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
choice.return_value = 'Delete and clone a different fork'
|
||||
yesno.return_value = False
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
assert ret is False
|
||||
git_clone_fork.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_home_exists_not_empty(subcommand, mock_cli, is_qmk_firmware, temp_directory):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
is_qmk_firmware.return_value = False
|
||||
(temp_directory / 'asdf').touch()
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
assert ret is False
|
||||
|
||||
|
||||
def test_setup_missing(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
is_qmk_firmware.return_value = False
|
||||
yesno.return_value = True
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'Would you like to clone' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
|
||||
|
||||
def test_setup_missing_failed(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
is_qmk_firmware.return_value = False
|
||||
yesno.return_value = True
|
||||
git_clone_fork.return_value = False
|
||||
|
||||
ret = subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'Would you like to clone' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_called_once()
|
||||
assert ret is False
|
||||
|
||||
|
||||
def test_setup_missing_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).home = PropertyMock(return_value=temp_directory)
|
||||
is_qmk_firmware.return_value = False
|
||||
yesno.return_value = False
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
yesno.assert_called_once()
|
||||
assert 'Would you like to clone' in yesno.call_args.args[0]
|
||||
git_clone_fork.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_chains_args_to_doctor(subcommand, mock_cli, is_qmk_firmware, yesno, choice, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=True)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=False)
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
mock_cli.run.assert_called_once()
|
||||
assert mock_cli.run.call_args.args[0] == [ANY, '--color', '--unicode', 'doctor', '-y']
|
||||
|
||||
|
||||
def test_setup_chains_args_to_doctor_no(subcommand, mock_cli, is_qmk_firmware, yesno, choice, git_clone_fork):
|
||||
type(mock_cli.args).yes = PropertyMock(return_value=False)
|
||||
type(mock_cli.args).no = PropertyMock(return_value=True)
|
||||
mock_cli.config.general.color = False
|
||||
mock_cli.config.general.unicode = False
|
||||
|
||||
subcommand.setup(mock_cli)
|
||||
|
||||
mock_cli.run.assert_called_once()
|
||||
assert mock_cli.run.call_args.args[0] == [ANY, '--no-color', '--no-unicode', 'doctor', '-n']
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def temp_env(temp_directory, monkeypatch):
|
||||
qmk_firmware = temp_directory / 'qmk_firmware'
|
||||
monkeypatch.setenv("QMK_HOME", qmk_firmware.as_posix())
|
||||
yield qmk_firmware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_cli(request):
|
||||
def run_cli_wrapper(args):
|
||||
cmd = f'{request.config.rootpath}/qmk' if request.config.getoption("--qmk") == 'wrapper' else 'qmk'
|
||||
|
||||
return subprocess.run([cmd, *args], capture_output=True, text=True)
|
||||
|
||||
return run_cli_wrapper
|
||||
|
||||
|
||||
@pytest.mark.system
|
||||
def test_cli_help(run_cli):
|
||||
completed_process = run_cli(['clone', '--help'])
|
||||
|
||||
assert 'usage:' in completed_process.stdout
|
||||
assert completed_process.returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.system
|
||||
def test_cli_setup_no(run_cli):
|
||||
completed_process = run_cli(['setup', '-n'])
|
||||
|
||||
assert 'Could not find qmk_firmware!' in completed_process.stderr
|
||||
assert 'Not cloning qmk_firmware due to user input or --no flag.' in completed_process.stderr
|
||||
assert completed_process.returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.system
|
||||
def test_cli_setup_yes(run_cli):
|
||||
completed_process = run_cli(['setup', '-y'])
|
||||
|
||||
assert 'Successfully cloned https://github.com/qmk/qmk_firmware' in completed_process.stderr
|
||||
assert 'Added https://github.com/qmk/qmk_firmware as remote upstream' in completed_process.stderr
|
||||
assert 'QMK Doctor is checking your environment' in completed_process.stderr
|
||||
assert completed_process.returncode == 0
|
||||
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
import subprocess
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
import qmk_cli.git
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen", side_effect=Exception("foobar"))
|
||||
@patch("qmk_cli.git.cli.log.error")
|
||||
def test_git_clone_except(mock_log_error, mock_popen):
|
||||
ret = qmk_cli.git.git_clone('/tmp', 'https://github.com/qmk/qmk_firmware', 'master')
|
||||
|
||||
assert "Could not run" in str(mock_log_error.call_args_list)
|
||||
assert "Exception:foobar" in str(mock_log_error.call_args_list)
|
||||
assert ret is False
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen")
|
||||
def test_git_clone_logs_output(mock_popen, capsys):
|
||||
type(mock_popen().__enter__()).returncode = PropertyMock(return_value=0)
|
||||
type(mock_popen().__enter__()).stdout = PropertyMock(return_value=['asdf'])
|
||||
|
||||
ret = qmk_cli.git.git_clone('/tmp', 'https://github.com/qmk/qmk_firmware', 'master')
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert 'asdf' in captured
|
||||
assert ret is True
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen")
|
||||
def test_git_clone_captures_exit_code(mock_popen):
|
||||
type(mock_popen().__enter__()).returncode = PropertyMock(return_value=1)
|
||||
|
||||
ret = qmk_cli.git.git_clone('/tmp', 'https://github.com/qmk/qmk_firmware', 'master')
|
||||
|
||||
assert ret is False
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen", side_effect=Exception("foobar"))
|
||||
@patch("qmk_cli.git.cli.log.error")
|
||||
def test_git_set_upstream_except(mock_log_error, mock_popen):
|
||||
ret = qmk_cli.git.git_set_upstream('/tmp')
|
||||
|
||||
assert "Could not run" in str(mock_log_error.call_args_list)
|
||||
assert "Exception:foobar" in str(mock_log_error.call_args_list)
|
||||
assert ret is False
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen")
|
||||
def test_git_set_upstream_logs_output(mock_popen, capsys):
|
||||
type(mock_popen().__enter__()).returncode = PropertyMock(return_value=0)
|
||||
type(mock_popen().__enter__()).stdout = PropertyMock(return_value=['asdf'])
|
||||
|
||||
ret = qmk_cli.git.git_set_upstream('/tmp')
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert 'asdf' in captured
|
||||
assert ret is True
|
||||
|
||||
|
||||
@patch("qmk_cli.git.subprocess.Popen")
|
||||
def test_git_set_upstream_captures_exit_code(mock_popen):
|
||||
type(mock_popen().__enter__()).returncode = PropertyMock(return_value=1)
|
||||
|
||||
ret = qmk_cli.git.git_set_upstream('/tmp')
|
||||
|
||||
assert ret is False
|
||||
|
||||
|
||||
@patch.object(qmk_cli.git, 'git_clone')
|
||||
@patch.object(qmk_cli.git, 'git_set_upstream')
|
||||
def test_git_clone_fork_fail(git_set_upstream, git_clone, temp_directory):
|
||||
git_clone.return_value = False
|
||||
git_set_upstream.return_value = False
|
||||
|
||||
ret = qmk_cli.git.git_clone_fork(temp_directory.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', False)
|
||||
|
||||
assert ret is False
|
||||
|
||||
|
||||
@patch.object(qmk_cli.git, 'git_clone')
|
||||
@patch.object(qmk_cli.git, 'git_set_upstream')
|
||||
def test_git_clone_fork_force(git_set_upstream, git_clone, temp_directory):
|
||||
(temp_directory / 'asdf').touch()
|
||||
|
||||
git_clone.return_value = True
|
||||
git_set_upstream.return_value = True
|
||||
|
||||
ret = qmk_cli.git.git_clone_fork(temp_directory.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', True)
|
||||
|
||||
assert ret is True
|
||||
assert (temp_directory / 'asdf').exists() is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_git_clone(temp_directory):
|
||||
qmk_firmware = temp_directory / 'qmk_firmware'
|
||||
ret = qmk_cli.git.git_clone(qmk_firmware.as_posix(), 'https://github.com/qmk/qmk_firmware', 'master')
|
||||
|
||||
from qmk_cli.helpers import is_qmk_firmware
|
||||
|
||||
assert is_qmk_firmware(qmk_firmware) is True
|
||||
assert ret is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_git_clone_fork(temp_directory):
|
||||
qmk_firmware = temp_directory / 'qmk_firmware'
|
||||
qmk_firmware.mkdir()
|
||||
(qmk_firmware / 'asdf').touch()
|
||||
ret = qmk_cli.git.git_clone_fork(qmk_firmware.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', True)
|
||||
|
||||
from qmk_cli.helpers import is_qmk_firmware
|
||||
|
||||
assert is_qmk_firmware(qmk_firmware) is True
|
||||
assert 'https://github.com/qmk/qmk_firmware' in subprocess.run(['git', '-C', qmk_firmware.as_posix(), 'remote', 'get-url', 'upstream'], capture_output=True, text=True).stdout
|
||||
assert ret is True
|
||||
@@ -0,0 +1,246 @@
|
||||
import os
|
||||
import stat
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
import qmk_cli.helpers
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_env():
|
||||
cur_dir = os.getcwd()
|
||||
|
||||
yield cur_dir
|
||||
|
||||
os.chdir(cur_dir)
|
||||
qmk_cli.helpers.find_qmk_firmware.cache_clear()
|
||||
qmk_cli.helpers.in_qmk_firmware.cache_clear()
|
||||
qmk_cli.helpers.find_qmk_userspace.cache_clear()
|
||||
qmk_cli.helpers.in_qmk_userspace.cache_clear()
|
||||
|
||||
|
||||
def test_abspath(temp_directory):
|
||||
tmp = temp_directory.absolute()
|
||||
ret = qmk_cli.helpers.AbsPath(tmp)
|
||||
assert ret.samefile(tmp)
|
||||
|
||||
|
||||
def test_abspath_str(temp_directory):
|
||||
tmp = temp_directory.absolute().as_posix()
|
||||
ret = qmk_cli.helpers.AbsPath(tmp)
|
||||
assert ret.samefile(tmp)
|
||||
|
||||
|
||||
def test_abspath_rel(temp_directory):
|
||||
with patch.dict('os.environ', {'ORIG_CWD': temp_directory.as_posix()}, clear=True):
|
||||
tmp = './asdf'
|
||||
ret = qmk_cli.helpers.AbsPath(tmp)
|
||||
assert ret.as_posix() == (temp_directory / 'asdf').as_posix()
|
||||
|
||||
|
||||
def test_rmtree(temp_directory):
|
||||
tmp = temp_directory / 'asdf.txt'
|
||||
tmp.touch()
|
||||
|
||||
qmk_cli.helpers.rmtree(temp_directory)
|
||||
|
||||
assert tmp.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Only triggers issue on windows")
|
||||
def test_rmtree_readonly(temp_directory):
|
||||
tmp = temp_directory / 'asdf.txt'
|
||||
tmp.touch()
|
||||
os.chmod(tmp, stat.S_IREAD)
|
||||
|
||||
qmk_cli.helpers.rmtree(temp_directory)
|
||||
|
||||
assert tmp.exists() is False
|
||||
|
||||
|
||||
def test_is_qmk_firmware(temp_directory):
|
||||
(temp_directory / 'quantum').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'requirements.txt').touch()
|
||||
(temp_directory / 'requirements-dev.txt').touch()
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
|
||||
assert qmk_cli.helpers.is_qmk_firmware(temp_directory) is True
|
||||
|
||||
|
||||
def test_is_qmk_firmware_partially_missing(temp_directory):
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
|
||||
assert qmk_cli.helpers.is_qmk_firmware(temp_directory) is False
|
||||
|
||||
|
||||
def test_find_qmk_firmware(temp_directory):
|
||||
(temp_directory / 'quantum').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'requirements.txt').touch()
|
||||
(temp_directory / 'requirements-dev.txt').touch()
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_firmware_env(temp_directory):
|
||||
with patch.dict('os.environ', {'QMK_HOME': temp_directory.as_posix()}, clear=True):
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_firmware_env_missing(temp_directory):
|
||||
with patch.dict('os.environ', {'QMK_HOME': (temp_directory / 'asdf').as_posix()}, clear=True):
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (temp_directory / 'asdf').as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_firmware_config(temp_directory):
|
||||
with patch.object(qmk_cli.helpers, 'cli') as cli:
|
||||
type(cli.config.user).qmk_home = PropertyMock(return_value=temp_directory.as_posix())
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_firmware_config_user():
|
||||
with patch.object(qmk_cli.helpers, 'cli') as cli:
|
||||
type(cli.config.user).qmk_home = PropertyMock(return_value='~/asdf')
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (Path.home() / 'asdf').as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_firmware_default(temp_directory):
|
||||
with patch('pathlib.Path.home') as home:
|
||||
home.return_value = temp_directory
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (temp_directory / 'qmk_firmware').as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_firmware(temp_directory):
|
||||
(temp_directory / 'quantum').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'requirements.txt').touch()
|
||||
(temp_directory / 'requirements-dev.txt').touch()
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_firmware_sub(temp_directory):
|
||||
(temp_directory / 'quantum').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'requirements.txt').touch()
|
||||
(temp_directory / 'requirements-dev.txt').touch()
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
os.chdir(temp_directory / 'quantum')
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_firmware_sub_sub(temp_directory):
|
||||
(temp_directory / 'quantum').mkdir(parents=True)
|
||||
(temp_directory / 'lib/python/qmk/cli').mkdir(parents=True)
|
||||
(temp_directory / 'requirements.txt').touch()
|
||||
(temp_directory / 'requirements-dev.txt').touch()
|
||||
(temp_directory / 'lib/python/qmk/cli/__init__.py').touch()
|
||||
os.chdir(temp_directory / 'lib' / 'python')
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_firmware_invalid(temp_directory):
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_firmware() is None
|
||||
|
||||
|
||||
def test_is_qmk_userspace(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('{"userspace_version": 2}')
|
||||
|
||||
assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is True
|
||||
|
||||
|
||||
def test_is_qmk_userspace_missing(temp_directory):
|
||||
assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False
|
||||
|
||||
|
||||
def test_is_qmk_userspace_invalid(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('asdf')
|
||||
|
||||
assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False
|
||||
|
||||
|
||||
def test_is_qmk_userspace_empty(temp_directory):
|
||||
(temp_directory / 'qmk.json').touch()
|
||||
|
||||
assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False
|
||||
|
||||
|
||||
def test_find_qmk_userspace(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('{"userspace_version": 2}')
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_userspace_env(temp_directory):
|
||||
with patch.dict('os.environ', {'QMK_USERSPACE': temp_directory.as_posix()}, clear=True):
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_userspace_env_missing(temp_directory):
|
||||
with patch.dict('os.environ', {'QMK_USERSPACE': (temp_directory / 'asdf').as_posix()}, clear=True):
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (temp_directory / 'asdf').as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_userspace_config(temp_directory):
|
||||
with patch.object(qmk_cli.helpers, 'cli') as cli:
|
||||
type(cli.config.user).overlay_dir = PropertyMock(return_value=temp_directory.as_posix())
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_userspace_config_user():
|
||||
with patch.object(qmk_cli.helpers, 'cli') as cli:
|
||||
type(cli.config.user).overlay_dir = PropertyMock(return_value='~/asdf')
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (Path.home() / 'asdf').as_posix()
|
||||
|
||||
|
||||
def test_find_qmk_userspace_default(temp_directory):
|
||||
with patch('pathlib.Path.home') as home:
|
||||
home.return_value = temp_directory
|
||||
|
||||
assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (temp_directory / 'qmk_userspace').as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_userspace(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('{"userspace_version": 2}')
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_userspace_sub(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('{"userspace_version": 2}')
|
||||
(temp_directory / 'keymaps' / 'test').mkdir(parents=True)
|
||||
os.chdir(temp_directory / 'keymaps')
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_userspace_sub_sub(temp_directory):
|
||||
(temp_directory / 'qmk.json').write_text('{"userspace_version": 2}')
|
||||
(temp_directory / 'keymaps' / 'test').mkdir(parents=True)
|
||||
os.chdir(temp_directory / 'keymaps' / 'test')
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix()
|
||||
|
||||
|
||||
def test_in_qmk_userspace_invalid(temp_directory):
|
||||
os.chdir(temp_directory)
|
||||
|
||||
assert qmk_cli.helpers.in_qmk_userspace() is None
|
||||
Reference in New Issue
Block a user