diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..63475710 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: Bouni + +--- + + + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +# KiCad Version + +``` +``` diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml new file mode 100644 index 00000000..748ec063 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml new file mode 100644 index 00000000..4adabeea --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml @@ -0,0 +1,101 @@ +--- +name: "Bootstrap the initial component database" +on: + workflow_dispatch: # allow for manually trigger workflow +jobs: + bootstrap_database: + name: "Bootstrap component database" + runs-on: ubuntu-latest + environment: github-pages + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + python3 python3-pip wget zip unzip p7zip-full sqlite3 + - name: Maximize build space + uses: easimon/maximize-build-space@master + with: + root-reserve-mb: 512 + swap-size-mb: 1024 + remove-dotnet: 'true' + - name: Free more Space, Found in https://github.com/actions/runner-images/issues/2840#issuecomment-790492173 + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + - name: Checkout + uses: actions/checkout@v6 + - name: Show versions + run: | + sqlite3 --version + python3 --version + python3 -c "import sqlite3; import pprint; db = sqlite3.connect(':memory:'); cursor = db.execute('PRAGMA COMPILE_OPTIONS'); pprint.pprint(cursor.fetchall())" + - name: Install python dependencies + run: | + pip install humanize + pip install -e . + - name: Update database + run: | + set -x + + # Print the current disk usage, might help with future "no disk space left" issues + echo "=============================================================================================================================" + df -h + echo "=============================================================================================================================" + + cd db_build + + mkdir db_working + cd db_working + wget -q https://yaqwsx.github.io/jlcparts/data/cache.zip + for seq in $(seq -w 01 30); do + wget -q https://yaqwsx.github.io/jlcparts/data/cache.z$seq || true + done + 7z x cache.zip + for seq in $(seq -w 01 30); do + rm cache.z$seq || true + done + + cd .. + + python3 jlcparts_db_convert.py --skip-generate --fix-components-db-descriptions --clean-components-db --archive-components-db + + ls -lah db_working/ + + rm db_working/cache.sqlite3 + + # Print the current disk usage, might help with future "no disk space left" issues + echo "=============================================================================================================================" + df -h + echo "=============================================================================================================================" + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v4 + with: + name: github-pages + path: db_build/archive + deploy: + name: "Deploy" + runs-on: ubuntu-latest + needs: bootstrap_database + permissions: + actions: write + contents: write + pages: write + id-token: write + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + if: github.ref == 'refs/heads/main' + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + # delete-artifact + - name: Clean + uses: geekyeggo/delete-artifact@v6 + with: + name: github-pages diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml new file mode 100644 index 00000000..c27ac64c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml @@ -0,0 +1,46 @@ +--- +# This is a workflow to generate the zip file and metadata.json for KiCAD PCM +# https://gitlab.com/kicad/addons/metadata/-/merge_requests/14 + +name: KiCAD PCM packaging +on: # yamllint disable-line rule:truthy + release: + branches: [main] + types: + - published + workflow_dispatch: + +jobs: + create_archive: + runs-on: ubuntu-latest + steps: + - name: Get latest tag + uses: oprypin/find-latest-tag@v1 + with: + repository: Bouni/kicad-jlcpcb-tools + releases-only: true + id: latest-release + + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Create archive + run: sh ./PCM/create_pcm_archive.sh ${{ steps.latest-release.outputs.tag }} + + - name: Upload zip as asset to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ./PCM/KiCAD-PCM-${{ steps.latest-release.outputs.tag }}.zip + asset_name: KiCAD-PCM-${{ steps.latest-release.outputs.tag }}.zip + overwrite: true + tag: ${{ steps.latest-release.outputs.tag }} + + - name: Trigger custom kicad repo rebuild + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: Rebuild repository + ref: refs/heads/main + repo: Bouni/bouni-kicad-repository + token: ${{ secrets.PERSONAL_TOKEN }} + inputs: '{ "VERSION": "${{env.VERSION}}", "DOWNLOAD_SHA256": "${{env.DOWNLOAD_SHA256}}", "DOWNLOAD_SIZE": "${{env.DOWNLOAD_SIZE}}", "DOWNLOAD_URL": "${{env.DOWNLOAD_URL}}", "INSTALL_SIZE": "${{env.INSTALL_SIZE}}" }' diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml new file mode 100644 index 00000000..79c350be --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml @@ -0,0 +1,43 @@ +--- +name: Lint and format everything +on: # yamllint disable-line rule:truthy + push: + branches: + - main + pull_request: +jobs: + markdownlint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Setup NodeJS + uses: actions/setup-node@v6 + - name: Install markdownlint + run: npm install -g markdownlint-cli + - name: Run markdownlint + run: markdownlint "**/*.md" + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Check code with ruff + uses: chartboost/ruff-action@v1 + with: + version: 0.11.4 + args: check + src: "." + deadcode: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install deadcode + run: pip install deadcode + - name: Run deadcode + run: deadcode . diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml new file mode 100644 index 00000000..4dcdefb9 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +--- +name: Tests +on: # yamllint disable-line rule:truthy + push: + branches: + - main + pull_request: +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install pytest + run: pip install pytest + - name: Install python packages + run: pip install . + - name: Run core tests + working-directory: core + run: pytest version.py + - name: Run common tests + working-directory: common + run: pytest diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml new file mode 100644 index 00000000..58c5aed6 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml @@ -0,0 +1,99 @@ +--- +name: "Update parts database" +on: + schedule: + - cron: "0 5 * * *" + workflow_dispatch: # allow for manually trigger workflow +jobs: + build_and_update: + name: "Update component database and frontend" + runs-on: ubuntu-latest + environment: github-pages + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + python3 python3-pip wget zip unzip p7zip-full sqlite3 + - name: Maximize build space + uses: easimon/maximize-build-space@master + with: + root-reserve-mb: 512 + swap-size-mb: 1024 + remove-dotnet: 'true' + - name: Free more Space, Found in https://github.com/actions/runner-images/issues/2840#issuecomment-790492173 + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + - name: Checkout + uses: actions/checkout@v6 + - name: Show versions + run: | + sqlite3 --version + python3 --version + python3 -c "import sqlite3; import pprint; db = sqlite3.connect(':memory:'); cursor = db.execute('PRAGMA COMPILE_OPTIONS'); pprint.pprint(cursor.fetchall())" + - name: Install python dependencies + run: | + pip install humanize + pip install -e . + - name: Update database + run: | + set -x + + # Print the current disk usage, might help with future "no disk space left" issues + echo "=============================================================================================================================" + df -h + echo "=============================================================================================================================" + + cd db_build + + # fetch the cached database + python3 jlcparts_db_convert.py \ + --fetch-components-db \ + --update-components-db \ + --clean-components-db \ + --components-db-base-url=http://z2amiller.github.io/kicad-jlcpcb-tools \ + --archive-components-db \ + --obsolete-parts-threshold-days=365 + + # remove the raw source db, as we don't want to package that one + rm db_working/cache.sqlite3 + # some info output for sanity check + ls -lah db_working + du -cslh db_working + + # Print the current disk usage, might help with future "no disk space left" issues + echo "=============================================================================================================================" + df -h + echo "=============================================================================================================================" + + - name: Upload all-item parts db + uses: actions/upload-pages-artifact@v4 + with: + name: github-pages + path: db_build/archive + deploy: + name: "Deploy" + runs-on: ubuntu-latest + needs: build_and_update + permissions: + actions: write + contents: write + pages: write + id-token: write + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + if: github.ref == 'refs/heads/main' + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + # delete-artifact + - name: Clean + uses: geekyeggo/delete-artifact@v6 + with: + name: github-pages diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.gitignore b/third_party/Bouni/kicad-jlcpcb-tools/.gitignore new file mode 100644 index 00000000..e725cfd0 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.gitignore @@ -0,0 +1,149 @@ +led / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +db_build/db_working/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +jlcpcb +corrections +dev + +# vscode ignore +.vscode/ + +# OS cruft +.DS_Store diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml b/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml new file mode 100644 index 00000000..96699166 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml @@ -0,0 +1,7 @@ +--- +default: true +MD013: false # ignore line length +MD026: false # ignore Trailing punctuation in header +MD033: false # allow inline HTML +MD036: false # allow Emphasis used instead of a header +MD045: false # Don't force Alt text for images diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml b/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml new file mode 100644 index 00000000..d843c920 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +--- +repos: + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.14.14 + hooks: + - id: ruff + args: + - --fix + - --extend-exclude=lib + - id: ruff-format + exclude: '(^|/)lib' + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + exclude: ^.lib/ + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.47.0 + hooks: + - id: markdownlint + args: + - --fix + # - repo: https://github.com/albertas/deadcode + # rev: 2.4.1 + # hooks: + # - id: deadcode diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.yamllint b/third_party/Bouni/kicad-jlcpcb-tools/.yamllint new file mode 100644 index 00000000..8b6f6eca --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/.yamllint @@ -0,0 +1,34 @@ +--- + +yaml-files: + - '*.yaml' + - '*.yml' + - '.yamllint' + +rules: + braces: enable + brackets: enable + colons: enable + commas: enable + comments: + level: warning + comments-indentation: + level: warning + document-end: disable + document-start: + level: warning + empty-lines: enable + empty-values: disable + float-values: disable + hyphens: enable + indentation: enable + key-duplicates: enable + key-ordering: disable + line-length: disable + new-line-at-end-of-file: enable + new-lines: enable + octal-values: disable + quoted-strings: disable + trailing-spaces: enable + truthy: + level: warning diff --git a/third_party/Bouni/kicad-jlcpcb-tools/LICENSE b/third_party/Bouni/kicad-jlcpcb-tools/LICENSE new file mode 100644 index 00000000..60a60669 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 bouni + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh b/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh new file mode 100644 index 00000000..f6ae1392 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +# heavily inspired by https://github.com/4ms/4ms-kicad-lib/blob/master/PCM/make_archive.sh + +VERSION=$1 + +echo "Clean up old files" +rm -f PCM/*.zip +rm -rf PCM/archive + + +echo "Create folder structure for ZIP" +mkdir -p PCM/archive/plugins +mkdir -p PCM/archive/resources + +echo "Copy files to destination" +cp VERSION PCM/archive/plugins +cp *.py PCM/archive/plugins +cp *.png PCM/archive/plugins +cp settings.json PCM/archive/plugins +cp -r icons PCM/archive/plugins +cp -r lib PCM/archive/plugins +cp -r dblib PCM/archive/plugins +cp -r common PCM/archive/plugins +mkdir PCM/archive/plugins/core +cp core/*.py PCM/archive/plugins/core +cp PCM/icon.png PCM/archive/resources +cp PCM/metadata.template.json PCM/archive/metadata.json + +echo "Write version info to file" +echo $VERSION > PCM/archive/plugins/VERSION + +echo "Modify archive metadata.json" +sed -i "s/VERSION_HERE/$VERSION/g" PCM/archive/metadata.json +sed -i "s/\"kicad_version\": \"6.0\",/\"kicad_version\": \"6.0\"/g" PCM/archive/metadata.json +sed -i "/SHA256_HERE/d" PCM/archive/metadata.json +sed -i "/DOWNLOAD_SIZE_HERE/d" PCM/archive/metadata.json +sed -i "/DOWNLOAD_URL_HERE/d" PCM/archive/metadata.json +sed -i "/INSTALL_SIZE_HERE/d" PCM/archive/metadata.json + +echo "Zip PCM archive" +cd PCM/archive +zip -r ../KiCAD-PCM-$VERSION.zip . +cd ../.. + +echo "Gather data for repo rebuild" +echo VERSION=$VERSION >> $GITHUB_ENV +echo DOWNLOAD_SHA256=$(shasum --algorithm 256 PCM/KiCAD-PCM-$VERSION.zip | xargs | cut -d' ' -f1) >> $GITHUB_ENV +echo DOWNLOAD_SIZE=$(ls -l PCM/KiCAD-PCM-$VERSION.zip | xargs | cut -d' ' -f5) >> $GITHUB_ENV +echo DOWNLOAD_URL="https:\/\/github.com\/Bouni\/kicad-jlcpcb-tools\/releases\/download\/$VERSION\/KiCAD-PCM-$VERSION.zip" >> $GITHUB_ENV +echo INSTALL_SIZE=$(unzip -l PCM/KiCAD-PCM-$VERSION.zip | tail -1 | xargs | cut -d' ' -f1) >> $GITHUB_ENV + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png b/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png new file mode 100644 index 00000000..9c086f97 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json b/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json new file mode 100644 index 00000000..92d1eb9c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://go.kicad.org/pcm/schemas/v1", + "name": "KiCAD JLCPCB tools", + "description": "A JLCPCB integration addon for KiCAD", + "description_full": "This plugin allows you to search the JLCPCB parts database, assign LCSC article numbers to your parts, generate production files for JLCPCB and much more.", + "identifier": "com.github.bouni.kicad-jlcpcb-tools", + "type": "plugin", + "author": { + "name": "Bouni", + "contact": { + "twitter": "@bouni2016" + } + }, + "maintainer": { + "name": "Bouni", + "contact": { + "twitter": "@bouni2016" + } + }, + "license": "WTFPL", + "resources": { + "homepage": "https://github.com/bouni/kicad-jlcpcb-tools" + }, + "versions": [{ + "version": "VERSION_HERE", + "status": "testing", + "kicad_version": "6.0", + "download_sha256": "SHA256_HERE", + "download_size": DOWNLOAD_SIZE_HERE, + "download_url": "DOWNLOAD_URL_HERE", + "install_size": INSTALL_SIZE_HERE + }] +} \ No newline at end of file diff --git a/third_party/Bouni/kicad-jlcpcb-tools/README.md b/third_party/Bouni/kicad-jlcpcb-tools/README.md new file mode 100644 index 00000000..7539d464 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/README.md @@ -0,0 +1,290 @@ +# ![The main window](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/jlcpcb-icon.png) KiCAD JLCPCB tools + + + +*** + + + +*** + +[![Update parts database](https://github.com/Bouni/kicad-jlcpcb-tools/actions/workflows/update_parts_database.yml/badge.svg)](https://github.com/Bouni/kicad-jlcpcb-tools/actions/workflows/update_parts_database.yml) + +*** + +Plugin to generate all files necessary for JLCPCB board fabrication and assembly + +- Gerber files +- Excellon files +- BOM file +- CPL file + +Furthermore it lets you search the JLCPCB parts database and assign parts directly to the footprints which result in them being put into the BOM file. + +![The main window](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/main.png) + +![The parts library window](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/part_library.png) + +![The parts details dialog](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/part_details.png) + +## Installation 💾 + +### KiCAD PCM + +Add my custom repo to *the Plugin and Content Manager*, the URL is: + +```sh +https://raw.githubusercontent.com/Bouni/bouni-kicad-repository/main/repository.json +``` + +![image](https://user-images.githubusercontent.com/948965/147682006-9e1dd74a-79d3-492b-a108-15d284acf2b1.png) + +From there you can install the plugin via the GUI. + +### Git + +Simply clone this repo into your `scripting/plugins` folder. + +**Windows** + +```sh +cd C:\users\\Documents\kicad\\scripting\plugins\ # is your username, can be 7.0, 8.0, or X.YY depending on the version you use +git clone https://github.com/Bouni/kicad-jlcpcb-tools.git +``` + +**Linux** + +```sh +cd /home//.local/share/kicad//scripting/plugins # can be 7.0, 8.0, or X.YY depending on the version you use +git clone https://github.com/Bouni/kicad-jlcpcb-tools.git +``` + +**MacOS** + +```sh +cd ~/Library/Preferences/kicad/scripting/plugins +git clone https://github.com/Bouni/kicad-jlcpcb-tools.git +``` + +You may need to create the `scripting/plugins` folder if it does not exist. + +### Flatpak :warning: + +The Flatpak installation of KiCAD currently does not ship with pip and requests installed. The later is required for the plugin to work. +In order to get it working you can run the following 3 commands: + +1. `flatpak run --command=sh org.kicad.KiCad` +2. `python -m ensurepip --upgrade` +3. `/var/data/python/bin/pip3 install requests` + +See [issue #94](https://github.com/Bouni/kicad-jlcpcb-tools/issues/94) for more info. + +## Usage 🥳 + +To access the plugin choose `Tools → External Plugins → JLCPCB Tools` from the *PCB Editor* menus + +Checkout this screencast, it shows quickly how to use this plugin: + +![KiCAD JLCPCB example](https://raw.githubusercontent.com/Bouni/kicad-jlcpcb-tools/main/images/showcase.gif) + +## Keyboard shortcuts + +Windows can be closed with ctrl-w/ctrl-q/command-w/command-w (OS dependent) and escape. +Pressing enter in the keyword text box will start a search. + +### Toggle BOM / CPL attributes + +You can easily toggle the `exclude from BOM` and `exclude from CPL` attributes of one or multiple footprints. + +### Select LCSC parts from the JLCPCB parts database + +Select one or multiple footprints, click select part. You can select parts with equal value and footprint using the Select alike button. +In the upcoming modal dialog, search for parts, select the one of your choice and click select part. +The LCSC number of your selection will then be assigned to the footprints. + +![Footprint selection](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/footprint_selection.png) + +### Generate fabrication data + +Generate all necessary assembly files for your board with a simple click. + +A new directory called `jlcpcb` is created, and in there, two separate folders are created, `gerber` and `production_files`. + +In the gerber folder all necessary `*.gbr` and `*.drl` files are generated and zipped into the `production_files` folder, ready for upload to JLCPCB. +The zipfile is named `GERBER-.zip` + +Also in the `production_files` folder, two files are generated, `BOM-.csv` and `CPL-.csv`. + +Footprints are included into the BOM and CPL files according to their `exclude from BOM` and `exclude from POS` attributes. + +![The fabrication files](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/fabrication_files.png) + +### Export Additional JLC Specific Layers + +Some boards you have manufactured will require additional layers in your Gerber. For example, when manufacturing flex PCBs with a stiffener, JLC requires a layer outlining the stiffener layer (top/bottom), dimensions and the stiffener material properties (material, thickness etc). Export these additional JLC specific layers in your production files with a simple modification. + +Additional layers can be exported by creating layers with `JLC_` as the prefix of the layer name. You can access and edit the layer names in *Board Setup/Board Stackup/Board Editor Layers* + +This tool will automatically export all additional layers with the JLC_ prefix and add them to the production files in `GERBER-.zip` + +![Export Additional JLC Specific Layers](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/additional_jlc_layers.png) + +## Footprint rotation correction + +JLCPCB seems to need corrected rotation information. @matthewlai implemented that in his [JLCKicadTools](https://github.com/matthewlai/JLCKicadTools) and I adopted his work in this plugin as well. +You can download Matthews file from GitHub and manage your own corrections in the Rotation manager. + +## Icons + +This plugin makes use of a lot of icons from the excellent [Material Design Icons](https://materialdesignicons.com/) + +## Development + +1. Fork repo +2. Git clone forked repo +3. Install pre-commit `pip install pre-commit` +4. Setup pre-commit `pre-commit run` +5. Create feature branch `git switch -c my-awesome-feature` +6. Make your changes +7. Commit your changes `git commit -m "Awesome new feature"` +8. Push to GitHub `git push` +9. Create PR + +Make sure you make use of pre-commit hooks in order to format everything nicely with `black` +In the near future I'll add `ruff` / `pylint` and possibly other pre-commit-hooks that enforce nice and clean code style. + +## How to rebuild the parts database + +The parts database is rebuilt by the [update_parts_database.yml GitHub workflow](https://github.com/Bouni/kicad-jlcpcb-tools/blob/main/.github/workflows/update_parts_database.yml) + +You can reference the steps in the 'Update database' section for the commands to run locally. + +## python libraries + +lib/ contains the necessary python packages that may not be a part of the KiCad python distribution. + +These packages include: + +- packaging + +To install a package, such as 'packaging': + +```python +pip install packaging --target ./lib +``` + +To update these packages: + +```python +pip install packaging --upgrade --target ./lib +``` + +Future versions of KiCad may have support for a requires.txt to automate this process. + +## Standalone mode + +Allows the plugin UI to be started without KiCAD, enabling debugging with an IDE like pycharm / vscode. + +Standalone mode is under development. + +### Limitations + +- All board / footprint / value data are hardcoded stubs, see standalone_impl.py + +### How to use + +To use the plugin in standlone mode you'll need to identify three pieces of information specific to your Kicad version, plugin path, and OS. + +#### Python + +The {KiCad python} should be used, this can be found at different locations depending on your system: + +| OS | Kicad python | +|--------|---------------------------------------------------------------------------------------------| +|Mac | /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3 | +|Linux | /usr/bin/python3 | +|Windows | C:\Program Files\KiCad\8.0\bin\python.exe | + +#### Working directory + +The {working directory} should be your plugins directory, ie: + +| OS | Working dir | +|--------|------------------------------------------------------------| +|Mac | ~/Documents/KiCad//scripting/plugins/ | +|Linux | ~/.local/share/kicad//scripting/plugins/ | +|Windows | %USERPROFILE%\Documents\KiCad\\scripting\plugins\ | + +> [!NOTE] +> can be 7.0, 8.0, or X.YY depending on the version you use + +#### Plugin folder name + +The {kicad-jlcpcb-tools folder name} should be the name of the kicad-jlcpcb-tools folder. + +- For Kicad managed plugins this may be like + +> com_github_bouni_kicad-jlcpcb-tools + +- If you are developing kicad-jlcpcb-tools this is the folder you cloned the kicad-jlcpcb-tools as. + +#### Command line + +- Change to the working directory as noted above +- Run the python interpreter with the {kicad-jlcpcb-tools folder name} folder as a module. + +For example: + +```sh +cd {working directory} +{kicad_python} -m {kicad-jlcpcb-tools folder name} +``` + +For example on Mac: + +```sh +/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3 -m kicad-jlcpcb-tools +``` + +For example on Linux: + +```sh +cd ~/.local/share/kicad/8.0/scripting/plugins/ && python -m kicad-jlcpcb-tools +``` + +For example on Windows: + +```cmd +& 'C:\Program Files\KiCad\8.0\bin\python.exe' -m kicad-jlcpcb-tools +``` + +#### IDE + +- Configure the command line to be '{kicad_python} -m {kicad-jlcpcb-tools folder name}' +- Set the working directory to {working directory} + +If using PyCharm or Jetbrains IDEs, set the interpreter to Kicad's python, {Kicad python} and under 'run configuration' select Python. + +Click on 'script path' and change instead to 'module name', +entering the name of the kicad-jlcpcb-tools folder, {kicad-jlcpcb-tools folder name}. + +## How to release new versions of this plugin + +[bouni-kicad-repository](https://raw.githubusercontent.com/Bouni/bouni-kicad-repository/main/repository.json) contains the +files for the latest version of the plugin, in the format KiCAD expects from external plugins. + +To release a new version of this plugin: + +1. In the kicad-jlcpcb-plugin repository: + 1. Visit the releases page ![Release step 1](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/release_step_1.png) + 1. Click on 'Choose a tag', enter the next release number, say 2025.04.01 for example, and click on 'Create Tag' ![Release step 2](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/release_step_2.png) + 1. Click 'Generate release notes' ![Release step 3](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/release_step_3.png) + 1. If the release notes looks good, click on 'Publish release' ![Release step 4](https://github.com/Bouni/kicad-jlcpcb-tools/raw/main/images/release_step_4.png) +1. Automatically the new release will trigger the 'kicad-pcm' workflow which will: + 1. Pull the latest plugin tag + 1. Create the appropriate pcm archive + 1. Upload the zip as an asset to a new GitHub release + 1. benc-uk/workflow-dispatch@v1 is used to trigger the 'Rebuild repository' workflow in [bouni-kicad-repository](https://github.com/Bouni/bouni-kicad-repository) +1. Automatically in the bouni-kicad-repository, the 'Rebuild repository' (rebuild.yml) workflow runs 'generate.py' + 1. generate.py updates .json and the latest .zip file using the release assets from the kicad-jlcpcb-plugin repository +1. The plugin should now be visible to users via the plugin manager. diff --git a/third_party/Bouni/kicad-jlcpcb-tools/VERSION b/third_party/Bouni/kicad-jlcpcb-tools/VERSION new file mode 100644 index 00000000..38f8e886 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/VERSION @@ -0,0 +1 @@ +dev diff --git a/third_party/Bouni/kicad-jlcpcb-tools/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/__init__.py new file mode 100644 index 00000000..6e32547a --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/__init__.py @@ -0,0 +1,17 @@ +"""Init file for plugin.""" + +import os +import sys + +lib_path = os.path.join(os.path.dirname(__file__), "lib") +if lib_path not in sys.path: + sys.path.append(lib_path) + +try: + from .plugin import JLCPCBPlugin # noqa: I001, E402 + + if __name__ != "__main__": + JLCPCBPlugin().register() +except ImportError: + # Plugin import may fail in test environments where KiCad is not available + pass diff --git a/third_party/Bouni/kicad-jlcpcb-tools/__main__.py b/third_party/Bouni/kicad-jlcpcb-tools/__main__.py new file mode 100644 index 00000000..3f48768d --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/__main__.py @@ -0,0 +1,19 @@ +"""Entry point for running the plugin in standalone mode.""" + +import wx + +from . import standalone_impl +from .mainwindow import JLCPCBTools + +if __name__ == "__main__": + print("starting jlcpcbtools standalone mode...") # noqa: T201 + + # See README.md for how to use this + + app = wx.App(None) + + dialog = JLCPCBTools(None, kicad_provider=standalone_impl.KicadStub()) + dialog.Center() + dialog.Show() + + app.MainLoop() diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py new file mode 100644 index 00000000..60fc693a --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py @@ -0,0 +1,30 @@ +"""Common modules for kicad-jlcpcb-tools. + +Provides reusable components for file management and database operations. +""" + +from .componentdb import ComponentsDatabase +from .filemgr import FileManager +from .jlcapi import ApiCategory, CategoryFetch, Component, JlcApi, LcscId +from .progress import ( + NestedProgressBar, + NoOpProgressBar, + PrintNestedProgressBar, + ProgressCallback, + TqdmNestedProgressBar, +) + +__all__ = [ + "FileManager", + "ApiCategory", + "CategoryFetch", + "Component", + "ComponentsDatabase", + "JlcApi", + "LcscId", + "NestedProgressBar", + "NoOpProgressBar", + "PrintNestedProgressBar", + "ProgressCallback", + "TqdmNestedProgressBar", +] diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py new file mode 100644 index 00000000..17e44bef --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py @@ -0,0 +1,338 @@ +"""Component database management.""" + +from collections.abc import Generator +import json +import sqlite3 +import time + +from .jlcapi import Component + +_CREATE_STATEMENTS = [ + """CREATE TABLE IF NOT EXISTS components ( + lcsc INTEGER PRIMARY KEY NOT NULL, + category_id INTEGER NOT NULL, + mfr TEXT NOT NULL, + package TEXT NOT NULL, + joints INTEGER NOT NULL, + manufacturer_id INTEGER NOT NULL, + basic INTEGER NOT NULL, + preferred INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL, + datasheet TEXT NOT NULL, + stock INTEGER NOT NULL, + price TEXT NOT NULL, + last_update INTEGER NOT NULL, + extra TEXT, + flag INTEGER NOT NULL DEFAULT 0, + last_on_stock INTEGER NOT NULL DEFAULT 0) + """, + """ + CREATE INDEX IF NOT EXISTS components_category + ON components (category_id) + """, + """ + CREATE INDEX IF NOT EXISTS components_manufacturer + ON components (manufacturer_id) + """, + """ + CREATE TABLE IF NOT EXISTS manufacturers ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + UNIQUE (id, name)) + """, + """ + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY NOT NULL, + category TEXT NOT NULL, + subcategory TEXT NOT NULL, + UNIQUE (category, subcategory)) + """, +] + + +def fixDescription(description, raw_extra_json): + """Fix empty descriptions. + + At some point, JLC started returning empty descriptions in + their parts CSV, but the description is still available in + the 'extra' JSON in the 'description' field. + Note that this takes the raw JSON string, not the already + parsed 'extra' dict - this lets it be used as a SQLite UDF. + """ + + if not description: + try: + e = json.loads(raw_extra_json) + desc = e.get("description", e.get("describe", "")) + return desc + except Exception: + pass + return description + + +class ComponentsDatabase: + """Class to manage the component cache database.""" + + def __init__(self, filepath: str) -> None: + self.conn = sqlite3.connect(filepath) + self.conn.row_factory = sqlite3.Row + self.conn.create_function("maybeFixDescription", 2, fixDescription) + self.manufacturer_cache: dict[str, int | None] = {} + self.category_cache: dict[tuple[str, str], int | None] = {} + for stmt in _CREATE_STATEMENTS: + self.conn.execute(stmt) + self.conn.commit() + + def manufacturerId(self, name: str) -> int | None: + """Get the manufacturer ID from the database, inserting if necessary. + + Note that this method has side effects; it inserts into the database and + commits the transaction if the manufacturer is not already present, so it's + not safe to do manufacturerId() lookups while inside another running transaction. + """ + if name in self.manufacturer_cache: + return self.manufacturer_cache[name] + cursor = self.conn.execute( + "SELECT id FROM manufacturers WHERE name = ?", (name,) + ) + row = cursor.fetchone() + if row: + self.manufacturer_cache[name] = row["id"] + return row["id"] + cursor = self.conn.execute( + "INSERT INTO manufacturers (name) VALUES (?)", (name,) + ) + self.conn.commit() + manufacturer_id = cursor.lastrowid + self.manufacturer_cache[name] = manufacturer_id + return manufacturer_id + + def categoryId(self, category: str, subcategory: str) -> int | None: + """Get the category ID from the database, inserting if necessary. + + Note that this method has side effects; it inserts into the database and + commits the transaction if the category is not already present, so it's + not safe to do categoryId() lookups while inside another running transaction. + """ + key = (category, subcategory) + if key in self.category_cache: + return self.category_cache[key] + cursor = self.conn.execute( + "SELECT id FROM categories WHERE category = ? AND subcategory = ?", + (category, subcategory), + ) + row = cursor.fetchone() + if row: + self.category_cache[key] = row["id"] + return row["id"] + cursor = self.conn.execute( + "INSERT INTO categories (category, subcategory) VALUES (?, ?)", + (category, subcategory), + ) + self.conn.commit() + category_id = cursor.lastrowid + self.category_cache[key] = category_id + return category_id + + def close(self) -> None: + """Close the database connection.""" + self.conn.close() + + def fix_description(self) -> None: + """Fix empty descriptions in the database by copying from 'extra' JSON. + + Note that this is only useful while boostrapping a copy of the database + that is missing the descriptions -- e.g. a fresh load of the yaqswx database + before the empty descriptions are fixed. It should be harmless to run against + a fixed database, though, since it will only attempt to fix descriptions that + are empty or NULL. + + Note that because this runs a large transaction, it can effectively double the + size of the database while it is running while sqlite journals the changes since + it can touch all rows in the components table. + """ + self.conn.execute( + """ + UPDATE components + SET description = maybeFixDescription(description, extra) + WHERE description IS NULL OR description = '' + """ + ) + self.conn.commit() + + def cleanup_stock(self) -> None: + """Set stock to zero for components not updated in the last 7 days. + + Since this script should update all in-stock components every time it runs, + any component that hasn't been updated in the last 7 days can be assumed + to be out of stock. Ideally this should only be run if the full database + update was successful. + + """ + seven_days_ago = int(time.time()) - 7 * 24 * 60 * 60 + now = int(time.time()) + self.conn.execute( + """ + UPDATE components + SET stock = 0, last_update = ? + WHERE stock > 0 AND last_update < ? + """, + (now, seven_days_ago), + ) + self.conn.commit() + + def truncate_old(self) -> None: + """Truncate components not updated in the last year. + + For components that have been out of stock for over a year, set their + price and extra fields to empty values to save space. Neither of these + fields are indexed in the kicad-jlcpcb-tools usage, so this stock should + remain searchable but the large price and extra fields that are there + "just in case" and for backwards compatibility can be removed. + + Because this clears a lot of space, it also runs a VACUUM to compact the + database to reclaim the freed space. + + Note that both the transaction to clear the old components and the VACUUM + can take a long time to run and temporarily nearly double the size of the database + while the changes are journaled. + """ + one_year_ago = int(time.time()) - 365 * 24 * 60 * 60 + self.conn.execute( + """ + UPDATE components + SET price = '[]', extra = '{}' where stock = 0 AND last_on_stock < ? + """, + (one_year_ago,), + ) + self.conn.commit() + self.conn.execute("VACUUM") + + @staticmethod + def cols() -> list[str]: + """Get the list of component database columns.""" + return [ + "lcsc", + "category_id", + "manufacturer_id", + "mfr", + "package", + "basic", + "preferred", + "description", + "datasheet", + "stock", + "price", + "extra", + "joints", + "last_update", + "last_on_stock", + ] + + def update_cache(self, components: "list[Component]") -> None: + """Update the parts database cache from JLCPCB using UPSERT.""" + + # Iterate the components first to set the category and manufacturer IDs. + # These functions might insert into the database so need to be done outside + # of the .executemany()'s implicit transaction. + for comp in components: + comp["category_id"] = self.categoryId(*comp.categoryKey()) + comp["manufacturer_id"] = self.manufacturerId(comp.manufacturerKey()) + + update_cols = [ + col for col in self.cols() if col not in ["lcsc", "last_on_stock"] + ] + self.conn.executemany( + f""" + INSERT INTO components ( + {", ".join(self.cols())} + ) VALUES ( + {", ".join(":" + col for col in self.cols())} + ) ON CONFLICT(lcsc) DO UPDATE SET + {", ".join(f"{col} = :{col}" for col in update_cols)} + , last_on_stock = CASE + WHEN excluded.stock > 0 THEN excluded.last_update + ELSE components.last_on_stock END + """, + (comp.asDatabaseRow() for comp in components), + ) + self.conn.commit() + + def count_components(self, where_clause: str = "") -> int: + """Count the number of components in the database. + + Args: + where_clause: SQL WHERE clause (without the WHERE keyword). If empty, + all components are counted. + + Returns: + Number of components matching the where_clause. + + """ + query = "SELECT COUNT(*) AS count FROM components" + + if where_clause: + query += f" WHERE {where_clause}" + + cursor = self.conn.execute(query) + row = cursor.fetchone() + return row["count"] if row else 0 + + def fetch_components( + self, where_clause: str = "", batch_size: int = 100000 + ) -> Generator[list[sqlite3.Row], None, None]: + """Yield batches of sqlite3.Row objects from the database. + + Args: + where_clause: SQL WHERE clause (without the WHERE keyword). If empty, + all components are returned. + batch_size: Number of components to yield in each batch (default 100000). + + Yields: + list[sqlite3.Row]: Lists of rows up to batch_size length. + + Example: + for batch in db.components_batch_generator("stock > 0", batch_size=10000): + for row in batch: + print(row["lcsc"]) + + """ + query = "SELECT * FROM components" + + if where_clause: + query += f" WHERE {where_clause}" + + query += " ORDER BY lcsc" + + cursor = self.conn.execute(query) + + while True: + rows = cursor.fetchmany(batch_size) + + if not rows: + break + + yield rows + + def get_manufacturers(self) -> dict[int, str]: + """Get all manufacturers from the database. + + Returns: + Dictionary mapping manufacturer ID to manufacturer name. + + """ + cursor = self.conn.execute("SELECT id, name FROM manufacturers") + return {row["id"]: row["name"] for row in cursor.fetchall()} + + def get_categories(self) -> dict[int, tuple[str, str]]: + """Get all categories from the database. + + Returns: + Dictionary mapping category ID to (category, subcategory) tuple. + + """ + cursor = self.conn.execute("SELECT id, category, subcategory FROM categories") + return { + row["id"]: (row["category"], row["subcategory"]) + for row in cursor.fetchall() + } diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py b/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py new file mode 100644 index 00000000..58307917 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 + +"""File management utilities for splitting, reassembling, and downloading files. + +This module provides classes to handle: +- Splitting large files into chunks for GitHub compatibility +- Reassembling split files using the split_file_reader package +- Downloading files from GitHub archives +""" + +import argparse +from collections.abc import Callable, Generator +import glob +from pathlib import Path +import shutil +import tempfile +from typing import Any +import zipfile + +import requests +from split_file_reader.split_file_reader import SplitFileReader +from split_file_reader.split_file_writer import SplitFileWriter + +from .progress import NestedProgressBar, NoOpProgressBar, TqdmNestedProgressBar + + +class FileManager: + """Manage file splitting, reassembly, and downloads.""" + + def __init__( + self, + file_path: Path | str, + chunk_size: int = 80000000, # 80 MB default + sentinel_filename: str = "chunk_num.txt", + compressed_output_file: str | None = None, + use_temp_dir: bool = False, + ): + """Initialize FileManager. + + Args: + file_path: Path to the file to zip and split. + chunk_size: Size of each split chunk in bytes (default 80MB for GitHub) + sentinel_filename: Name of the sentinel file that tracks chunk count. + compressed_output_file: Path for compressed output file + (defaults to file_path.zip). May end up being + the prefix of the split files. + use_temp_dir: If True, create a temporary working directory for + intermediate files. Useful for large operations. + + """ + self.file_path = Path(file_path) + self.chunk_size = chunk_size + self.sentinel_filename = Path(sentinel_filename) + self.compressed_output_file = ( + Path(compressed_output_file) + if compressed_output_file + else Path(f"{self.file_path}.zip") + ) + self.use_temp_dir = use_temp_dir + self.temp_dir: Path | None = None + + def _get_work_dir(self) -> Path: + """Get the working directory, creating temp dir if needed. + + Returns: + Path: The working directory (either temp or current). + + """ + if self.use_temp_dir: + if self.temp_dir is None: + self.temp_dir = Path(tempfile.mkdtemp(prefix="filemanager_")) + print(f"Created temporary working directory: {self.temp_dir}") + return self.temp_dir + return Path(".") + + def cleanup_temp_dir(self) -> None: + """Clean up the temporary working directory if it exists. + + This method should be called when finished with the FileManager + to clean up temporary files. + + """ + if self.temp_dir and self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + print(f"Cleaned up temporary directory: {self.temp_dir}") + self.temp_dir = None + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit, ensuring temp dir cleanup.""" + self.cleanup_temp_dir() + return False + + def compress_and_split( + self, output_dir: Path | None = None, delete_original: bool = False + ) -> int: + """Split the file into chunks, creating a sentinel file with chunk count. + + This method maintains compatibility with Generate.split() output format. + It splits the file at self.file_path into numbered chunks (e.g., file.zip.001, .002, etc.) + and creates a sentinel file indicating the number of chunks. + + Returns: + int: The number of chunks created + + Raises: + FileNotFoundError: If the file to split does not exist. + + """ + if not self.file_path.exists(): + raise FileNotFoundError(f"File to split not found: {self.file_path}") + + class SplitTracker: + """Custom SplitFileWriter to track chunk count.""" + + def __init__(self, file_prefix: str): + self.file_prefix = file_prefix + self.files = [] + + def gen_split(self) -> Generator[Any, Any, None]: + while True: + name = f"{self.file_prefix}{len(self.files) + 1:03d}" + with open(name, "wb") as output_file: + self.files.append(name) + yield output_file + + def get_chunk_count(self) -> int: + return len(self.files) + + work_dir = self._get_work_dir() + print(f"Chunking {self.file_path}") + + # Build output file path in working directory + # If output_dir is provided, always use it (takes precedence) + # Otherwise, determine based on compressed_output_file + if output_dir is not None: + # Convert output_dir to absolute path to ensure files are created in correct location + actual_output_dir = Path(output_dir).resolve() + elif self.compressed_output_file.is_absolute(): + actual_output_dir = self.compressed_output_file.parent + else: + actual_output_dir = work_dir / self.compressed_output_file.parent + + actual_output_dir.mkdir(parents=True, exist_ok=True) + output_prefix = actual_output_dir / self.compressed_output_file.name + tracker = SplitTracker(str(output_prefix) + ".") + with ( + SplitFileWriter(tracker.gen_split(), self.chunk_size) as writer, + zipfile.ZipFile( + file=writer, mode="w", compression=zipfile.ZIP_DEFLATED + ) as zip_writer, + ): + zip_writer.write(self.file_path, arcname=self.file_path.name) + + # Create sentinel file indicating the number of chunks + sentinel_path = actual_output_dir / self.sentinel_filename + with open(sentinel_path, "w", encoding="utf-8") as f: + f.write(str(tracker.get_chunk_count())) + + print( + f"Created {tracker.get_chunk_count()} chunks with sentinel file: {sentinel_path}" + ) + if delete_original: + self.file_path.unlink() + print(f"Deleted original file: {self.file_path}") + return tracker.get_chunk_count() + + def reassemble( + self, + output_path: Path | str | None = None, + input_dir: Path | str | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> Path: + """Reassemble split chunks back into the original file. + + Uses split_file_reader to intelligently handle reassembly by reading + the chunk metadata and combining them in the correct order. + + Args: + output_path: Path for the reassembled file (defaults to self.file_path) + input_dir: Directory containing the chunk files (defaults to current directory) + progress_callback: Optional callback function(bytes_read, total_bytes) + for progress reporting + + Returns: + Path: Path to the reassembled file + + Raises: + FileNotFoundError: If chunk files are missing + ValueError: If sentinel file cannot be read or is invalid. + + """ + if output_path is None: + output_path = self.file_path + + if input_dir is None: + input_dir = Path(".") + else: + input_dir = Path(input_dir) + + output_path = Path(output_path) + # Search for chunk files in the input directory + search_pattern = input_dir / f"{self.compressed_output_file.name}*" + files = sorted(glob.glob(str(search_pattern))) + print( + f"Matching chunks with prefix: {search_pattern} got {[Path(f).name for f in files]}" + ) + print(f"Reassembling {len(files)} chunks into {output_path}") + + # Create a temporary directory to extract the zip contents + with tempfile.TemporaryDirectory() as temp_extract_dir: + with ( + SplitFileReader(files, "r") as sfr, + zipfile.ZipFile(sfr, "r") as zip_reader, # type: ignore + ): + zip_reader.extractall(path=temp_extract_dir) + + # The zip should contain one file - move it to the desired output path + extracted_files = list(Path(temp_extract_dir).iterdir()) + if len(extracted_files) == 1 and extracted_files[0].is_file(): + # Single file case - move it directly + shutil.copy2(extracted_files[0], output_path) + else: + # Multiple files or directory structure - copy the entire extracted content + output_path.parent.mkdir(parents=True, exist_ok=True) + for item in extracted_files: + if item.is_file(): + shutil.copy2(item, output_path.parent / item.name) + else: + shutil.copytree( + item, output_path.parent / item.name, dirs_exist_ok=True + ) + + print(f"Successfully reassembled file: {output_path}") + return output_path + + def download( + self, + url: str, + output_path: Path | str | None = None, + output_dir: Path | str | None = None, + progress_manager: NestedProgressBar | None = None, + ) -> Path: + """Download a file from a URL. + + Supports both simple file downloads and split chunk downloads from GitHub. + + Args: + url: URL to download (can be file URL or base URL for chunks) + output_path: Path where the downloaded file should be saved (for simple downloads) + output_dir: Directory to download chunks into (for chunk downloads) + progress_manager: Optional NestedProgressBar instance for progress reporting. + + Returns: + Path: Path to the downloaded file. + + Raises: + FileNotFoundError: If file cannot be found at the remote location + OSError: If download fails. + + """ + # Handle simple file download if output_path is provided + if output_path is not None: + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Downloading file from {url}") + try: + response = requests.get(url, allow_redirects=True, timeout=300) + response.raise_for_status() + with open(output_path, "wb") as f: + f.write(response.content) + print(f"Successfully downloaded to {output_path}") + return output_path + except requests.RequestException as e: + raise OSError(f"Failed to download from {url}: {e}") from e + + if progress_manager is None: + progress_manager = NoOpProgressBar() + + if output_dir is None: + output_dir = Path(".") + else: + output_dir = Path(output_dir) + + # Use temp dir for downloads if configured, otherwise use output_dir + download_dir = self._get_work_dir() if self.use_temp_dir else output_dir + download_dir.mkdir(parents=True, exist_ok=True) + + # Download sentinel file first to determine chunk count + sentinel_url = f"{url}/{self.sentinel_filename}" + sentinel_local = download_dir / self.sentinel_filename + + print(f"Downloading sentinel file from {sentinel_url}") + try: + response = requests.get(sentinel_url, allow_redirects=True, timeout=300) + response.raise_for_status() + with open(sentinel_local, "w", encoding="utf-8") as f: + f.write(response.text) + except requests.RequestException as e: + raise FileNotFoundError( + f"Could not download sentinel file from {sentinel_url}: {e}" + ) from e + + # Read sentinel to get chunk count + try: + with open(sentinel_local, encoding="utf-8") as f: + chunk_count = int(f.read().strip()) + except ValueError as e: + raise ValueError(f"Invalid sentinel file format at {sentinel_local}") from e + + print(f"Downloading {chunk_count} chunks from {url}") + + with progress_manager.outer( + chunk_count, + description=f"Downloading {self.compressed_output_file.name} ", + ) as outer_pbar: # type: ignore + for i in range(1, chunk_count + 1): + chunk_filename = f"{self.compressed_output_file.name}.{i:03d}" + chunk_url = f"{url}/{chunk_filename}" + chunk_local = download_dir / chunk_filename + + try: + with progress_manager.inner( + description=f"Downloading {chunk_filename}" + ) as inner_pbar: # type: ignore + response = requests.get( + chunk_url, + allow_redirects=True, + stream=True, + timeout=300, + ) + response.raise_for_status() + + # Get file size from headers + file_size = int(response.headers.get("Content-Length", 0)) + inner_pbar.set_total(file_size) + + # Download in chunks + with open(chunk_local, "wb") as f: + for chunk_data in response.iter_content(chunk_size=4096): + if chunk_data: + f.write(chunk_data) + inner_pbar.update(len(chunk_data)) + + outer_pbar.update() + except requests.RequestException as e: + raise OSError( + f"Failed to download chunk {chunk_filename} from {chunk_url}: {e}" + ) from e + + # If using temp dir, copy files to output_dir + if self.use_temp_dir and download_dir != output_dir: + output_dir.mkdir(parents=True, exist_ok=True) + for file in download_dir.glob(f"{self.compressed_output_file.name}*"): + shutil.copy2(file, output_dir / file.name) + print(f"Copied downloaded files to {output_dir}") + + return output_dir + + def download_and_reassemble( + self, + url: str, + output_path: Path | str | None = None, + output_dir: Path | str | None = None, + progress_manager: NestedProgressBar | None = None, + cleanup: bool = True, + ) -> Path: + """Download split chunks from GitHub and reassemble into final file. + + This is a convenience method that combines download() and + reassemble() into a single workflow, automatically cleaning up all + intermediate files (chunks and compressed archive). + + Args: + url: Base URL to the GitHub archive (without filename or chunk extension) + output_path: Final output filename (defaults to self.file_path) + output_dir: Directory for chunks and final output file (defaults to current directory) + progress_manager: Optional NestedProgressBar instance for progress reporting. + cleanup: If True, delete all intermediate files after reassembly + (default: True) + + Returns: + Path: Path to the final reassembled file (uncompressed) + + Raises: + FileNotFoundError: If sentinel file cannot be found or chunk files are missing + ValueError: If sentinel file format is invalid + OSError: If download or reassembly fails + + """ + if progress_manager is None: + progress_manager = NoOpProgressBar() + + if output_dir is None: + output_dir = Path(".") + else: + output_dir = Path(output_dir) + + if output_path is None: + output_path = self.file_path + else: + output_path = Path(output_path) + + # Ensure output_path has correct parent directory + if output_path.parent != output_dir: + output_path = output_dir / output_path.name + + try: + # Step 1: Download split files + print("Starting download and reassemble workflow") + print(f" Final output: {output_path}") + self.download( + url=url, + output_dir=output_dir, + progress_manager=progress_manager, + ) + + # Step 2: Reassemble the downloaded chunks + print("\nReassembling chunks...") + reassembled_file = self.reassemble( + output_path=output_path, input_dir=output_dir + ) + + # Step 3: Clean up intermediate files + if cleanup: + self._cleanup_intermediate_files(output_dir) + + print("\n✓ Successfully completed download and reassembly") + print(f" Final file: {reassembled_file}") + print(f" Size: {reassembled_file.stat().st_size:,} bytes") + + return reassembled_file + + except Exception as e: + print(f"\n✗ Error during download and reassemble: {e}") + raise + + def _cleanup_intermediate_files(self, directory: Path | str) -> None: + """Clean up intermediate chunk and compressed files. + + Removes: + - Chunk files (e.g., parts-fts5.db.001, .002, etc.) + - Compressed archive (e.g., parts-fts5.db.zip) + - Sentinel file (e.g., chunk_num_fts5.txt) + + Args: + directory: Directory containing intermediate files + + """ + directory = Path(directory) + files_deleted = 0 + + # Delete chunk files + for chunk_file in directory.glob(f"{self.compressed_output_file.name}.*"): + try: + chunk_file.unlink() + files_deleted += 1 + print(f" Deleted: {chunk_file.name}") + except OSError as e: + print(f" Warning: Could not delete {chunk_file.name}: {e}") + + # Delete sentinel file + sentinel_path = directory / self.sentinel_filename + if sentinel_path.exists(): + try: + sentinel_path.unlink() + files_deleted += 1 + print(f" Deleted: {sentinel_path.name}") + except OSError as e: + print(f" Warning: Could not delete {sentinel_path.name}: {e}") + + print(f"Cleaned up {files_deleted} intermediate files") + + +def main() -> None: + """Download parts-fts5.db files from GitHub.""" + + parser = argparse.ArgumentParser( + description="Download parts-fts5.db split files from GitHub release" + ) + parser.add_argument( + "-u", + "--url", + type=str, + default="https://bouni.github.io/kicad-jlcpcb-tools/", + help="Base URL to GitHub release directory containing split files", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("."), + help="Output directory for downloaded files (default: current directory)", + ) + parser.add_argument( + "--reassemble", + action="store_true", + default=True, + help="Reassemble files after downloading", + ) + parser.add_argument( + "--output-file", + type=Path, + help="Output filename for reassembled file (only with --reassemble)", + ) + + args = parser.parse_args() + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Downloading parts-fts5.db files from {args.url}") + print(f"Output directory: {output_dir.absolute()}") + + try: + manager = FileManager( + file_path="parts-fts5.db", sentinel_filename="chunk_num_fts5.txt" + ) + + # Download and reassemble in a single operation with cleanup + output_file = args.output_file or (Path(args.output) / "parts-fts5.db") + manager.download_and_reassemble( + args.url, + output_dir=args.output, + output_path=output_file, + progress_manager=TqdmNestedProgressBar(), + cleanup=True, + ) + + except FileNotFoundError as e: + print(f"\n✗ File not found: {e}") + except ValueError as e: + print(f"\n✗ Invalid sentinel file: {e}") + except OSError as e: + print(f"\n✗ Download error: {e}") + except Exception as e: + print(f"\n✗ Unexpected error: {e}") + + +if __name__ == "__main__": + main() diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py b/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py new file mode 100644 index 00000000..75bd9d57 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py @@ -0,0 +1,343 @@ +"""JLCPCB API interaction classes and utilities.""" + +from collections.abc import Generator +import json +import time +from typing import Any, NamedTuple + +from cachetools import TTLCache, cached +from ratelimit import limits, sleep_and_retry +import requests +from retry import retry + + +class ApiCategory(NamedTuple): + """Component category from JLCPCB API.""" + + primary: str + secondary: str + count: int + + def __repr__(self) -> str | None: + """Return the string representation of the ApiCategory.""" + return ( + f"{self.primary} {f'| {self.secondary}' if self.secondary else ''} ({self.count})" + if self.primary + else "" + ) + + +class JlcApi: + """Stateless class to interact with the JLCPCB API. + + This is mostly a collection of static methods for fetching data. + """ + + BASE_URL = "https://jlcpcb.com/api/overseas-pcb-order/v1/shoppingCart/smtGood" + + @cached(cache=TTLCache(maxsize=1, ttl=180)) + @retry(Exception, tries=5, delay=2, backoff=2) + @staticmethod + def getToken() -> str: + """Fetch a new XSRF token from JLCPCB. Caches the result for 3 minutes.""" + resp = requests.get(f"{JlcApi.BASE_URL}/getXSRFToken") + token = resp.cookies.get_dict()["XSRF-TOKEN"] + return token + + @staticmethod + def componentList(token: str, request: dict[str, Any]) -> Any: + """Fetch component list from JLCPCB API. + + This doesn't retry explicitly but is intended to be called from a method + decorated with @retry. + + Args: + token: XSRF token string + request: Request payload as a dictionary + + Returns: + Parsed JSON response body + + """ + url = f"{JlcApi.BASE_URL}/selectSmtComponentList" + headers = { + "Content-Type": "application/json", + "X-XSRF-TOKEN": token, + } + resp = requests.post(url, headers=headers, json=request) + if resp.status_code != 200: + raise RuntimeError(f"Cannot fetch component list: {resp.text}") + body = resp.json() + if body["code"] in [563, 564, 404, 429]: + # Intentionally don't raise an exception here - these are effectively + # "no data" responses so retrying doesn't help. + return {} + if body["code"] != 200: + raise RuntimeError(f"{body['code']}: {body['message']}") + return body + + @retry(Exception, tries=5, delay=30, backoff=2) + @staticmethod + def fetchCategories(instockOnly: bool) -> list[ApiCategory]: + """Fetch component categories from JLCPCB. + + Grabs the list of all categories available via the API along with + their stock counts. + + Args: + instockOnly: If True, only fetch categories and counts of in-stock items + + Returns: + List of ApiCategory objects (which are simple wrappers around primary/secondary/count) + + """ + token = JlcApi.getToken() + request = { + "searchType": 1, + "presaleTypes": ["stock"] if instockOnly else [], + } + body = JlcApi.componentList(token, request) + categories = [] + for primary in body["data"]["sortAndCountVoList"]: + primaryName = primary["sortName"] + for secondary in primary["childSortList"]: + secondaryName = secondary["sortName"] + count = secondary["componentCount"] + categories.append(ApiCategory(primaryName, secondaryName, count)) + return categories + + @staticmethod + def collapseCategories( + categories: list[ApiCategory], limit: int + ) -> list[ApiCategory]: + """Collapse small secondary categories into their primary category. + + JLC's API limits the size of a page to 1000 items, and allows only 100 pages per + query, so categories with more than 100,000 items need to be split by subcategory. + However, most top-level categories don't need to be split to stay under this limit. + (I have no idea what will happen if a single secondary category exceeds this limit - + The SMD resistor category has ~90K in-stock items as of early 2026) + + For each primary category, if the sum of all secondary categories under it + is less than the limit, replace them all with a single category with an + empty secondary category name. This is useful to reduce the number of API + calls to JLC - there are lots of tiny subcategories that we would otherwise + make separate requests for. + + Args: + categories: Full list of ApiCategory objects + limit: Minimum count threshold for keeping secondary categories + + Returns: + Modified list of categories with collapsed entries + + """ + # Group categories by primary + primary_groups: dict[str, list[ApiCategory]] = {} + for cat in categories: + if cat.primary not in primary_groups: + primary_groups[cat.primary] = [] + primary_groups[cat.primary].append(cat) + + result = [] + for primary, cats in primary_groups.items(): + total_count = sum(cat.count for cat in cats) + + if total_count < limit: + # Collapse: replace all secondary categories with one collapsed entry + result.append(ApiCategory(primary, "", total_count)) + else: + # Keep as-is + result.extend(cats) + return result + + +class CategoryFetch: + """Class to manage the lifecycle of fetching components from a category. + + Args: + category: ApiCategory object representing the category to fetch + rateLimit: If True, apply rate limiting to API calls (default: True) + pageSize: Number of items per page to fetch (default: 1000, maximum allowed by API) + + """ + + def __init__( + self, category: ApiCategory, rateLimit: bool = True, pageSize: int = 1000 + ) -> None: + self.category = category + self.instockOnly = True + self.pageSize = pageSize + self.rateLimit = rateLimit + self.currentPage = 1 + self.token = JlcApi.getToken() + + @sleep_and_retry + @limits(calls=1, period=3) + def _rateLimitedNextPage(self) -> list[Any]: + """Rate-limited wrapper around _fetchNextPage.""" + return self._fetchNextPage() + + @retry(Exception, tries=5, delay=2, backoff=2) + def _fetchNextPage(self) -> list[Any]: + request = { + "searchType": 2, + "presaleTypes": ["stock"] if self.instockOnly else [], + "firstSortName": self.category.primary, + "currentPage": self.currentPage, + "pageSize": self.pageSize, + } + if self.category.secondary: + request["secondSortName"] = self.category.secondary + body = JlcApi.componentList(self.token, request) + if not body: + return [] + components = body["data"]["componentPageInfo"]["list"] + self.currentPage += 1 + return components + + def fetchAll(self) -> Generator[list[Any], None, None]: + """Fetch all components in the category, yielding each page. + + Yields: + List of component dicts for each page fetched from the API. + + """ + while True: + components = ( + self._rateLimitedNextPage() if self.rateLimit else self._fetchNextPage() + ) + if not components: + break + yield components + + +class LcscId: + """LCSC ID wrapper. + + It can be confusing whether you're working with an LCSC ID as a string like "C12345" + or as an integer like 12345. This class wraps both representations and provides + conversion methods. Generally the DB wants integer format, while the API and user-facing + tools want string format. + """ + + def __init__(self, lcsc: str | int) -> None: + self.lcsc = lcsc + + def toDbKey(self) -> int: + """Convert to database / integer format.""" + if isinstance(self.lcsc, int): + return self.lcsc + return int(self.lcsc[1:]) + + def toComponent(self) -> str: + """Convert to API / string format.""" + if isinstance(self.lcsc, str) and self.lcsc.startswith("C"): + return self.lcsc + return f"C{self.lcsc}" + + +class Component(dict[str, Any]): + """Component from JLCPCB API.""" + + def __init__(self, data: dict[str, Any]) -> None: + super().__init__(data) + self.data = data + + def asDatabaseRow(self) -> dict[str, Any]: + """Convert to ComponentDatabase row format.""" + return { + "lcsc": LcscId(self["componentCode"]).toDbKey(), + "category_id": self["category_id"], + "manufacturer_id": self["manufacturer_id"], + "joints": 0, # Set to zero, not in API, needed for inseting new items + "mfr": self["componentModelEn"], + "package": self["componentSpecificationEn"], + "manufacturer": self["componentBrandEn"], + "basic": 1 if self["componentLibraryType"] == "base" else 0, + "preferred": 1 if self["preferredComponentFlag"] else 0, + "description": self["describe"], + "datasheet": self["dataManualUrl"] + or "https://jlcpcb.com/partdetail/" + self["urlSuffix"], + "stock": self["stockCount"], + "price": self.translated_component_prices(), + "extra": self.stripForExtra(), + "last_update": int(time.time()), + "last_on_stock": int(time.time()), + } + + def categoryKey(self) -> tuple[str, str]: + """Get the category key (primary, secondary). + + For whatever reason, the JLCPCB API uses "secondSortName" for primary + categories and "firstSortName" for secondary categories on the returned + components, despite the opposite naming on the category listing endpoint. + """ + return (self["secondSortName"], self["firstSortName"]) + + def manufacturerKey(self) -> str: + """Get the manufacturer name.""" + return self["componentBrandEn"] + + def stripForExtra(self) -> str: + """Get extra data as JSON string, excluding known fields.""" + extra_data = self.data.copy() + # Remove fields that are already represented elsewhere + for field in [ + "componentCode", + "firstSortName", + "secondSortName", + "componentModelEn", + "componentSpecificationEn", + "componentBrandEn", + "componentLibraryType", + "preferredComponentFlag", + "describe", + "dataManualUrl", + "componentPriceList", + "imageList", + "componentPrices", + "buyComponentPrices", + ]: + extra_data.pop(field, None) + for key in list(extra_data.keys()): + if extra_data[key] is None: + extra_data.pop(key) + return json.dumps(extra_data) + + def translated_component_prices(self) -> str: + """Translate component prices from JLC API format to internal format. + + Input format: + [ + {"startNumber": 1, "endNumber": 199, "productPrice": 0.0122}, + {"startNumber": 200, "endNumber": 599, "productPrice": 0.0098}, + ... + ] + + Output format: + '[{"qFrom": 1, "qTo": 199, "price": 0.0122}, ...]' + + Args: + component_prices: List of price brackets from the API + + Returns: + JSON string of transformed price brackets + + """ + component_prices = self["componentPrices"] + if not component_prices: + return "[]" + + # Transform the price brackets + transformed_prices = [] + for bracket in component_prices: + transformed_bracket = { + "qFrom": bracket["startNumber"], + "qTo": bracket["endNumber"] if bracket["endNumber"] != -1 else None, + "price": bracket["productPrice"], + } + transformed_prices.append(transformed_bracket) + + # Return as JSON string + return json.dumps(transformed_prices) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py new file mode 100644 index 00000000..15af92e3 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py @@ -0,0 +1,321 @@ +"""Parts database generation and management.""" + +from datetime import date, datetime +import os +from pathlib import Path +import sqlite3 +from typing import Any + +import humanize + +from .componentdb import ComponentsDatabase +from .filemgr import FileManager +from .progress import NestedProgressBar +from .translate import ComponentTranslator + +# Register date adapter to avoid deprecation warning +# See: https://docs.python.org/3/library/sqlite3.html#sqlite3-adapter-converter-recipes + + +def _adapt_date(val: date) -> str: + """Adapt datetime.date to ISO format string for SQLite storage.""" + return val.isoformat() + + +def _convert_date(val: bytes) -> date: + """Convert ISO format string from SQLite back to datetime.date.""" + return date.fromisoformat(val.decode()) + + +sqlite3.register_adapter(date, _adapt_date) +sqlite3.register_converter("date", _convert_date) + +_CREATE_STATEMENTS = [ + """ + CREATE virtual TABLE IF NOT EXISTS parts using fts5 ( + 'LCSC Part', + 'First Category', + 'Second Category', + 'MFR.Part', + 'Package', + 'Solder Joint' unindexed, + 'Manufacturer', + 'Library Type', + 'Description', + 'Datasheet' unindexed, + 'Price' unindexed, + 'Stock' unindexed + , tokenize="trigram") + """, + """ + CREATE TABLE IF NOT EXISTS mapping ( + 'footprint', + 'value', + 'LCSC' + ) + """, + """ + CREATE TABLE IF NOT EXISTS meta ( + 'filename', + 'size', + 'partcount', + 'date', + 'last_update' + ) + """, + """ + CREATE TABLE IF NOT EXISTS categories ( + 'First Category', + 'Second Category' + ) + """, +] + + +class Generate: + """Orchestrates the generation of parts database from component data. + + This class manages the workflow of fetching components from a component database, + translating them to parts database format, and reporting statistics. + """ + + def __init__( + self, + componentdb: "ComponentsDatabase", + partsdb: "PartsDatabase", + progress: "NestedProgressBar", + translator: ComponentTranslator | None = None, + populate_preferred: bool = False, + ): + """Initialize the generator. + + Args: + componentdb: ComponentsDatabase instance to fetch components from + partsdb: PartsDatabase instance to insert translated parts into + translator: Optional pre-configured ComponentTranslator. If not provided, + one will be created from the componentdb lookup tables. + progress: Optional NestedProgressBar for progress reporting. If not provided, + progress will be printed to console. + populate_preferred: Whether to populate preferred parts as 'Preferred' + or the backwards-compatible 'Extended'. + + + """ + self.componentdb = componentdb + self.partsdb = partsdb + self.translator = translator + self.progress = progress + self.populate_preferred = populate_preferred + self.total_components = 0 + self.loaded_components = 0 + + def generate(self, where_clause: str = "") -> None: + """Generate parts database by translating components. + + Fetches components from the component database in batches, translates them, + and inserts them into the parts database. Statistics are tracked and reported. + + Args: + where_clause: Optional SQL WHERE clause to filter components. + + """ + # Initialize translator if not provided + if self.translator is None: + manufacturers = self.componentdb.get_manufacturers() + categories = self.componentdb.get_categories() + self.translator = ComponentTranslator( + manufacturers, categories, populate_preferred=self.populate_preferred + ) + + total_components = self.componentdb.count_components(where_clause=where_clause) + self.total_components = total_components + print( + f"Translating {humanize.intcomma(total_components)} components to parts database" + ) + + # Use progress bar if provided, otherwise just iterate + with self.progress.outer(total_components, "Translating components") as pbar: # type: ignore + self._process_batches(where_clause, pbar) + + self.partsdb.post_build() + print("Done importing parts") + + def _process_batches(self, where_clause: str, pbar=None) -> None: + """Process component batches. + + Args: + where_clause: SQL WHERE clause to filter components. + pbar: Optional progress bar callback for updating outer bar. + + """ + batch_count = 0 + for batch in self.componentdb.fetch_components(where_clause=where_clause): + batch_count += 1 + + # Translate rows + translated_rows = [] + for component_row in batch: + translated_row = self.translator.translate(component_row) # type: ignore + translated_rows.append(translated_row) + self.loaded_components += len(batch) + + if pbar is None: + print( + f"Processed {humanize.intcomma(self.loaded_components)} / " + + f"{humanize.intcomma(self.total_components)} components" + ) + else: + pbar.update(len(batch)) + + # Insert into parts database + self.partsdb.update_parts(translated_rows) + + def report_stats(self) -> None: + """Report statistics from the translation process.""" + if self.translator is None: + print("No data processed yet") + return + + ( + price_entries_total, + price_entries_deleted_total, + price_entries_duplicates_deleted_total, + ) = self.translator.get_statistics() + print("Translation Statistics:") + print(f"Total price entries processed: {price_entries_total}") + print( + f"Price value filtering trimmed {price_entries_deleted_total} (including {price_entries_duplicates_deleted_total} duplicates) out of {price_entries_total} entries {(price_entries_deleted_total / price_entries_total) * 100 if price_entries_total != 0 else 0:.2f}%" + ) + + +class PartsDatabase: + """Manages generation of parts database from component data.""" + + def __init__( + self, + output_db: Path, + archive_dir: Path, + chunk_num: Path = Path("chunk_num_fts5.txt"), + skip_cleanup: bool = False, + ): + """Initialize the parts database manager. + + Args: + output_db: Path where the output database will be written + archive_dir: Directory where split archive files will be stored + chunk_num: Path to chunk number sentinel file + jlcparts_db_name: Name of the source jlcparts database + skip_cleanup: If True, don't delete temporary files after splitting + + """ + self.output_db = output_db + self.archive_dir = archive_dir + self.chunk_num = chunk_num + self.skip_cleanup = skip_cleanup + + self.remove_original() + self.conn = sqlite3.connect(self.output_db) + self.part_count = 0 + self.create_tables() + + def remove_original(self): + """Remove the original output database.""" + if self.output_db.exists(): + self.output_db.unlink() + + def close_sqlite(self): + """Close sqlite connections.""" + self.conn.close() + + def create_tables(self): + """Create tables.""" + for stmt in _CREATE_STATEMENTS: + self.conn.execute(stmt) + self.conn.commit() + + def update_parts(self, rows: list[dict[str, Any]]) -> None: + """Update parts database with a list of component rows. + + Args: + rows: List of sqlite3.Row objects from components table + translator: ComponentTranslator to use for translating rows + + """ + if not rows: + return + + data = rows[0] + columns = ", ".join([f'"{k}"' for k in data]) + placeholders = ", ".join( + [f":{k.replace(' ', '_').replace('.', '_')}" for k in data] + ) + newrows = [ + {k.replace(" ", "_").replace(".", "_"): v for k, v in row.items()} + for row in rows + ] + self.conn.executemany( + f"INSERT INTO parts ({columns}) VALUES ({placeholders})", newrows + ) + self.conn.commit() + + self.part_count += len(rows) + + def populate_categories(self): + """Populate the categories table.""" + self.conn.execute( + 'INSERT INTO categories SELECT DISTINCT "First Category", "Second Category" FROM parts ORDER BY UPPER("First Category"), UPPER("Second Category")' + ) + + def optimize(self): + """FTS5 optimize to minimize query times.""" + print("Optimizing fts5 parts table") + self.conn.execute("insert into parts(parts) values('optimize')") + print("Done optimizing fts5 parts table") + + def meta_data(self): + """Populate the metadata table.""" + db_size = os.stat(self.output_db).st_size + self.conn.execute( + "INSERT INTO meta VALUES(?, ?, ?, ?, ?)", + [ + "cache.sqlite3", + db_size, + self.part_count, + date.today().isoformat(), + datetime.now().isoformat(), + ], + ) + self.conn.commit() + + def split(self): + """Split the compressed database so we stay below GitHub's 100MB limit. + + Uses FileManager to split the file and create a sentinel file. + This maintains compatibility with the previous output format. + """ + file_manager = FileManager( + file_path=self.output_db, + chunk_size=80000000, # 80 MB to stay well below GitHub's 100MB limit + sentinel_filename=str(self.chunk_num), + ) + file_manager.compress_and_split(output_dir=self.archive_dir) + + def cleanup(self): + """Remove the compressed zip file and output db after splitting.""" + print(f"Deleting {self.output_db}") + os.unlink(self.output_db) + + def post_build(self): + """Actions to perform after building the database.""" + print( + f"Generated parts database with {humanize.intcomma(self.part_count)} parts" + ) + self.populate_categories() + self.meta_data() + self.optimize() + self.close_sqlite() + self.split() + if self.skip_cleanup: + print("Skipping cleanup") + else: + self.cleanup() diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py b/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py new file mode 100644 index 00000000..54501d03 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 + +"""Progress bar management for nested operations. + +Provides abstract and concrete implementations for tracking nested progress +during file operations. +""" + +from abc import ABC +from collections.abc import Callable +from contextlib import contextmanager + +import tqdm + + +class NestedProgressBar(ABC): + """Abstract base class for nested progress bar management. + + Provides context manager methods for managing outer and inner progress bars + with automatic cleanup. + """ + + def outer(self, total: int, description: str = ""): + """Context manager for outer progress bar. + + Args: + total: Total number of items for outer progress. + description: Description for the outer progress bar. + + Yields: + ProgressCallback: Object with update() method for outer progress. + + """ + + yield ProgressCallback(lambda inc: None) + + def inner(self, total: int | None = None, description: str = ""): + """Context manager for inner progress bar. + + Args: + total: Total number of items for inner progress (default: None). + description: Description for the inner progress bar. + + Yields: + ProgressCallback: Object with update() and set_total() methods. + + """ + + yield InnerProgressCallback(None, lambda inc: None) + + +class ProgressCallback: + """Helper class to track progress updates.""" + + def __init__(self, callback: Callable[[int], None]) -> None: + """Initialize with a callback function. + + Args: + callback: Function to call with increment amount. + + """ + self.callback = callback + + def update(self, increment: int = 1) -> None: + """Update progress. + + Args: + increment: Amount to advance. + + """ + self.callback(increment) + + def __call__(self, increment: int = 1) -> None: + """Allow direct calling as function.""" + self.update(increment) + + +class InnerProgressCallback(ProgressCallback): + """Extended progress callback with ability to update total.""" + + def __init__(self, bar: tqdm.tqdm | None, callback: Callable[[int], None]) -> None: + """Initialize with bar reference and callback. + + Args: + bar: The tqdm progress bar instance. + callback: Function to call with increment amount. + + """ + super().__init__(callback) + self.bar = bar + + def set_total(self, total: int) -> None: + """Update the total size. + + Args: + total: New total size. + + """ + if self.bar is not None: + self.bar.total = total + + +class TqdmNestedProgressBar(NestedProgressBar): + """Nested progress bar implementation using tqdm with context managers.""" + + def __init__(self) -> None: + """Initialize the nested progress bar.""" + self.outer_bar: tqdm.tqdm | None = None + self.inner_bar: tqdm.tqdm | None = None + + @contextmanager + def outer(self, total: int, description: str = ""): + """Context manager for outer progress bar. + + Usage: + with progress.outer(10, "Processing") as pbar: + for i in range(10): + pbar.update() + + Args: + total: Total number of items for outer progress. + description: Description for the outer progress bar. + + Yields: + ProgressCallback: Object with update() method. + + """ + self.outer_bar = tqdm.tqdm( + total=total, + desc=description, + unit="item", + position=0, + ) + try: + + def _update_outer(inc: int) -> None: + if self.outer_bar is not None: + self.outer_bar.update(inc) + + yield ProgressCallback(_update_outer) + finally: + if self.outer_bar is not None: + self.outer_bar.close() + self.outer_bar = None + + @contextmanager + def inner(self, total: int | None = None, description: str = ""): + """Context manager for inner progress bar. + + Usage: + with progress.inner(1024, "File") as pbar: + pbar.update(256) + pbar.set_total(2048) # Update total if needed + + Args: + total: Total number of items for inner progress. + description: Description for the inner progress bar. + + Yields: + InnerProgressCallback: Object with update() and set_total() methods. + + """ + if total is None: + total = 0 + + self.inner_bar = tqdm.tqdm( + total=total, + desc=description, + unit_divisor=1024, + miniters=1, + leave=False, + position=1, + ) + try: + + def _update_inner(inc: int) -> None: + if self.inner_bar is not None: + self.inner_bar.update(inc) + + yield InnerProgressCallback(self.inner_bar, _update_inner) + finally: + if self.inner_bar is not None: + self.inner_bar.close() + self.inner_bar = None + + +class NoOpProgressBar(NestedProgressBar): + """No-operation progress bar that does nothing. + + Useful as a default when progress tracking is not desired. + """ + + @contextmanager + def outer(self, total: int, description: str = ""): + """Context manager for outer progress bar (no-op). + + Args: + total: Total number of items for outer progress (ignored). + description: Description for the outer progress bar (ignored). + + Yields: + ProgressCallback: Silent callback object. + + """ + + def _update_outer(inc: int) -> None: + pass + + yield ProgressCallback(_update_outer) + + @contextmanager + def inner(self, total: int | None = None, description: str = ""): + """Context manager for inner progress bar (no-op). + + Args: + total: Total number of items for inner progress (ignored). + description: Description for the inner progress bar (ignored). + + Yields: + InnerProgressCallback: Silent callback object. + + """ + + class NoOpInnerCallback(InnerProgressCallback): + """Silent inner callback.""" + + def __init__(self) -> None: + """Initialize silent callback.""" + super().__init__(None, lambda _: None) + + def set_total(self, total: int) -> None: + """Update total (no-op).""" + pass + + yield NoOpInnerCallback() + + +class PrintNestedProgressBar(NestedProgressBar): + """Simple progress bar implementation using print statements. + + Emits progress lines when configured thresholds are reached. + Useful for simple console output without fancy progress bar rendering. + """ + + def __init__( + self, outer_threshold: int = 100000, inner_threshold: int = 100000 + ) -> None: + """Initialize the print-based progress bar. + + Args: + outer_threshold: Number of items to process before printing outer progress. + inner_threshold: Number of items to process before printing inner progress. + + """ + self.outer_threshold = outer_threshold + self.inner_threshold = inner_threshold + self.outer_description = "" + self.inner_description = "" + self.outer_count = 0 + self.inner_count = 0 + self.outer_total = 0 + self.inner_total = 0 + self.outer_accumulator = 0 + self.inner_accumulator = 0 + + @contextmanager + def outer(self, total: int, description: str = ""): + """Context manager for outer progress bar. + + Usage: + with progress.outer(100000, "Processing") as pbar: + for i in range(100000): + pbar.update(1000) + + Args: + total: Total number of items for outer progress. + description: Description for the outer progress bar. + + Yields: + ProgressCallback: Object with update() method. + + """ + self.outer_total = total + self.outer_description = description + self.outer_count = 0 + self.outer_accumulator = 0 + + print(f"Starting: {description} (total: {total})") + + def _update_outer(inc: int) -> None: + self.outer_count += inc + self.outer_accumulator += inc + if ( + self.outer_accumulator >= self.outer_threshold + or self.outer_count >= self.outer_total + ): + print( + f"{self.outer_description}: {self.outer_count}/{self.outer_total}" + ) + self.outer_accumulator = 0 + + try: + yield ProgressCallback(_update_outer) + finally: + if self.outer_count > 0: + print( + f"Completed: {self.outer_description} ({self.outer_count}/{self.outer_total})" + ) + + @contextmanager + def inner(self, total: int = 0, description: str = ""): + """Context manager for inner progress bar. + + Usage: + with progress.outer(100, "Outer") as outer_pbar: + with progress.inner(1000, "Inner") as inner_pbar: + for i in range(1000): + inner_pbar.update() + + Args: + total: Total number of items for inner progress. + description: Description for the inner progress bar. + + Yields: + InnerProgressCallback: Object with update() and set_total() methods. + + """ + self.inner_total = total + self.inner_description = description + self.inner_count = 0 + self.inner_accumulator = 0 + + if description: + print( + f" Starting: {description}" + (f" (total: {total})" if total else "") + ) + + def _update_inner(inc: int) -> None: + self.inner_count += inc + self.inner_accumulator += inc + if ( + self.inner_accumulator >= self.inner_threshold + or self.inner_count >= self.inner_total + ): + print( + f" {self.inner_description}: {self.inner_count}/{self.inner_total}" + ) + self.inner_accumulator = 0 + + class PrintInnerCallback(InnerProgressCallback): + """Inner progress callback using print statements.""" + + def __init__(self, callback: Callable[[int], None]) -> None: + """Initialize with callback.""" + super().__init__(None, callback) + self._total = total + + def set_total(self, new_total: int) -> None: + """Update the total size.""" + nonlocal total + total = new_total + self._total = new_total + self.bar = None # Don't use tqdm bar + + try: + yield PrintInnerCallback(_update_inner) + finally: + if description and self.inner_count > 0: + print( + f" Completed: {description}" + + ( + f" ({self.inner_count}/{self.inner_total})" + if self.inner_total + else f" ({self.inner_count})" + ) + ) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py new file mode 100644 index 00000000..ce85f7aa --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py @@ -0,0 +1,580 @@ +"""Tests for the componentdb module.""" + +import json +from pathlib import Path +import shutil +import sqlite3 +import sys +import tempfile +import time + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest + +from common.componentdb import _CREATE_STATEMENTS, ComponentsDatabase, fixDescription + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def temp_dir(): + """Create a temporary test directory and clean up after test.""" + test_dir = Path(tempfile.mkdtemp(prefix="test_componentdb_")) + yield test_dir + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.fixture +def temp_db(temp_dir): + """Create a temporary test database and clean up after test.""" + db_path = temp_dir / "test_components.db" + db = ComponentsDatabase(str(db_path)) + yield db + db.close() + + +# ============================================================================ +# fixDescription Tests +# ============================================================================ + + +class TestFixDescription: + """Tests for fixDescription utility function.""" + + def test_fix_description_with_empty_description(self): + """FixDescription extracts description from extra JSON when empty.""" + extra_json = json.dumps({"description": "Extracted description"}) + result = fixDescription("", extra_json) + assert result == "Extracted description" + + def test_fix_description_with_none_description(self): + """FixDescription handles None description.""" + extra_json = json.dumps({"description": "Extracted description"}) + result = fixDescription(None, extra_json) + assert result == "Extracted description" + + def test_fix_description_preserves_existing(self): + """FixDescription preserves existing non-empty description.""" + extra_json = json.dumps({"description": "Should not use this"}) + result = fixDescription("Original description", extra_json) + assert result == "Original description" + + def test_fix_description_falls_back_to_describe(self): + """FixDescription falls back to 'describe' key if 'description' missing.""" + extra_json = json.dumps({"describe": "Describe fallback"}) + result = fixDescription("", extra_json) + assert result == "Describe fallback" + + def test_fix_description_invalid_json(self): + """FixDescription handles invalid JSON gracefully.""" + result = fixDescription("", "invalid json") + assert result == "" + + def test_fix_description_no_description_keys(self): + """FixDescription returns empty string when no description keys found.""" + extra_json = json.dumps({"other_key": "value"}) + result = fixDescription("", extra_json) + assert result == "" + + def test_fix_description_empty_extra_json(self): + """FixDescription handles empty extra JSON.""" + result = fixDescription("", "{}") + assert result == "" + + +# ============================================================================ +# ComponentsDatabase Tests +# ============================================================================ + + +def _insert_component( + db, + lcsc, + category_id=1, + mfr="MFR", + package="0805", + joints=2, + manufacturer_id=1, + basic=1, + description="Component", + datasheet="http://example.com", + stock=100, + price="[]", + last_update=None, + extra=None, +): + """Insert a component into the test database.""" + if last_update is None: + last_update = int(time.time()) + + params = [ + lcsc, + category_id, + mfr, + package, + joints, + manufacturer_id, + basic, + description, + datasheet, + stock, + price, + last_update, + ] + + if extra is not None: + db.conn.execute( + """INSERT INTO components + (lcsc, category_id, mfr, package, joints, manufacturer_id, + basic, description, datasheet, stock, price, last_update, extra) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + params + [extra], + ) + else: + db.conn.execute( + """INSERT INTO components + (lcsc, category_id, mfr, package, joints, manufacturer_id, + basic, description, datasheet, stock, price, last_update) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + params, + ) + + +class TestComponentsDatabase: + """Tests for ComponentsDatabase class.""" + + def test_components_database_init(self, temp_db, temp_dir): + """ComponentsDatabase initializes with database file.""" + db_path = temp_dir / "test_components.db" + assert db_path.exists() + assert temp_db.conn is not None + + def test_components_database_creates_tables(self, temp_db): + """ComponentsDatabase creates all required tables.""" + cursor = temp_db.conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + + assert "components" in tables + assert "manufacturers" in tables + assert "categories" in tables + + def test_components_database_creates_indexes(self, temp_db): + """ComponentsDatabase creates all required indexes.""" + cursor = temp_db.conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='index'") + indexes = [row[0] for row in cursor.fetchall()] + + assert "components_category" in indexes + assert "components_manufacturer" in indexes + + def test_components_database_row_factory(self, temp_db): + """ComponentsDatabase uses row_factory for sqlite3.Row.""" + assert temp_db.conn.row_factory == sqlite3.Row + + def test_components_database_has_fix_description_udf(self, temp_db): + """ComponentsDatabase registers maybeFixDescription UDF.""" + # Test by using the function in a query + cursor = temp_db.conn.cursor() + result = cursor.execute( + "SELECT maybeFixDescription('', ?)", + (json.dumps({"description": "Test"}),), + ).fetchone() + + assert result[0] == "Test" + + def test_manufacturer_id_new_manufacturer(self, temp_db): + """ComponentsDatabase manufacturerId inserts new manufacturer.""" + mfr_id = temp_db.manufacturerId("Samsung") + assert mfr_id is not None + assert mfr_id > 0 + + def test_manufacturer_id_duplicate_returns_same(self, temp_db): + """ComponentsDatabase manufacturerId returns same ID for duplicate.""" + mfr_id_1 = temp_db.manufacturerId("Samsung") + mfr_id_2 = temp_db.manufacturerId("Samsung") + + assert mfr_id_1 == mfr_id_2 + + def test_manufacturer_id_caching(self, temp_db): + """ComponentsDatabase caches manufacturer IDs.""" + temp_db.manufacturerId("Intel") + assert "Intel" in temp_db.manufacturer_cache + + cached_id = temp_db.manufacturer_cache["Intel"] + new_id = temp_db.manufacturerId("Intel") + + assert cached_id == new_id + + def test_manufacturer_id_different_manufacturers(self, temp_db): + """ComponentsDatabase assigns different IDs to different manufacturers.""" + samsung_id = temp_db.manufacturerId("Samsung") + intel_id = temp_db.manufacturerId("Intel") + + assert samsung_id != intel_id + + def test_category_id_new_category(self, temp_db): + """ComponentsDatabase categoryId inserts new category.""" + cat_id = temp_db.categoryId("Resistors", "Fixed Resistors") + assert cat_id is not None + assert cat_id > 0 + + def test_category_id_duplicate_returns_same(self, temp_db): + """ComponentsDatabase categoryId returns same ID for duplicate.""" + cat_id_1 = temp_db.categoryId("Resistors", "Fixed Resistors") + cat_id_2 = temp_db.categoryId("Resistors", "Fixed Resistors") + + assert cat_id_1 == cat_id_2 + + def test_category_id_caching(self, temp_db): + """ComponentsDatabase caches category IDs.""" + temp_db.categoryId("Capacitors", "Ceramic") + key = ("Capacitors", "Ceramic") + assert key in temp_db.category_cache + + cached_id = temp_db.category_cache[key] + new_id = temp_db.categoryId("Capacitors", "Ceramic") + + assert cached_id == new_id + + def test_category_id_different_subcategories(self, temp_db): + """ComponentsDatabase assigns different IDs to different subcategories.""" + ceramic_id = temp_db.categoryId("Capacitors", "Ceramic") + film_id = temp_db.categoryId("Capacitors", "Film") + + assert ceramic_id != film_id + + def test_count_components_empty(self, temp_db): + """ComponentsDatabase count_components returns 0 for empty database.""" + count = temp_db.count_components() + assert count == 0 + + def test_count_components_with_data(self, temp_db): + """ComponentsDatabase count_components returns correct count.""" + # Add some components + for i in range(5): + _insert_component( + temp_db, + 100000 + i, + mfr=f"MFR{i}", + description=f"Component {i}", + ) + temp_db.conn.commit() + + count = temp_db.count_components() + assert count == 5 + + def test_count_components_with_where_clause(self, temp_db): + """ComponentsDatabase count_components filters with where_clause.""" + # Add components with different stock levels + for i in range(5): + stock = 100 if i < 3 else 0 + _insert_component( + temp_db, + 100000 + i, + mfr=f"MFR{i}", + description=f"Component {i}", + stock=stock, + ) + temp_db.conn.commit() + + total = temp_db.count_components() + in_stock = temp_db.count_components("stock > 0") + + assert total == 5 + assert in_stock == 3 + + def test_fetch_components_empty(self, temp_db): + """ComponentsDatabase fetch_components returns empty list for empty database.""" + batches = list(temp_db.fetch_components()) + assert len(batches) == 0 + + def test_fetch_components_single_batch(self, temp_db): + """ComponentsDatabase fetch_components yields components in batches.""" + # Add 5 components + for i in range(5): + _insert_component( + temp_db, + 100000 + i, + mfr=f"MFR{i}", + description=f"Component {i}", + ) + temp_db.conn.commit() + + batches = list(temp_db.fetch_components(batch_size=10)) + + assert len(batches) == 1 + assert len(batches[0]) == 5 + + def test_fetch_components_multiple_batches(self, temp_db): + """ComponentsDatabase fetch_components splits into multiple batches.""" + # Add 25 components + for i in range(25): + _insert_component( + temp_db, + 100000 + i, + mfr=f"MFR{i}", + description=f"Component {i}", + ) + temp_db.conn.commit() + + batches = list(temp_db.fetch_components(batch_size=10)) + + assert len(batches) == 3 + assert len(batches[0]) == 10 + assert len(batches[1]) == 10 + assert len(batches[2]) == 5 + + def test_fetch_components_with_where_clause(self, temp_db): + """ComponentsDatabase fetch_components filters with where_clause.""" + # Add components with different stock levels + for i in range(5): + stock = 100 if i % 2 == 0 else 0 + _insert_component( + temp_db, + 100000 + i, + mfr=f"MFR{i}", + description=f"Component {i}", + stock=stock, + ) + temp_db.conn.commit() + + batches = list(temp_db.fetch_components("stock > 0")) + + assert len(batches) == 1 + assert len(batches[0]) == 3 + + def test_get_manufacturers_empty(self, temp_db): + """ComponentsDatabase get_manufacturers returns empty dict for empty database.""" + manufacturers = temp_db.get_manufacturers() + assert len(manufacturers) == 0 + + def test_get_manufacturers_with_data(self, temp_db): + """ComponentsDatabase get_manufacturers returns all manufacturers.""" + mfr_samsung = temp_db.manufacturerId("Samsung") + mfr_intel = temp_db.manufacturerId("Intel") + + manufacturers = temp_db.get_manufacturers() + + assert len(manufacturers) == 2 + assert mfr_samsung is not None + assert mfr_intel is not None + assert manufacturers[mfr_samsung] == "Samsung" + assert manufacturers[mfr_intel] == "Intel" + + def test_get_categories_empty(self, temp_db): + """ComponentsDatabase get_categories returns empty dict for empty database.""" + categories = temp_db.get_categories() + assert len(categories) == 0 + + def test_get_categories_with_data(self, temp_db): + """ComponentsDatabase get_categories returns all categories.""" + cat_resistor = temp_db.categoryId("Resistors", "Fixed") + cat_capacitor = temp_db.categoryId("Capacitors", "Ceramic") + + categories = temp_db.get_categories() + + assert len(categories) == 2 + assert cat_resistor is not None + assert cat_capacitor is not None + assert categories[cat_resistor] == ("Resistors", "Fixed") + assert categories[cat_capacitor] == ("Capacitors", "Ceramic") + + def test_cols_static_method(self): + """ComponentsDatabase.cols returns expected column list.""" + cols = ComponentsDatabase.cols() + + assert isinstance(cols, list) + assert "lcsc" in cols + assert "category_id" in cols + assert "manufacturer_id" in cols + assert "mfr" in cols + assert "package" in cols + assert "basic" in cols + assert "preferred" in cols + assert "description" in cols + assert "datasheet" in cols + assert "stock" in cols + assert "price" in cols + assert "extra" in cols + assert "joints" in cols + assert "last_update" in cols + + def test_fix_description_method(self, temp_db): + """ComponentsDatabase fix_description updates empty descriptions.""" + # Insert component with empty description but extra JSON with description + extra = json.dumps({"description": "Fixed Description"}) + _insert_component( + temp_db, + 100000, + description="", + extra=extra, + ) + temp_db.conn.commit() + + temp_db.fix_description() + + cursor = temp_db.conn.cursor() + cursor.execute("SELECT description FROM components WHERE lcsc = ?", (100000,)) + result = cursor.fetchone() + + assert result[0] == "Fixed Description" + + def test_cleanup_stock_old_components(self, temp_db): + """ComponentsDatabase cleanup_stock sets old components to zero stock.""" + now = int(time.time()) + eight_days_ago = now - (8 * 24 * 60 * 60) + + # Insert old component with stock + _insert_component( + temp_db, + 100000, + stock=100, + last_update=eight_days_ago, + ) + temp_db.conn.commit() + + temp_db.cleanup_stock() + + cursor = temp_db.conn.cursor() + cursor.execute("SELECT stock FROM components WHERE lcsc = ?", (100000,)) + result = cursor.fetchone() + + assert result[0] == 0 + + def test_cleanup_stock_recent_components(self, temp_db): + """ComponentsDatabase cleanup_stock preserves recent component stock.""" + now = int(time.time()) + + # Insert recent component with stock + _insert_component( + temp_db, + 100000, + stock=100, + last_update=now, + ) + temp_db.conn.commit() + + temp_db.cleanup_stock() + + cursor = temp_db.conn.cursor() + cursor.execute("SELECT stock FROM components WHERE lcsc = ?", (100000,)) + result = cursor.fetchone() + + assert result[0] == 100 + + def test_truncate_old_clears_old_out_of_stock(self, temp_db): + """ComponentsDatabase truncate_old clears price/extra for old out-of-stock.""" + now = int(time.time()) + over_year_ago = now - (400 * 24 * 60 * 60) + + # Insert old out-of-stock component + temp_db.conn.execute( + """INSERT INTO components + (lcsc, category_id, mfr, package, joints, manufacturer_id, + basic, description, datasheet, stock, price, last_update, last_on_stock) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + 100000, + 1, + "MFR", + "0805", + 2, + 1, + 1, + "Component", + "http://example.com", + 0, + "[1.00, 2.00]", + now, + over_year_ago, + ), + ) + temp_db.conn.commit() + + temp_db.truncate_old() + + cursor = temp_db.conn.cursor() + cursor.execute("SELECT price, extra FROM components WHERE lcsc = ?", (100000,)) + result = cursor.fetchone() + + assert result[0] == "[]" + assert result[1] == "{}" + + def test_truncate_old_preserves_in_stock(self, temp_db): + """ComponentsDatabase truncate_old preserves in-stock components.""" + now = int(time.time()) + over_year_ago = now - (400 * 24 * 60 * 60) + + # Insert old but in-stock component + temp_db.conn.execute( + """INSERT INTO components + (lcsc, category_id, mfr, package, joints, manufacturer_id, + basic, description, datasheet, stock, price, last_update, last_on_stock) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + 100000, + 1, + "MFR", + "0805", + 2, + 1, + 1, + "Component", + "http://example.com", + 100, + "[1.00, 2.00]", + now, + over_year_ago, + ), + ) + temp_db.conn.commit() + + temp_db.truncate_old() + + cursor = temp_db.conn.cursor() + cursor.execute("SELECT price, extra FROM components WHERE lcsc = ?", (100000,)) + result = cursor.fetchone() + + # Should be preserved because stock > 0 + assert result[0] == "[1.00, 2.00]" + + +# ============================================================================ +# Constants Tests +# ============================================================================ + + +class TestCreateStatements: + """Tests for _CREATE_STATEMENTS constants.""" + + def test_create_statements_exists(self): + """_CREATE_STATEMENTS constant exists.""" + assert _CREATE_STATEMENTS is not None + assert isinstance(_CREATE_STATEMENTS, list) + + def test_create_statements_not_empty(self): + """_CREATE_STATEMENTS has statements.""" + assert len(_CREATE_STATEMENTS) > 0 + + def test_create_statements_are_strings(self): + """All _CREATE_STATEMENTS are SQL strings.""" + for stmt in _CREATE_STATEMENTS: + assert isinstance(stmt, str) + assert "CREATE" in stmt.upper() + + def test_components_table_statement(self): + """Components table statement exists.""" + assert any("components" in stmt.lower() for stmt in _CREATE_STATEMENTS) + + def test_manufacturers_table_statement(self): + """Manufacturers table statement exists.""" + assert any("manufacturers" in stmt.lower() for stmt in _CREATE_STATEMENTS) + + def test_categories_table_statement(self): + """Categories table statement exists.""" + assert any("categories" in stmt.lower() for stmt in _CREATE_STATEMENTS) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py new file mode 100644 index 00000000..39126fc2 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py @@ -0,0 +1,417 @@ +"""Test FileManager split and reassemble workflow.""" + +from http.server import HTTPServer, SimpleHTTPRequestHandler +import os +from pathlib import Path +import shutil +import tempfile +import threading +import time + +import pytest + +from common.filemgr import FileManager + + +@pytest.fixture +def temp_test_dir(): + """Create a temporary test directory and clean up after test. + + This fixture creates a unique temporary directory for each test, + allowing tests to safely create and modify files without affecting + other tests or the system. The directory and all its contents are + automatically deleted after the test completes, even if the test fails. + + All test output files should be created within this directory to ensure + proper cleanup. + + Returns: + Path: The temporary directory path. + + """ + test_dir = Path(tempfile.mkdtemp(prefix="test_filemgr_")) + yield test_dir + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.fixture +def temp_file(temp_test_dir): + """Create a temporary test file with content. + + Creates a test file in the temporary test directory with predefined content. + The file is automatically cleaned up when temp_test_dir is cleaned up. + + Args: + temp_test_dir: The temporary test directory fixture. + + Returns: + tuple: (test_file_path, test_content) for assertions and verification. + + """ + test_file = temp_test_dir / "test_data.txt" + test_content = "Test content " * 10000 + test_file.write_text(test_content) + return test_file, test_content + + +@pytest.fixture +def http_server(temp_test_dir): + """Start a temporary HTTP server serving files from temp_test_dir. + + This fixture creates a local HTTP server that serves files from the + temporary test directory. The server runs in a background thread and + is automatically shut down after the test completes. + + The temp_test_dir is automatically cleaned up after the fixture exits, + which ensures all test files are removed. + + Yields: + tuple: (server_url, temp_test_dir) where server_url is the base URL + (e.g., http://127.0.0.1:12345) to use for requests. + + """ + + class QuietHTTPRequestHandler(SimpleHTTPRequestHandler): + """HTTP request handler that suppresses logging.""" + + def log_message(self, format, *args): + pass # Suppress server logs during tests + + def translate_path(self, path): + """Override to serve from temp_test_dir.""" + # Simplified: just map the request path directly to temp_test_dir + path = path.split("?", 1)[0] # Remove query string + path = path.split("#", 1)[0] # Remove fragment + words = path.split("/") + words = filter(None, words) # Remove empty strings + path = temp_test_dir + for word in words: + if word in (os.curdir, os.pardir): + continue + path = path / word + return str(path) + + server = HTTPServer(("localhost", 0), QuietHTTPRequestHandler) + host, port = server.server_address[:2] + server_url = f"http://{host}:{port}" + + # Start server in background thread + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Give server time to start + time.sleep(0.1) + + yield server_url, temp_test_dir + + # Shutdown server + server.shutdown() + server.server_close() + + +# ============================================================================ +# Basic Tests (existing) +# ============================================================================ + + +def test_split_and_reassemble(temp_file, temp_test_dir): + """Test the split and reassemble workflow.""" + test_file, test_content = temp_file + output_dir = temp_test_dir / "output" + output_dir.mkdir() + + # Test the split operation + fm = FileManager( + file_path=test_file, + chunk_size=5000, # Small chunks for testing + sentinel_filename="test_chunks.txt", + ) + + chunk_count = fm.compress_and_split(output_dir=output_dir) + assert chunk_count > 0, "Should create at least one chunk" + + # Verify chunk files exist + chunk_files = list(output_dir.glob("test_data.txt.zip.*")) + assert len(chunk_files) == chunk_count, ( + "Number of chunk files should match chunk count" + ) + + # Verify sentinel file exists + sentinel_file = output_dir / "test_chunks.txt" + assert sentinel_file.exists(), "Sentinel file should exist" + assert int(sentinel_file.read_text()) == chunk_count, ( + "Sentinel should contain chunk count" + ) + + # Test reassemble + reassembled_file = output_dir / "test_data_reassembled.txt" + reassembled_path = fm.reassemble(output_path=reassembled_file, input_dir=output_dir) + + # Verify reassembled file + assert reassembled_path.exists(), "Reassembled file should exist" + reassembled_content = reassembled_path.read_text() + assert reassembled_content == test_content, "Content should match original" + + +def test_compress_and_split(temp_test_dir): + """Test the compress_and_split operation.""" + test_file = temp_test_dir / "test_file.txt" + test_file.write_text("Test content " * 5000) + + output_dir = temp_test_dir / "output" + output_dir.mkdir() + + fm = FileManager( + file_path=test_file, + chunk_size=8000, + sentinel_filename="chunk_count.txt", + ) + + chunk_count = fm.compress_and_split(output_dir=output_dir, delete_original=True) + + # Verify original file was deleted + assert not test_file.exists(), ( + "Original file should be deleted when delete_original=True" + ) + + # Verify chunks were created + chunk_files = sorted(output_dir.glob("test_file.txt.zip.*")) + assert len(chunk_files) == chunk_count, "All chunks should be created" + + +def test_temp_dir_context_manager(temp_test_dir): + """Test temporary working directory feature with context manager.""" + test_file = temp_test_dir / "test_file.txt" + test_file.write_text("Hello World" * 1000) + + # Test with use_temp_dir=True + with FileManager(test_file, use_temp_dir=True) as fm: + assert fm.use_temp_dir is True, "use_temp_dir should be True" + work_dir = fm._get_work_dir() + assert work_dir.exists(), "Work directory should exist" + temp_dir_path = fm.temp_dir + assert temp_dir_path is not None, "Temp dir should be created" + + # After context exit, temp dir should be cleaned up + assert fm.temp_dir is None, "Temp dir should be None after context exit" + assert not temp_dir_path.exists(), "Temp dir should be deleted after context exit" + + +def test_temp_dir_without_context_manager(temp_test_dir): + """Test temporary working directory without context manager.""" + test_file = temp_test_dir / "test_file.txt" + test_file.write_text("Hello World" * 1000) + + fm = FileManager(test_file, use_temp_dir=True) + work_dir = fm._get_work_dir() + assert work_dir.exists(), "Work directory should exist" + + temp_dir_path = fm.temp_dir + assert temp_dir_path is not None, "Temp dir should be created" + + # Manually cleanup + fm.cleanup_temp_dir() + assert fm.temp_dir is None, "Temp dir should be None after cleanup" + assert not temp_dir_path.exists(), "Temp dir should be deleted after cleanup" + + +def test_no_temp_dir(temp_test_dir): + """Test FileManager with use_temp_dir=False.""" + test_file = temp_test_dir / "test_file.txt" + test_file.write_text("Hello World" * 1000) + + fm = FileManager(test_file, use_temp_dir=False) + work_dir = fm._get_work_dir() + assert work_dir == Path("."), "Work directory should be current directory" + assert fm.temp_dir is None, "Temp dir should be None when use_temp_dir=False" + + +# ============================================================================ +# End-to-End Tests with HTTP Server +# ============================================================================ + + +def test_download_single_file(http_server, temp_test_dir): + """Test downloading a single file from GitHub.""" + server_url, serve_dir = http_server + + # Create a test file in the server directory + test_file = serve_dir / "download_test.txt" + test_content = "Downloaded file content" + test_file.write_text(test_content) + + # Download the file + download_dir = temp_test_dir / "downloads" + download_dir.mkdir() + + fm = FileManager( + file_path=download_dir / "download_test.txt", + use_temp_dir=False, + ) + downloaded_path = fm.download( + url=f"{server_url}/download_test.txt", + output_path=download_dir / "download_test.txt", + ) + + assert downloaded_path.exists(), "Downloaded file should exist" + assert downloaded_path.read_text() == test_content, ( + "Downloaded content should match" + ) + + +def test_download_and_reassemble_split_chunks(http_server, temp_test_dir): + """Test downloading split chunks and reassembling them.""" + server_url, serve_dir = http_server + + # Create original file and split it + original_file = serve_dir / "original.txt" + original_content = "Original file content " * 5000 + original_file.write_text(original_content) + + # Split the file + split_output_dir = serve_dir / "chunks" + split_output_dir.mkdir() + + fm = FileManager( + file_path=original_file, + chunk_size=3000, + sentinel_filename="chunk_count.txt", + ) + fm.compress_and_split(output_dir=split_output_dir) + + # Now download and reassemble + download_dir = temp_test_dir / "downloads" + download_dir.mkdir() + + fm_download = FileManager( + file_path=download_dir / "original.txt", + sentinel_filename="chunk_count.txt", + use_temp_dir=False, + ) + + reassembled_path = fm_download.download_and_reassemble( + url=f"{server_url}/chunks/", + output_dir=download_dir, + output_path=download_dir / "reassembled.txt", + ) + + assert reassembled_path.exists(), "Reassembled file should exist" + assert reassembled_path.read_text() == original_content, ( + "Reassembled content should match original" + ) + + +def test_download_and_reassemble_with_multiple_chunks(http_server, temp_test_dir): + """Test download_and_reassemble with many chunks.""" + server_url, serve_dir = http_server + + # Create a larger file that will be split into many chunks + original_file = serve_dir / "large.txt" + original_content = ( + "Large file content " * 100000 + ) # Much larger to ensure multiple chunks + original_file.write_text(original_content) + + # Split into small chunks + split_output_dir = serve_dir / "large_chunks" + split_output_dir.mkdir() + + fm = FileManager( + file_path=original_file, + chunk_size=1000, # Smaller chunks to ensure multiple + sentinel_filename="large_chunk_count.txt", + ) + chunk_count = fm.compress_and_split(output_dir=split_output_dir) + assert chunk_count > 3, "Should create multiple chunks for this test" + + # Download and reassemble + download_dir = temp_test_dir / "downloads" + download_dir.mkdir() + + fm_download = FileManager( + file_path=download_dir / "large.txt", + sentinel_filename="large_chunk_count.txt", + use_temp_dir=False, + ) + + reassembled_path = fm_download.download_and_reassemble( + url=f"{server_url}/large_chunks/", + output_dir=download_dir, + output_path=download_dir / "large_reassembled.txt", + ) + + assert reassembled_path.exists(), "Reassembled file should exist" + assert reassembled_path.read_text() == original_content, ( + "Reassembled content should match original" + ) + assert len(list(download_dir.glob("large.txt.zip.*"))) == 0, ( + "Downloaded chunks should be cleaned up after reassembly" + ) + + +def test_download_missing_sentinel_file(http_server, temp_test_dir): + """Test download_and_reassemble handles missing sentinel file gracefully.""" + server_url, serve_dir = http_server + + # Create chunks directory without sentinel file + chunks_dir = serve_dir / "no_sentinel" + chunks_dir.mkdir() + + download_dir = temp_test_dir / "downloads" + download_dir.mkdir() + + fm_download = FileManager( + file_path=download_dir / "test.txt", + sentinel_filename="chunk_count.txt", + use_temp_dir=False, + ) + + # Should raise an error or handle gracefully + with pytest.raises((FileNotFoundError, Exception)): + fm_download.download_and_reassemble( + url=f"{server_url}/no_sentinel/", + output_dir=download_dir, + ) + + +def test_download_partial_chunks(http_server, temp_test_dir): + """Test download_and_reassemble handles incomplete chunk sets.""" + server_url, serve_dir = http_server + + # Create chunks directory with only some chunks + original_file = serve_dir / "partial.txt" + original_content = "Partial file " * 50000 # Much larger to ensure multiple chunks + original_file.write_text(original_content) + + chunks_dir = serve_dir / "partial_chunks" + chunks_dir.mkdir() + + fm = FileManager( + file_path=original_file, + chunk_size=1000, # Smaller to ensure multiple chunks + sentinel_filename="partial_count.txt", + ) + chunk_count = fm.compress_and_split(output_dir=chunks_dir) + assert chunk_count > 1, "Should create multiple chunks for this test" + + # Remove some chunks to simulate incomplete download + chunk_files = sorted(chunks_dir.glob("partial.txt.zip.*")) + if len(chunk_files) > 1: + chunk_files[-1].unlink() + + download_dir = temp_test_dir / "downloads" + download_dir.mkdir() + + fm_download = FileManager( + file_path=download_dir / "partial.txt", + sentinel_filename="partial_count.txt", + use_temp_dir=False, + ) + + # Should raise an error due to missing chunk + with pytest.raises((FileNotFoundError, Exception)): + fm_download.download_and_reassemble( + url=f"{server_url}/partial_chunks/", + output_dir=download_dir, + output_path=download_dir / "partial_reassembled.txt", + ) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py new file mode 100644 index 00000000..58630870 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py @@ -0,0 +1,558 @@ +"""Tests for the jlcapi module.""" + +import json +from pathlib import Path +import sys +from unittest import mock + +import pytest + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.jlcapi import ApiCategory, CategoryFetch, Component, JlcApi, LcscId + +# ============================================================================ +# ApiCategory Tests +# ============================================================================ + + +class TestApiCategory: + """Tests for ApiCategory NamedTuple.""" + + def test_api_category_creation(self): + """ApiCategory can be created with primary, secondary, and count.""" + cat = ApiCategory("Resistors", "Thick Film", 10000) + assert cat.primary == "Resistors" + assert cat.secondary == "Thick Film" + assert cat.count == 10000 + + def test_api_category_repr_with_secondary(self): + """ApiCategory repr shows primary and secondary with count.""" + cat = ApiCategory("Capacitors", "Ceramic", 5000) + result = repr(cat) + assert "Capacitors" in result + assert "Ceramic" in result + assert "5000" in result + + def test_api_category_repr_without_secondary(self): + """ApiCategory repr shows only primary with count when secondary is empty.""" + cat = ApiCategory("Diodes", "", 1500) + result = repr(cat) + assert "Diodes" in result + assert "1500" in result + assert "|" not in result + + def test_api_category_repr_with_empty_primary(self): + """ApiCategory repr returns empty string when primary is empty.""" + cat = ApiCategory("", "Sub", 100) + result = repr(cat) + assert result == "" + + +# ============================================================================ +# LcscId Tests +# ============================================================================ + + +class TestLcscId: + """Tests for LcscId conversion class.""" + + def test_lcsc_id_from_string(self): + """LcscId can be initialized with string format.""" + lcsc = LcscId("C12345") + assert lcsc.lcsc == "C12345" + + def test_lcsc_id_from_integer(self): + """LcscId can be initialized with integer format.""" + lcsc = LcscId(12345) + assert lcsc.lcsc == 12345 + + def test_to_db_key_from_string(self): + """LcscId.toDbKey converts string to integer.""" + lcsc = LcscId("C12345") + assert lcsc.toDbKey() == 12345 + + def test_to_db_key_from_integer(self): + """LcscId.toDbKey returns integer unchanged.""" + lcsc = LcscId(12345) + assert lcsc.toDbKey() == 12345 + + def test_to_component_from_string(self): + """LcscId.toComponent returns string unchanged if already C-prefixed.""" + lcsc = LcscId("C12345") + assert lcsc.toComponent() == "C12345" + + def test_to_component_from_integer(self): + """LcscId.toComponent adds C prefix to integer.""" + lcsc = LcscId(12345) + assert lcsc.toComponent() == "C12345" + + def test_to_component_from_string_without_prefix(self): + """LcscId.toComponent adds C prefix if missing.""" + lcsc = LcscId("12345") + assert lcsc.toComponent() == "C12345" + + +# ============================================================================ +# Component Tests +# ============================================================================ + + +class TestComponent: + """Tests for Component class.""" + + @pytest.fixture + def sample_component_data(self): + """Sample component data from API.""" + return { + "componentCode": "C12345", + "category_id": 1, + "manufacturer_id": 5, + "componentModelEn": "1k Resistor", + "componentSpecificationEn": "0805", + "componentBrandEn": "Samsung", + "componentLibraryType": "base", + "preferredComponentFlag": False, + "describe": "1k resistor", + "dataManualUrl": "https://example.com/datasheet.pdf", + "stockCount": 10000, + "componentPrices": [ + {"startNumber": 1, "endNumber": 99, "productPrice": 0.05}, + {"startNumber": 100, "endNumber": 999, "productPrice": 0.04}, + ], + "imageList": [], + "componentPriceList": [], + "buyComponentPrices": [], + "firstSortName": "Resistors", + "secondSortName": "Thick Film", + "urlSuffix": "123456", + "other_field": "other_value", + } + + def test_component_creation(self, sample_component_data): + """Component can be created from data dict.""" + comp = Component(sample_component_data) + assert comp["componentCode"] == "C12345" + assert comp["category_id"] == 1 + + def test_component_as_dict(self, sample_component_data): + """Component inherits from dict and behaves like one.""" + comp = Component(sample_component_data) + assert comp.get("componentCode") == "C12345" + assert "manufacturer_id" in comp + + def test_component_category_key(self, sample_component_data): + """Component.categoryKey returns (secondary, primary) tuple.""" + comp = Component(sample_component_data) + primary, secondary = comp.categoryKey() + assert primary == "Thick Film" + assert secondary == "Resistors" + + def test_component_manufacturer_key(self, sample_component_data): + """Component.manufacturerKey returns manufacturer name.""" + comp = Component(sample_component_data) + assert comp.manufacturerKey() == "Samsung" + + def test_component_translated_prices(self, sample_component_data): + """Component.translated_component_prices converts price format.""" + comp = Component(sample_component_data) + result = json.loads(comp.translated_component_prices()) + + assert len(result) == 2 + assert result[0]["qFrom"] == 1 + assert result[0]["qTo"] == 99 + assert result[0]["price"] == 0.05 + assert result[1]["qFrom"] == 100 + assert result[1]["qTo"] == 999 + assert result[1]["price"] == 0.04 + + def test_component_translated_prices_handles_negative_one_end(self): + """Component.translated_component_prices converts -1 endNumber to None.""" + data = { + "componentPrices": [ + {"startNumber": 1, "endNumber": -1, "productPrice": 0.02}, + ], + } + comp = Component(data) + result = json.loads(comp.translated_component_prices()) + + assert result[0]["qTo"] is None + + def test_component_translated_prices_empty(self): + """Component.translated_component_prices returns empty list for no prices.""" + comp = Component({"componentPrices": []}) + result = comp.translated_component_prices() + assert result == "[]" + + def test_component_as_database_row(self, sample_component_data): + """Component.asDatabaseRow converts to database format.""" + comp = Component(sample_component_data) + row = comp.asDatabaseRow() + + assert row["lcsc"] == 12345 + assert row["category_id"] == 1 + assert row["manufacturer_id"] == 5 + assert row["mfr"] == "1k Resistor" + assert row["package"] == "0805" + assert row["manufacturer"] == "Samsung" + assert row["basic"] == 1 + assert row["preferred"] == 0 + assert row["description"] == "1k resistor" + assert row["stock"] == 10000 + assert "last_update" in row + assert "last_on_stock" in row + + def test_component_as_database_row_datasheet_fallback(self): + """Component.asDatabaseRow uses fallback datasheet URL if none provided.""" + data = { + "componentCode": "C12345", + "componentModelEn": "Component", + "componentSpecificationEn": "0805", + "componentBrandEn": "Brand", + "componentLibraryType": "base", + "preferredComponentFlag": False, + "describe": "Description", + "dataManualUrl": None, + "stockCount": 100, + "category_id": 1, + "manufacturer_id": 1, + "componentPrices": [], + "urlSuffix": "suffix123", + } + comp = Component(data) + row = comp.asDatabaseRow() + assert row["datasheet"] == "https://jlcpcb.com/partdetail/suffix123" + + def test_component_as_database_row_preferred_flag(self): + """Component.asDatabaseRow sets preferred when flag is true.""" + data = { + "componentCode": "C12345", + "componentModelEn": "Component", + "componentSpecificationEn": "0805", + "componentBrandEn": "Brand", + "componentLibraryType": "advanced", + "preferredComponentFlag": True, + "describe": "Description", + "dataManualUrl": None, + "stockCount": 100, + "category_id": 1, + "manufacturer_id": 1, + "componentPrices": [], + "urlSuffix": "suffix123", + } + comp = Component(data) + row = comp.asDatabaseRow() + assert row["basic"] == 0 + assert row["preferred"] == 1 + + def test_component_strip_for_extra(self, sample_component_data): + """Component.stripForExtra removes known fields from extra data.""" + comp = Component(sample_component_data) + extra = json.loads(comp.stripForExtra()) + + # Known fields should be removed + assert "componentCode" not in extra + assert "componentModelEn" not in extra + assert "componentPrices" not in extra + assert "dataManualUrl" not in extra + + # Other fields should remain + assert "other_field" in extra + assert extra["other_field"] == "other_value" + + def test_component_strip_for_extra_removes_none_values(self): + """Component.stripForExtra removes None values.""" + data = { + "other_field": "value", + "none_field": None, + } + comp = Component(data) + extra = json.loads(comp.stripForExtra()) + + assert "none_field" not in extra + assert "other_field" in extra + + +# ============================================================================ +# JlcApi Tests +# ============================================================================ + + +class TestJlcApi: + """Tests for JlcApi static class.""" + + def test_jlcapi_base_url(self): + """JlcApi has correct base URL.""" + assert "jlcpcb.com" in JlcApi.BASE_URL + assert "smtGood" in JlcApi.BASE_URL + + @mock.patch("common.jlcapi.requests.get") + def test_get_token_success(self, mock_get): + """JlcApi.getToken fetches and returns XSRF token.""" + mock_response = mock.Mock() + mock_response.cookies.get_dict.return_value = {"XSRF-TOKEN": "test_token_123"} + mock_get.return_value = mock_response + + # Clear cache to ensure fresh fetch + JlcApi.getToken.cache_clear() + + token = JlcApi.getToken() + assert token == "test_token_123" + mock_get.assert_called_once() + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_success(self, mock_post): + """JlcApi.componentList returns parsed JSON response.""" + response_data = { + "code": 200, + "message": "Success", + "data": {"components": []}, + } + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + result = JlcApi.componentList("token123", {"request": "data"}) + assert result == response_data + mock_post.assert_called_once() + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_http_error(self, mock_post): + """JlcApi.componentList raises on non-200 status.""" + mock_response = mock.Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_post.return_value = mock_response + + with pytest.raises(RuntimeError, match="Cannot fetch component list"): + JlcApi.componentList("token123", {}) + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_api_error_no_data(self, mock_post): + """JlcApi.componentList returns empty dict for certain error codes.""" + response_data = {"code": 563, "message": "Error"} + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + result = JlcApi.componentList("token123", {}) + assert result == {} + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_api_error_rate_limit(self, mock_post): + """JlcApi.componentList returns empty dict for rate limit error (429).""" + response_data = {"code": 429, "message": "Too Many Requests"} + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + result = JlcApi.componentList("token123", {}) + assert result == {} + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_api_error_not_found(self, mock_post): + """JlcApi.componentList returns empty dict for 404 error.""" + response_data = {"code": 404, "message": "Not Found"} + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + result = JlcApi.componentList("token123", {}) + assert result == {} + + @mock.patch("common.jlcapi.requests.post") + def test_component_list_other_error_raises(self, mock_post): + """JlcApi.componentList raises for other error codes.""" + response_data = {"code": 400, "message": "Bad Request"} + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + with pytest.raises(RuntimeError, match="400"): + JlcApi.componentList("token123", {}) + + def test_collapse_categories_no_collapse_needed(self): + """JlcApi.collapseCategories keeps categories above limit.""" + categories = [ + ApiCategory("Resistors", "Thick Film", 60000), + ApiCategory("Resistors", "Thin Film", 50000), + ApiCategory("Capacitors", "Ceramic", 100000), + ] + result = JlcApi.collapseCategories(categories, limit=100000) + + # Resistors total (110000) >= limit so kept + # Capacitors total (100000) is not < limit so kept + assert len(result) == 3 + assert result == categories + + def test_collapse_categories_collapse_small(self): + """JlcApi.collapseCategories collapses categories with total < limit.""" + categories = [ + ApiCategory("Diodes", "General", 5000), + ApiCategory("Diodes", "Schottky", 3000), + ApiCategory("Resistors", "Thick Film", 50000), + ] + result = JlcApi.collapseCategories(categories, limit=100000) + + # Diodes total (8000) < limit, should be collapsed + # Resistors total (50000) < limit, should also be collapsed + assert len(result) == 2 + assert any( + cat.primary == "Diodes" and cat.secondary == "" and cat.count == 8000 + for cat in result + ) + assert any( + cat.primary == "Resistors" and cat.secondary == "" and cat.count == 50000 + for cat in result + ) + + def test_collapse_categories_multiple_primaries(self): + """JlcApi.collapseCategories handles multiple primary categories.""" + categories = [ + ApiCategory("A", "Sub1", 10000), + ApiCategory("A", "Sub2", 15000), + ApiCategory("B", "Sub1", 5000), + ApiCategory("B", "Sub2", 8000), + ApiCategory("C", "Sub1", 60000), + ] + result = JlcApi.collapseCategories(categories, limit=100000) + + # A and B should be collapsed, C kept + a_collapsed = [cat for cat in result if cat.primary == "A"] + assert len(a_collapsed) == 1 + assert a_collapsed[0].count == 25000 + assert a_collapsed[0].secondary == "" + + +# ============================================================================ +# CategoryFetch Tests +# ============================================================================ + + +class TestCategoryFetch: + """Tests for CategoryFetch class.""" + + @mock.patch("common.jlcapi.JlcApi.getToken") + def test_category_fetch_init(self, mock_get_token): + """CategoryFetch initializes with category and settings.""" + mock_get_token.return_value = "test_token" + category = ApiCategory("Resistors", "Thick Film", 5000) + + fetch = CategoryFetch(category, rateLimit=True, pageSize=500) + + assert fetch.category == category + assert fetch.pageSize == 500 + assert fetch.rateLimit is True + assert fetch.currentPage == 1 + assert fetch.instockOnly is True + + @mock.patch("common.jlcapi.JlcApi.getToken") + def test_category_fetch_builds_request_with_secondary(self, mock_get_token): + """CategoryFetch builds request with secondary category.""" + mock_get_token.return_value = "test_token" + category = ApiCategory("Resistors", "Thick Film", 5000) + fetch = CategoryFetch(category, rateLimit=False, pageSize=100) + + with mock.patch.object(fetch, "_fetchNextPage", return_value=[]): + list(fetch.fetchAll()) + + # The request should include both primary and secondary + + @mock.patch("common.jlcapi.JlcApi.getToken") + @mock.patch("common.jlcapi.JlcApi.componentList") + def test_category_fetch_collapsed_category( + self, mock_component_list, mock_get_token + ): + """CategoryFetch handles collapsed categories (empty secondary).""" + mock_get_token.return_value = "test_token" + mock_component_list.return_value = {} + category = ApiCategory("Resistors", "", 80000) + + fetch = CategoryFetch(category, rateLimit=False) + list(fetch.fetchAll()) + + # Should have made at least one API call + assert mock_component_list.call_count >= 1 + + @mock.patch("common.jlcapi.JlcApi.getToken") + @mock.patch("common.jlcapi.JlcApi.componentList") + def test_category_fetch_single_page(self, mock_component_list, mock_get_token): + """CategoryFetch.fetchAll yields single page when components fit.""" + mock_get_token.return_value = "test_token" + + components_page1 = [{"componentCode": "C1"}, {"componentCode": "C2"}] + mock_component_list.side_effect = [ + {"data": {"componentPageInfo": {"list": components_page1}}}, + {"data": {"componentPageInfo": {"list": []}}}, + ] + + category = ApiCategory("Resistors", "Thick Film", 2) + fetch = CategoryFetch(category, rateLimit=False) + + pages = list(fetch.fetchAll()) + assert len(pages) == 1 + assert pages[0] == components_page1 + + @mock.patch("common.jlcapi.JlcApi.getToken") + @mock.patch("common.jlcapi.JlcApi.componentList") + def test_category_fetch_multiple_pages(self, mock_component_list, mock_get_token): + """CategoryFetch.fetchAll yields multiple pages.""" + mock_get_token.return_value = "test_token" + + page1 = [{"componentCode": "C1"}] + page2 = [{"componentCode": "C2"}] + page3 = [] + + mock_component_list.side_effect = [ + {"data": {"componentPageInfo": {"list": page1}}}, + {"data": {"componentPageInfo": {"list": page2}}}, + {"data": {"componentPageInfo": {"list": page3}}}, + ] + + category = ApiCategory("Test", "Cat", 100) + fetch = CategoryFetch(category, rateLimit=False) + + pages = list(fetch.fetchAll()) + assert len(pages) == 2 + assert pages[0] == page1 + assert pages[1] == page2 + + @mock.patch("common.jlcapi.JlcApi.getToken") + @mock.patch("common.jlcapi.JlcApi.componentList") + def test_category_fetch_empty_result(self, mock_component_list, mock_get_token): + """CategoryFetch.fetchAll handles empty response.""" + mock_get_token.return_value = "test_token" + mock_component_list.return_value = {} + + category = ApiCategory("Test", "Cat", 0) + fetch = CategoryFetch(category, rateLimit=False) + + pages = list(fetch.fetchAll()) + assert len(pages) == 0 + + @mock.patch("common.jlcapi.JlcApi.getToken") + @mock.patch("common.jlcapi.JlcApi.componentList") + def test_category_fetch_tracks_page_number( + self, mock_component_list, mock_get_token + ): + """CategoryFetch increments page number on each fetch.""" + mock_get_token.return_value = "test_token" + + mock_component_list.side_effect = [ + {"data": {"componentPageInfo": {"list": [{"componentCode": "C1"}]}}}, + {"data": {"componentPageInfo": {"list": []}}}, + ] + + category = ApiCategory("Test", "Cat", 100) + fetch = CategoryFetch(category, rateLimit=False) + + list(fetch.fetchAll()) + + # Check that currentPage was incremented twice (1 -> 2 on first fetch, 2 -> 3 on second empty fetch) + assert fetch.currentPage == 3 diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py new file mode 100644 index 00000000..c35063bb --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py @@ -0,0 +1,103 @@ +"""Tests for the SQL escaping helpers in search_escape.py.""" + +# ruff: noqa: D102 + +from pathlib import Path +import sqlite3 +import sys + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from search_escape import ( + escape_fts_phrase as _escape_fts_phrase, + escape_like_term as _escape_like_term, +) + +# --------------------------------------------------------------------------- +# _escape_like_term +# --------------------------------------------------------------------------- + + +class TestEscapeLikeTerm: + """Unit tests for _escape_like_term.""" + + def test_percent_is_escaped(self): + assert _escape_like_term("1%") == "1\\%" + + def test_underscore_is_escaped(self): + assert _escape_like_term("1_") == "1\\_" + + def test_backslash_is_escaped(self): + assert _escape_like_term("a\\b") == "a\\\\b" + + def test_single_quote_is_escaped(self): + assert _escape_like_term("it's") == "it''s" + + def test_plain_term_unchanged(self): + assert _escape_like_term("10k") == "10k" + + def test_multiple_special_chars(self): + assert _escape_like_term("%_50%") == "\\%\\_50\\%" + + # Integration: verify SQLite actually honours the escaped LIKE pattern. + + def _like_matches(self, haystack: str, needle: str) -> bool: + """Return True if haystack matches the escaped LIKE pattern for needle.""" + escaped = _escape_like_term(needle) + sql = f"SELECT 1 WHERE '{haystack}' LIKE '%{escaped}%' ESCAPE '\\'" + return sqlite3.connect(":memory:").execute(sql).fetchone() is not None + + def test_percent_matches_literally_not_as_wildcard(self): + # "1%" must appear as a literal substring — '1' immediately followed by '%' + assert self._like_matches("1% tolerance", "1%") is True # contains "1%" + assert self._like_matches("1x tolerance", "1%") is False # no literal '%' + assert self._like_matches("10%", "1%") is False # '1' and '%' not adjacent + + def test_underscore_matches_literally_not_as_wildcard(self): + assert self._like_matches("1_", "1_") is True + assert self._like_matches("1x", "1_") is False + + def test_plain_substring_still_matches(self): + assert self._like_matches("10k resistor", "10") is True + + def test_no_match_on_unrelated_string(self): + assert self._like_matches("100n capacitor", "1%") is False + + +# --------------------------------------------------------------------------- +# _escape_fts_phrase +# --------------------------------------------------------------------------- + + +class TestEscapeFtsPhrase: + """Unit tests for _escape_fts_phrase.""" + + def test_double_quote_is_doubled(self): + assert _escape_fts_phrase('say "hello"') == 'say ""hello""' + + def test_single_quote_is_doubled(self): + assert _escape_fts_phrase("it's") == "it''s" + + def test_both_quote_types(self): + assert _escape_fts_phrase('"it\'s"') == '""it\'\'s""' + + def test_plain_term_unchanged(self): + assert _escape_fts_phrase("resistor") == "resistor" + + def test_empty_string(self): + assert _escape_fts_phrase("") == "" + + # Integration: verify the escaped phrase embeds safely in a MATCH string. + + def test_double_quote_in_fts_match_does_not_crash(self): + con = sqlite3.connect(":memory:") + con.execute("CREATE VIRTUAL TABLE t USING fts5(description)") + con.execute("INSERT INTO t VALUES ('say hello world')") + con.execute("INSERT INTO t VALUES ('say \"hello\" world')") + escaped = _escape_fts_phrase('say "hello"') + # Should not raise; FTS5 phrase with escaped double-quote + rows = con.execute( + f"SELECT description FROM t WHERE t MATCH '\"{escaped}\"'" + ).fetchall() + assert any('"hello"' in r[0] for r in rows) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py new file mode 100644 index 00000000..23e887d9 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py @@ -0,0 +1,874 @@ +"""Tests for the partsdb module.""" + +from datetime import date +from pathlib import Path +import shutil +import sqlite3 +import sys +import tempfile +from unittest.mock import Mock, patch + +import pytest + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from common.partsdb import _CREATE_STATEMENTS, Generate, PartsDatabase +from common.progress import NoOpProgressBar +from common.translate import ComponentTranslator + +# ============================================================================ +# Pytest Fixtures +# ============================================================================ + + +@pytest.fixture +def temp_test_dir(): + """Create a temporary test directory and clean up after test. + + This fixture creates a unique temporary directory for each test. + The directory and all its contents are automatically deleted after + the test completes, even if the test fails. + + Returns: + Path: The temporary directory path. + + """ + test_dir = Path(tempfile.mkdtemp(prefix="test_partsdb_")) + yield test_dir + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.fixture +def parts_database(temp_test_dir): + """Create a PartsDatabase instance and ensure it's closed after test. + + This fixture automatically creates a PartsDatabase with a temporary + output database and archive directory. The database connection is + automatically closed at the end of the test, ensuring proper cleanup + without requiring manual close_sqlite() calls. + + Args: + temp_test_dir: The temporary directory fixture. + + Yields: + tuple: (database, output_db_path) for use in tests. + The database is automatically closed after the test. + + """ + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + output_db = temp_test_dir / "test_parts.db" + + db = PartsDatabase(output_db, archive_dir) + + yield db, output_db + + # Ensure database is closed after test + if db.conn: + db.close_sqlite() + + +# ============================================================================ +# PartsDatabase Tests +# ============================================================================ + + +class TestPartsDatabase: + """Tests for PartsDatabase class.""" + + def test_parts_database_init(self, parts_database): + """PartsDatabase initializes with correct paths.""" + db, output_db = parts_database + + assert output_db.exists() + assert isinstance(db.part_count, int) + assert db.part_count == 0 + + def test_parts_database_init_removes_existing(self, temp_test_dir): + """PartsDatabase removes existing output database.""" + # Create an existing database + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + output_db.write_text("old content") + assert output_db.exists() + + db = PartsDatabase(output_db, archive_dir) + + # Old file should be removed and replaced with new database + assert output_db.exists() + # Verify it's a valid database + conn = sqlite3.connect(output_db) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + conn.close() + assert len(tables) > 0 + db.close_sqlite() + + def test_parts_database_custom_chunk_num(self, temp_test_dir): + """PartsDatabase accepts custom chunk_num filename.""" + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + custom_chunk = Path("custom_chunk.txt") + db = PartsDatabase(output_db, archive_dir, chunk_num=custom_chunk) + assert db.chunk_num == custom_chunk + db.close_sqlite() + + def test_parts_database_create_tables(self, parts_database): + """PartsDatabase creates all required tables.""" + db, _ = parts_database + + cursor = db.conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + + # Should have parts (FTS5), mapping, meta, categories + assert "parts" in tables + assert "mapping" in tables + assert "meta" in tables + assert "categories" in tables + + def test_parts_database_parts_table_schema(self, parts_database): + """PartsDatabase creates parts table with correct columns.""" + db, _ = parts_database + + cursor = db.conn.cursor() + cursor.execute("PRAGMA table_info(parts)") + columns = [row[1] for row in cursor.fetchall()] + + # FTS5 virtual tables have slightly different schema, just verify parts table exists + assert len(columns) > 0 + + def test_parts_database_update_parts_single_row(self, parts_database): + """PartsDatabase updates parts with single row.""" + db, _ = parts_database + + row = { + "LCSC Part": "C123456", + "First Category": "Resistors", + "Second Category": "Fixed Resistors", + "MFR.Part": "TEST001", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "Samsung", + "Library Type": "Basic", + "Description": "Test Resistor", + "Datasheet": "http://example.com", + "Price": "1.00", + "Stock": "1000", + } + + db.update_parts([row]) + assert db.part_count == 1 + + # Verify data was inserted + cursor = db.conn.cursor() + cursor.execute('SELECT COUNT(*) FROM parts WHERE "LCSC Part" = ?', ("C123456",)) + count = cursor.fetchone()[0] + + assert count == 1 + + def test_parts_database_update_parts_multiple_rows(self, parts_database): + """PartsDatabase updates parts with multiple rows.""" + db, _ = parts_database + + rows = [ + { + "LCSC Part": f"C{100000 + i}", + "First Category": "Category", + "Second Category": "SubCategory", + "MFR.Part": f"TEST{i:03d}", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "Mfr", + "Library Type": "Basic", + "Description": f"Part {i}", + "Datasheet": "http://example.com", + "Price": "1.00", + "Stock": "1000", + } + for i in range(10) + ] + + db.update_parts(rows) + assert db.part_count == 10 + + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM parts") + count = cursor.fetchone()[0] + + assert count == 10 + + def test_parts_database_update_parts_empty_list(self, parts_database): + """PartsDatabase handles empty update list gracefully.""" + db, _ = parts_database + + db.update_parts([]) + assert db.part_count == 0 + + def test_parts_database_update_parts_with_special_chars(self, parts_database): + """PartsDatabase handles special characters in part data.""" + db, _ = parts_database + + row = { + "LCSC Part": "C999999", + "First Category": "Resistors & Capacitors", + "Second Category": "Film/Foil", + "MFR.Part": "TEST-001-A", + "Package": "1206", + "Solder Joint": 2, + "Manufacturer": "Inc.", + "Library Type": "Preferred", + "Description": 'Resistor "precision" type (±1%)', + "Datasheet": "http://example.com/ds.pdf?v=1.0", + "Price": "2.50-3.00", + "Stock": "5000+", + } + + db.update_parts([row]) + assert db.part_count == 1 + + def test_parts_database_populate_categories(self, parts_database): + """PartsDatabase populates categories from parts.""" + db, _ = parts_database + + # Insert some parts + rows = [ + { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "T1", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M1", + "Library Type": "Basic", + "Description": "D1", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + { + "LCSC Part": "C2", + "First Category": "Capacitors", + "Second Category": "Ceramic", + "MFR.Part": "T2", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M2", + "Library Type": "Basic", + "Description": "D2", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + ] + db.update_parts(rows) + db.populate_categories() + + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM categories") + count = cursor.fetchone()[0] + + assert count == 2 + + def test_parts_database_metadata(self, parts_database): + """PartsDatabase records metadata.""" + db, _ = parts_database + + # Add some parts first + row = { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "T1", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M1", + "Library Type": "Basic", + "Description": "D1", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + } + db.update_parts([row]) + db.meta_data() + + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM meta") + count = cursor.fetchone()[0] + + cursor.execute("SELECT partcount FROM meta") + part_count = cursor.fetchone()[0] + + assert count == 1 + assert part_count == 1 + + def test_parts_database_metadata_records_size(self, parts_database): + """PartsDatabase metadata records database size.""" + db, _ = parts_database + + # Add some data + rows = [ + { + "LCSC Part": f"C{i}", + "First Category": "Cat", + "Second Category": "Sub", + "MFR.Part": f"T{i}", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M", + "Library Type": "Basic", + "Description": f"Description {i}", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + } + for i in range(5) + ] + db.update_parts(rows) + db.meta_data() + + cursor = db.conn.cursor() + cursor.execute("SELECT size FROM meta") + size = cursor.fetchone()[0] + + # Size should be positive and reasonable + assert size > 0 + + def test_parts_database_metadata_records_date(self, parts_database): + """PartsDatabase metadata records current date.""" + db, _ = parts_database + + db.meta_data() + + cursor = db.conn.cursor() + cursor.execute("SELECT date FROM meta") + result = cursor.fetchone()[0] + + # Should record today's date as a string + assert str(date.today()) in result or result == str(date.today()) + + def test_parts_database_remove_original(self, temp_test_dir): + """PartsDatabase removes existing database.""" + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + # Create a file + output_db.write_text("test") + assert output_db.exists() + + db = PartsDatabase(output_db, archive_dir) + # File should be removed during init and recreated as database + assert output_db.exists() + db.close_sqlite() + + @patch("common.partsdb.FileManager") + def test_parts_database_split(self, mock_fm, temp_test_dir): + """PartsDatabase calls FileManager to split database.""" + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + db = PartsDatabase(output_db, archive_dir) + + # Add some data + row = { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "T1", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M1", + "Library Type": "Basic", + "Description": "D1", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + } + db.update_parts([row]) + db.close_sqlite() + + # Now test split + db2 = PartsDatabase(output_db, archive_dir) + db2.close_sqlite() + + # Mock the split process so it doesn't actually run + with patch("common.partsdb.FileManager"): + pass + + @patch("common.partsdb.os.unlink") + @patch("common.partsdb.FileManager") + def test_parts_database_cleanup(self, mock_fm, mock_unlink, temp_test_dir): + """PartsDatabase cleanup removes original database file.""" + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + db = PartsDatabase(output_db, archive_dir) + db.close_sqlite() + + db.cleanup() + mock_unlink.assert_called_once() + + def test_parts_database_skip_cleanup_flag(self, temp_test_dir): + """PartsDatabase respects skip_cleanup flag.""" + output_db = temp_test_dir / "test_parts.db" + archive_dir = temp_test_dir / "archive" + archive_dir.mkdir() + + db = PartsDatabase(output_db, archive_dir, skip_cleanup=True) + assert db.skip_cleanup is True + db.close_sqlite() + + @patch("common.partsdb.os.unlink") + @patch("common.partsdb.FileManager") + def test_parts_database_post_build_builds_categories( + self, mock_fm, mock_unlink, parts_database + ): + """PartsDatabase post_build calls populate_categories.""" + db, _ = parts_database + + # Insert some parts with different categories + rows = [ + { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "T1", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M1", + "Library Type": "Basic", + "Description": "D1", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + { + "LCSC Part": "C2", + "First Category": "Capacitors", + "Second Category": "Ceramic", + "MFR.Part": "T2", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M2", + "Library Type": "Basic", + "Description": "D2", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + { + "LCSC Part": "C3", + "First Category": "Resistors", + "Second Category": "Variable", + "MFR.Part": "T3", + "Package": "0603", + "Solder Joint": 2, + "Manufacturer": "M3", + "Library Type": "Basic", + "Description": "D3", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + ] + db.update_parts(rows) + + # Mock methods that would fail in test environment + with ( + patch.object(db, "close_sqlite"), + patch.object(db, "split"), + patch.object( + db, "populate_categories", wraps=db.populate_categories + ) as mock_populate, + ): + db.post_build() + + # Verify populate_categories was called + mock_populate.assert_called_once() + + +# ============================================================================ +# Generate Class Tests +# ============================================================================ + + +class TestGenerate: + """Tests for Generate class.""" + + def test_generate_init(self, parts_database): + """Generate initializes with database and translator.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + progress = NoOpProgressBar() + translator = ComponentTranslator({}, {}) + + gen = Generate(mock_componentdb, partsdb, progress, translator) + + assert gen.componentdb == mock_componentdb + assert gen.partsdb == partsdb + assert gen.translator == translator + assert gen.progress == progress + assert gen.total_components == 0 + assert gen.loaded_components == 0 + + def test_generate_init_without_translator(self, parts_database): + """Generate creates translator if not provided.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + mock_componentdb.get_manufacturers.return_value = {1: "Samsung"} + mock_componentdb.get_categories.return_value = {1: ("Resistors", "Fixed")} + + progress = NoOpProgressBar() + + gen = Generate(mock_componentdb, partsdb, progress) + + assert gen.translator is None # Not created until generate() is called + + def test_generate_tracks_components(self, parts_database): + """Generate tracks total and loaded component counts.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + mock_componentdb.count_components.return_value = 100 + mock_componentdb.fetch_components.return_value = [] + + progress = NoOpProgressBar() + + gen = Generate(mock_componentdb, partsdb, progress) + + assert gen.total_components == 0 + gen.total_components = 100 + + assert gen.total_components == 100 + + @patch("common.partsdb.ComponentTranslator") + def test_generate_creates_translator_on_demand( + self, mock_translator_class, parts_database + ): + """Generate creates translator if not provided during generate().""" + partsdb, _ = parts_database + mock_componentdb = Mock() + mock_componentdb.count_components.return_value = 0 + mock_componentdb.fetch_components.return_value = [] + mock_componentdb.get_manufacturers.return_value = {} + mock_componentdb.get_categories.return_value = {} + + progress = NoOpProgressBar() + + gen = Generate(mock_componentdb, partsdb, progress) + + # Translator is None initially + assert gen.translator is None + + # Call generate (but with mocked data so it doesn't do much) + with patch.object(gen, "_process_batches"), patch.object(partsdb, "post_build"): + gen.generate() + + # Translator should still be None in this context (mocked away) + + def test_generate_process_batches_empty(self, parts_database): + """Generate handles empty component batches.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + mock_componentdb.fetch_components.return_value = [] + + progress = NoOpProgressBar() + translator = ComponentTranslator({}, {}) + + gen = Generate(mock_componentdb, partsdb, progress, translator) + gen._process_batches("", None) + + assert gen.loaded_components == 0 + + def test_generate_process_batches_single_batch(self, parts_database): + """Generate processes a single batch of components.""" + partsdb, _ = parts_database + # Create mock component rows + mock_rows = [] + for _ in range(5): + mock_row = Mock() + mock_rows.append(mock_row) + + mock_componentdb = Mock() + mock_componentdb.fetch_components.return_value = [mock_rows] + + progress = NoOpProgressBar() + + # Create a translator that returns valid part data + translator = Mock(spec=ComponentTranslator) + translator.translate.side_effect = [ + { + "LCSC Part": f"C{i}", + "First Category": "Cat", + "Second Category": "Sub", + "MFR.Part": f"T{i}", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M", + "Library Type": "Basic", + "Description": f"D{i}", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + } + for i in range(5) + ] + + gen = Generate(mock_componentdb, partsdb, progress, translator) + gen.total_components = 5 + gen._process_batches("", None) + + assert gen.loaded_components == 5 + + def test_generate_report_stats_no_translator(self, parts_database): + """Generate reports error when no data processed.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + progress = NoOpProgressBar() + + gen = Generate(mock_componentdb, partsdb, progress) + + # Should not crash, just report no data + gen.translator = None + # This would print to stdout, we just verify it doesn't crash + gen.report_stats() + + def test_generate_report_stats_with_data(self, parts_database): + """Generate reports statistics from translator.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + progress = NoOpProgressBar() + + translator = Mock(spec=ComponentTranslator) + translator.get_statistics.return_value = (1000, 50, 10) + + gen = Generate(mock_componentdb, partsdb, progress, translator) + gen.report_stats() + + translator.get_statistics.assert_called_once() + + def test_generate_report_stats_zero_deletion(self, parts_database): + """Generate handles zero deletions in statistics.""" + partsdb, _ = parts_database + mock_componentdb = Mock() + progress = NoOpProgressBar() + + translator = Mock(spec=ComponentTranslator) + translator.get_statistics.return_value = (1000, 0, 0) + + gen = Generate(mock_componentdb, partsdb, progress, translator) + gen.report_stats() + + translator.get_statistics.assert_called_once() + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +class TestPartsDBIntegration: + """Integration tests for partsdb module.""" + + def test_parts_database_full_workflow(self, parts_database): + """PartsDatabase handles full workflow: create, insert, optimize, metadata.""" + db, output_db = parts_database + + # Insert parts + rows = [ + { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "T1", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M1", + "Library Type": "Basic", + "Description": "D1", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + }, + ] + db.update_parts(rows) + + # Populate categories + db.populate_categories() + + # Add metadata + db.meta_data() + + # Verify everything worked + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM parts") + parts_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM categories") + categories_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM meta") + meta_count = cursor.fetchone()[0] + + assert parts_count == 1 + assert categories_count == 1 + assert meta_count == 1 + + def test_multiple_category_insertion(self, parts_database): + """PartsDatabase handles multiple categories correctly.""" + db, _ = parts_database + + # Insert parts with different categories + rows = [ + { + "LCSC Part": f"C{i}", + "First Category": f"Category{i // 3}", + "Second Category": f"SubCategory{i}", + "MFR.Part": f"T{i}", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "M", + "Library Type": "Basic", + "Description": f"D{i}", + "Datasheet": "D", + "Price": "1", + "Stock": "1000", + } + for i in range(9) + ] + db.update_parts(rows) + db.populate_categories() + + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM categories") + categories_count = cursor.fetchone()[0] + + # Should have 3 first categories with multiple second categories + assert categories_count > 0 + + def test_fts5_search_capability(self, parts_database): + """PartsDatabase parts table supports FTS5 search.""" + db, _ = parts_database + + rows = [ + { + "LCSC Part": "C1", + "First Category": "Resistors", + "Second Category": "Fixed", + "MFR.Part": "RESMFR001", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "ResistorCorp", + "Library Type": "Basic", + "Description": "10K resistor precision type", + "Datasheet": "http://example.com", + "Price": "0.01", + "Stock": "10000", + }, + { + "LCSC Part": "C2", + "First Category": "Capacitors", + "Second Category": "Ceramic", + "MFR.Part": "CAPMFR001", + "Package": "0805", + "Solder Joint": 2, + "Manufacturer": "CapacitorCorp", + "Library Type": "Preferred", + "Description": "100nF ceramic capacitor", + "Datasheet": "http://example.com", + "Price": "0.005", + "Stock": "50000", + }, + ] + db.update_parts(rows) + + # Test FTS5 search + cursor = db.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM parts WHERE parts MATCH ?", ("resistor",)) + count = cursor.fetchone()[0] + + assert count > 0 + + def test_special_field_name_handling(self, parts_database): + """PartsDatabase handles field names with spaces and dots correctly.""" + db, _ = parts_database + + # Fields have spaces like "LCSC Part", "Solder Joint", etc. + row = { + "LCSC Part": "C999", + "First Category": "Test", + "Second Category": "Test", + "MFR.Part": "TEST.MFR.001", + "Package": "BGA", + "Solder Joint": 144, + "Manufacturer": "TestCorp", + "Library Type": "Extended", + "Description": "Complex test part", + "Datasheet": "http://example.com/datasheet.pdf", + "Price": "99.99", + "Stock": "100", + } + db.update_parts([row]) + + cursor = db.conn.cursor() + cursor.execute('SELECT COUNT(*) FROM parts WHERE "LCSC Part" = ?', ("C999",)) + count = cursor.fetchone()[0] + + assert count == 1 + + +# ============================================================================ +# Constants Tests +# ============================================================================ + + +class TestCreateStatements: + """Tests for CREATE_STATEMENTS constants.""" + + def test_create_statements_exists(self): + """CREATE_STATEMENTS constant exists.""" + assert _CREATE_STATEMENTS is not None + assert isinstance(_CREATE_STATEMENTS, list) + + def test_create_statements_not_empty(self): + """CREATE_STATEMENTS has statements.""" + assert len(_CREATE_STATEMENTS) > 0 + + def test_create_statements_are_strings(self): + """All CREATE_STATEMENTS are SQL strings.""" + for stmt in _CREATE_STATEMENTS: + assert isinstance(stmt, str) + assert "CREATE" in stmt.upper() + + def test_create_statements_count(self): + """CREATE_STATEMENTS has expected number of table definitions.""" + # Should have: parts (FTS5), mapping, meta, categories + assert len(_CREATE_STATEMENTS) >= 4 + + def test_parts_table_statement(self): + """Parts table statement creates FTS5 virtual table.""" + parts_stmt = _CREATE_STATEMENTS[0] + assert "parts" in parts_stmt.lower() + assert "fts5" in parts_stmt.lower() + assert "LCSC Part" in parts_stmt + + def test_mapping_table_statement(self): + """Mapping table statement exists.""" + assert any("mapping" in stmt.lower() for stmt in _CREATE_STATEMENTS) + + def test_meta_table_statement(self): + """Meta table statement exists.""" + assert any("meta" in stmt.lower() for stmt in _CREATE_STATEMENTS) + + def test_categories_table_statement(self): + """Categories table statement exists.""" + assert any("categories" in stmt.lower() for stmt in _CREATE_STATEMENTS) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py new file mode 100644 index 00000000..185d9e39 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py @@ -0,0 +1,68 @@ +"""Tests for part selector text highlight helpers.""" + +# ruff: noqa: D103 + +from partselector_highlight import ( + HighlightQueryCache, + filtered_highlight_terms, + find_highlight_spans, + normalize_highlight_terms, +) + + +def test_normalize_highlight_terms_splits_and_deduplicates(): + assert normalize_highlight_terms(" 10k %0603% 10K ") == ["10k", "0603"] + + +def test_find_highlight_spans_matches_multiple_terms_case_insensitively(): + assert find_highlight_spans("10K resistor 0603", ["10k", "0603"]) == [ + (0, 3), + (13, 17), + ] + + +def test_find_highlight_spans_merges_overlapping_matches(): + assert find_highlight_spans("abcde", ["abc", "bcd"]) == [(0, 4)] + + +def test_filtered_highlight_terms_skips_short_terms(): + assert filtered_highlight_terms("1 12 1206") == ["12", "1206"] + + +def test_highlight_query_cache_stores_negative_results(): + cache = HighlightQueryCache() + cache.prepare("zzzz") + spans = cache.get_spans("Murata Electronics") + assert spans == [] + + +def test_highlight_query_cache_reuses_cached_negative_results(): + cache = HighlightQueryCache() + cache.prepare("zzzz") + first = cache.get_spans("Murata Electronics") + second = cache.get_spans("Murata Electronics") + + assert first == [] + assert second == [] + assert first is second + + +def test_highlight_query_cache_prepare_changes_terms_and_resets_spans(): + cache = HighlightQueryCache() + cache.prepare("murata") + spans_before = cache.get_spans("Murata Electronics") + cache.prepare("1206") + spans_after = cache.get_spans("Murata Electronics") + + assert spans_before != [] + assert cache.get_terms() == ["1206"] + assert spans_after == [] + + +def test_highlight_query_cache_clear_resets_all_state(): + cache = HighlightQueryCache() + cache.prepare("murata 1206") + _ = cache.get_spans("Murata Electronics") + cache.clear() + + assert cache.get_terms() == [] diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py new file mode 100644 index 00000000..b689ca2c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py @@ -0,0 +1,622 @@ +"""Tests for the translate module.""" + +import json + +from common.translate import ComponentTranslator, Price, PriceEntry, process_description + +# ============================================================================ +# PriceEntry Tests +# ============================================================================ + + +class TestPriceEntry: + """Tests for PriceEntry class.""" + + def test_price_entry_init(self): + """PriceEntry initializes with correct values.""" + entry = PriceEntry(10, 100, "5.50") + assert entry.min_quantity == 10 + assert entry.max_quantity == 100 + assert entry.price_dollars_str == "5.50" + assert entry.price_dollars == 5.50 + + def test_price_entry_init_no_max(self): + """PriceEntry can be created without max_quantity.""" + entry = PriceEntry(500, None, "1.25") + assert entry.min_quantity == 500 + assert entry.max_quantity is None + assert entry.price_dollars == 1.25 + + def test_price_entry_parse_with_qto(self): + """PriceEntry.Parse extracts data from dict with qTo.""" + entry_dict = {"qFrom": "100", "qTo": "500", "price": "2.50"} + entry = PriceEntry.Parse(entry_dict) + assert entry.min_quantity == 100 + assert entry.max_quantity == 500 + assert entry.price_dollars_str == "2.50" + + def test_price_entry_parse_without_qto(self): + """PriceEntry.Parse handles missing qTo as None.""" + entry_dict = {"qFrom": "1000", "qTo": None, "price": "0.75"} + entry = PriceEntry.Parse(entry_dict) + assert entry.min_quantity == 1000 + assert entry.max_quantity is None + assert entry.price_dollars == 0.75 + + def test_price_entry_repr(self): + """PriceEntry string representation is correct.""" + entry = PriceEntry(1, 100, "5.50") + assert repr(entry) == "1-100:5.50" + + def test_price_entry_repr_no_max(self): + """PriceEntry repr shows empty max when None.""" + entry = PriceEntry(500, None, "1.00") + assert repr(entry) == "500-:1.00" + + def test_price_entry_float_conversion(self): + """PriceEntry converts string price to float correctly.""" + entry = PriceEntry(1, 10, "0.005") + assert entry.price_dollars == 0.005 + assert isinstance(entry.price_dollars, float) + + +# ============================================================================ +# Price Class Tests +# ============================================================================ + + +class TestPrice: + """Tests for Price class.""" + + def test_price_init_single_entry(self): + """Price initializes with single price entry.""" + price_data = [{"qFrom": "1", "qTo": "100", "price": "5.00"}] + price = Price(price_data) + assert len(price.price_entries) == 1 + assert price.price_entries[0].min_quantity == 1 + + def test_price_init_multiple_entries(self): + """Price initializes with multiple price entries.""" + price_data = [ + {"qFrom": "1", "qTo": "100", "price": "5.00"}, + {"qFrom": "101", "qTo": "500", "price": "3.00"}, + {"qFrom": "501", "qTo": None, "price": "1.50"}, + ] + price = Price(price_data) + assert len(price.price_entries) == 3 + + def test_price_init_empty(self): + """Price can be initialized with empty list.""" + price = Price([]) + assert len(price.price_entries) == 0 + + def test_reduce_precision_single_value(self): + """Reduce precision converts price to 3 decimal places.""" + entries = [PriceEntry(1, 100, "0.123456789")] + result = Price.reduce_precision(entries) + assert result[0].price_dollars_str == "0.123" + assert result[0].price_dollars == 0.123 + + def test_reduce_precision_multiple_values(self): + """Reduce precision works on multiple entries.""" + entries = [ + PriceEntry(1, 100, "5.123456"), + PriceEntry(101, 200, "3.999999"), + PriceEntry(201, None, "1.111111"), + ] + result = Price.reduce_precision(entries) + assert len(result) == 3 + assert result[0].price_dollars_str == "5.123" + assert result[1].price_dollars_str == "4.000" + assert result[2].price_dollars_str == "1.111" + + def test_reduce_precision_rounding(self): + """Reduce precision rounds correctly.""" + entries = [ + PriceEntry(1, 100, "0.9999"), + PriceEntry(101, 200, "0.1234"), + PriceEntry(201, None, "0.0001"), + ] + result = Price.reduce_precision(entries) + assert result[0].price_dollars == 1.000 + assert result[1].price_dollars == 0.123 + assert result[2].price_dollars == 0.000 + + def test_filter_below_cutoff_keeps_first(self): + """Filter below cutoff always keeps first entry.""" + entries = [ + PriceEntry(1, 100, "0.05"), + PriceEntry(101, 200, "0.02"), + ] + result = Price.filter_below_cutoff(entries, 0.03) + assert len(result) == 1 + assert result[0].price_dollars == 0.05 + + def test_filter_below_cutoff_multiple_entries(self): + """Filter below cutoff removes only entries below threshold.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.30"), + PriceEntry(201, 300, "0.20"), + PriceEntry(301, 400, "0.10"), + ] + result = Price.filter_below_cutoff(entries, 0.25) + assert len(result) == 2 + assert result[0].price_dollars == 0.40 + assert result[1].price_dollars == 0.30 + + def test_filter_below_cutoff_empty_list(self): + """Filter below cutoff handles empty list.""" + entries = [] + result = Price.filter_below_cutoff(entries, 0.01) + assert len(result) == 0 + + def test_filter_below_cutoff_sets_final_max_none(self): + """Filter below cutoff sets last entry max_quantity to None.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.30"), + PriceEntry(201, None, "0.20"), + ] + result = Price.filter_below_cutoff(entries, 0.15) + assert result[-1].max_quantity is None + + def test_filter_below_cutoff_all_above_threshold(self): + """Filter below cutoff keeps all when all above threshold.""" + entries = [ + PriceEntry(1, 100, "0.50"), + PriceEntry(101, 200, "0.40"), + PriceEntry(201, None, "0.30"), + ] + result = Price.filter_below_cutoff(entries, 0.20) + assert len(result) == 3 + + def test_filter_duplicate_prices_single_entry(self): + """Filter duplicate prices works with single entry.""" + entries = [PriceEntry(1, 100, "0.50")] + result = Price.filter_duplicate_prices(entries) + assert len(result) == 1 + assert result[0].price_dollars_str == "0.50" + + def test_filter_duplicate_prices_no_duplicates(self): + """Filter duplicate prices preserves unique prices.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.30"), + PriceEntry(201, None, "0.20"), + ] + result = Price.filter_duplicate_prices(entries) + assert len(result) == 3 + + def test_filter_duplicate_prices_consecutive_duplicates(self): + """Filter duplicate prices merges consecutive duplicates.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.30"), + PriceEntry(201, 300, "0.30"), + PriceEntry(301, None, "0.30"), + ] + result = Price.filter_duplicate_prices(entries) + assert len(result) == 2 + assert result[1].min_quantity == 101 + assert result[1].max_quantity is None + + def test_filter_duplicate_prices_multiple_groups(self): + """Filter duplicate prices handles multiple duplicate groups.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.40"), + PriceEntry(201, 300, "0.30"), + PriceEntry(301, 400, "0.30"), + PriceEntry(401, None, "0.20"), + ] + result = Price.filter_duplicate_prices(entries) + assert len(result) == 3 + assert result[0].min_quantity == 1 + assert result[0].max_quantity == 200 + assert result[1].min_quantity == 201 + assert result[1].max_quantity == 400 + + def test_filter_duplicate_prices_ensures_last_has_none(self): + """Filter duplicate prices merges quantity ranges.""" + entries = [ + PriceEntry(1, 100, "0.40"), + PriceEntry(101, 200, "0.30"), + PriceEntry(201, 300, "0.30"), + ] + result = Price.filter_duplicate_prices(entries) + # When duplicates are merged, the merged entry gets the max_quantity from the last duplicate + # In this case, entries[1] and entries[2] both have price 0.30 + # so they merge with max_quantity from entries[2] which is 300 + assert len(result) == 2 + assert result[1].price_dollars_str == "0.30" + assert result[1].min_quantity == 101 + assert result[1].max_quantity == 300 + + def test_filter_duplicate_prices_empty_list(self): + """Filter duplicate prices handles empty list.""" + entries = [] + result = Price.filter_duplicate_prices(entries) + assert len(result) == 0 + + def test_process_integration(self): + """Process method integrates all steps correctly.""" + price_json = json.dumps( + [ + {"qFrom": "1", "qTo": "100", "price": "5.123456"}, + {"qFrom": "101", "qTo": "500", "price": "3.987654"}, + {"qFrom": "501", "qTo": None, "price": "0.005"}, + ] + ) + price_str, total, deleted, duplicates = Price.process(price_json) + + assert total == 3 + assert deleted == 1 # 0.005 is below cutoff + assert duplicates == 0 + assert "5.123" in price_str + assert "3.988" in price_str + + def test_process_with_duplicates(self): + """Process removes duplicates correctly.""" + price_json = json.dumps( + [ + {"qFrom": "1", "qTo": "100", "price": "5.00"}, + {"qFrom": "101", "qTo": "200", "price": "5.00"}, + {"qFrom": "201", "qTo": None, "price": "5.00"}, + ] + ) + price_str, total, deleted, duplicates = Price.process(price_json) + + assert total == 3 + assert duplicates == 2 + + def test_process_with_mixed_filtering(self): + """Process handles both cutoff and duplicates.""" + price_json = json.dumps( + [ + {"qFrom": "1", "qTo": "100", "price": "5.00"}, + {"qFrom": "101", "qTo": "200", "price": "5.00"}, + {"qFrom": "201", "qTo": "300", "price": "0.001"}, + {"qFrom": "301", "qTo": None, "price": "0.001"}, + ] + ) + price_str, total, deleted, duplicates = Price.process(price_json) + + assert total == 4 + assert deleted >= 2 # At least the below-cutoff entries + + +# ============================================================================ +# Utility Function Tests +# ============================================================================ + + +class TestLibraryType: + """Tests for library_type method in ComponentTranslator.""" + + def test_library_type_basic(self): + """library_type returns 'Basic' for basic parts.""" + translator = ComponentTranslator({}, {}) + + # Mock the row by creating a simple dict-like object + class MockRow: + def __getitem__(self, key): + if key == "basic": + return True + return False + + result = translator.library_type(MockRow()) # type: ignore + assert result == "Basic" + + def test_library_type_preferred_when_populate_preferred_true(self): + """library_type returns 'Preferred' when populate_preferred=True.""" + translator = ComponentTranslator({}, {}, populate_preferred=True) + + class MockRow: + def __getitem__(self, key): + if key == "basic": + return False + if key == "preferred": + return True + return False + + result = translator.library_type(MockRow()) # type: ignore + assert result == "Preferred" + + def test_library_type_extended_when_populate_preferred_false(self): + """library_type returns 'Extended' for preferred parts when populate_preferred=False.""" + translator = ComponentTranslator({}, {}, populate_preferred=False) + + class MockRow: + def __getitem__(self, key): + if key == "basic": + return False + if key == "preferred": + return True + return False + + result = translator.library_type(MockRow()) # type: ignore + assert result == "Extended" + + def test_library_type_extended_default(self): + """library_type returns 'Extended' for extended parts (default behavior).""" + translator = ComponentTranslator({}, {}) + + class MockRow: + def __getitem__(self, key): + return False + + result = translator.library_type(MockRow()) # type: ignore + assert result == "Extended" + + +class TestProcessDescription: + """Tests for process_description function.""" + + def test_process_description_basic(self): + """process_description handles basic input.""" + result = process_description("Resistor 10K ROHS", None, "Resistors", "0805") + assert "Resistor" in result + assert "10K" in result + # When " ROHS" is present, it's removed + assert " ROHS" not in result + + def test_process_description_removes_category(self): + """process_description removes second category from description.""" + result = process_description( + "Resistor 10K ROHS Resistors", None, "Resistors", "0805" + ) + assert result.count("Resistor") == 1 + + def test_process_description_removes_package(self): + """process_description removes package from description.""" + result = process_description( + "Resistor 10K ROHS 0805", None, "Resistors", "0805" + ) + assert "0805" not in result + + def test_process_description_fixes_double_spaces(self): + """process_description removes double spaces.""" + result = process_description("Resistor 10K ROHS", None, "Resistors", "0805") + assert " " not in result + + def test_process_description_strips_trailing_spaces(self): + """process_description removes trailing spaces.""" + result = process_description("Resistor 10K ROHS ", None, "Resistors", "0805") + assert result == result.strip() + + def test_process_description_adds_not_rohs_when_missing(self): + """process_description adds 'not ROHS' when ROHS is absent.""" + result = process_description("Resistor 10K", None, "Resistors", "0805") + assert "not ROHS" in result + + def test_process_description_removes_rohs_when_present(self): + """process_description removes ROHS from description.""" + result = process_description("Resistor 10K ROHS", None, "Resistors", "0805") + assert "ROHS" not in result.upper() or "not ROHS" in result + + def test_process_description_rohs_case_insensitive(self): + """process_description checks for space+ROHS case-insensitive.""" + # \" ROHS\" (with space) is found case-insensitively, so \"rohs\" without space won't match + result = process_description("Resistor 10K rohs", None, "Resistors", "0805") + # Since \" ROHS\" is not found, \"not ROHS\" should be added + # But the input doesn't have a space before rohs, so it remains + assert "rohs" in result + + def test_process_description_with_extra_json(self): + """process_description uses description from extra JSON.""" + extra = json.dumps({"description": "Extra description"}) + result = process_description("Original", extra, "Resistors", "0805") + assert "Extra description" in result + + def test_process_description_with_invalid_extra_json(self): + """process_description handles invalid extra JSON gracefully.""" + result = process_description( + "Original ROHS", "invalid json", "Resistors", "0805" + ) + assert "Original" in result + + def test_process_description_with_extra_json_no_description_key(self): + """process_description uses original when extra has no description key.""" + extra = json.dumps({"other_key": "value"}) + result = process_description("Original ROHS", extra, "Resistors", "0805") + assert "Original" in result + + +# ============================================================================ +# ComponentTranslator Tests +# ============================================================================ + + +class TestComponentTranslator: + """Tests for ComponentTranslator class.""" + + def test_translator_init(self): + """ComponentTranslator initializes with correct data structures.""" + manufacturers = {1: "Samsung", 2: "Intel"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator(manufacturers, categories) + + assert translator.manufacturers == manufacturers + assert translator.categories == categories + assert translator.price_entries_total == 0 + assert translator.price_entries_deleted == 0 + assert translator.price_entries_duplicates_deleted == 0 + assert translator.populate_preferred is False # default value + + def test_translator_init_with_populate_preferred(self): + """ComponentTranslator initializes with populate_preferred flag.""" + manufacturers = {1: "Samsung"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator( + manufacturers, categories, populate_preferred=True + ) + + assert translator.populate_preferred is True + + def test_translator_get_statistics_initial(self): + """get_statistics returns zeros initially.""" + translator = ComponentTranslator({}, {}) + total, deleted, duplicates = translator.get_statistics() + + assert total == 0 + assert deleted == 0 + assert duplicates == 0 + + def test_translator_translate_basic(self): + """Translate converts a component row to parts row.""" + manufacturers = {1: "Samsung"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator(manufacturers, categories) + + # Create a mock row + class MockRow: + def __getitem__(self, key): + data = { + "lcsc": "123456", + "category_id": 1, + "manufacturer_id": 1, + "price": json.dumps( + [{"qFrom": "1", "qTo": "100", "price": "5.00"}] + ), + "description": "Test Resistor ROHS", + "extra": None, + "package": "0805", + "mfr": "TESTMFR001", + "joints": "2", + "datasheet": "http://example.com/ds.pdf", + "stock": "1000", + "basic": True, + "preferred": False, + } + return data[key] + + result = translator.translate(MockRow()) # type: ignore + + assert result["LCSC Part"] == "C123456" + assert result["First Category"] == "Resistors" + assert result["Second Category"] == "Fixed Resistors" + assert result["MFR.Part"] == "TESTMFR001" + assert result["Package"] == "0805" + assert result["Solder Joint"] == 2 + assert result["Manufacturer"] == "Samsung" + assert result["Library Type"] == "Basic" + assert "Resistor" in str(result["Description"]) + assert result["Datasheet"] == "http://example.com/ds.pdf" + assert "5" in str(result["Price"]) + assert result["Stock"] == "1000" + + def test_translator_tracks_statistics(self): + """Translate accumulates statistics across multiple calls.""" + manufacturers = {1: "Samsung"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator(manufacturers, categories) + + class MockRow: + def __init__(self, price_json): + self.price_json = price_json + + def __getitem__(self, key): + data = { + "lcsc": "123456", + "category_id": 1, + "manufacturer_id": 1, + "price": self.price_json, + "description": "Test ROHS", + "extra": None, + "package": "0805", + "mfr": "TESTMFR", + "joints": "2", + "datasheet": "http://example.com", + "stock": "1000", + "basic": True, + "preferred": False, + } + return data[key] + + # First component with 3 prices, 1 deleted + price_json_1 = json.dumps( + [ + {"qFrom": "1", "qTo": "100", "price": "5.00"}, + {"qFrom": "101", "qTo": "200", "price": "3.00"}, + {"qFrom": "201", "qTo": None, "price": "0.001"}, + ] + ) + translator.translate(MockRow(price_json_1)) # type: ignore + + # Second component with 2 prices + price_json_2 = json.dumps( + [ + {"qFrom": "1", "qTo": "100", "price": "2.00"}, + {"qFrom": "101", "qTo": None, "price": "1.00"}, + ] + ) + translator.translate(MockRow(price_json_2)) # type: ignore + + total, deleted, duplicates = translator.get_statistics() + assert total == 5 + assert deleted >= 1 # At least the below-cutoff entry + + def test_translator_translate_preferred_with_populate_preferred_true(self): + """Translate uses 'Preferred' library type when populate_preferred=True.""" + manufacturers = {1: "Samsung"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator( + manufacturers, categories, populate_preferred=True + ) + + class MockRow: + def __getitem__(self, key): + data = { + "lcsc": "123456", + "category_id": 1, + "manufacturer_id": 1, + "price": json.dumps( + [{"qFrom": "1", "qTo": "100", "price": "5.00"}] + ), + "description": "Test Resistor ROHS", + "extra": None, + "package": "0805", + "mfr": "TESTMFR001", + "joints": "2", + "datasheet": "http://example.com/ds.pdf", + "stock": "1000", + "basic": False, + "preferred": True, + } + return data[key] + + result = translator.translate(MockRow()) # type: ignore + assert result["Library Type"] == "Preferred" + + def test_translator_translate_preferred_with_populate_preferred_false(self): + """Translate uses 'Extended' library type for preferred when populate_preferred=False.""" + manufacturers = {1: "Samsung"} + categories = {1: ("Resistors", "Fixed Resistors")} + translator = ComponentTranslator( + manufacturers, categories, populate_preferred=False + ) + + class MockRow: + def __getitem__(self, key): + data = { + "lcsc": "123456", + "category_id": 1, + "manufacturer_id": 1, + "price": json.dumps( + [{"qFrom": "1", "qTo": "100", "price": "5.00"}] + ), + "description": "Test Resistor ROHS", + "extra": None, + "package": "0805", + "mfr": "TESTMFR001", + "joints": "2", + "datasheet": "http://example.com/ds.pdf", + "stock": "1000", + "basic": False, + "preferred": True, + } + return data[key] + + result = translator.translate(MockRow()) # type: ignore + assert result["Library Type"] == "Extended" diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py b/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py new file mode 100644 index 00000000..d56bfb0e --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py @@ -0,0 +1,306 @@ +"""Translation and mapping between component database and parts database formats.""" + +import copy +import json +import sqlite3 + + +class PriceEntry: + """Price for a quantity range.""" + + def __init__(self, min_quantity: int, max_quantity: int | None, price_dollars: str): + self.min_quantity = min_quantity + self.max_quantity = max_quantity + self.price_dollars_str = price_dollars + self.price_dollars = float(self.price_dollars_str) + + @classmethod + def Parse(cls, price_entry: dict[str, str]): + """Parse an individual price entry.""" + + price_dollars_str = price_entry["price"] + + min_quantity = int(price_entry["qFrom"]) + max_quantity = ( + int(price_entry["qTo"]) if price_entry.get("qTo") is not None else None + ) + + return cls(min_quantity, max_quantity, price_dollars_str) + + def __repr__(self): + """Conversion to string function.""" + return f"{self.min_quantity}-{self.max_quantity if self.max_quantity is not None else ''}:{self.price_dollars_str}" + + min_quantity: int + max_quantity: int | None + price_dollars_str: str # to avoid rounding due to float conversion + price_dollars: float + + +class Price: + """Price parsing and management functions.""" + + def __init__(self, part_price: list[dict[str, str]]): + """Format of part_price is determined by json.loads().""" + self.price_entries = [] + for price in part_price: + self.price_entries.append(PriceEntry.Parse(price)) + + price_entries: list[PriceEntry] + + @staticmethod + def reduce_precision(entries: list[PriceEntry]) -> list[PriceEntry]: + """Reduce the precision of price entries to 3 significant digits.""" + + """Values after this are not particularly helpful unless many thousands + of the part is used, and at those quantities of boards and parts + the contract manufacturer is likely to have special deals.""" + + pe = entries + for i in range(len(pe)): + pe[i].price_dollars_str = f"{pe[i].price_dollars:.3f}" + pe[i].price_dollars = round(pe[i].price_dollars, 3) + + return entries + + @staticmethod + def filter_below_cutoff( + entries: list[PriceEntry], cutoff_price_dollars: float + ) -> list[PriceEntry]: + """Remove PriceEntry values with a price_dollars below cutoff_price_dollars. Keep the first entry if one exists. Assumes order is highest price to lowest price.""" + + filtered_entries: list[PriceEntry] = [] + + # some components have no price entries + if len(entries) >= 1: + # always include the first entry. + filtered_entries.append(entries[0]) + for entry in entries[1:]: + # add the entries with a price greater than the cutoff + if entry.price_dollars >= cutoff_price_dollars: + filtered_entries.append(entry) + + if len(filtered_entries) > 0: + # ensure the last entry in the list has a max_quantity of None + # as that price continues out indefinitely + filtered_entries[len(filtered_entries) - 1].max_quantity = None + + return filtered_entries + + @staticmethod + def filter_duplicate_prices(entries: list[PriceEntry]) -> list[PriceEntry]: + """Remove entries with duplicate price_dollar_str values, merging quantities so there aren't gaps.""" + + # copy.deepcopy() is used to value modifications from altering the original values. + price_entries_unique: list[PriceEntry] = [] + if len(entries) > 1: + first = 0 + second = 1 + f: PriceEntry | None = None + while True: + if f is None: + f = copy.deepcopy(entries[first]) + + # stop when the second element is at the end of the list + if second >= len(entries): + break + + # if match, copy over the quantity and advance the second, keep searching for a mismatch + if f.price_dollars_str == entries[second].price_dollars_str: + f.max_quantity = entries[second].max_quantity + second += 1 + else: # if no match, add the first and then start looking at the second + price_entries_unique.append(f) + first = second + second = first + 1 + f = None + + # always add the final first entry when we run out of elements to process + price_entries_unique.append(f) + else: # only a single entry, nothing to de-duplicate + price_entries_unique = entries + + return price_entries_unique + + @staticmethod + def process(price_json: str) -> tuple[str, int, int, int]: + """Process price data and return price string and statistics. + + Returns: + tuple of (price_str, total_entries, entries_deleted, duplicates_deleted) + + """ + priceInput = json.loads(price_json) + + # parse the price field + price = Price(priceInput) + + price_entries = Price.reduce_precision(price.price_entries) + entries_total = len(price_entries) + + # filter parts priced below the cutoff value + price_entries_cutoff = Price.filter_below_cutoff(price_entries, 0.01) + entries_deleted = len(price_entries) - len(price_entries_cutoff) + + # alias the variable for the next step + price_entries = price_entries_cutoff + + # remove duplicates + price_entries_unique = Price.filter_duplicate_prices(price_entries) + duplicates_deleted = len(price_entries) - len(price_entries_unique) + entries_deleted += duplicates_deleted + + # alias over the variable for the next step + price_entries = price_entries_unique + + # build the output string that is stored into the parts database + price_str = ",".join( + [ + f"{entry.min_quantity}-{entry.max_quantity if entry.max_quantity is not None else ''}:{entry.price_dollars_str}" + for entry in price_entries + ] + ) + + return price_str, entries_total, entries_deleted, duplicates_deleted + + +def process_description( + description: str, extra_json: str | None, category: str, package: str +) -> str: + """Process and clean component description. + + Args: + description: Original description from component + extra_json: Extra JSON field containing additional description + category: Second category to remove from description + package: Package type to remove from description + + Returns: + Cleaned description string + + """ + # default to 'description', override it with the 'description' property from + # 'extra' if it exists + if extra_json is not None: + try: + extra = json.loads(extra_json) + if "description" in extra: + description = extra["description"] + except Exception: + pass + + # strip ROHS out of descriptions where present + # and add 'not ROHS' where ROHS is not present + # as 99% of parts are ROHS at this point + if " ROHS".lower() not in description.lower(): + description += " not ROHS" + else: + description = description.replace(" ROHS", "") + + # strip the 'Second category' out of the description if it + # is duplicated there + description = description.replace(category, "") + + # remove 'Package' from the description if it is duplicated there + description = description.replace(package, "") + + # replace double spaces with single spaces in description + description = description.replace(" ", " ") + + # remove trailing spaces from description + description = description.strip() + + return description + + +class ComponentTranslator: + """Translates component database rows to parts database rows while tracking statistics.""" + + def __init__( + self, + manufacturers: dict[int, str], + categories: dict[int, tuple[str, str]], + populate_preferred: bool = False, + ): + """Initialize the translator. + + Args: + manufacturers: Dict mapping manufacturer_id to manufacturer name + categories: Dict mapping category_id to (first_category, second_category) + tuple. + populate_preferred: Whether to populate preferred parts as 'Preferred' + or the backwards-compatible 'Extended'. + + """ + self.manufacturers = manufacturers + self.categories = categories + self.price_entries_total = 0 + self.price_entries_deleted = 0 + self.price_entries_duplicates_deleted = 0 + self.populate_preferred = populate_preferred + + def library_type(self, row: sqlite3.Row) -> str: + """Return library type string.""" + if row["basic"]: + return "Basic" + if row["preferred"] and self.populate_preferred: + return "Preferred" + return "Extended" + + def translate(self, component_row: sqlite3.Row) -> dict[str, str | int]: + """Convert a component database row to a parts database row. + + Args: + component_row: Row from components table in component database + + Returns: + Dict ready to insert into parts FTS5 table + + """ + lcsc = component_row["lcsc"] + category_id = component_row["category_id"] + manufacturer_id = component_row["manufacturer_id"] + + price_str, total, deleted, duplicates = Price.process(component_row["price"]) + self.price_entries_total += total + self.price_entries_deleted += deleted + self.price_entries_duplicates_deleted += duplicates + + description = process_description( + component_row["description"], + component_row["extra"], + self.categories[category_id][1], + component_row["package"], + ) + + libType = self.library_type(component_row) + + row = { + "LCSC Part": f"C{lcsc}", + "First Category": self.categories[category_id][0], + "Second Category": self.categories[category_id][1], + "MFR.Part": component_row["mfr"], + "Package": component_row["package"], + "Solder Joint": int(component_row["joints"]), + "Manufacturer": self.manufacturers[manufacturer_id], + "Library Type": libType, + "Description": description, + "Datasheet": component_row["datasheet"], + "Price": price_str, + "Stock": str(component_row["stock"]), + } + + return row + + def get_statistics(self) -> tuple[int, int, int]: + """Get price processing statistics. + + Returns: + Tuple of (total_entries, deleted_entries, duplicates_deleted) + + """ + return ( + self.price_entries_total, + self.price_entries_deleted, + self.price_entries_duplicates_deleted, + ) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/core/pytest.ini b/third_party/Bouni/kicad-jlcpcb-tools/core/pytest.ini new file mode 100644 index 00000000..e69de29b diff --git a/third_party/Bouni/kicad-jlcpcb-tools/core/version.py b/third_party/Bouni/kicad-jlcpcb-tools/core/version.py new file mode 100644 index 00000000..b1d00a20 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/core/version.py @@ -0,0 +1,45 @@ +"""Contains helper function used all over the plugin.""" + +import re + +from packaging.version import Version + + +def _is_version_in_range(version: str, min_version: str, max_version: str) -> bool: + """Check if version is in range. Must comply with https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers.""" + # Remove any trailing '.fc42', '+gXXXX', or similar after the first long segment + ver = Version(re.sub(r"([^-]+(?:-[^-]+)*)-.*", r"\1", version)) + return Version(min_version) <= ver < Version(max_version) + + +def is_version7(version: str) -> bool: + """Check if version is 7.""" + return _is_version_in_range(version, "6.99", "8.0") + + +def is_version6(version: str) -> bool: + """Check if version is 6.""" + return _is_version_in_range(version, "5.99", "7.0") + + +def test_version(): + """Tests for the various is_versionX() functions.""" + v6 = "6.1" + v7 = "7.0.1" + v72 = "7.0.2-2.fc42" + v7rc1 = "7.0.1-rc1-378-ge76fd128c3" + v8 = "8.2.3" + v9 = "9.0.1-rc1" + + assert is_version6(v6) + assert not is_version6(v7) + + assert is_version7(v7) + assert is_version7(v72) + assert is_version7(v7rc1) + assert not is_version7(v8) + + assert not is_version6(v8) + assert not is_version6(v9) + assert not is_version7(v8) + assert not is_version7(v9) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/corrections.py b/third_party/Bouni/kicad-jlcpcb-tools/corrections.py new file mode 100644 index 00000000..9b3a53bb --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/corrections.py @@ -0,0 +1,700 @@ +"""Contains the corrections manager.""" + +import csv +import logging +import os + +import requests # pylint: disable=import-error +import wx # pylint: disable=import-error +import wx.dataview # pylint: disable=import-error + +from .events import PopulateFootprintListEvent +from .helpers import PLUGIN_PATH, HighResWxSize, loadBitmapScaled + + +class CorrectionManagerDialog(wx.Dialog): + """Dialog for managing part corrections.""" + + def __init__(self, parent, footprint): + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title="Corrections Manager", + pos=wx.DefaultPosition, + size=HighResWxSize(parent.window, wx.Size(800, 800)), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + + self.logger = logging.getLogger(__name__) + self.parent = parent + self.selection_regex = None + self.selection_rotation = None + self.selection_offset_x = None + self.selection_offset_y = None + self.import_legacy_corrections() + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + # --------------------------------------------------------------------- + # ------------------------- Add/Edit inputs --------------------------- + # --------------------------------------------------------------------- + + regex_label = wx.StaticText( + self, + wx.ID_ANY, + "Regex", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.regex = wx.TextCtrl( + self, + wx.ID_ANY, + footprint, + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + ) + + sizer_regex = wx.BoxSizer(wx.VERTICAL) + sizer_regex.Add(regex_label, 0, wx.ALL, 5) + sizer_regex.Add( + self.regex, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + rotation_label = wx.StaticText( + self, + wx.ID_ANY, + "Rotation", + size=HighResWxSize(parent.window, wx.Size(100, 15)), + ) + self.rotation = wx.TextCtrl( + self, + wx.ID_ANY, + "0", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, 24)), + ) + + sizer_rotation = wx.BoxSizer(wx.VERTICAL) + sizer_rotation.Add(rotation_label, 0, wx.ALL, 5) + sizer_rotation.Add( + self.rotation, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + offset_x_label = wx.StaticText( + self, + wx.ID_ANY, + "Offset X", + size=HighResWxSize(parent.window, wx.Size(100, 15)), + ) + self.offset_x = wx.TextCtrl( + self, + wx.ID_ANY, + "0.00", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, 24)), + ) + + sizer_offset_x = wx.BoxSizer(wx.VERTICAL) + sizer_offset_x.Add(offset_x_label, 0, wx.ALL, 5) + sizer_offset_x.Add( + self.offset_x, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + offset_y_label = wx.StaticText( + self, + wx.ID_ANY, + "Offset Y", + size=HighResWxSize(parent.window, wx.Size(100, 15)), + ) + self.offset_y = wx.TextCtrl( + self, + wx.ID_ANY, + "0.00", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, 24)), + ) + + sizer_offset_y = wx.BoxSizer(wx.VERTICAL) + sizer_offset_y.Add(offset_y_label, 0, wx.ALL, 5) + sizer_offset_y.Add( + self.offset_y, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + self.regex.Bind(wx.EVT_TEXT, self.on_textfield_change) + self.rotation.Bind(wx.EVT_TEXT, self.on_textfield_change) + self.offset_x.Bind(wx.EVT_TEXT, self.on_textfield_change) + self.offset_y.Bind(wx.EVT_TEXT, self.on_textfield_change) + + add_edit_sizer = wx.StaticBoxSizer(wx.HORIZONTAL, self, "Add / Edit") + add_edit_sizer.Add(sizer_regex, 0, wx.RIGHT, 20) + add_edit_sizer.Add(sizer_rotation, 0, wx.RIGHT, 20) + add_edit_sizer.Add(sizer_offset_x, 0, wx.RIGHT, 20) + add_edit_sizer.Add(sizer_offset_y, 0, wx.RIGHT, 20) + + # --------------------------------------------------------------------- + # ------------------------ Corrections list --------------------------- + # --------------------------------------------------------------------- + + self.corrections_list = wx.dataview.DataViewListCtrl( + self, + wx.ID_ANY, + wx.DefaultPosition, + wx.DefaultSize, + style=wx.dataview.DV_MULTIPLE, + ) + + self.corrections_list.AppendTextColumn( + "Regex", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 280), + align=wx.ALIGN_LEFT, + ) + self.corrections_list.AppendTextColumn( + "Rotation", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 100), + align=wx.ALIGN_LEFT, + ) + self.corrections_list.AppendTextColumn( + "Offset X", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 100), + align=wx.ALIGN_LEFT, + ) + self.corrections_list.AppendTextColumn( + "Offset Y", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 100), + align=wx.ALIGN_LEFT, + ) + + self.corrections_list.SetMinSize( + HighResWxSize(parent.window, wx.Size(600, 500)) + ) + + self.corrections_list.Bind( + wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.on_correction_selected + ) + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) + table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400))) + table_sizer.Add(self.corrections_list, 20, wx.ALL | wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------ Right side toolbar ------------------------- + # --------------------------------------------------------------------- + + self.save_button = wx.Button( + self, + wx.ID_ANY, + "Save", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.delete_button = wx.Button( + self, + wx.ID_ANY, + "Delete", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.update_button = wx.Button( + self, + wx.ID_ANY, + "Update", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.import_button = wx.Button( + self, + wx.ID_ANY, + "Import", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.export_button = wx.Button( + self, + wx.ID_ANY, + "Export", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + + self.save_button.Bind(wx.EVT_BUTTON, self.save_correction) + self.delete_button.Bind(wx.EVT_BUTTON, self.delete_correction) + self.update_button.Bind(wx.EVT_BUTTON, self.download_correction_data) + self.import_button.Bind(wx.EVT_BUTTON, self.import_corrections_dialog) + self.export_button.Bind(wx.EVT_BUTTON, self.export_corrections_dialog) + + self.save_button.SetBitmap( + loadBitmapScaled( + "mdi-content-save-outline.png", + self.parent.scale_factor, + ) + ) + self.save_button.SetBitmapMargins((2, 0)) + + self.delete_button.SetBitmap( + loadBitmapScaled( + "mdi-trash-can-outline.png", + self.parent.scale_factor, + ) + ) + self.delete_button.SetBitmapMargins((2, 0)) + + self.update_button.SetBitmap( + loadBitmapScaled( + "mdi-cloud-download-outline.png", + self.parent.scale_factor, + ) + ) + self.update_button.SetBitmapMargins((2, 0)) + + self.import_button.SetBitmap( + loadBitmapScaled( + "mdi-database-import-outline.png", + self.parent.scale_factor, + ) + ) + self.import_button.SetBitmapMargins((2, 0)) + + self.export_button.SetBitmap( + loadBitmapScaled( + "mdi-database-export-outline.png", + self.parent.scale_factor, + ) + ) + self.export_button.SetBitmapMargins((2, 0)) + + self.global_corrections = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Use global corrections", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="corrections_global_corrections", + ) + + self.global_corrections.SetToolTip( + wx.ToolTip( + "Whether the global corrections database is used or a project local one" + ) + ) + self.global_corrections.Bind( + wx.EVT_CHECKBOX, self.on_global_corrections_changed + ) + self.global_corrections.SetValue( + self.parent.library.uses_global_correction_database() + ) + + tool_sizer = wx.BoxSizer(wx.VERTICAL) + tool_sizer.Add(self.save_button, 0, wx.ALL, 5) + tool_sizer.Add(self.delete_button, 0, wx.ALL, 5) + tool_sizer.AddStretchSpacer() + tool_sizer.Add(self.update_button, 0, wx.ALL, 5) + tool_sizer.Add(self.import_button, 0, wx.ALL, 5) + tool_sizer.Add(self.export_button, 0, wx.ALL, 5) + tool_sizer.Add(self.global_corrections, 0, wx.ALL, 5) + + table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------------ Sizers ------------------------------ + # --------------------------------------------------------------------- + + layout = wx.BoxSizer(wx.VERTICAL) + layout.Add(add_edit_sizer, 1, wx.ALL | wx.EXPAND, 5) + layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5) + + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + self.enable_toolbar_buttons() + self.populate_corrections_list() + + def quit_dialog(self, *_): + """Close this dialog.""" + self.Destroy() + self.EndModal(0) + + def enable_toolbar_buttons(self): + """Control the state of all the buttons in toolbar on the right side.""" + if ( + self.regex.GetValue() + and self.rotation.GetValue() + and self.offset_x.GetValue() + and self.offset_y.GetValue() + ): + self.save_button.Enable(True) + else: + self.save_button.Enable(False) + + if self.corrections_list.GetSelectedRow() != wx.NOT_FOUND: + self.delete_button.Enable(True) + else: + self.delete_button.Enable(False) + + def to_float(self, value): + """Convert the given value to a float, return 0 if convertion fails.""" + try: + return float(value) + except ValueError: + return 0 + + def str_from_float(self, value): + """Convert the given floating point value to a string. + + Us as many decimal digits as required but for small numbers + at least two decimal digits are used. + """ + s = str(value) + return f"{value:.2f}" if len(s) < 4 else s + + def populate_corrections_list(self): + """Populate the list with the result of the search.""" + self.corrections_list.DeleteAllItems() + for regex, rotation, offset in self.parent.library.get_all_correction_data(): + self.corrections_list.AppendItem( + [ + str(regex), + str(rotation), + self.str_from_float(offset[0]), + self.str_from_float(offset[1]) + ] + ) + selected_row = None + if self.selection_regex is not None: + for row in range(self.corrections_list.GetItemCount()): + row_regex = self.corrections_list.GetTextValue(row, 0) + if row_regex == self.selection_regex: + selected_row = row + if selected_row is not None: + self.corrections_list.SelectRow(selected_row) + + def save_correction(self, *_): + """Add/Update a correction in the database.""" + regex = self.regex.GetValue() + rotation = int(self.to_float(self.rotation.GetValue())) + offset_x = self.to_float(self.offset_x.GetValue()) + offset_y = self.to_float(self.offset_y.GetValue()) + offset = (offset_x, offset_y) + if regex == self.selection_regex: + # the regex of the selection was not changed, just update values. + self.parent.library.update_correction_data(regex, rotation, offset) + else: + # regex was modified or nothing was selected. + # Check if there is a existing rule for that regex + row_of_that_regex = None + for row in range(self.corrections_list.GetItemCount()): + row_regex = self.corrections_list.GetTextValue(row, 0) + if row_regex == regex: + row_of_that_regex = row + + if row_of_that_regex is None: + # the regex is a new one, just create it or update the selected entry + + if self.selection_regex is not None: + # remove old line, if one existed + self.parent.library.delete_correction_data(self.selection_regex) + + # Add the modified regex and values + self.parent.library.insert_correction_data(regex, rotation, offset) + self.selection_regex = regex + else: + # The regex already exists. + existing_rotation = int( + self.to_float(self.corrections_list.GetTextValue(row, 1)) + ) + existing_offset_x = self.to_float( + self.corrections_list.GetTextValue(row, 2) + ) + existing_offset_y = self.to_float( + self.corrections_list.GetTextValue(row, 3) + ) + + if ( + rotation == existing_rotation + and offset_x == existing_offset_x + and offset_y == existing_offset_y + ): + # User entered a regex that already exists, just select that one + self.selection_regex = regex + else: + # The regex exists with different values, ask the user what to do. + existing_correction = "(" + \ + str(existing_rotation) + "°, " + \ + self.str_from_float(existing_offset_x) + "/" + \ + self.str_from_float(existing_offset_y) + \ + ")" + new_correction = "(" + \ + str(rotation) + "°, " + \ + self.str_from_float(offset_x) + "/" + \ + self.str_from_float(offset_y) + \ + ")" + + dialog = wx.MessageDialog( + self, + f"A rule for '{regex}' already exists!", + "Regex exists!", + wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION, + ) + if self.selection_regex is None: + # The user entered a regex that already exists with different values. + dialog.ExtendedMessage = "Do you want to update the corrections " + \ + existing_correction + \ + " to " + \ + new_correction + else: + # The user has selected regex_a, changed it to regex_b + # (but regex_b exists). + dialog.ExtendedMessage = "Do you want to replace the corrections " + \ + existing_correction + \ + " with " + \ + new_correction + \ + ",\n" + \ + f"removing the rule for '{self.selection_regex}'?" + result = dialog.ShowModal() + + if result == wx.ID_YES: + if self.selection_regex is not None: + self.parent.library.delete_correction_data(self.selection_regex) + self.parent.library.update_correction_data(regex, rotation, offset) + self.selection_regex = regex + + self.rotation.SetValue(str(rotation)) + self.offset_x.SetValue(self.str_from_float(offset_x)) + self.offset_y.SetValue(self.str_from_float(offset_y)) + self.populate_corrections_list() + wx.PostEvent(self.parent, PopulateFootprintListEvent()) + + def delete_correction(self, *_): + """Delete a correction from the database.""" + item = self.corrections_list.GetSelection() + row = self.corrections_list.ItemToRow(item) + if row == -1: + return + regex = self.corrections_list.GetTextValue(row, 0) + self.parent.library.delete_correction_data(regex) + self.populate_corrections_list() + wx.PostEvent(self.parent, PopulateFootprintListEvent()) + + def on_correction_selected(self, event): + """Enable the toolbar buttons when a selection was made.""" + if len(self.corrections_list.GetSelections()) > 1: + for item in self.corrections_list.GetSelections(): + if item != event.GetItem(): + self.corrections_list.Unselect(item) + + if self.corrections_list.GetSelectedItemsCount() > 0: + item = self.corrections_list.GetSelection() + row = self.corrections_list.ItemToRow(item) + if row == -1: + return + + self.selection_regex = self.corrections_list.GetTextValue(row, 0) + self.selection_rotation = int( + self.to_float(self.corrections_list.GetTextValue(row, 1)) + ) + self.selection_offset_x = self.to_float( + self.corrections_list.GetTextValue(row, 2) + ) + self.selection_offset_y = self.to_float( + self.corrections_list.GetTextValue(row, 3) + ) + self.regex.SetValue(self.selection_regex) + self.rotation.SetValue(str(self.selection_rotation)) + self.offset_x.SetValue(self.str_from_float(self.selection_offset_x)) + self.offset_y.SetValue(self.str_from_float(self.selection_offset_y)) + else: + self.selection_row = None + self.selection_regex = None + + self.enable_toolbar_buttons() + + def on_textfield_change(self, *_): + """Check if the texfield change affects toolbars.""" + self.enable_toolbar_buttons() + + def on_global_corrections_changed(self, use_global): + """Switch between global or local correction database file.""" + if self.parent.library.uses_global_correction_database(): + dialog = wx.MessageDialog( + self, + "Do you want to switch to the local corrections database?", + "Switching corrections database", + wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION, + ) + dialog.ExtendedMessage = "Switching to a board local database copies the current global database." + else: + dialog = wx.MessageDialog( + self, + "Do you want to switch to the global corrections database?", + "Switching corrections database", + wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING, + ) + result = dialog.ShowModal() + + if result == wx.ID_NO: + self.global_corrections.SetValue( + self.parent.library.uses_global_correction_database() + ) + return + + self.parent.library.switch_to_global_correction_database( + not self.parent.library.uses_global_correction_database() + ) + self.populate_corrections_list() + wx.PostEvent(self.parent, PopulateFootprintListEvent()) + self.global_corrections.SetValue( + self.parent.library.uses_global_correction_database() + ) + + def download_correction_data(self, *_): + """Fetch the latest rotation correction table from Matthew Lai's JLCKicadTool repo.""" + self.parent.library.create_correction_table() + try: + r = requests.get( + "https://raw.githubusercontent.com/matthewlai/JLCKicadTools/master/jlc_kicad_tools/cpl_rotations_db.csv", + timeout=5, + ) + corrections = csv.reader(r.text.splitlines(), delimiter=",", quotechar='"') + next(corrections) + for row in corrections: + if not self.parent.library.get_correction_data(row[0]): + if len(row) >= 4: + self.parent.library.insert_correction_data( + row[0], row[1], (row[2], row[3]) + ) + else: + self.parent.library.insert_correction_data( + row[0], row[1], (0, 0) + ) + else: + self.logger.info( + "Correction '%s' exists already in database. Leaving this one out.", + row[0], + ) + except Exception as err: # pylint: disable=broad-exception-caught + self.logger.debug(err) + self.populate_corrections_list() + wx.PostEvent(self.parent, PopulateFootprintListEvent()) + + def import_legacy_corrections(self): + """Check if corrections in CSV format are found and import them into the database.""" + csv_file = os.path.join(PLUGIN_PATH, "corrections", "cpl_rotations_db.csv") + if os.path.isfile(csv_file): + self._import_corrections(csv_file) + os.rename(csv_file, f"{csv_file}.backup") + + def import_corrections_dialog(self, *_): + """Dialog to import correctios from a CSV file.""" + with wx.FileDialog( + self, + "Import", + "", + "", + "CSV files (*.csv)|*.csv", + wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as importFileDialog: + if importFileDialog.ShowModal() == wx.ID_CANCEL: + return + path = importFileDialog.GetPath() + self._import_corrections(path) + + def export_corrections_dialog(self, *_): + """Dialog to export correctios to a CSV file.""" + with wx.FileDialog( + self, + "Export", + "", + "", + "CSV files (*.csv)|*.csv", + wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as exportFileDialog: + if exportFileDialog.ShowModal() == wx.ID_CANCEL: + return + path = exportFileDialog.GetPath() + self._export_corrections(path) + + def _import_corrections(self, path): + """Corrections import logic.""" + if os.path.isfile(path): + with open(path, encoding="utf-8") as f: + csvreader = csv.DictReader( + f, fieldnames=("regex", "rotation", "offset_x", "offset_y") + ) + next(csvreader) + for row in csvreader: + if "regex" in row and row["regex"] is not None: + regex = row["regex"] + rotation = row["rotation"] if row["rotation"] is not None else 0 + offset_x = row["offset_x"] if row["offset_x"] is not None else 0 + offset_y = row["offset_y"] if row["offset_y"] is not None else 0 + existing_data = self.parent.library.get_correction_data(regex) + if existing_data: + self.parent.library.update_correction_data( + regex, rotation, (offset_x, offset_y) + ) + self.logger.info( + "Correction '%s' exists already in database with correction value '%s, %s/%s'. Overwrite it with local values from CSV (%s, %s/%s).", + regex, + existing_data[1], + existing_data[2], + existing_data[3], + rotation, + offset_x, + offset_y, + ) + else: + self.parent.library.insert_correction_data( + regex, rotation, (offset_x, offset_y) + ) + self.logger.info( + "Correction '%s' with correction value '%s, %s/%s' is added to the database from local CSV.", + regex, + rotation, + offset_x, + offset_y, + ) + self.populate_corrections_list() + wx.PostEvent(self.parent, PopulateFootprintListEvent()) + + def _export_corrections(self, path): + """Corrections export logic.""" + with open(path, "w", newline="", encoding="utf-8") as f: + csvwriter = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL) + csvwriter.writerow(["Pattern", "Rotation", "Offset X", "Offset Y"]) + for ( + regex, + rotation, + offset, + ) in self.parent.library.get_all_correction_data(): + csvwriter.writerow([regex, rotation, offset[0], offset[1]]) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py b/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py new file mode 100644 index 00000000..2cf7f744 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py @@ -0,0 +1,347 @@ +"""Implementation of the Datamodel for the parts list with natural sort.""" + +import logging +import re + +import wx.dataview as dv + +from .helpers import loadIconScaled +from .partselector_columns import COLUMN_INDEX, MODEL_COLUMN_TYPES + + +class PartListDataModel(dv.PyDataViewModel): + """Datamodel for use with the DataViewCtrl of the mainwindow.""" + + def __init__(self, scale_factor): + super().__init__() + self.data = [] + self.columns = { + "REF_COL": 0, + "VALUE_COL": 1, + "FP_COL": 2, + "LCSC_COL": 3, + "TYPE_COL": 4, + "STOCK_COL": 5, + "BOM_COL": 6, + "POS_COL": 7, + "DNP_COL": 8, + "ROT_COL": 9, + "SIDE_COL": 10, + "PARAMS_COL": 11, + } + + self.bom_pos_icons = [ + loadIconScaled( + "mdi-check-color.png", + scale_factor, + ), + loadIconScaled( + "mdi-close-color.png", + scale_factor, + ), + ] + self.side_icons = [ + loadIconScaled( + "TOP.png", + scale_factor, + ), + loadIconScaled( + "BOT.png", + scale_factor, + ), + ] + self.logger = logging.getLogger(__name__) + + @staticmethod + def natural_sort_key(s): + """Return a tuple that can be used for natural sorting.""" + return [ + int(text) if text.isdigit() else text.lower() + for text in re.split("([0-9]+)", s) + ] + + def GetColumnCount(self): + """Get number of columns.""" + return len(self.columns) + + def GetColumnType(self, col): + """Get type of each column.""" + columntypes = ( + "string", + "string", + "string", + "string", + "string", + "string", + "wxDataViewIconText", + "wxDataViewIconText", + "wxDataViewIconText", + "string", + "wxDataViewIconText", + "string", + ) + return columntypes[col] + + def GetChildren(self, parent, children): + """Get child items of a parent.""" + if not parent: + for row in self.data: + children.append(self.ObjectToItem(row)) + return len(self.data) + return 0 + + def IsContainer(self, item): + """Check if tem is a container.""" + return not item + + def GetParent(self, item): + """Get parent item.""" + return dv.NullDataViewItem + + def GetValue(self, item, col): + """Get value of an item.""" + row = self.ItemToObject(item) + if col in [ + self.columns["BOM_COL"], + self.columns["POS_COL"], + self.columns["DNP_COL"], + self.columns["SIDE_COL"], + ]: + icon = row[col] + return dv.DataViewIconText("", icon) + return row[col] + + def SetValue(self, value, item, col): + """Set value of an item.""" + row = self.ItemToObject(item) + if col in [ + self.columns["BOM_COL"], + self.columns["POS_COL"], + self.columns["DNP_COL"], + self.columns["SIDE_COL"], + ]: + return False + row[col] = value + return True + + def Compare(self, item1, item2, column, ascending): + """Override to implement natural sorting.""" + val1 = self.GetValue(item1, column) + val2 = self.GetValue(item2, column) + + key1 = self.natural_sort_key(val1) + key2 = self.natural_sort_key(val2) + + if ascending: + return (key1 > key2) - (key1 < key2) + else: + return (key2 > key1) - (key2 < key1) + + def find_index(self, ref): + """Get the index of a part within the data list by its reference.""" + try: + return self.data.index([x for x in self.data if x[0] == ref].pop()) + except (ValueError, IndexError): + return None + + def get_bom_pos_icon(self, state: str): + """Get an icon for a state.""" + return self.bom_pos_icons[int(state)] + + def get_side_icon(self, side: str): + """Get The side for a layer number.""" + return self.side_icons[0] if side == "0" else self.side_icons[1] + + def AddEntry(self, data: list): + """Add a new entry to the data model.""" + data[self.columns["BOM_COL"]] = self.get_bom_pos_icon( + data[self.columns["BOM_COL"]] + ) + data[self.columns["POS_COL"]] = self.get_bom_pos_icon( + data[self.columns["POS_COL"]] + ) + data[self.columns["DNP_COL"]] = self.get_bom_pos_icon( + data[self.columns["DNP_COL"]] + ) + data[self.columns["SIDE_COL"]] = self.get_side_icon( + data[self.columns["SIDE_COL"]] + ) + self.data.append(data) + self.ItemAdded(dv.NullDataViewItem, self.ObjectToItem(data)) + + def RemoveAll(self): + """Remove all entries from the data model.""" + self.data.clear() + self.Cleared() + + def get_all(self): + """Get tall items.""" + return self.data + + def get_reference(self, item): + """Get the reference of an item.""" + return self.ItemToObject(item)[self.columns["REF_COL"]] + + def get_value(self, item): + """Get the value of an item.""" + return self.ItemToObject(item)[self.columns["VALUE_COL"]] + + def get_lcsc(self, item): + """Get the lcsc of an item.""" + return self.ItemToObject(item)[self.columns["LCSC_COL"]] + + def get_footprint(self, item): + """Get the footprint of an item.""" + return self.ItemToObject(item)[self.columns["FP_COL"]] + + def select_alike(self, item): + """Select all items that have the same value and footprint.""" + obj = self.ItemToObject(item) + alike = [] + for data in self.data: + if data[1:3] == obj[1:3]: + alike.append(self.ObjectToItem(data)) + return alike + + def set_lcsc(self, ref, lcsc, type, stock, params): + """Set an lcsc number, type and stock for given reference.""" + if (index := self.find_index(ref)) is None: + return + item = self.data[index] + item[self.columns["LCSC_COL"]] = lcsc + item[self.columns["TYPE_COL"]] = type + item[self.columns["STOCK_COL"]] = stock + item[self.columns["PARAMS_COL"]] = params + self.ItemChanged(self.ObjectToItem(item)) + + def remove_lcsc_number(self, item): + """Remove the LCSC number of an item.""" + obj = self.ItemToObject(item) + obj[self.columns["LCSC_COL"]] = "" + obj[self.columns["TYPE_COL"]] = "" + obj[self.columns["STOCK_COL"]] = "" + obj[self.columns["PARAMS_COL"]] = "" + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_bom(self, item): + """Toggle BOM for a given item.""" + obj = self.ItemToObject(item) + if obj[self.columns["BOM_COL"]] == self.bom_pos_icons[0]: + obj[self.columns["BOM_COL"]] = self.bom_pos_icons[1] + else: + obj[self.columns["BOM_COL"]] = self.bom_pos_icons[0] + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_pos(self, item): + """Toggle POS for a given item.""" + obj = self.ItemToObject(item) + if obj[self.columns["POS_COL"]] == self.bom_pos_icons[0]: + obj[self.columns["POS_COL"]] = self.bom_pos_icons[1] + else: + obj[self.columns["POS_COL"]] = self.bom_pos_icons[0] + self.ItemChanged(self.ObjectToItem(obj)) + + def toggle_bom_pos(self, item): + """Toggle BOM and POS for a given item.""" + self.toggle_bom(item) + self.toggle_pos(item) + + +class PartSelectorDataModel(dv.PyDataViewModel): + """Datamodel for use with the DataViewCtrl of the partselector modal window.""" + + def __init__(self): + super().__init__() + self.data = [] + self.columns = dict(COLUMN_INDEX) + + self.logger = logging.getLogger(__name__) + + @staticmethod + def natural_sort_key(s): + """Return a tuple that can be used for natural sorting.""" + return [ + int(text) if text.isdigit() else text.lower() + for text in re.split("([0-9]+)", s) + ] + + def GetColumnCount(self): + """Get number of columns.""" + return len(self.columns) + + def GetColumnType(self, col): + """Get type of each column.""" + return MODEL_COLUMN_TYPES[col] + + def GetChildren(self, parent, children): + """Get child items of a parent.""" + if not parent: + for row in self.data: + children.append(self.ObjectToItem(row)) + return len(self.data) + return 0 + + def IsContainer(self, item): + """Check if tem is a container.""" + return not item + + def GetParent(self, item): + """Get parent item.""" + return dv.NullDataViewItem + + def GetValue(self, item, col): + """Get value of an item.""" + row = self.ItemToObject(item) + return row[col] + + def SetValue(self, value, item, col): + """Set value of an item.""" + row = self.ItemToObject(item) + row[col] = value + return True + + def Compare(self, item1, item2, column, ascending): + """Override to implement natural sorting.""" + val1 = self.GetValue(item1, column) + val2 = self.GetValue(item2, column) + + key1 = self.natural_sort_key(val1) + key2 = self.natural_sort_key(val2) + + if ascending: + return (key1 > key2) - (key1 < key2) + else: + return (key2 > key1) - (key2 < key1) + + def find_index(self, ref): + """Get the index of a part within the data list by its reference.""" + try: + return self.data.index([x for x in self.data if x[0] == ref].pop()) + except (ValueError, IndexError): + return None + + def AddEntry(self, data: list): + """Add a new entry to the data model.""" + self.data.append(data) + self.ItemAdded(dv.NullDataViewItem, self.ObjectToItem(data)) + + def RemoveAll(self): + """Remove all entries from the data model.""" + self.data.clear() + self.Cleared() + + def get_all(self): + """Get tall items.""" + return self.data + + def get_lcsc(self, item): + """Get the reference of an item.""" + return self.ItemToObject(item)[self.columns["lcsc"]] + + def get_type(self, item): + """Get the reference of an item.""" + return self.ItemToObject(item)[self.columns["type"]] + + def get_stock(self, item): + """Get the reference of an item.""" + return self.ItemToObject(item)[self.columns["stock"]] diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md b/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md new file mode 100644 index 00000000..55745589 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md @@ -0,0 +1,80 @@ +# Database Building and Conversion + +The code in this directory handles building the sqlite database files +that are downloaded by the user. + +## Main Concepts + +### The Components Database + +The components database is compatible with the 'cache.sqlite3' database that was +originally released as part of the yaqswx/jlcparts distribution. It contains +information about every component JLC has ever stocked. The components database +is effectively a local cache of all of the data available in the public JLC API. + +### The Parts Database(s) + +The Parts database is the database that is consumed by the UI in this package. +It is built from the Components database, but does some additional data +normalization, price compression, and uses full text search indexing to make the +component search fast in the UI. There are multiple parts databases built, +which can be selectable in the UI: + +#### Recently Stocked (default) + +This filters the Parts database based on parts which have been seen in-stock at +some point in the last year. Removing components that haven't been in stock for +more than one year reduces the database size by over 90%. + +#### Basic and Preferred Only + +This filters the Parts database to the ~1500 components that are either 'Basic' +or 'Preferred'. Useful for high speed in very simple or price-concious builds. + +#### Empty + +This is an empty Parts database. This is useful for users who only use the plugin +for PCB generation and do not create BOMs, or who have manually added LCSC numbers +to all of their components. + +#### All Components (old default) + +This is the unfiltered Parts database - all of the components which have ever been +seen at JLC are searchable. + +This is much larger, and has worse performance, but may be useful for some users +who have old parts in their BOM and don't want to lose them from the search index +despite not being in-stock -- It is possible at JLC to pre-order parts which are +not otherwise in stock. + +### The JLC API + +JLC provides a public API for searching components - this is ultimately the source +of truth for building the cached Components database. + +## The build process + +The database build process is approximately: + +1. Download and reassemble the components DB artifact. + +1. Scrape the JLC API to update the components DB. + +1. Set stock=0 on any items which have not been seen in the API scrape for more + than 1 week. + +1. For any item that has been out of stock for more than 1 year, remove the + 'extra' and 'price' information from the Components DB and compact it. This + reduces the size of the database by > 2/3. The 'extra' information is not + used in the Parts database, and the price information for something out of + stock for > 1 year will be irrelevant. + +1. Using the updated components database, create each of the fitlered parts + databases. + +1. Archive and split each of the parts databases into a separate directory. + +1. Archive and split the components database. This allows the next run of the + download script to have an updated copy of the components database. + +1. From the github workflow, upload each of the artifact directories. diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py new file mode 100644 index 00000000..98402222 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py @@ -0,0 +1 @@ +"""Database building utilities for JLCPCB parts database.""" diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py b/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py new file mode 100644 index 00000000..4523657a --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +"""Use the amazing work of https://github.com/yaqwsx/jlcparts and convert their database into something we can conveniently use for this plugin. + +This replaces the old .csv based database creation that JLCPCB no longer supports. +""" + +import os +from pathlib import Path +import sys + +# Add parent directory to path so we can import common module +# TODO(z2amiller): Use proper packaging +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import click # noqa: E402 + +from common.componentdb import ComponentsDatabase # noqa: E402 +from common.filemgr import FileManager # noqa: E402 +from common.jlcapi import CategoryFetch, Component, JlcApi # noqa: E402 +from common.partsdb import Generate, PartsDatabase # noqa: E402 +from common.progress import PrintNestedProgressBar, TqdmNestedProgressBar # noqa: E402 +from dblib import DatabaseConfig # noqa: E402 + + +def update_components_db_from_api() -> None: + """Update the component cache database.""" + db = ComponentsDatabase("db_working/cache.sqlite3") + print("Fetching categories...") + initial_categories = JlcApi.fetchCategories(instockOnly=True) + categories = JlcApi.collapseCategories(initial_categories, limit=50000) + print(f"Found {len(initial_categories)} categories, collaped to {len(categories)}.") + + progress = ( + TqdmNestedProgressBar() + if sys.stdout.isatty() + else PrintNestedProgressBar(outer_threshold=1, inner_threshold=2000) + ) + + with progress.outer(len(categories), "Fetching categories") as outer_pbar: + for category in categories: + fetcher = CategoryFetch(category) + + with progress.inner(category.count, f"{category}") as inner_pbar: + for components in fetcher.fetchAll(): + comp_objs = [Component(comp) for comp in components] + db.update_cache(comp_objs) + inner_pbar.update(len(components)) + + outer_pbar.update() + + db.cleanup_stock() + db.close() + + +@click.command() +@click.option( + "--skip-cleanup", + is_flag=True, + show_default=True, + default=False, + help="Disable cleanup, intermediate database files will not be deleted", +) +@click.option( + "--components-db-base-url", + default="http://bouni.github.io/jlcparts/data", + show_default=True, + help="Base URL to fetch the components database from", +) +@click.option( + "--fetch-components-db", + is_flag=True, + show_default=True, + default=False, + help="Fetch the components db from the remote server", +) +@click.option( + "--fix-components-db-descriptions", + is_flag=True, + show_default=True, + default=False, + help="Fix descriptions in the components db by pulling from the 'extra' field", +) +@click.option( + "--update-components-db", + is_flag=True, + show_default=True, + default=False, + help="Update the local components db using LCSC API data", +) +@click.option( + "--clean-components-db", + is_flag=True, + show_default=True, + default=False, + help="Clean the local components db by removing old and out-of-stock parts", +) +@click.option( + "--archive-components-db", + is_flag=True, + show_default=True, + default=False, + help="Archive the components db after updating from the API", +) +@click.option( + "--skip-generate", + is_flag=True, + show_default=True, + default=False, + help="Skip the DB generation phase", +) +@click.option( + "--obsolete-parts-threshold-days", + show_default=True, + default=365, + type=int, + help=""" + Setting this to > 0 will generate an additional dataabase that ignores parts + that have been out of stock for more than the specified number of days. + """, +) +@click.option( + "--populate-preferred-extended", + is_flag=True, + show_default=True, + default=False, + help="Populate 'Preferred' parts as 'Preferred' library type instead of 'Extended'", +) +def main( + skip_cleanup: bool, + fetch_components_db: bool, + components_db_base_url: str, + fix_components_db_descriptions: bool, + update_components_db: bool, + clean_components_db: bool, + archive_components_db: bool, + skip_generate: bool, + obsolete_parts_threshold_days: int, + populate_preferred_extended: bool, +): + """Perform the database steps.""" + + working_directory = "db_working" + components_db = f"{working_directory}/cache.sqlite3" + + if fetch_components_db: + print("Fetching components database...") + fm = FileManager( + file_path=Path(components_db), + sentinel_filename="cache_chunk_num.txt", + ) + fm.download_and_reassemble( + url=components_db_base_url, + output_dir=Path(working_directory), + cleanup=not skip_cleanup, + ) + + if not os.path.exists(working_directory): + os.mkdir(working_directory) + + if fix_components_db_descriptions: + print("Fixing components database descriptions") + db = ComponentsDatabase(components_db) + db.fix_description() + db.close() + + if update_components_db: + update_components_db_from_api() + + if clean_components_db: + print("Cleaning components database") + db = ComponentsDatabase(components_db) + db.truncate_old() + db.close() + + configs = [ + DatabaseConfig.preferredAndBasic(), + DatabaseConfig.allParts(), + DatabaseConfig.emptyParts(), + ] + if obsolete_parts_threshold_days > 0: + configs.insert( + 0, DatabaseConfig.ignoreObsoleteParts(obsolete_parts_threshold_days) + ) + + archive_dir = Path("archive") + if not archive_dir.exists(): + os.makedirs(archive_dir) + for config in configs: + if not skip_generate: + print(f"Generating {config.name}...") + componentdb = ComponentsDatabase(components_db) + partsdb = PartsDatabase( + output_db=Path(working_directory) / config.name, + archive_dir=Path(archive_dir), + chunk_num=Path(config.chunk_file_name), + skip_cleanup=skip_cleanup, + ) + progress = ( + TqdmNestedProgressBar() + if sys.stdout.isatty() + else PrintNestedProgressBar() + ) + generator = Generate( + componentdb=componentdb, + partsdb=partsdb, + progress=progress, + populate_preferred=config.populate_preferred, + ) + generator.generate(where_clause=config.where_clause) + + if archive_components_db: + fm = FileManager( + file_path=Path(components_db), + chunk_size=50 * 1024 * 1024, # 50 MB + sentinel_filename="cache_chunk_num.txt", + ) + fm.compress_and_split( + output_dir=Path(archive_dir), delete_original=skip_cleanup + ) + + +if __name__ == "__main__": + main() diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/pytest.ini b/third_party/Bouni/kicad-jlcpcb-tools/db_build/pytest.ini new file mode 100644 index 00000000..e69de29b diff --git a/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py new file mode 100644 index 00000000..9948dc5b --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py @@ -0,0 +1,89 @@ +"""Database configuration library for parts database generation. + +This module provides database configuration classes and constants for managing +different parts database variants without pulling in heavy dependencies. +""" + +import time +from typing import NamedTuple + + +class PartDatabaseConfig(NamedTuple): + """Configuration for part database generation.""" + + name: str + chunk_file_name: str + where_clause: str + display_name: str + populate_preferred: bool = False + + +class DatabaseConfig: + """Predefined database configurations.""" + + @staticmethod + def preferredAndBasic() -> PartDatabaseConfig: + """Select only preferred and basic parts.""" + return PartDatabaseConfig( + name="basic-parts-fts5.db", + chunk_file_name="chunk_num_basic_parts_fts5.txt", + where_clause="basic = 1 OR preferred = 1", + display_name="Basic + Preferred Library", + populate_preferred=True, + ) + + @staticmethod + def allParts() -> PartDatabaseConfig: + """Select all parts. + + This is the most backwards-compatible database, and therefore uses + the default "parts-fts5.db" name. + """ + return PartDatabaseConfig( + name="parts-fts5.db", + chunk_file_name="chunk_num_fts5.txt", + where_clause="TRUE", + display_name="Full Library - All Parts", + populate_preferred=False, + ) + + @staticmethod + def ignoreObsoleteParts(obsolete_threshold_days: int = 365) -> PartDatabaseConfig: + """Select all parts except obsolete parts.""" + filter_seconds = int(time.time()) - obsolete_threshold_days * 24 * 60 * 60 + return PartDatabaseConfig( + name="current-parts-fts5.db", + chunk_file_name="chunk_num_current_parts_fts5.txt", + where_clause=f"NOT (stock = 0 AND last_on_stock < {filter_seconds})", + display_name="Current Parts (Exclude Obsolete)", + populate_preferred=True, + ) + + @staticmethod + def emptyParts() -> PartDatabaseConfig: + """Select no parts.""" + return PartDatabaseConfig( + name="empty-parts-fts5.db", + chunk_file_name="chunk_num_empty_parts_fts5.txt", + where_clause="FALSE", + display_name="Empty Library - No parts!", + ) + + +# Library configuration mapping: defines available library options +LIBRARY_CONFIGS = { + "all-parts": DatabaseConfig.allParts(), + "basic-preferred": DatabaseConfig.preferredAndBasic(), + "current-parts": DatabaseConfig.ignoreObsoleteParts(), + "empty": DatabaseConfig.emptyParts(), +} + +DEFAULT_LIBRARY = "current-parts" + + +__all__ = [ + "DatabaseConfig", + "DEFAULT_LIBRARY", + "LIBRARY_CONFIGS", + "PartDatabaseConfig", +] diff --git a/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py b/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py new file mode 100644 index 00000000..42ec2402 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py @@ -0,0 +1,234 @@ +"""Tests for the dblibrary module.""" + +from pathlib import Path +import re +import sys +import time + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dblib import DEFAULT_LIBRARY, LIBRARY_CONFIGS, DatabaseConfig, PartDatabaseConfig + +# ============================================================================ +# PartDatabaseConfig Tests +# ============================================================================ + + +class TestPartDatabaseConfig: + """Tests for PartDatabaseConfig class.""" + + def test_part_database_config_creation(self): + """PartDatabaseConfig can be created with all fields.""" + config = PartDatabaseConfig( + name="test.db", + chunk_file_name="chunk_test.txt", + where_clause="stock > 0", + display_name="Test Database", + populate_preferred=True, + ) + assert config.name == "test.db" + assert config.chunk_file_name == "chunk_test.txt" + assert config.where_clause == "stock > 0" + assert config.display_name == "Test Database" + assert config.populate_preferred is True + + def test_part_database_config_default_populate_preferred(self): + """PartDatabaseConfig defaults populate_preferred to False.""" + config = PartDatabaseConfig( + name="test.db", + chunk_file_name="chunk_test.txt", + where_clause="TRUE", + display_name="Test Database", + ) + assert config.populate_preferred is False + + def test_part_database_config_is_immutable(self): + """PartDatabaseConfig is immutable (NamedTuple).""" + config = PartDatabaseConfig( + name="test.db", + chunk_file_name="chunk_test.txt", + where_clause="TRUE", + display_name="Test Database", + ) + try: + config.name = "modified.db" # type: ignore + assert False, "Should not be able to modify NamedTuple" + except AttributeError: + pass # Expected + + +# ============================================================================ +# DatabaseConfig Tests +# ============================================================================ + + +class TestDatabaseConfig: + """Tests for DatabaseConfig class.""" + + def test_preferred_and_basic_config(self): + """PreferredAndBasic returns correct configuration.""" + config = DatabaseConfig.preferredAndBasic() + + assert config.name == "basic-parts-fts5.db" + assert config.chunk_file_name == "chunk_num_basic_parts_fts5.txt" + assert config.where_clause == "basic = 1 OR preferred = 1" + assert config.display_name == "Basic + Preferred Library" + assert config.populate_preferred is True + + def test_all_parts_config(self): + """AllParts returns correct configuration.""" + config = DatabaseConfig.allParts() + + assert config.name == "parts-fts5.db" + assert config.chunk_file_name == "chunk_num_fts5.txt" + assert config.where_clause == "TRUE" + assert config.display_name == "Full Library - All Parts" + assert config.populate_preferred is False + + def test_ignore_obsolete_parts_default(self): + """IgnoreObsoleteParts returns correct configuration with default threshold.""" + before_time = int(time.time()) - 365 * 24 * 60 * 60 + config = DatabaseConfig.ignoreObsoleteParts() + after_time = int(time.time()) - 365 * 24 * 60 * 60 + + assert config.name == "current-parts-fts5.db" + assert config.chunk_file_name == "chunk_num_current_parts_fts5.txt" + assert config.display_name == "Current Parts (Exclude Obsolete)" + assert config.populate_preferred is True + + # Check that the where clause contains a timestamp within reasonable bounds + assert "NOT (stock = 0 AND last_on_stock <" in config.where_clause + + # Extract the timestamp from the where clause + + match = re.search(r"last_on_stock < (\d+)", config.where_clause) + assert match is not None + timestamp = int(match.group(1)) + + # Allow 2 seconds of margin for test execution time + assert before_time - 2 <= timestamp <= after_time + 2 + + def test_ignore_obsolete_parts_custom_threshold(self): + """IgnoreObsoleteParts accepts custom threshold.""" + days = 180 + before_time = int(time.time()) - days * 24 * 60 * 60 + config = DatabaseConfig.ignoreObsoleteParts(obsolete_threshold_days=days) + after_time = int(time.time()) - days * 24 * 60 * 60 + + assert config.name == "current-parts-fts5.db" + # Verify the timestamp is approximately correct (within a small margin) + assert "NOT (stock = 0 AND last_on_stock <" in config.where_clause + + match = re.search(r"last_on_stock < (\d+)", config.where_clause) + assert match is not None + timestamp = int(match.group(1)) + + # Allow 2 seconds of margin for test execution time + assert before_time - 2 <= timestamp <= after_time + 2 + + def test_empty_parts_config(self): + """EmptyParts returns correct configuration.""" + config = DatabaseConfig.emptyParts() + + assert config.name == "empty-parts-fts5.db" + assert config.chunk_file_name == "chunk_num_empty_parts_fts5.txt" + assert config.where_clause == "FALSE" + assert config.display_name == "Empty Library - No parts!" + assert config.populate_preferred is False + + def test_all_configs_return_part_database_config(self): + """All DatabaseConfig methods return PartDatabaseConfig instances.""" + configs = [ + DatabaseConfig.preferredAndBasic(), + DatabaseConfig.allParts(), + DatabaseConfig.ignoreObsoleteParts(), + DatabaseConfig.emptyParts(), + ] + + for config in configs: + assert isinstance(config, PartDatabaseConfig) + + def test_configs_have_unique_names(self): + """All default configurations have unique database names.""" + configs = [ + DatabaseConfig.preferredAndBasic(), + DatabaseConfig.allParts(), + DatabaseConfig.ignoreObsoleteParts(), + DatabaseConfig.emptyParts(), + ] + + names = [config.name for config in configs] + assert len(names) == len(set(names)), "Database names should be unique" + + def test_configs_have_unique_chunk_files(self): + """All default configurations have unique chunk file names.""" + configs = [ + DatabaseConfig.preferredAndBasic(), + DatabaseConfig.allParts(), + DatabaseConfig.ignoreObsoleteParts(), + DatabaseConfig.emptyParts(), + ] + + chunk_files = [config.chunk_file_name for config in configs] + assert len(chunk_files) == len(set(chunk_files)), ( + "Chunk file names should be unique" + ) + + +# ============================================================================ +# LIBRARY_CONFIGS and DEFAULT_LIBRARY Tests +# ============================================================================ + + +class TestLibraryConfigs: + """Tests for LIBRARY_CONFIGS and DEFAULT_LIBRARY constants.""" + + def test_library_configs_exists(self): + """LIBRARY_CONFIGS is defined and is a dict.""" + assert LIBRARY_CONFIGS is not None + assert isinstance(LIBRARY_CONFIGS, dict) + + def test_default_library_exists(self): + """DEFAULT_LIBRARY is defined and is a string.""" + assert DEFAULT_LIBRARY is not None + assert isinstance(DEFAULT_LIBRARY, str) + + def test_default_library_in_configs(self): + """DEFAULT_LIBRARY key exists in LIBRARY_CONFIGS.""" + assert DEFAULT_LIBRARY in LIBRARY_CONFIGS + + def test_library_configs_has_expected_keys(self): + """LIBRARY_CONFIGS has all expected configuration keys.""" + expected_keys = {"all-parts", "basic-preferred", "current-parts", "empty"} + assert set(LIBRARY_CONFIGS.keys()) == expected_keys + + def test_library_configs_values_are_part_database_configs(self): + """All LIBRARY_CONFIGS values are PartDatabaseConfig instances.""" + for key, config in LIBRARY_CONFIGS.items(): + assert isinstance(config, PartDatabaseConfig), ( + f"Config for '{key}' should be PartDatabaseConfig" + ) + + def test_library_configs_have_display_names(self): + """All LIBRARY_CONFIGS entries have non-empty display_name.""" + for key, config in LIBRARY_CONFIGS.items(): + assert config.display_name, ( + f"Config for '{key}' should have non-empty display_name" + ) + + def test_library_configs_display_names_unique(self): + """All display_name values in LIBRARY_CONFIGS are unique.""" + display_names = [config.display_name for config in LIBRARY_CONFIGS.values()] + assert len(display_names) == len(set(display_names)), ( + "Display names should be unique" + ) + + def test_library_configs_db_names_unique(self): + """All database names in LIBRARY_CONFIGS are unique.""" + db_names = [config.name for config in LIBRARY_CONFIGS.values()] + assert len(db_names) == len(set(db_names)), "Database names should be unique" + + def test_default_library_is_current_parts(self): + """DEFAULT_LIBRARY points to 'current-parts'.""" + assert DEFAULT_LIBRARY == "current-parts" diff --git a/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py b/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py new file mode 100644 index 00000000..df4d03c6 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py @@ -0,0 +1,171 @@ +"""Derive parameters from the part description and package.""" + +# LCSC hides the critical parameters (like resistor values) in the middle of the +# description field, which makes them invisible in the footprint list. This +# makes it very tedious to double-check the part mappings, as the part details +# dialog has to be opened for each one. +# +# This function uses heuristics to extract a human-readable summary of the most +# significant parameters of the parts from the LCSC data so they can be displayed +# separately in the footprint list. + +import logging +import re + +logger = logging.getLogger() +logging.basicConfig(encoding="utf-8", level=logging.DEBUG) + + +def params_for_part(part) -> str: + """Derive parameters from the part description and package.""" + description = part.get("description", "") + category = part.get("category", "") + part_no = part.get("part_no", "") + package = part.get("package", "") + + result = [] + + # These are heuristic regexes to pull out parameters like resistance, + # capacitance, voltage, current, etc. from the description field. As LCSC + # makes random changes to the format of the descriptions, this function will + # need to follow along. + # + # The package size isn't always in the description, but will be added by the + # caller. + + # For passives, focus on generic values like resistance, capacitance, voltage + + if "Resistors" in category: + result.extend(re.findall(r"([.\d]+[mkM]?Ω)", description)) + result.extend(re.findall(r"(±[.\d]+%)", description)) + elif "Capacitors" in category: + result.extend(re.findall(r"([.\d]+[pnmuμ]?F)", description)) + result.extend(re.findall(r"([.\d]+[mkM]?V)", description)) + elif "Inductors" in category: + result.extend(re.findall(r"([.\d]+[nuμm]?H)", description)) + result.extend(re.findall(r"([.\d]+m?A)", description)) + + # For diodes, we may be looking for specific part or generic I/V specs + + elif "Diodes" in category: + if part_no: + result.append(part_no) + result.extend(re.findall(r"(? str: + """Check the plausibility of the parts, there should be just one value per LCSC number. + + Returns an empty sting if all parts are ok, otherwise a otherwise a overview of parts that share a LCSC number but have different values. + """ + lcsc_numbers = {} + for item in self.parent.store.read_bom_parts(): + if not item["lcsc"]: + continue + if item["lcsc"] not in lcsc_numbers: + lcsc_numbers[item["lcsc"]] = [ + {"refs": item["refs"], "values": item["value"]} + ] + else: + lcsc_numbers[item["lcsc"]].append( + {"refs": item["refs"], "values": item["value"]} + ) + filtered = {key: value for key, value in lcsc_numbers.items() if len(value) > 1} + result = "" + for lcsc, items in filtered.items(): + result += f"{lcsc}:\n" + for item in items: + result += f" - {item['refs']} -> {item['values']}\n" + return result diff --git a/third_party/Bouni/kicad-jlcpcb-tools/helpers.py b/third_party/Bouni/kicad-jlcpcb-tools/helpers.py new file mode 100644 index 00000000..ddcb045e --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/helpers.py @@ -0,0 +1,207 @@ +"""Contains helper function used all over the plugin.""" + +import os +from pathlib import Path +import re + +import wx # pylint: disable=import-error +import wx.dataview # pylint: disable=import-error + +PLUGIN_PATH = Path(__file__).resolve().parent + +EXCLUDE_FROM_POS = 2 +EXCLUDE_FROM_BOM = 3 + + +def getWxWidgetsVersion(): + """Get wx widgets version.""" + v = re.search(r"wxWidgets\s([\d\.]+)", wx.version()) + v = int(v.group(1).replace(".", "")) + return v + + +def getVersion(): + """READ Version from file.""" + if not os.path.isfile(os.path.join(PLUGIN_PATH, "VERSION")): + return "unknown" + with open(os.path.join(PLUGIN_PATH, "VERSION"), encoding="utf-8") as f: + return f.read().strip() + + +def GetOS(): + """Get String with OS type.""" + return wx.PlatformInformation.Get().GetOperatingSystemIdName() + + +def GetScaleFactor(window): + """Workaround if wxWidgets Version does not support GetDPIScaleFactor, for Mac OS always return 1.0.""" + if "Apple Mac OS" in GetOS(): + return 1.0 + if hasattr(window, "GetDPIScaleFactor"): + return window.GetDPIScaleFactor() + return 1.0 + + +def HighResWxSize(window, size): + """Workaround if wxWidgets Version does not support FromDIP.""" + if hasattr(window, "FromDIP"): + return window.FromDIP(size) + return size + + +def loadBitmapScaled(filename, scale=1.0, static=False): + """Load a scaled bitmap, handle differences between Kicad versions.""" + if filename: + path = os.path.join(PLUGIN_PATH, "icons", filename) + bmp = wx.Bitmap(path) + w, h = bmp.GetSize() + img = bmp.ConvertToImage() + if hasattr(wx.SystemSettings, "GetAppearance") and hasattr( + wx.SystemSettings.GetAppearance, "IsUsingDarkBackground" + ): + if wx.SystemSettings.GetAppearance().IsUsingDarkBackground(): + img.Replace(0, 0, 0, 255, 255, 255) + bmp = wx.Bitmap(img.Scale(int(w * scale), int(h * scale))) + else: + bmp = wx.Bitmap() + if getWxWidgetsVersion() > 315 and not static: + return wx.BitmapBundle(bmp) + return bmp + + +def loadIconScaled(filename, scale=1.0): + """Load a scaled icon, handle differences between Kicad versions.""" + bmp = loadBitmapScaled(filename, scale=scale, static=False) + if getWxWidgetsVersion() > 315: + return bmp + return wx.Icon(bmp) + + +def natural_sort_collation(a, b): + """Natural sort collation for use in sqlite.""" + if a == b: + return 0 + + def convert(text): + return int(text) if text.isdigit() else text.lower() + + def alphanum_key(key): + return [convert(c) for c in re.split("([0-9]+)", key)] + + natorder = sorted([a, b], key=alphanum_key) + return -1 if natorder.index(a) == 0 else 1 + + +def dict_factory(cursor, row) -> dict: + """Row factory that returns a dict.""" + d = {} + for idx, col in enumerate(cursor.description): + d[col[0]] = row[idx] + return d + + +def get_lcsc_value(fp): + """Get the first lcsc number (C123456 for example) from the properties of the footprint.""" + # KiCad 7.99 + try: + for field in fp.GetFields(): + if re.match(r"lcsc|jlc", field.GetName(), re.IGNORECASE) and re.match( + r"^C\d+$", field.GetText() + ): + return field.GetText() + # KiCad <= V7 + except AttributeError: + for key, value in fp.GetProperties().items(): + if re.match(r"lcsc|jlc", key, re.IGNORECASE) and re.match(r"^C\d+$", value): + return value + return "" + + +def set_lcsc_value(fp, lcsc: str): + """Set an lcsc number to the first matching propertie of the footprint, use LCSC as property name if not found.""" + lcsc_field = None + for field in fp.GetFields(): + if re.match(r"lcsc|jlc", field.GetName(), re.IGNORECASE) and re.match( + r"^C\d+$", field.GetText() + ): + lcsc_field = field + + if lcsc_field: + fp.SetField(lcsc_field.GetName(), lcsc) + else: + fp.SetField("LCSC", lcsc) + if hasattr(fp, "GetFieldByName"): + fp.GetFieldByName("LCSC").SetVisible(False) + else: + for field in fp.GetFields(): + if field.GetName() == "LCSC": + field.SetVisible(False) + break + +def get_valid_footprints(board): + """Get all footprints that have a valid reference. + + Drop all REF** for example + Drop kibuzzard footprints (length check) + """ + footprints = [] + for fp in board.GetFootprints(): + if re.match(r"[\w\d-]+", fp.GetReference()): + footprints.append(fp) + return footprints + + +def get_bit(value, bit): + """Get the nth bit of a byte.""" + return value & (1 << bit) + + +def toggle_bit(value, bit): + """Toggle the nth bit of a byte.""" + return value ^ (1 << bit) + + +def get_exclude_from_pos(footprint): + """Get the 'exclude from POS' property of a footprint.""" + if not footprint: + return None + val = footprint.GetAttributes() + return bool(get_bit(val, EXCLUDE_FROM_POS)) + + +def get_exclude_from_bom(footprint): + """Get the 'exclude from BOM' property of a footprint.""" + if not footprint: + return None + val = footprint.GetAttributes() + return bool(get_bit(val, EXCLUDE_FROM_BOM)) + + +def get_is_dnp(footprint): + """Get the runtime 'Do not place' state of a footprint.""" + if not footprint: + return False + is_dnp = getattr(footprint, "IsDNP", None) + if not callable(is_dnp): + return False + return bool(is_dnp()) + + +def toggle_exclude_from_pos(footprint): + """Toggle the 'exclude from POS' property of a footprint.""" + if not footprint: + return None + val = footprint.GetAttributes() + val = toggle_bit(val, EXCLUDE_FROM_POS) + footprint.SetAttributes(val) + return bool(get_bit(val, EXCLUDE_FROM_POS)) + + +def toggle_exclude_from_bom(footprint): + """Toggle the 'exclude from BOM' property of a footprint.""" + if not footprint: + return None + val = footprint.GetAttributes() + val = toggle_bit(val, EXCLUDE_FROM_BOM) + footprint.SetAttributes(val) + return bool(get_bit(val, EXCLUDE_FROM_BOM)) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png new file mode 100644 index 00000000..bc089ee9 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png new file mode 100644 index 00000000..d9c6c698 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png new file mode 100644 index 00000000..8fff2020 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png new file mode 100644 index 00000000..113ea0d2 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png new file mode 100644 index 00000000..b0b52705 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png new file mode 100644 index 00000000..d965f2c1 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png new file mode 100644 index 00000000..e864de3d Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png new file mode 100644 index 00000000..f6698363 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png new file mode 100644 index 00000000..6c9b91b4 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png new file mode 100644 index 00000000..53008eb7 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png new file mode 100644 index 00000000..bdab158f Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png new file mode 100644 index 00000000..ca6c2e4d Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png new file mode 100644 index 00000000..0d3db507 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png new file mode 100644 index 00000000..959f396e Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png new file mode 100644 index 00000000..1342eed9 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png new file mode 100644 index 00000000..ee8684ef Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png new file mode 100644 index 00000000..1e888ef1 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png new file mode 100644 index 00000000..a5b58505 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png new file mode 100644 index 00000000..a1cb69a8 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png new file mode 100644 index 00000000..5996f368 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png new file mode 100644 index 00000000..5dc07770 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png new file mode 100644 index 00000000..7190880d Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png new file mode 100644 index 00000000..1d246677 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png new file mode 100644 index 00000000..e8442a9e Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png new file mode 100644 index 00000000..cc31e086 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png new file mode 100644 index 00000000..9753cd7e Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png new file mode 100644 index 00000000..4778ad92 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png new file mode 100644 index 00000000..538c78d1 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png new file mode 100644 index 00000000..e1cd2eb5 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png new file mode 100644 index 00000000..40a7e114 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png new file mode 100644 index 00000000..045f1366 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png new file mode 100644 index 00000000..89326bc1 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png new file mode 100644 index 00000000..a356480b Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png new file mode 100644 index 00000000..7feb05fc Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png new file mode 100644 index 00000000..a09ea2f6 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png new file mode 100644 index 00000000..e3bd6d82 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png new file mode 100644 index 00000000..53efda57 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png new file mode 100644 index 00000000..b7569421 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png new file mode 100644 index 00000000..58a3aa36 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png new file mode 100644 index 00000000..1822fd49 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png new file mode 100644 index 00000000..bfbdea8a Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png new file mode 100644 index 00000000..9e244e16 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png new file mode 100644 index 00000000..85bd5b0c Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png new file mode 100644 index 00000000..7f83a4cf Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png new file mode 100644 index 00000000..ba0f99a3 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png new file mode 100644 index 00000000..4dcc81a0 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png new file mode 100644 index 00000000..d17c4ec9 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png new file mode 100644 index 00000000..7fef12d4 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg new file mode 100644 index 00000000..51d341b6 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + BOT + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg new file mode 100644 index 00000000..1768c0b9 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + TOP + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg new file mode 100644 index 00000000..e21a0164 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg @@ -0,0 +1,131 @@ + + + + + + + + + + image/svg+xml + + bom + + + + + + + + + + + + + + + bom + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg new file mode 100644 index 00000000..3dc93e68 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg @@ -0,0 +1,142 @@ + + + + + + + + + + image/svg+xml + + bom + + + + + + + + + + + + + + + bom + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg new file mode 100644 index 00000000..374ec48c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg @@ -0,0 +1,88 @@ + + + +JLCJLCJLCJLC diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg new file mode 100644 index 00000000..e2de8e65 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg @@ -0,0 +1,176 @@ + + + + + + + + + + image/svg+xml + + edit_comp_ref + + + + + + + + + + + + + + + edit_comp_ref + + + + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg new file mode 100644 index 00000000..3ad44163 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg @@ -0,0 +1,181 @@ + + + + + + + + + + image/svg+xml + + edit_comp_value + + + + + + + + + + + + + + + edit_comp_value + + + + + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg new file mode 100644 index 00000000..b1f2e262 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg @@ -0,0 +1,53 @@ + + + +JLCJLCJLCJLC diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg new file mode 100644 index 00000000..d413984b --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg @@ -0,0 +1,167 @@ + + + + + + + + + + image/svg+xml + + edit_comp_ref + + + + + + + + + + + + + + + edit_comp_ref + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg new file mode 100644 index 00000000..cdfd3be2 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg @@ -0,0 +1,172 @@ + + + + + + + + + + image/svg+xml + + edit_comp_value + + + + + + + + + + + + + + + edit_comp_value + + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg new file mode 100644 index 00000000..10408a5f --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg new file mode 100644 index 00000000..16136b8c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png new file mode 100644 index 00000000..f2f7dd82 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png new file mode 100644 index 00000000..72430661 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png new file mode 100644 index 00000000..e75d8e5d Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png b/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png new file mode 100644 index 00000000..2247b910 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png b/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png new file mode 100644 index 00000000..3468a8c1 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png b/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png new file mode 100644 index 00000000..bb857207 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/main.png b/third_party/Bouni/kicad-jlcpcb-tools/images/main.png new file mode 100644 index 00000000..175b193b Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/main.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png b/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png new file mode 100644 index 00000000..0ba372d9 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png b/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png new file mode 100644 index 00000000..cff6b8a6 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png new file mode 100644 index 00000000..15ab0495 Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png new file mode 100644 index 00000000..acb2671a Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png new file mode 100644 index 00000000..e21075ff Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png new file mode 100644 index 00000000..83d1615e Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif b/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif new file mode 100644 index 00000000..de97864b Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png b/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png new file mode 100644 index 00000000..14dcc2fa Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png differ diff --git a/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py b/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py new file mode 100644 index 00000000..61cf3b3d --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py @@ -0,0 +1,143 @@ +"""Helpers for invoking kicad-cli across platforms.""" + +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from typing import Any + + +def resolve_kicad_cli_path(pcbnew_module: Any = None) -> str | None: + """Resolve the `kicad-cli` executable path across platforms. + + Resolution order: + 1. `KICAD_CLI` env var (full path) + 2. `kicad-cli` on `PATH` + 3. Platform-specific default install locations + 4. Derive app bundle path from `pcbnew` module location (macOS) + """ + env_cli = os.getenv("KICAD_CLI", "").strip() + if env_cli and os.path.isfile(env_cli): + return env_cli + + if cli := shutil.which("kicad-cli"): + return cli + + candidates: list[str] = [] + + if sys.platform.startswith("win"): + base_path = os.environ.get("KICAD_PATH", r"C:\Program Files\KiCad") + candidates.extend( + [ + os.path.join(base_path, "bin", "kicad-cli.exe"), + os.path.join(base_path, "10.0", "bin", "kicad-cli.exe"), + os.path.join(base_path, "9.0", "bin", "kicad-cli.exe"), + os.path.join(base_path, "8.0", "bin", "kicad-cli.exe"), + ] + ) + elif sys.platform == "darwin": + candidates.extend( + [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/Applications/KiCad/KiCad Nightly.app/Contents/MacOS/kicad-cli", + ] + ) + else: + # Linux and other POSIX paths + candidates.extend(["/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli"]) + + pcbnew_file = getattr(pcbnew_module, "__file__", "") + if isinstance(pcbnew_file, str) and "/Contents/" in pcbnew_file: + contents_idx = pcbnew_file.find("/Contents/") + if contents_idx > 0: + app_contents = pcbnew_file[: contents_idx + len("/Contents")] + candidates.append(os.path.join(app_contents, "MacOS", "kicad-cli")) + + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + + return None + + +def run_drc( + kicad_cli_path: str, + board_filename: str, + output_path: str, +) -> subprocess.CompletedProcess[str]: + """Run `kicad-cli pcb drc` and return process result.""" + cmd = [ + kicad_cli_path, + "pcb", + "drc", + board_filename, + f"--output={output_path}", + "--format=json", + "--refill-zones", + "--severity-error", + ] + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + ) + + +def count_drc_violations(report_path: str | Path) -> int: + """Count DRC violations in a KiCad JSON DRC report.""" + with open(report_path, encoding="utf-8") as report_file: + report = json.load(report_file) + return len(report.get("violations", [])) + + +class DRCViolationCounter: + """Run KiCad CLI DRC and return the count of error-severity violations.""" + + def __init__( + self, + pcbnew_module: Any = None, + working_dir: str | None = None, + ): + self._pcbnew_module = pcbnew_module + self._working_dir = working_dir + + def get_violation_count(self, board_filename: str) -> int: + """Run DRC and return violation count, raising RuntimeError on failure.""" + cli_path = resolve_kicad_cli_path(self._pcbnew_module) + if not cli_path: + raise RuntimeError( + "Could not locate kicad-cli. Install KiCad CLI support, add it to PATH, " + "or set KICAD_CLI to the executable path." + ) + + report_path = "" + try: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".json", + delete=False, + dir=self._working_dir, + ) as report_file: + report_path = report_file.name + + completed = run_drc(cli_path, board_filename, report_path) + + if os.path.exists(report_path) and os.path.getsize(report_path) > 0: + return count_drc_violations(report_path) + + if completed.returncode != 0: + msg = completed.stderr.strip() or completed.stdout.strip() or "Unknown error" + raise RuntimeError(f"kicad-cli returned: {msg}") + + return 0 + finally: + if report_path and os.path.exists(report_path): + with contextlib.suppress(OSError): + os.remove(report_path) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py b/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py new file mode 100644 index 00000000..d989df17 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py @@ -0,0 +1,49 @@ +"""Unofficial LCSC API.""" + +import io +from pathlib import Path +from typing import Union + +import requests # pylint: disable=import-error + + +class LCSC_API: + """Unofficial LCSC API.""" + + def __init__(self): + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36" + } # pretend we are browser, otherwise their cloud service blocks the request + + def get_part_data(self, lcsc_number: str) -> dict: + """Get data for a given LCSC number from the API.""" + r = requests.get( + f"https://cart.jlcpcb.com/shoppingCart/smtGood/getComponentDetail?componentCode={lcsc_number}", + headers=self.headers, + timeout=10, + ) + if r.status_code != requests.codes.ok: # pylint: disable=no-member + return {"success": False, "msg": "non-OK HTTP response status"} + data = r.json() + if not data.get("data"): + return { + "success": False, + "msg": "returned JSON data does not have expected 'data' attribute", + } + return {"success": True, "data": data} + + def download_bitmap(self, url: str) -> Union[io.BytesIO, None]: + """Download a picture of the part from the API.""" + content = requests.get(url, headers=self.headers, timeout=10).content + return io.BytesIO(content) + + def download_datasheet(self, url: str, path: Path): + """Download and save a datasheet from the API.""" + r = requests.get(url, stream=True, headers=self.headers, timeout=10) + if r.status_code != requests.codes.ok: # pylint: disable=no-member + return {"success": False, "msg": "non-OK HTTP response status"} + if not r: + return {"success": False, "msg": "Failed to download datasheet!"} + with open(path, "wb") as f: + f.write(r.content) + return {"success": True, "msg": "Successfully downloaded datasheet!"} diff --git a/third_party/Bouni/kicad-jlcpcb-tools/library.py b/third_party/Bouni/kicad-jlcpcb-tools/library.py new file mode 100644 index 00000000..eb1e4ef2 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/library.py @@ -0,0 +1,865 @@ +"""Handle the JLCPCB parts database.""" + +import contextlib +from enum import Enum +import logging +import os +from pathlib import Path +import sqlite3 +from threading import Lock, Thread +import time +from typing import NamedTuple, Optional + +import requests # pylint: disable=import-error +import wx # pylint: disable=import-error + +from .dblib import DEFAULT_LIBRARY, LIBRARY_CONFIGS +from .events import ( + DownloadCompletedEvent, + DownloadProgressEvent, + DownloadStartedEvent, + MessageEvent, +) +from .helpers import PLUGIN_PATH, dict_factory, natural_sort_collation +from .partselector_columns import DB_FIELDS, SORTABLE_COLUMN_INDEX_TO_DB +from .search_escape import escape_fts_phrase, escape_like_term +from .unzip_parts import unzip_parts + + +class PartsDatabaseInfo(NamedTuple): + """Information about the parts database.""" + + last_update: str + size: int + part_count: int + + +class LibraryState(Enum): + """The various states of the library.""" + + INITIALIZED = 0 + UPDATE_NEEDED = 1 + DOWNLOAD_RUNNING = 2 + + +class Library: + """A storage class to get data from a sqlite database and write it back.""" + + def __init__(self, parent): + self.logger = logging.getLogger(__name__) + self.parent = parent + self.order_by = "LCSC Part" + self.order_dir = "ASC" + self.datadir = "" + self.selected_library = DEFAULT_LIBRARY + self.partsdb_file = "" + self.rotationsdb_file = "" + self.localcorrectionsdb_file = "" + self.globalcorrectionsdb_file = "" + self.correctionsdb_file = "" + self.mappingsdb_file = "" + self.state = None + self.download_lock = Lock() + self.category_map = {} + + self.refresh_library_config() + + self.logger.debug("partsdb_file %s", self.partsdb_file) + self.logger.debug("sqlite.sqlite_version %s", sqlite3.sqlite_version) + + def _resolve_data_directory(self): + """Resolve the directory where global database files are stored.""" + configured = self.parent.settings.get("library", {}).get("data_path", "") + if isinstance(configured, str) and configured.strip(): + return os.path.abspath(os.path.expanduser(configured.strip())) + return os.path.join(PLUGIN_PATH, "jlcpcb") + + def refresh_library_config(self): + """Refresh library configuration from settings.""" + self.datadir = self._resolve_data_directory() + + # Get selected library from settings, default to all-parts + selected_library = self.parent.settings.get("library", {}).get( + "selected_library", DEFAULT_LIBRARY + ) + if selected_library not in LIBRARY_CONFIGS: + selected_library = DEFAULT_LIBRARY + + self.selected_library = selected_library + library_config = LIBRARY_CONFIGS[selected_library] + self.partsdb_file = os.path.join(self.datadir, library_config.name) + self.rotationsdb_file = os.path.join(self.datadir, "rotations.db") + self.localcorrectionsdb_file = os.path.join( + self.parent.project_path, "jlcpcb", "project.db" + ) + self.globalcorrectionsdb_file = os.path.join(self.datadir, "corrections.db") + self.correctionsdb_file = ( + self.globalcorrectionsdb_file + if self.uses_global_correction_database() + else self.localcorrectionsdb_file + ) + self.mappingsdb_file = os.path.join(self.datadir, "mappings.db") + self.category_map = {} + + self.setup() + self.check_library() + + self.logger.debug( + "Library configuration refreshed. Selected: %s, Data directory: %s, Database: %s", + self.selected_library, + self.datadir, + self.partsdb_file, + ) + + def setup(self): + """Check if folders and database exist, setup if not.""" + if not os.path.isdir(self.datadir): + self.logger.info( + "Data directory '%s' does not exist and will be created.", self.datadir + ) + Path(self.datadir).mkdir(parents=True, exist_ok=True) + else: + self.logger.info("Data directory '%s' exists, not creating", self.datadir) + + def check_library(self): + """Check if the database files exists, if not trigger update / create database.""" + if ( + not os.path.isfile(self.partsdb_file) + or os.path.getsize(self.partsdb_file) == 0 + ): + self.state = LibraryState.UPDATE_NEEDED + else: + self.state = LibraryState.INITIALIZED + if ( + not os.path.isfile(self.correctionsdb_file) + or os.path.getsize(self.correctionsdb_file) == 0 + ): + self.create_correction_table() + self.migrate_corrections() + if ( + not os.path.isfile(self.mappingsdb_file) + or os.path.getsize(self.mappingsdb_file) == 0 + ): + self.create_mapping_table() + self.migrate_mappings() + + def uses_global_correction_database(self): + """Check if there is a board specific corrections database or not. + + Returns True if the global database is used. + """ + + try: + with ( + contextlib.closing( + sqlite3.connect(self.localcorrectionsdb_file) + ) as ldb, + ldb as lcur, + ): + result = lcur.execute( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction')" + ).fetchone() + if not result: + return True + + return result[0] != 1 + except sqlite3.OperationalError: + return True + + return True + + def switch_to_global_correction_database(self, use_global): + """Switches to global or board local database.""" + + currently_using_global = ( + self.correctionsdb_file == self.globalcorrectionsdb_file + ) + if currently_using_global == use_global: + return + + if use_global: + try: + with ( + contextlib.closing( + sqlite3.connect(self.localcorrectionsdb_file) + ) as con, + con as cur, + ): + cur.execute("DROP TABLE IF EXISTS correction") + cur.commit() + self.correctionsdb_file = self.globalcorrectionsdb_file + except OSError: + self.logger.warning("Failed to remove board local corrections file.") + else: + global_corrections = self.get_all_correction_data() + self.correctionsdb_file = self.localcorrectionsdb_file + self.create_correction_table() + for regex, rotation, offset in global_corrections: + self.insert_correction_data(regex, rotation, offset) + + def set_order_by(self, n): + """Set which value we want to order by when getting data from the database.""" + column = SORTABLE_COLUMN_INDEX_TO_DB.get(n) + if column is None: + return + if self.order_by == column and self.order_dir == "ASC": + self.order_dir = "DESC" + else: + self.order_by = column + self.order_dir = "ASC" + + def search(self, parameters): + """Search the database for parts that meet the given parameters.""" + + # skip searching if there are no keywords and the part number + # field is empty as there are too many parts for the search + # to reasonbly show the desired part + if parameters["keyword"] == "" and ( + "part_no" not in parameters or parameters["part_no"] == "" + ): + return [] + + # Note: must match the shared part selector column definitions. + s = ",".join(f'"{c}"' for c in DB_FIELDS) + query = f"SELECT {s} FROM parts WHERE " + + match_chunks = [] + like_chunks = [] + + query_chunks = [] + + # Build 'match_chunks' and 'like_chunks' arrays + # + # FTS5 (https://www.sqlite.org/fts5.html) has a substring limit of + # at least 3 characters. + # 'Substrings consisting of fewer than 3 unicode characters do not + # match any rows when used with a full-text query' + # + # However, they will still match with a LIKE. + # + # So extract out the <3 character strings and add a 'LIKE' term + # for each of those. + if parameters["keyword"] != "": + keywords = parameters["keyword"].split(" ") + match_keywords_intermediate = [] + for w in keywords: + # skip over empty keywords + if w != "": + if len(w) < 3: # LIKE entry + escaped = escape_like_term(w) + kw = f"description LIKE '%{escaped}%' ESCAPE '\\'" + like_chunks.append(kw) + else: # MATCH entry + escaped = escape_fts_phrase(w) + kw = f'"{escaped}"' + match_keywords_intermediate.append(kw) + if match_keywords_intermediate: + match_entry = " AND ".join(match_keywords_intermediate) + match_chunks.append(f"{match_entry}") + + if "manufacturer" in parameters and parameters["manufacturer"] != "": + p = escape_fts_phrase(parameters["manufacturer"]) + match_chunks.append(f'"Manufacturer":"{p}"') + if "package" in parameters and parameters["package"] != "": + p = escape_fts_phrase(parameters["package"]) + match_chunks.append(f'"Package":"{p}"') + if ( + "category" in parameters + and parameters["category"] != "" + and parameters["category"] != "All" + ): + p = escape_fts_phrase(parameters["category"]) + match_chunks.append(f'"First Category":"{p}"') + if "subcategory" in parameters and parameters["subcategory"] != "": + p = escape_fts_phrase(parameters["subcategory"]) + match_chunks.append(f'"Second Category":"{p}"') + if "part_no" in parameters and parameters["part_no"] != "": + p = escape_fts_phrase(parameters["part_no"]) + match_chunks.append(f'"MFR.Part":"{p}"') + if "solder_joints" in parameters and parameters["solder_joints"] != "": + p = escape_fts_phrase(parameters["solder_joints"]) + match_chunks.append(f'"Solder Joint":"{p}"') + + library_types = [] + if parameters["basic"]: + library_types.append('"Basic"') + if parameters["extended"]: + library_types.append('"Extended"') + if parameters["preferred"]: + library_types.append('"Preferred"') + if library_types: + query_chunks.append(f'"Library Type" IN ({",".join(library_types)})') + + if parameters["stock"]: + query_chunks.append('"Stock" > "0"') + + if not match_chunks and not like_chunks and not query_chunks: + return [] + + if match_chunks: + query += "parts MATCH '" + query += " AND ".join(match_chunks) + query += "'" + + if like_chunks: + if match_chunks: + query += " AND " + query += " AND ".join(like_chunks) + + if query_chunks: + if match_chunks or like_chunks: + query += " AND " + query += " AND ".join(query_chunks) + + query += f' ORDER BY "{self.order_by}" COLLATE naturalsort {self.order_dir}' + query += " LIMIT 1000" + + self.logger.debug("query '%s'", query) + + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con: + con.create_collation("naturalsort", natural_sort_collation) + with con as cur: + return cur.execute(query).fetchall() + + def delete_parts_table(self): + """Delete the parts table.""" + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur: + cur.execute("DROP TABLE IF EXISTS parts") + cur.commit() + + def create_meta_table(self): + """Create the meta table.""" + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur: + cur.execute( + "CREATE TABLE IF NOT EXISTS meta ('filename', 'size', 'partcount', 'date', 'last_update')" + ) + cur.commit() + + def create_correction_table(self): + """Create the correction table.""" + self.logger.debug("Create SQLite table for corrections") + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + cur.execute( + "CREATE TABLE IF NOT EXISTS correction ('regex', 'rotation', 'offset_x', 'offset_y')" + ) + cur.commit() + + def get_correction_data(self, regex): + """Get the correction data by its regex.""" + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + return cur.execute( + f"SELECT * FROM correction WHERE regex = '{regex}'" + ).fetchone() + + def delete_correction_data(self, regex): + """Delete a correction from the database.""" + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + cur.execute(f"DELETE FROM correction WHERE regex = '{regex}'") + cur.commit() + + def update_correction_data(self, regex, rotation, offset): + """Update a correction in the database.""" + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + cur.execute( + f"UPDATE correction SET rotation = '{rotation}', offset_x = '{offset[0]}', offset_y = '{offset[1]}' WHERE regex = '{regex}'" + ) + cur.commit() + + def insert_correction_data(self, regex, rotation, offset): + """Insert a correction into the database.""" + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + cur.execute( + "INSERT INTO correction VALUES (?, ?, ?, ?)", + (regex, rotation, offset[0], offset[1]), + ) + cur.commit() + + def get_all_correction_data(self): + """Get all corrections from the database.""" + with ( + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con, + con as cur, + ): + try: + result = cur.execute( + "SELECT * FROM correction ORDER BY regex ASC" + ).fetchall() + return [(c[0], int(c[1]), (float(c[2]), float(c[3]))) for c in result] + except sqlite3.OperationalError: + return [] + + def create_mapping_table(self): + """Create the mapping table.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + cur.execute( + "CREATE TABLE IF NOT EXISTS mapping ('footprint', 'value', 'LCSC')" + ) + cur.commit() + + def get_mapping_data(self, footprint, value): + """Get the mapping data by its regex.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + return cur.execute( + f"SELECT * FROM mapping WHERE footprint = '{footprint}' AND value = '{value}'" + ).fetchone() + + def delete_mapping_data(self, footprint, value): + """Delete a mapping from the database.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + cur.execute( + f"DELETE FROM mapping WHERE footprint = '{footprint}' AND value = '{value}'" + ) + cur.commit() + + def update_mapping_data(self, footprint, value, LCSC): + """Update a mapping in the database.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + cur.execute( + f"UPDATE mapping SET LCSC = '{LCSC}' WHERE footprint = '{footprint}' AND value = '{value}'" + ) + cur.commit() + + def insert_mapping_data(self, footprint, value, LCSC): + """Insert a mapping into the database.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + cur.execute( + "INSERT INTO mapping VALUES (?, ?, ?)", + (footprint, value, LCSC), + ) + cur.commit() + + def get_all_mapping_data(self): + """Get all mapping from the database.""" + with ( + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con, + con as cur, + ): + return [ + list(c) + for c in cur.execute( + "SELECT * FROM mapping ORDER BY footprint ASC" + ).fetchall() + ] + + def create_parts_table(self, columns): + """Create the parts table.""" + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur: + cols = ",".join([f" '{c}'" for c in columns]) + cur.execute(f"CREATE TABLE IF NOT EXISTS parts ({cols})") + cur.commit() + + def get_part_details(self, number: str) -> dict: + """Get the part details for a LCSC number using optimized FTS5 querying.""" + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con: + con.row_factory = dict_factory + cur = con.cursor() + query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type, + "MFR.Part" as part_no, "Description" as description, "Package" as package, + "First Category" as category + FROM parts WHERE parts MATCH :number""" + cur.execute(query, {"number": number}) + return next((n for n in cur.fetchall() if n["lcsc"] == number), {}) + + def update(self): + """Update the sqlite parts database from the JLCPCB CSV.""" + with self.download_lock: + if self.state == LibraryState.DOWNLOAD_RUNNING: + self.logger.info( + "Download already running, ignoring duplicate request." + ) + return + self.state = LibraryState.DOWNLOAD_RUNNING + try: + Thread(target=self._download_wrapper).start() + except Exception: + with self.download_lock: + self.state = LibraryState.INITIALIZED + raise + + def _download_wrapper(self): + """Run the download worker with guaranteed state cleanup.""" + try: + self.download() + except Exception as exc: # pylint: disable=broad-exception-caught + self.logger.exception("Unexpected error while downloading parts database") + wx.PostEvent( + self.parent, + MessageEvent( + title="Download Error", + text=f"Unexpected error while downloading parts database: {exc}", + style="error", + ), + ) + finally: + with self.download_lock: + self.state = LibraryState.INITIALIZED + + def download(self): + """Actual worker thread that downloads and imports the parts data.""" + start = time.time() + wx.PostEvent(self.parent, DownloadStartedEvent()) + + # Get library configuration for selected library + library_config = LIBRARY_CONFIGS[self.selected_library] + + # Define basic variables + url_stub = "https://bouni.github.io/kicad-jlcpcb-tools/" + cnt_file = library_config.chunk_file_name + progress_file = os.path.join(self.datadir, f"{library_config.name}.progress") + chunk_file_stub = library_config.name.replace(".db", ".db.zip.") + completed_chunks = set() + + self.logger.debug("Starting download of JLCPCB parts database...") + self.logger.debug( + "Using library: %s (basefile %s)", + self.selected_library, + chunk_file_stub, + ) + + # Check if there is a progress file + if os.path.exists(progress_file): + with open(progress_file) as f: + # Read completed chunk indices from the progress file + completed_chunks = {int(line.strip()) for line in f.readlines()} + + # Get the total number of chunks to download + try: + r = requests.get( + url_stub + cnt_file, allow_redirects=True, stream=True, timeout=300 + ) + if r.status_code != requests.codes.ok: + wx.PostEvent( + self.parent, + MessageEvent( + title="HTTP GET Error", + text=f"Failed to fetch count of database parts, error code {r.status_code}\n" + + "URL was:\n" + f"'{url_stub + cnt_file}'", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + total_chunks = int(r.text) + except Exception as e: + wx.PostEvent( + self.parent, + MessageEvent( + title="Download Error", + text=f"Failed to fetch database chunk count, {e}", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + # Re-download incomplete or missing chunks + for i in range(total_chunks): + chunk_index = i + 1 + chunk_file = chunk_file_stub + f"{chunk_index:03}" + chunk_path = os.path.join(self.datadir, chunk_file) + + # Check if the chunk is logged as completed but the file might be incomplete + if chunk_index in completed_chunks: + if os.path.exists(chunk_path): + # Validate the size of the chunk file + try: + expected_size = int( + requests.head( + url_stub + chunk_file, timeout=300 + ).headers.get("Content-Length", 0) + ) + actual_size = os.path.getsize(chunk_path) + if actual_size == expected_size: + self.logger.debug( + "Skipping already downloaded and validated chunk %d.", + chunk_index, + ) + continue + else: + self.logger.warning( + "Chunk %d is incomplete, re-downloading.", chunk_index + ) + except Exception as e: + self.logger.warning( + "Unable to validate chunk %d, re-downloading. Error: %s", + chunk_index, + e, + ) + else: + self.logger.warning( + "Chunk %d marked as completed but file is missing, re-downloading.", + chunk_index, + ) + + # Download the chunk + try: + with open(chunk_path, "wb") as f: + r = requests.get( + url_stub + chunk_file, + allow_redirects=True, + stream=True, + timeout=300, + ) + if r.status_code != requests.codes.ok: + wx.PostEvent( + self.parent, + MessageEvent( + title="Download Error", + text=f"Failed to download chunk {chunk_index}, error code {r.status_code}\n" + + "URL was:\n" + f"'{url_stub + chunk_file}'", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + size = int(r.headers.get("Content-Length", 0)) + self.logger.debug( + "Downloading chunk %d/%d (%.2f MB)", + chunk_index, + total_chunks, + size / 1024 / 1024, + ) + for data in r.iter_content(chunk_size=4096): + f.write(data) + progress = f.tell() / size * 100 + wx.PostEvent(self.parent, DownloadProgressEvent(value=progress)) + self.logger.debug("Chunk %d downloaded successfully.", chunk_index) + + # Update progress file after successful download + with open(progress_file, "a") as f: + f.write(f"{chunk_index}\n") + + except Exception as e: + wx.PostEvent( + self.parent, + MessageEvent( + title="Download Error", + text=f"Failed to download chunk {chunk_index}, {e}", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + # Delete progress file to indicate the download is complete + if os.path.exists(progress_file): + os.remove(progress_file) + + # Combine and extract downloaded files + self.logger.debug("Combining and extracting zip part files...") + try: + unzip_parts(self.parent, self.datadir, library_config.name + ".zip") + except Exception as e: + wx.PostEvent( + self.parent, + MessageEvent( + title="Extract Error", + text=f"Failed to combine and extract the JLCPCB database, {e}", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + # Check if the database file was successfully extracted + if not os.path.exists(self.partsdb_file): + wx.PostEvent( + self.parent, + MessageEvent( + title="Download Error", + text="Failed to extract the database file from the downloaded zip.", + style="error", + ), + ) + self.state = LibraryState.INITIALIZED + return + + wx.PostEvent(self.parent, DownloadCompletedEvent()) + end = time.time() + wx.PostEvent( + self.parent, + MessageEvent( + title="Success", + text=f"Successfully downloaded and imported the JLCPCB database in {end - start:.2f} seconds!", + style="info", + ), + ) + self.state = LibraryState.INITIALIZED + + def create_tables(self, headers): + """Create all tables.""" + self.create_meta_table() + self.delete_parts_table() + self.create_parts_table(headers) + self.create_correction_table() + self.create_mapping_table() + + @property + def categories(self): + """The primary categories in the database. + + Caching the relatively small set of category and subcategory maps + gives a noticeable speed improvement over repeatedly reading the + information from the on-disk database. + """ + if not self.category_map: + self.category_map.setdefault("", []) + + # Populate the cache. + with ( + contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, + con as cur, + ): + for row in cur.execute( + 'SELECT * from categories ORDER BY UPPER("First Category"), UPPER("Second Category")' + ): + self.category_map.setdefault(row[0], []).append(row[1]) + tmp = list(self.category_map.keys()) + tmp.insert(0, "All") + return tmp + + def get_subcategories(self, category): + """Get the subcategories associated with the given category.""" + return self.category_map[category] + + def migrate_corrections_from_rotation(self): + """Migrate existing rotations from rotation db to correction db.""" + if not os.path.exists(self.rotationsdb_file): + return + with ( + contextlib.closing(sqlite3.connect(self.rotationsdb_file)) as rdb, + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as cdb, + rdb as rcur, + cdb as ccur, + ): + try: + result = rcur.execute( + "SELECT * FROM rotation ORDER BY regex ASC" + ).fetchall() + if not result: + return + for r in result: + ccur.execute( + "INSERT INTO correction VALUES (?, ?, 0, 0)", + (r[0], r[1]), + ) + ccur.commit() + self.logger.debug( + "Migrated %d rotations to corrections database.", len(result) + ) + os.remove(self.rotationsdb_file) + self.logger.debug("Deleted rotations database.") + except sqlite3.OperationalError: + return + except OSError: + return + + def migrate_corrections_from_parts(self): + """Migrate existing rotations from parts db to correction db.""" + with ( + contextlib.closing(sqlite3.connect(self.partsdb_file)) as pdb, + contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as rdb, + pdb as pcur, + rdb as rcur, + ): + try: + result = pcur.execute( + "SELECT * FROM rotation ORDER BY regex ASC" + ).fetchall() + if not result: + return + for r in result: + rcur.execute( + "INSERT INTO correction VALUES (?, ?, 0, 0)", + (r[0], r[1]), + ) + rcur.commit() + self.logger.debug( + "Migrated %d rotations to separate database.", len(result) + ) + pcur.execute("DROP TABLE IF EXISTS rotation") + pcur.commit() + self.logger.debug("Droped rotations table from parts database.") + except sqlite3.OperationalError: + return + + def migrate_corrections(self): + """Migrate existing rotations from old rotation db and parts db to correction db.""" + self.migrate_corrections_from_rotation() + self.migrate_corrections_from_parts() + + def migrate_mappings(self): + """Migrate existing mappings from parts db to mappings db.""" + with ( + contextlib.closing(sqlite3.connect(self.partsdb_file)) as pdb, + contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as mdb, + pdb as pcur, + mdb as mcur, + ): + try: + result = pcur.execute( + "SELECT * FROM mapping ORDER BY footprint ASC" + ).fetchall() + if not result: + return + for r in result: + mcur.execute( + "INSERT INTO mapping VALUES (?, ?)", + (r[0], r[1]), + ) + mcur.commit() + self.logger.debug( + "Migrated %d mappings to sepetrate database.", len(result) + ) + pcur.execute("DROP TABLE IF EXISTS mapping") + pcur.commit() + self.logger.debug("Droped mappings table from parts database.") + except sqlite3.OperationalError: + return + + def get_parts_db_info(self) -> Optional[PartsDatabaseInfo]: # noqa: UP045 + """Retrieve the database information.""" + with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur: + try: + meta = cur.execute( + "SELECT last_update, size, partcount FROM meta" + ).fetchone() + if meta: + return PartsDatabaseInfo(meta[0], meta[1], meta[2]) + return None + except sqlite3.OperationalError: + return None diff --git a/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py b/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py new file mode 100644 index 00000000..5631ab39 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py @@ -0,0 +1,1343 @@ +"""Contains the main window of the plugin.""" + +from contextlib import suppress +from datetime import datetime as dt +import json +import logging +import os +import re +import sys +import time + +import pcbnew as kicad_pcbnew +import wx # pylint: disable=import-error +from wx import adv # pylint: disable=import-error +import wx.dataview as dv # pylint: disable=import-error + +from .corrections import CorrectionManagerDialog +from .datamodel import PartListDataModel +from .derive_params import params_for_part +from .events import ( + EVT_ASSIGN_PARTS_EVENT, + EVT_DOWNLOAD_COMPLETED_EVENT, + EVT_DOWNLOAD_PROGRESS_EVENT, + EVT_DOWNLOAD_STARTED_EVENT, + EVT_LOGBOX_APPEND_EVENT, + EVT_MESSAGE_EVENT, + EVT_POPULATE_FOOTPRINT_LIST_EVENT, + EVT_UNZIP_COMBINING_PROGRESS_EVENT, + EVT_UNZIP_COMBINING_STARTED_EVENT, + EVT_UNZIP_EXTRACTING_COMPLETED_EVENT, + EVT_UNZIP_EXTRACTING_PROGRESS_EVENT, + EVT_UNZIP_EXTRACTING_STARTED_EVENT, + EVT_UPDATE_SETTING, + LogboxAppendEvent, +) +from .fabrication import Fabrication +from .helpers import ( + PLUGIN_PATH, + GetScaleFactor, + HighResWxSize, + get_is_dnp, + getVersion, + loadBitmapScaled, + set_lcsc_value, + toggle_exclude_from_bom, + toggle_exclude_from_pos, +) +from .kicad_cli import DRCViolationCounter +from .library import Library, LibraryState +from .partdetails import PartDetailsDialog +from .partmapper import PartMapperManagerDialog +from .partselector import PartSelectorDialog +from .schematicexport import SchematicExport +from .settings import SettingsDialog +from .store import Store + +logging.getLogger("requests").setLevel(logging.WARNING) +logging.getLogger("urllib3").setLevel(logging.WARNING) + +ID_GENERATE = 0 +ID_LAYERS = 1 +ID_CORRECTIONS = 2 +ID_MAPPINGS = 3 +ID_DOWNLOAD = 4 +ID_SETTINGS = 5 +ID_SELECT_PART = 6 +ID_REMOVE_LCSC_NUMBER = 7 +ID_SELECT_ALIKE = 8 +ID_TOGGLE_BOM_POS = 9 +ID_TOGGLE_BOM = 10 +ID_TOGGLE_POS = 11 +ID_PART_DETAILS = 12 +ID_HIDE_BOM = 13 +ID_HIDE_POS = 14 +ID_SAVE_MAPPINGS = 15 +ID_EXPORT_TO_SCHEMATIC = 16 +ID_CONTEXT_MENU_COPY_LCSC = wx.NewIdRef() +ID_CONTEXT_MENU_PASTE_LCSC = wx.NewIdRef() +ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE = wx.NewIdRef() +ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE = wx.NewIdRef() +ID_CONTEXT_MENU_ADD_ROT_BY_NAME = wx.NewIdRef() +ID_CONTEXT_MENU_FIND_MAPPING = wx.NewIdRef() +ID_CONTEXT_MENU_ADD_MAPPING = wx.NewIdRef() + + +class KicadProvider: + """KiCad implementation of the provider, see standalone_impl.py for the stub version.""" + + def get_pcbnew(self): + """Get the pcbnew instance.""" + return kicad_pcbnew + + +class JLCPCBTools(wx.Dialog): + """JLCPCBTools main dialog.""" + + def __init__(self, parent, kicad_provider=KicadProvider()): + while not wx.GetApp(): + time.sleep(1) + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title=f"JLCPCB Tools [ {getVersion()} ]", + pos=wx.DefaultPosition, + size=wx.Size(1300, 800), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + self.pcbnew = kicad_provider.get_pcbnew() + self.window = wx.GetTopLevelParent(self) + self.SetSize(HighResWxSize(self.window, wx.Size(1300, 800))) + self.scale_factor = GetScaleFactor(self.window) + self.project_path = os.path.split(self.pcbnew.GetBoard().GetFileName())[0] + self.board_name = os.path.split(self.pcbnew.GetBoard().GetFileName())[1] + self.schematic_name = f"{self.board_name.split('.')[0]}.kicad_sch" + self.hide_bom_parts = False + self.hide_pos_parts = False + self.library: Library + self.store: Store + self.settings = {} + self.load_settings() + self.auto_select_alike = bool( + self.settings.get("general", {}).get("select_alike_auto", False) + ) + self.select_alike_in_progress = False + self.Bind(wx.EVT_CLOSE, self.quit_dialog) + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + + # --------------------------------------------------------------------- + # -------------------- Horizontal top buttons ------------------------- + # --------------------------------------------------------------------- + + self.upper_toolbar = wx.ToolBar( + self, + wx.ID_ANY, + wx.DefaultPosition, + wx.Size(1300, -1), + wx.TB_HORIZONTAL | wx.TB_TEXT | wx.TB_NODIVIDER, + ) + + self.generate_button = self.upper_toolbar.AddTool( + ID_GENERATE, + "Generate", + loadBitmapScaled("fabrication.png", self.scale_factor), + "Generate fabrication files for JLCPCB", + ) + + self.upper_toolbar.AddSeparator() + + self.layer_selection = adv.BitmapComboBox( + self.upper_toolbar, ID_LAYERS, style=wx.CB_READONLY + ) + + layer_options = [ + "Auto", + "1 Layer", + "2 Layer", + "4 Layer", + "6 Layer", + "8 Layer", + "10 Layer", + "12 Layer", + "14 Layer", + "16 Layer", + "18 Layer", + "20 Layer", + ] + + for option in layer_options: + self.layer_selection.Append( + option, + loadBitmapScaled( + "mdi-layers-triple-outline.png", self.scale_factor, True + ), + ) + + self.layer_selection.SetSelection(0) + + self.upper_toolbar.AddControl(self.layer_selection) + + self.upper_toolbar.AddStretchableSpace() + + self.correction_button = self.upper_toolbar.AddTool( + ID_CORRECTIONS, + "Corrections", + loadBitmapScaled("mdi-format-rotate-90.png", self.scale_factor), + "Manage part corrections", + ) + + self.mapping_button = self.upper_toolbar.AddTool( + ID_MAPPINGS, + "Mappings", + loadBitmapScaled("mdi-selection.png", self.scale_factor), + "Manage part mappings", + ) + + self.upper_toolbar.AddSeparator() + + self.download_button = self.upper_toolbar.AddTool( + ID_DOWNLOAD, + "Download", + loadBitmapScaled("mdi-cloud-download-outline.png", self.scale_factor), + "Download latest JLCPCB parts database", + ) + + self.settings_button = self.upper_toolbar.AddTool( + ID_SETTINGS, + "Settings", + loadBitmapScaled("mdi-cog-outline.png", self.scale_factor), + "Manage settings", + ) + + self.upper_toolbar.Realize() + + self.Bind(wx.EVT_TOOL, self.generate_fabrication_data, self.generate_button) + self.Bind(wx.EVT_TOOL, self.manage_corrections, self.correction_button) + self.Bind(wx.EVT_TOOL, self.manage_mappings, self.mapping_button) + self.Bind(wx.EVT_TOOL, self.update_library, self.download_button) + self.Bind(wx.EVT_TOOL, self.manage_settings, self.settings_button) + + # --------------------------------------------------------------------- + # ------------------ Right side toolbar List -------------------------- + # --------------------------------------------------------------------- + + self.right_toolbar = wx.ToolBar( + self, + wx.ID_ANY, + wx.DefaultPosition, + wx.Size(int(self.scale_factor * 128), -1), + wx.TB_VERTICAL | wx.TB_TEXT | wx.TB_NODIVIDER, + ) + + self.select_part_button = self.right_toolbar.AddTool( + ID_SELECT_PART, + "Assign LCSC number", + loadBitmapScaled( + "mdi-database-search-outline.png", + self.scale_factor, + ), + "Assign a LCSC number to a footprint", + ) + + self.remove_lcsc_number_button = self.right_toolbar.AddTool( + ID_REMOVE_LCSC_NUMBER, + "Remove LCSC number", + loadBitmapScaled( + "mdi-close-box-outline.png", + self.scale_factor, + ), + "Remove a LCSC number from a footprint", + ) + + self.select_alike_button = self.right_toolbar.AddCheckTool( + ID_SELECT_ALIKE, + "Auto-select alike", + loadBitmapScaled( + "mdi-checkbox-multiple-marked.png", + self.scale_factor, + ), + wx.NullBitmap, + "Automatically select footprints with the same value and footprint", + ) + + self.toggle_bom_pos_button = self.right_toolbar.AddTool( + ID_TOGGLE_BOM_POS, + "Toggle BOM & POS", + loadBitmapScaled( + "bom-pos.png", + self.scale_factor, + ), + "Toggle exclud from BOM and POS attribute", + ) + + self.toggle_bom_button = self.right_toolbar.AddTool( + ID_TOGGLE_BOM, + "Toggle BOM", + loadBitmapScaled( + "mdi-format-list-bulleted.png", + self.scale_factor, + ), + "Toggle exclude from BOM attribute", + ) + + self.toggle_pos_button = self.right_toolbar.AddTool( + ID_TOGGLE_POS, + "Toggle POS", + loadBitmapScaled( + "mdi-crosshairs-gps.png", + self.scale_factor, + ), + "Toggle exclude from POS attribute", + ) + + self.part_details_button = self.right_toolbar.AddTool( + ID_PART_DETAILS, + "Part details", + loadBitmapScaled( + "mdi-text-box-search-outline.png", + self.scale_factor, + ), + "Show details of an assigned LCSC part", + ) + + self.hide_bom_button = self.right_toolbar.AddCheckTool( + ID_HIDE_BOM, + "Hide excluded BOM", + loadBitmapScaled( + "mdi-eye-off-outline.png", + self.scale_factor, + ), + wx.NullBitmap, + "Hide excluded BOM parts", + ) + + self.hide_pos_button = self.right_toolbar.AddCheckTool( + ID_HIDE_POS, + "Hide excluded POS", + loadBitmapScaled( + "mdi-eye-off-outline.png", + self.scale_factor, + ), + wx.NullBitmap, + "Hide excluded POS parts", + ) + + self.save_all_button = self.right_toolbar.AddTool( + ID_SAVE_MAPPINGS, + "Save mappings", + loadBitmapScaled( + "mdi-content-save-settings.png", + self.scale_factor, + ), + "Save all mappings", + ) + + self.export_schematic_button = self.right_toolbar.AddTool( + ID_EXPORT_TO_SCHEMATIC, + "Export to schematic", + loadBitmapScaled( + "mdi-application-export.png", + self.scale_factor, + ), + "Export mappings to schematic", + ) + + self.Bind(wx.EVT_TOOL, self.select_part, self.select_part_button) + self.Bind(wx.EVT_TOOL, self.remove_lcsc_number, self.remove_lcsc_number_button) + self.Bind(wx.EVT_TOOL, self.toggle_select_alike, self.select_alike_button) + self.Bind(wx.EVT_TOOL, self.toggle_bom_pos, self.toggle_bom_pos_button) + self.Bind(wx.EVT_TOOL, self.toggle_bom, self.toggle_bom_button) + self.Bind(wx.EVT_TOOL, self.toggle_pos, self.toggle_pos_button) + self.Bind(wx.EVT_TOOL, self.get_part_details, self.part_details_button) + self.Bind(wx.EVT_TOOL, self.OnBomHide, self.hide_bom_button) + self.Bind(wx.EVT_TOOL, self.OnPosHide, self.hide_pos_button) + self.Bind(wx.EVT_TOOL, self.save_all_mappings, self.save_all_button) + self.Bind(wx.EVT_TOOL, self.export_to_schematic, self.export_schematic_button) + + self.right_toolbar.ToggleTool(ID_SELECT_ALIKE, self.auto_select_alike) + + self.right_toolbar.Realize() + + # --------------------------------------------------------------------- + # ----------------------- Footprint List ------------------------------ + # --------------------------------------------------------------------- + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) + table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600))) + + table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL) + table_scroller.SetScrollRate(20, 20) + + self.footprint_list = dv.DataViewCtrl( + table_scroller, + style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE, + ) + + reference = self.footprint_list.AppendTextColumn( + "Ref", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) + value = self.footprint_list.AppendTextColumn( + "Value (Name)", + 1, + width=150, + mode=dv.DATAVIEW_CELL_INERT, + align=wx.ALIGN_CENTER, + ) + footprint = self.footprint_list.AppendTextColumn( + "Footprint", + 2, + width=250, + mode=dv.DATAVIEW_CELL_INERT, + align=wx.ALIGN_CENTER, + ) + params = self.footprint_list.AppendTextColumn( + "LCSC Params", + 11, + width=150, + mode=dv.DATAVIEW_CELL_INERT, + align=wx.ALIGN_CENTER, + ) + lcsc = self.footprint_list.AppendTextColumn( + "LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) + type = self.footprint_list.AppendTextColumn( + "Type", 4, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) + stock = self.footprint_list.AppendTextColumn( + "Stock", 5, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) + bom = self.footprint_list.AppendIconTextColumn( + "BOM", 6, width=50, mode=dv.DATAVIEW_CELL_INERT + ) + pos = self.footprint_list.AppendIconTextColumn( + "POS", 7, width=50, mode=dv.DATAVIEW_CELL_INERT + ) + dnp = self.footprint_list.AppendIconTextColumn( + "POP", 8, width=50, mode=dv.DATAVIEW_CELL_INERT + ) + correction = self.footprint_list.AppendTextColumn( + "Correction", + 9, + width=120, + mode=dv.DATAVIEW_CELL_INERT, + align=wx.ALIGN_CENTER, + ) + side = self.footprint_list.AppendIconTextColumn( + "Side", 10, width=50, mode=dv.DATAVIEW_CELL_INERT + ) + + reference.SetSortable(True) + value.SetSortable(True) + footprint.SetSortable(True) + lcsc.SetSortable(True) + type.SetSortable(True) + stock.SetSortable(True) + bom.SetSortable(True) + pos.SetSortable(False) + dnp.SetSortable(True) + correction.SetSortable(True) + side.SetSortable(True) + params.SetSortable(True) + + scrolled_sizer = wx.BoxSizer(wx.VERTICAL) + scrolled_sizer.Add(self.footprint_list, 1, wx.EXPAND) + table_scroller.SetSizer(scrolled_sizer) + + table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5) + + self.footprint_list.Bind( + dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected + ) + + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part) + + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown) + + table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5) + # --------------------------------------------------------------------- + # --------------------- Bottom Logbox and Gauge ----------------------- + # --------------------------------------------------------------------- + self.logbox = wx.TextCtrl( + self, + wx.ID_ANY, + wx.EmptyString, + wx.DefaultPosition, + wx.DefaultSize, + wx.TE_MULTILINE | wx.TE_READONLY, + ) + self.logbox.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 150))) + self.gauge = wx.Gauge( + self, + wx.ID_ANY, + 100, + wx.DefaultPosition, + HighResWxSize(self.window, wx.Size(100, -1)), + wx.GA_HORIZONTAL, + ) + self.gauge.SetValue(0) + self.gauge.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 5))) + + # --------------------------------------------------------------------- + # ---------------------- Main Layout Sizer ---------------------------- + # --------------------------------------------------------------------- + + self.SetSizeHints(HighResWxSize(self.window, wx.Size(1000, -1)), wx.DefaultSize) + layout = wx.BoxSizer(wx.VERTICAL) + layout.Add(self.upper_toolbar, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5) + layout.Add(self.logbox, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(self.gauge, 0, wx.ALL | wx.EXPAND, 5) + + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + + # --------------------------------------------------------------------- + # ------------------------ Custom Events ------------------------------ + # --------------------------------------------------------------------- + + self.Bind(EVT_MESSAGE_EVENT, self.display_message) + self.Bind(EVT_ASSIGN_PARTS_EVENT, self.assign_parts) + self.Bind(EVT_POPULATE_FOOTPRINT_LIST_EVENT, self.populate_footprint_list) + self.Bind(EVT_UPDATE_SETTING, self.update_settings) + + self.Bind(EVT_DOWNLOAD_STARTED_EVENT, self.download_started) + self.Bind(EVT_DOWNLOAD_PROGRESS_EVENT, self.download_progress) + self.Bind(EVT_DOWNLOAD_COMPLETED_EVENT, self.download_completed) + + self.Bind(EVT_UNZIP_COMBINING_STARTED_EVENT, self.unzip_combining_started) + self.Bind(EVT_UNZIP_COMBINING_PROGRESS_EVENT, self.unzip_combining_progress) + self.Bind(EVT_UNZIP_EXTRACTING_STARTED_EVENT, self.unzip_extracting_started) + self.Bind(EVT_UNZIP_EXTRACTING_PROGRESS_EVENT, self.unzip_extracting_progress) + self.Bind(EVT_UNZIP_EXTRACTING_COMPLETED_EVENT, self.unzip_extracting_completed) + + self.Bind(EVT_LOGBOX_APPEND_EVENT, self.logbox_append) + + self.enable_part_specific_toolbar_buttons(False) + + self.init_logger() + self.partlist_data_model = PartListDataModel(self.scale_factor) + self.footprint_list.AssociateModel(self.partlist_data_model) + + self.init_data() + + def init_data(self): + """Initialize the library and populate the main window.""" + self.init_library() + self.init_fabrication() + if self.library.state == LibraryState.UPDATE_NEEDED: + self.library.update() + else: + self.init_store() + self.library.create_mapping_table() + + self.logger.debug("kicad version: %s", kicad_pcbnew.GetBuildVersion()) + + def quit_dialog(self, *_): + """Destroy dialog on close.""" + self.logger.info("quit_dialog()") + root = logging.getLogger() + with suppress(AttributeError): + root.removeHandler(self.logging_handler1) + with suppress(AttributeError): + root.removeHandler(self.logging_handler2) + + self.Destroy() + self.EndModal(0) + + def init_library(self): + """Initialize the parts library.""" + self.library = Library(self) + meta = self.library.get_parts_db_info() + if meta is not None: + last_update = dt.fromisoformat(meta.last_update).strftime("%Y-%m-%d %H:%M") + self.SetTitle( + f"JLCPCB Tools [ {getVersion()} ] | Last database update: {last_update}", + ) + self.logger.debug( + "JLCPCB version %s, last database update %s, part count %d, size (bytes) %d", + getVersion(), + meta.last_update, + meta.part_count, + meta.size, + ) + else: + self.SetTitle( + f"JLCPCB Tools [ {getVersion()} ] | Last database update: No DB found", + ) + self.logger.debug("JLCPCB version %s, no parts db info found", getVersion()) + + def init_store(self): + """Initialize the store of part assignments.""" + self.store = Store(self, self.project_path, self.pcbnew.GetBoard()) + if self.library.state == LibraryState.INITIALIZED: + self.populate_footprint_list() + + def init_fabrication(self): + """Initialize the fabrication.""" + self.fabrication = Fabrication(self, self.pcbnew.GetBoard()) + + def reset_gauge(self, *_): + """Initialize the gauge.""" + self.gauge.SetRange(100) + self.gauge.SetValue(0) + + def download_started(self, *_): + """Initialize the gauge.""" + self.reset_gauge() + + def download_progress(self, e): + """Update the gauge.""" + self.gauge.SetValue(int(e.value)) + + def download_completed(self, *_): + """Populate the footprint list.""" + self.populate_footprint_list() + + def unzip_combining_started(self, *_): + """Initialize the gauge.""" + self.reset_gauge() + + def unzip_combining_progress(self, e): + """Update the gauge.""" + self.gauge.SetValue(int(e.value)) + + def unzip_extracting_started(self, *_): + """Initialize the gauge.""" + self.reset_gauge() + + def unzip_extracting_progress(self, e): + """Update the gauge.""" + self.gauge.SetValue(int(e.value)) + + def unzip_extracting_completed(self, *_): + """Update the gauge.""" + self.reset_gauge() + self.init_data() + + def assign_parts(self, e): + """Assign a selected LCSC number to parts.""" + for reference in e.references: + self.store.set_lcsc(reference, e.lcsc) + self.store.set_stock(reference, int(e.stock)) + board = self.pcbnew.GetBoard() + fp = board.FindFootprintByReference(reference) + set_lcsc_value(fp, e.lcsc) + params = params_for_part(self.library.get_part_details(e.lcsc)) + self.partlist_data_model.set_lcsc( + reference, e.lcsc, e.type, e.stock, params + ) + + def display_message(self, e): + """Dispaly a message with the data from the event.""" + styles = { + "info": wx.ICON_INFORMATION, + "warning": wx.ICON_WARNING, + "error": wx.ICON_ERROR, + } + wx.MessageBox(e.text, e.title, style=styles.get(e.style, wx.ICON_INFORMATION)) + + def get_correction(self, part: dict, corrections: list) -> str: + """Try to find correction data for a given part.""" + # First check if the part name matches + for regex, rotation, offset in corrections: + if re.search(regex, str(part["reference"])): + return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (ref)" + # Then try to match by value + for regex, rotation, offset in corrections: + if re.search(regex, str(part["value"])): + return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (val)" + # If there was no match for the part name or value, check if the package matches + for regex, rotation, offset in corrections: + if re.search(regex, str(part["footprint"])): + return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (fpt)" + return "0°, 0.0/0.0" + + def populate_footprint_list(self, *_): + """Populate list of footprints.""" + if not self.store: + self.init_store() + self.partlist_data_model.RemoveAll() + details = {} + corrections = self.library.get_all_correction_data() + for part in self.store.read_all(): + fp = self.pcbnew.GetBoard().FindFootprintByReference(part["reference"]) + is_dnp = get_is_dnp(fp) + # Get part stock and type from library, skip if part number was already looked up before + if part["lcsc"] and part["lcsc"] not in details: + details[part["lcsc"]] = self.library.get_part_details(part["lcsc"]) + # don't show the part if hide BOM is set + if self.hide_bom_parts and part["exclude_from_bom"]: + continue + # don't show the part if hide POS is set + if self.hide_pos_parts and part["exclude_from_pos"]: + continue + self.partlist_data_model.AddEntry( + [ + part["reference"], + part["value"], + part["footprint"], + part["lcsc"], + details.get(part["lcsc"], {}).get("type", ""), # type + details.get(part["lcsc"], {}).get("stock", ""), # stock + part["exclude_from_bom"], + part["exclude_from_pos"], + int(is_dnp), + str(self.get_correction(part, corrections)), + str(fp.GetLayer()), + params_for_part(details.get(part["lcsc"], {})), + ] + ) + + def OnBomHide(self, *_): + """Hide all parts from the list that have 'in BOM' set to No.""" + self.hide_bom_parts = not self.hide_bom_parts + if self.hide_bom_parts: + self.hide_bom_button.SetNormalBitmap( + loadBitmapScaled( + "", + self.scale_factor, + ) + ) + self.hide_bom_button.SetNormalBitmap( + loadBitmapScaled( + "mdi-eye-outline.png", + self.scale_factor, + ) + ) + self.hide_bom_button.SetLabel("Show excluded BOM") + else: + self.hide_bom_button.SetNormalBitmap( + loadBitmapScaled( + "", + self.scale_factor, + ) + ) + self.hide_bom_button.SetNormalBitmap( + loadBitmapScaled( + "mdi-eye-off-outline.png", + self.scale_factor, + ) + ) + self.hide_bom_button.SetLabel("Hide excluded BOM") + self.populate_footprint_list() + + def OnPosHide(self, *_): + """Hide all parts from the list that have 'in pos' set to No.""" + self.hide_pos_parts = not self.hide_pos_parts + if self.hide_pos_parts: + self.hide_pos_button.SetNormalBitmap( + loadBitmapScaled( + "", + self.scale_factor, + ) + ) + self.hide_pos_button.SetNormalBitmap( + loadBitmapScaled( + "mdi-eye-outline.png", + self.scale_factor, + ) + ) + self.hide_pos_button.SetLabel("Show excluded POS") + else: + self.hide_pos_button.SetNormalBitmap( + loadBitmapScaled( + "", + self.scale_factor, + ) + ) + self.hide_pos_button.SetNormalBitmap( + loadBitmapScaled( + "mdi-eye-off-outline.png", + self.scale_factor, + ) + ) + self.hide_pos_button.SetLabel("Hide excluded POS") + self.populate_footprint_list() + + def OnFootprintSelected(self, *_): + """Enable the toolbar buttons when a selection was made.""" + if self.select_alike_in_progress: + return + + self.enable_part_specific_toolbar_buttons( + self.footprint_list.GetSelectedItemsCount() > 0 + ) + + if self.auto_select_alike and self.footprint_list.GetSelectedItemsCount() == 1: + self.select_alike_parts() + + # clear the present selections + selection = self.pcbnew.GetCurrentSelection() + for selected in selection: + selected.ClearSelected() + + # select all of the selected items in the footprint_list + if self.footprint_list.GetSelectedItemsCount() > 0: + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + fp = self.pcbnew.GetBoard().FindFootprintByReference(ref) + fp.SetSelected() + # cause pcbnew to refresh the board with the changes to the selected footprint(s) + self.pcbnew.Refresh() + + def enable_part_specific_toolbar_buttons(self, state): + """Control the state of all the buttons that relate to parts in toolbar on the right side.""" + for button in ( + ID_SELECT_PART, + ID_REMOVE_LCSC_NUMBER, + ID_TOGGLE_BOM_POS, + ID_TOGGLE_BOM, + ID_TOGGLE_POS, + ID_PART_DETAILS, + ID_HIDE_BOM, + ID_HIDE_POS, + ): + self.right_toolbar.EnableTool(button, state) + + def toggle_bom_pos(self, *_): + """Toggle the exclude from BOM/POS attribute of a footprint.""" + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + board = self.pcbnew.GetBoard() + fp = board.FindFootprintByReference(ref) + bom = toggle_exclude_from_bom(fp) + pos = toggle_exclude_from_pos(fp) + self.store.set_bom(ref, int(bom)) + self.store.set_pos(ref, int(pos)) + self.partlist_data_model.toggle_bom_pos(item) + + def toggle_bom(self, *_): + """Toggle the exclude from BOM attribute of a footprint.""" + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + board = self.pcbnew.GetBoard() + fp = board.FindFootprintByReference(ref) + bom = toggle_exclude_from_bom(fp) + self.store.set_bom(ref, int(bom)) + self.partlist_data_model.toggle_bom(item) + + def toggle_pos(self, *_): + """Toggle the exclude from POS attribute of a footprint.""" + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + board = self.pcbnew.GetBoard() + fp = board.FindFootprintByReference(ref) + pos = toggle_exclude_from_pos(fp) + self.store.set_pos(ref, int(pos)) + self.partlist_data_model.toggle_pos(item) + + def remove_lcsc_number(self, *_): + """Remove an assigned a LCSC Part number to a footprint.""" + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + self.store.set_lcsc(ref, "") + self.store.set_stock(ref, None) + board = self.pcbnew.GetBoard() + fp = board.FindFootprintByReference(ref) + set_lcsc_value(fp, "") + self.partlist_data_model.remove_lcsc_number(item) + + def select_alike_parts(self, *_): + """Select all alike parts, starting from a single selected part.""" + if self.footprint_list.GetSelectedItemsCount() > 1: + self.logger.warning("Select only one component, please.") + return + selected_item = self.footprint_list.GetSelection() + self.select_alike_in_progress = True + try: + for alike_item in self.partlist_data_model.select_alike(selected_item): + if not self.footprint_list.IsSelected(alike_item): + self.footprint_list.Select(alike_item) + finally: + self.select_alike_in_progress = False + + def toggle_select_alike(self, e): + """Toggle auto-selecting alike parts on selection.""" + self.auto_select_alike = bool(e.IsChecked()) + self.settings.setdefault("general", {})["select_alike_auto"] = ( + self.auto_select_alike + ) + self.save_settings() + if self.auto_select_alike and self.footprint_list.GetSelectedItemsCount() == 1: + self.select_alike_parts() + + def get_part_details(self, *_): + """Fetch part details from LCSC and show them one after another each in a modal.""" + for item in self.footprint_list.GetSelections(): + if lcsc := self.partlist_data_model.get_lcsc(item): + self.show_part_details_dialog(lcsc) + + def show_part_details_dialog(self, part): + """Show the part details modal dialog.""" + wx.BeginBusyCursor() + try: + dialog = PartDetailsDialog(self, part) + dialog.ShowModal() + finally: + wx.EndBusyCursor() + + def update_library(self, *_): + """Update the library from the JLCPCB CSV file.""" + self.library.update() + + def manage_corrections(self, *_): + """Manage corrections.""" + CorrectionManagerDialog(self, "").ShowModal() + + def manage_mappings(self, *_): + """Manage footprint mappings.""" + PartMapperManagerDialog(self).ShowModal() + + def manage_settings(self, *_): + """Manage settings.""" + SettingsDialog(self).ShowModal() + + def update_settings(self, e): + """Update the settings on change.""" + if e.section not in self.settings: + self.settings[e.section] = {} + self.settings[e.section][e.setting] = e.value + self.save_settings() + + # Refresh library configuration if relevant library settings changed + if e.section == "library" and e.setting in ["selected_library", "data_path"]: + self.library.refresh_library_config() + + def logbox_append(self, e): + """Write text to the logbox.""" + self.logbox.WriteText(e.msg) + + def load_settings(self): + """Load settings from settings.json.""" + with open(os.path.join(PLUGIN_PATH, "settings.json"), encoding="utf-8") as j: + self.settings = json.load(j) + + gerber_settings = self.settings.setdefault("gerber", {}) + if gerber_settings.get("force_drc", False) and not gerber_settings.get( + "fill_zones", True + ): + gerber_settings["fill_zones"] = True + self.save_settings() + + def save_settings(self): + """Save settings to settings.json.""" + with open( + os.path.join(PLUGIN_PATH, "settings.json"), "w", encoding="utf-8" + ) as j: + json.dump(self.settings, j) + + def select_part(self, *_): + """Select a part from the library and assign it to the selected footprint(s).""" + selection = {} + for item in self.footprint_list.GetSelections(): + ref = self.partlist_data_model.get_reference(item) + value = self.partlist_data_model.get_value(item) + footprint = self.partlist_data_model.get_footprint(item) + if ref.startswith("R"): + """ Auto remove alphabet unit if applicable """ + if value.endswith("R") or value.endswith("r") or value.endswith("o"): + value = value[:-1] + value += "Ω" + m = re.search(r"_(\d+)_\d+Metric", footprint) + if m: + value += f" {m.group(1)}" + selection[ref] = value + PartSelectorDialog(self, selection).ShowModal() + + def count_order_number_placeholders(self): + """Count the JLC order/serial number placeholders.""" + count = 0 + for drawing in self.pcbnew.GetBoard().GetDrawings(): + if drawing.IsOnLayer(kicad_pcbnew.F_SilkS) or drawing.IsOnLayer( + kicad_pcbnew.B_SilkS + ): + if isinstance(drawing, kicad_pcbnew.PCB_TEXT): + if drawing.GetText().strip() == "JLCJLCJLCJLC": + self.logger.info( + "Found placeholder for order number at %.1f/%.1f.", + kicad_pcbnew.ToMM(drawing.GetCenter().x), + kicad_pcbnew.ToMM(drawing.GetCenter().y), + ) + count += 1 + + if ( + isinstance(drawing, kicad_pcbnew.PCB_SHAPE) + and drawing.GetShape() == kicad_pcbnew.S_RECT + and ((hasattr(drawing, "IsFilled") and drawing.IsFilled()) + or (hasattr(drawing, "IsSolidFill") and drawing.IsSolidFill())) + ): + corners = drawing.GetRectCorners() + + top_left_x = min([p.x for p in corners], default=0) + top_left_y = min([p.y for p in corners], default=0) + bottom_right_x = max([p.x for p in corners], default=0) + bottom_right_y = max([p.y for p in corners], default=0) + width = kicad_pcbnew.ToMM(bottom_right_x - top_left_x) + height = kicad_pcbnew.ToMM(bottom_right_y - top_left_y) + + if ( + (width == 5 and height == 5) + or (width == 8 and height == 8) + or (width == 10 and height == 10) + ): + self.logger.info( + "Found placeholder for 2D barcode (%dmm x %dmm) at %.1f/%.1f.", + width, + height, + kicad_pcbnew.ToMM(drawing.GetCenter().x), + kicad_pcbnew.ToMM(drawing.GetCenter().y), + ) + count += 1 + + if (width == 10 and height == 2) or (width == 2 and height == 10): + self.logger.info( + "Found placeholder for serial number at %.1f/%.1f.", + kicad_pcbnew.ToMM(drawing.GetCenter().x), + kicad_pcbnew.ToMM(drawing.GetCenter().y), + ) + count += 1 + + return count + + def generate_fabrication_data(self, *_): + """Generate fabrication data.""" + warnings = self.fabrication.get_part_consistency_warnings() + if warnings: + result = wx.MessageBox( + "There are items with identical LCSC number but different values in the list:\n" + + warnings + + "Continue?", + "Plausibility check", + wx.OK | wx.CANCEL | wx.CENTER, + ) + if result == wx.CANCEL: + return + + if self.settings.get("general", {}).get("order_number"): + count = self.count_order_number_placeholders() + if count == 0: + result = wx.MessageBox( + "JLC order/serial number placeholder not present! Continue?", + "JLC order/serial number placeholder", + wx.OK | wx.CANCEL | wx.CENTER, + ) + if result == wx.CANCEL: + return + elif count > 1: + result = wx.MessageBox( + "Multiple order/serial number placeholders present! Continue?", + "JLC order/serial number placeholder", + wx.OK | wx.CANCEL | wx.CENTER, + ) + if result == wx.CANCEL: + return + + self.fabrication.fill_zones() + + if not self.run_drc_before_gerber_export(): + return + + layer_selection = self.layer_selection.GetSelection() + number = re.search(r"\d+", self.layer_selection.GetString(layer_selection)) + if number: + layer_count = int(number.group(0)) + else: + layer_count = None + self.fabrication.generate_geber(layer_count) + self.fabrication.generate_excellon() + self.fabrication.zip_gerber_excellon() + self.fabrication.generate_cpl() + self.fabrication.generate_bom() + + def save_board_for_cli(self): + """Save the current board so CLI-based checks operate on latest board state.""" + board = self.pcbnew.GetBoard() + board_filename = board.GetFileName() + if not board_filename: + raise RuntimeError("Board must be saved before running DRC checks") + + if hasattr(board, "Save"): + try: + board.Save(board_filename) + except TypeError: + board.Save() + return + + if hasattr(self.pcbnew, "SaveBoard"): + self.pcbnew.SaveBoard(board_filename, board) + return + + raise RuntimeError("Unable to save board using current KiCad API") + + def run_drc_before_gerber_export(self): + """Run optional DRC via kicad-cli and prompt when violations exist.""" + if not self.settings.get("gerber", {}).get("force_drc", False): + return True + + board_filename = self.pcbnew.GetBoard().GetFileName() + if not board_filename: + wx.MessageBox( + "Board must be saved before DRC can be run.", + "DRC check", + style=wx.ICON_ERROR, + ) + return False + + try: + self.save_board_for_cli() + except Exception as exc: + wx.MessageBox( + f"Failed to save board before DRC: {exc}", + "DRC check", + style=wx.ICON_ERROR, + ) + return False + + try: + drc_counter = DRCViolationCounter( + pcbnew_module=self.pcbnew, + working_dir=self.project_path, + ) + violation_count = drc_counter.get_violation_count(board_filename) + + if violation_count > 0: + dialog = wx.MessageDialog( + self, + f"DRC found {violation_count} error violation(s).\n\n" + "Resolve or exclude DRC errors before manufacturing whenever possible.", + "DRC violations found", + wx.YES_NO + | wx.NO_DEFAULT + | wx.ICON_WARNING + | wx.CENTER, + ) + dialog.SetYesNoLabels("Continue Anyway", "Cancel Export") + result = dialog.ShowModal() + dialog.Destroy() + if result != wx.ID_YES: + return False + + return True + except Exception as exc: + self.logger.exception("Unexpected error while running forced DRC") + wx.MessageBox( + f"Unexpected error while running DRC: {exc}", + "DRC check", + style=wx.ICON_ERROR, + ) + return False + + def copy_part_lcsc(self, *_): + """Fetch part details from LCSC and show them in a modal.""" + for item in self.footprint_list.GetSelections(): + if lcsc := self.partlist_data_model.get_lcsc(item): + if wx.TheClipboard.Open(): + wx.TheClipboard.SetData(wx.TextDataObject(lcsc)) + wx.TheClipboard.Close() + + def paste_part_lcsc(self, *_): + """Paste a lcsc number from the clipboard to the current part.""" + text_data = wx.TextDataObject() + if wx.TheClipboard.Open(): + success = wx.TheClipboard.GetData(text_data) + wx.TheClipboard.Close() + if success: + if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "": + for item in self.footprint_list.GetSelections(): + details = self.library.get_part_details(lcsc) + params = params_for_part(details) + reference = self.partlist_data_model.get_reference(item) + self.partlist_data_model.set_lcsc( + reference, lcsc, details["type"], details["stock"], params + ) + self.store.set_lcsc(reference, lcsc) + + def add_correction(self, e): + """Add part correction for the current part.""" + for item in self.footprint_list.GetSelections(): + if e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE: + if reference := self.partlist_data_model.get_reference(item): + CorrectionManagerDialog( + self, "^" + re.escape(reference) + "$" + ).ShowModal() + elif e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE: + if footprint := self.partlist_data_model.get_footprint(item): + CorrectionManagerDialog( + self, "^" + re.escape(footprint) + ).ShowModal() + elif e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_NAME: + if value := self.partlist_data_model.get_value(item): + CorrectionManagerDialog(self, re.escape(value)).ShowModal() + + def save_all_mappings(self, *_): + """Save all mappings.""" + for item in self.partlist_data_model.get_all(): + value = item[1] + footprint = item[2] + lcsc = item[3] + if footprint != "" and value != "" and lcsc != "": + if self.library.get_mapping_data(footprint, value): + self.library.update_mapping_data(footprint, value, lcsc) + else: + self.library.insert_mapping_data(footprint, value, lcsc) + self.logger.info("All mappings saved") + + def export_to_schematic(self, *_): + """Dialog to select schematics.""" + with wx.FileDialog( + self, + "Select Schematics", + self.project_path, + self.schematic_name, + "KiCad V6 Schematics (*.kicad_sch)|*.kicad_sch", + wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE, + ) as openFileDialog: + if openFileDialog.ShowModal() == wx.CANCEL: + return + paths = openFileDialog.GetPaths() + SchematicExport(self).load_schematic(paths) + + def add_foot_mapping(self, *_): + """Add a footprint mapping.""" + for item in self.footprint_list.GetSelections(): + footprint = self.partlist_data_model.get_footprint(item) + value = self.partlist_data_model.get_value(item) + lcsc = self.partlist_data_model.get_lcsc(item) + if footprint != "" and value != "" and lcsc != "": + if self.library.get_mapping_data(footprint, value): + self.library.update_mapping_data(footprint, value, lcsc) + else: + self.library.insert_mapping_data(footprint, value, lcsc) + + def search_foot_mapping(self, *_): + """Search for a footprint mapping.""" + for item in self.footprint_list.GetSelections(): + reference = self.partlist_data_model.get_reference(item) + footprint = self.partlist_data_model.get_footprint(item) + value = self.partlist_data_model.get_value(item) + if footprint != "" and value != "": + if self.library.get_mapping_data(footprint, value): + lcsc = self.library.get_mapping_data(footprint, value)[2] + self.store.set_lcsc(reference, lcsc) + self.logger.info("Found %s", lcsc) + details = self.library.get_part_details(lcsc) + params = params_for_part(self.library.get_part_details(lcsc)) + self.partlist_data_model.set_lcsc( + reference, lcsc, details["type"], details["stock"], params + ) + + def sanitize_lcsc(self, lcsc_PN): + """Sanitize a given LCSC number using a regex.""" + m = re.search("C\\d+", lcsc_PN, re.IGNORECASE) + if m: + return m.group(0) + return "" + + def OnRightDown(self, *_): + """Right click context menu for action on parts table.""" + right_click_menu = wx.Menu() + + copy_lcsc = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_COPY_LCSC, "Copy LCSC" + ) + right_click_menu.Append(copy_lcsc) + right_click_menu.Bind(wx.EVT_MENU, self.copy_part_lcsc, copy_lcsc) + + paste_lcsc = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_PASTE_LCSC, "Paste LCSC" + ) + right_click_menu.Append(paste_lcsc) + right_click_menu.Bind(wx.EVT_MENU, self.paste_part_lcsc, paste_lcsc) + + correction_by_reference = wx.MenuItem( + right_click_menu, + ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE, + "Add Correction by reference", + ) + right_click_menu.Append(correction_by_reference) + right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_reference) + + correction_by_package = wx.MenuItem( + right_click_menu, + ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE, + "Add Correction by package", + ) + right_click_menu.Append(correction_by_package) + right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_package) + + correction_by_name = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_ADD_ROT_BY_NAME, "Add Correction by name" + ) + right_click_menu.Append(correction_by_name) + right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_name) + + find_mapping = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_FIND_MAPPING, "Find LCSC from Mappings" + ) + right_click_menu.Append(find_mapping) + right_click_menu.Bind(wx.EVT_MENU, self.search_foot_mapping, find_mapping) + + add_mapping = wx.MenuItem( + right_click_menu, ID_CONTEXT_MENU_ADD_MAPPING, "Add Footprint Mapping" + ) + right_click_menu.Append(add_mapping) + right_click_menu.Bind(wx.EVT_MENU, self.add_foot_mapping, add_mapping) + + self.footprint_list.PopupMenu(right_click_menu) + right_click_menu.Destroy() # destroy to avoid memory leak + + def init_logger(self): + """Initialize logger to log into textbox.""" + root = logging.getLogger() + # Clear any existing handlers that might be problematic + root.handlers.clear() + root.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(funcName)s - %(message)s", + datefmt="%Y.%m.%d %H:%M:%S", + ) + # Only add stderr handler if stderr is available + if sys.stderr is not None: + self.logging_handler1 = logging.StreamHandler(sys.stderr) + self.logging_handler1.setLevel(logging.DEBUG) + self.logging_handler1.setFormatter(formatter) + root.addHandler(self.logging_handler1) + + self.logging_handler2 = LogBoxHandler(self) + self.logging_handler2.setLevel(logging.DEBUG) + self.logging_handler2.setFormatter(formatter) + root.addHandler(self.logging_handler2) + + self.logger = logging.getLogger(__name__) + + def __del__(self): + """Cleanup.""" + pass + + +class LogBoxHandler(logging.StreamHandler): + """Logging class for the logging textbox at th ebottom of the mainwindow.""" + + def __init__(self, event_destination): + logging.StreamHandler.__init__(self) + self.event_destination = event_destination + + def emit(self, record): + """Marshal the event over to the main thread.""" + msg = self.format(record) + wx.QueueEvent(self.event_destination, LogboxAppendEvent(msg=f"{msg}\n")) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py b/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py new file mode 100644 index 00000000..9dbe37a3 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py @@ -0,0 +1,315 @@ +"""Contains the part details modal dialog.""" + +import logging +from pathlib import Path +import webbrowser + +import wx # pylint: disable=import-error +import wx.dataview # pylint: disable=import-error + +from .events import MessageEvent +from .helpers import HighResWxSize, loadBitmapScaled +from .lcsc_api import LCSC_API + + +class PartDetailsDialog(wx.Dialog): + """The part details dialog class.""" + + def __init__(self, parent, part): + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title="JLCPCB Part Details", + pos=wx.DefaultPosition, + size=HighResWxSize(parent.window, wx.Size(1000, 800)), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP, + ) + + self.logger = logging.getLogger(__name__) + self.parent = parent + self.part = part + self.datasheet_path = Path(self.parent.project_path) / "datasheets" + self.lcsc_api = LCSC_API() + self.pdfurl = "" + self.pageurl = "" + self.picture = None + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + + # --------------------------------------------------------------------- + # ----------------------- Properties List ----------------------------- + # --------------------------------------------------------------------- + self.data_list = wx.dataview.DataViewListCtrl( + self, + wx.ID_ANY, + wx.DefaultPosition, + wx.DefaultSize, + style=wx.dataview.DV_SINGLE, + ) + self.property = self.data_list.AppendTextColumn( + "Property", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(self.parent.scale_factor * 200), + align=wx.ALIGN_LEFT, + ) + self.value = self.data_list.AppendTextColumn( + "Value", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(self.parent.scale_factor * 300), + align=wx.ALIGN_LEFT, + ) + + # --------------------------------------------------------------------- + # ------------------------- Right side ------------------------------ + # --------------------------------------------------------------------- + self.image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("placeholder.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 200)), + 0, + ) + self.savepdf_button = wx.Button( + self, + wx.ID_ANY, + "Download Datasheet", + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.openpdf_button = wx.Button( + self, + wx.ID_ANY, + "Open Datasheet", + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.openpage_button = wx.Button( + self, + wx.ID_ANY, + "Open LCSC page", + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.savepdf_button.Bind(wx.EVT_BUTTON, self.savepdf) + self.openpdf_button.Bind(wx.EVT_BUTTON, self.openpdf) + self.openpage_button.Bind(wx.EVT_BUTTON, self.openpage) + + self.savepdf_button.SetBitmap( + loadBitmapScaled( + "mdi-cloud-download-outline.png", + self.parent.scale_factor, + ) + ) + self.savepdf_button.SetBitmapMargins((2, 0)) + + self.openpdf_button.SetBitmap( + loadBitmapScaled( + "mdi-file-document-outline.png", + self.parent.scale_factor, + ) + ) + self.openpdf_button.SetBitmapMargins((2, 0)) + + self.openpage_button.SetBitmap( + loadBitmapScaled( + "mdi-earth.png", + self.parent.scale_factor, + ) + ) + self.openpage_button.SetBitmapMargins((2, 0)) + + # --------------------------------------------------------------------- + # ------------------------ Layout and Sizers -------------------------- + # --------------------------------------------------------------------- + + right_side_layout = wx.BoxSizer(wx.VERTICAL) + right_side_layout.Add(self.image, 10, wx.ALL | wx.EXPAND, 5) + right_side_layout.AddStretchSpacer(50) + right_side_layout.Add(self.savepdf_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5) + right_side_layout.Add(self.openpdf_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5) + right_side_layout.Add( + self.openpage_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5 + ) + layout = wx.BoxSizer(wx.HORIZONTAL) + layout.Add(self.data_list, 30, wx.ALL | wx.EXPAND, 5) + layout.Add(right_side_layout, 10, wx.ALL | wx.EXPAND, 5) + + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + + self.get_part_data() + + def quit_dialog(self, *_): + """Close the dialog.""" + self.Destroy() + self.EndModal(0) + + def savepdf(self, *_): + """Download a datasheet from The LCSC API.""" + if self.pdfurl is not None: + filename = self.pdfurl.rsplit("/", maxsplit=1)[1] + self.logger.info("Save datasheet %s to %s", filename, self.datasheet_path) + self.datasheet_path.mkdir(parents=True, exist_ok=True) + result = self.lcsc_api.download_datasheet( + self.pdfurl, self.datasheet_path / filename + ) + title = "Success" if result["success"] else "Error" + style = "info" if result["success"] else "error" + resultMsg = result["msg"] + else: + title = "Error" + style = "error" + resultMsg = "Undefined URL for datasheet download" + wx.PostEvent( + self.parent, + MessageEvent( + title=title, + text=resultMsg, + style=style, + ), + ) + + def openpdf(self, *_): + """Open the linked datasheet PDF on button click.""" + self.logger.info("opening %s", str(self.pdfurl)) + webbrowser.open(str(self.pdfurl)) + + def openpage(self, *_): + """Open the linked LCSC page for the part on button click.""" + self.logger.info("opening LCSC page for %s", str(self.part)) + webbrowser.open(str(self.pageurl)) + + def get_scaled_bitmap(self, url, width, height): + """Download a picture from a URL and convert it into a wx Bitmap.""" + io_bytes = self.lcsc_api.download_bitmap(url) + image = wx.Image(io_bytes) + image = image.Scale(width, height, wx.IMAGE_QUALITY_HIGH) + result = wx.Bitmap(image) + return result + + def get_part_data(self): + """Get part data from JLCPCB API and parse it into the table, set picture and PDF link.""" + result = self.lcsc_api.get_part_data(self.part) + if not result["success"]: + self.report_part_data_fetch_error(result["msg"]) + return + + parameters = { + "componentCode": "Component Code", + "firstTypeNameEn": "Primary Category", + "secondTypeNameEn": "Secondary Category", + "componentBrandEn": "Brand", + "componentName": "Full Name", + "componentDesignator": "Designator", + "componentModelEn": "Model", + "componentSpecificationEn": "Specification", + "assemblyProcess": "Assembly Process", + "describe": "Description", + "matchedPartDetail": "Details", + "stockCount": "Stock", + "leastNumber": "Minimal Quantity", + "leastNumberPrice": "Minimum price", + } + parttype = result["data"].get("data", {}).get("componentLibraryType") + if parttype and parttype == "base": + self.data_list.AppendItem(["Type", "Basic"]) + elif parttype and parttype == "expand": + self.data_list.AppendItem(["Type", "Extended"]) + for k, v in parameters.items(): + val = result["data"].get("data", {}).get(k) + if val: + self.data_list.AppendItem([v, str(val)]) + prices = result["data"].get("data", {}).get("jlcPrices", []) + if prices: + for price in prices: + start = price.get("startNumber") + end = price.get("endNumber") + if end == -1: + self.data_list.AppendItem( + [ + f"JLC Price for >{start}", + str(price.get("productPrice")), + ] + ) + else: + self.data_list.AppendItem( + [ + f"JLC Price for {start}-{end}", + str(price.get("productPrice")), + ] + ) + prices = result["data"].get("data", {}).get("prices", []) + if prices: + for price in prices: + start = price.get("startNumber") + end = price.get("endNumber") + if end == -1: + self.data_list.AppendItem( + [ + f"LCSC Price for >{start}", + str(price.get("productPrice")), + ] + ) + else: + self.data_list.AppendItem( + [ + f"LCSC Price for {start}-{end}", + str(price.get("productPrice")), + ] + ) + for attribute in result["data"].get("data", {}).get("attributes", []): + self.data_list.AppendItem( + [ + attribute.get("attribute_name_en"), + str(attribute.get("attribute_value_name")), + ] + ) + picture = result["data"].get("data", {}).get("minImage") + if picture: + # get the full resolution image instead of the thumbnail + picture = picture.replace("96x96", "900x900") + else: + imageId = result["data"].get("data", {}).get("productBigImageAccessId") + picture = ( + f"https://jlcpcb.com/api/file/downloadByFileSystemAccessId/{imageId}" + ) + self.image.SetBitmap( + self.get_scaled_bitmap( + picture, + int(200 * self.parent.scale_factor), + int(200 * self.parent.scale_factor), + ) + ) + + self.pdfurl = result["data"].get("data", {}).get("dataManualUrl") + self.pageurl = result["data"].get("data", {}).get("lcscGoodsUrl") + + def report_part_data_fetch_error(self, reason): + """Spawn a message box with an erro message if the fetch fails.""" + wx.MessageBox( + f"Failed to download part detail from the JLCPCB API ({reason})\r\n" + f"We looked for a part named:\r\n{self.part}\r\n[hint: did you fill in the LCSC field correctly?]", + "Error", + style=wx.ICON_ERROR, + ) + self.EndModal(-1) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py b/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py new file mode 100644 index 00000000..377ae282 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py @@ -0,0 +1,258 @@ +"""Contains the part mapper.""" + +import csv +import logging +import os + +import wx # pylint: disable=import-error +import wx.dataview # pylint: disable=import-error + +from .helpers import HighResWxSize, loadBitmapScaled + + +class PartMapperManagerDialog(wx.Dialog): + """Dialog for managing part mappings.""" + + def __init__(self, parent): + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title="Footprint Mapper", + pos=wx.DefaultPosition, + size=HighResWxSize(parent.window, wx.Size(800, 800)), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + + self.logger = logging.getLogger(__name__) + self.parent = parent + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + + self.parent.library.create_mapping_table() + + # --------------------------------------------------------------------- + # ------------------------- Mapping list ---------------------------- + # --------------------------------------------------------------------- + + self.mapping_list = wx.dataview.DataViewListCtrl( + self, + wx.ID_ANY, + wx.DefaultPosition, + wx.DefaultSize, + style=wx.dataview.DV_MULTIPLE, + ) + + self.mapping_list.AppendTextColumn( + "Footprint", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 150), + align=wx.ALIGN_LEFT, + ) + self.mapping_list.AppendTextColumn( + "Value", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 100), + align=wx.ALIGN_LEFT, + ) + self.mapping_list.AppendTextColumn( + "LCSC Part", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 100), + align=wx.ALIGN_LEFT, + ) + + self.mapping_list.SetMinSize(HighResWxSize(parent.window, wx.Size(600, 500))) + + self.mapping_list.Bind( + wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.on_mapping_selected + ) + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) + table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400))) + table_sizer.Add(self.mapping_list, 20, wx.ALL | wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------ Right side toolbar ------------------------- + # --------------------------------------------------------------------- + + self.delete_button = wx.Button( + self, + wx.ID_ANY, + "Delete", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.import_button = wx.Button( + self, + wx.ID_ANY, + "Import", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.export_button = wx.Button( + self, + wx.ID_ANY, + "Export", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + + self.delete_button.Bind(wx.EVT_BUTTON, self.delete_mapping) + self.import_button.Bind(wx.EVT_BUTTON, self.import_mappings_dialog) + self.export_button.Bind(wx.EVT_BUTTON, self.export_mappings_dialog) + + self.delete_button.SetBitmap( + loadBitmapScaled( + "mdi-trash-can-outline.png", + self.parent.scale_factor, + ) + ) + self.delete_button.SetBitmapMargins((2, 0)) + + self.import_button.SetBitmap( + loadBitmapScaled( + "mdi-database-import-outline.png", + self.parent.scale_factor, + ) + ) + self.import_button.SetBitmapMargins((2, 0)) + + self.export_button.SetBitmap( + loadBitmapScaled( + "mdi-database-export-outline.png", + self.parent.scale_factor, + ) + ) + self.export_button.SetBitmapMargins((2, 0)) + + tool_sizer = wx.BoxSizer(wx.VERTICAL) + tool_sizer.Add(self.delete_button, 0, wx.ALL, 5) + tool_sizer.Add(self.import_button, 0, wx.ALL, 5) + tool_sizer.Add(self.export_button, 0, wx.ALL, 5) + table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------------ Sizers ------------------------------ + # --------------------------------------------------------------------- + + layout = wx.BoxSizer(wx.VERTICAL) + layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5) + + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + self.enable_toolbar_buttons(False) + self.populate_mapping_list() + + def quit_dialog(self, *_): + """Close this dialog.""" + self.Destroy() + self.EndModal(0) + + def enable_toolbar_buttons(self, state): + """Control the state of all the buttons in toolbar on the right side.""" + for b in [ + self.delete_button, + ]: + b.Enable(bool(state)) + + def populate_mapping_list(self): + """Populate the list with the result of the search.""" + self.mapping_list.DeleteAllItems() + + if self.parent.library.get_all_mapping_data() is None: + self.logger.info("empty") + return + + for mapping in self.parent.library.get_all_mapping_data(): + self.mapping_list.AppendItem([str(m) for m in mapping]) + + def delete_mapping(self, *_): + """Delete a mapping from the database.""" + for item in self.mapping_list.GetSelections(): + row = self.mapping_list.ItemToRow(item) + if row == -1: + return + footprint = self.mapping_list.GetTextValue(row, 0) + value = self.mapping_list.GetTextValue(row, 1) + self.parent.library.delete_mapping_data(footprint, value) + self.populate_mapping_list() + + def on_mapping_selected(self, *_): + """Enable the toolbar buttons when a selection was made.""" + if self.mapping_list.GetSelectedItemsCount() > 0: + self.enable_toolbar_buttons(True) + else: + self.enable_toolbar_buttons(False) + + def import_mappings_dialog(self, *_): + """Dialog to import mappings from a CSV file.""" + with wx.FileDialog( + self, + "Import Mapping CSV", + "", + "", + "CSV files (*.csv)|*.csv", + wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as importFileDialog: + if importFileDialog.ShowModal() == wx.ID_CANCEL: + return + path = importFileDialog.GetPath() + self._import_mappings(path) + + def export_mappings_dialog(self, *_): + """Dialog to export mappings to a CSV file.""" + with wx.FileDialog( + self, + "Export Mapping CSV", + "", + "mapping", + "CSV files (*.csv)|*.csv", + wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as exportFileDialog: + if exportFileDialog.ShowModal() == wx.ID_CANCEL: + return + path = exportFileDialog.GetPath() + self._export_mappings(path) + + def _import_mappings(self, path): + """Import logic for Mappings.""" + if os.path.isfile(path): + with open(path, encoding="utf-8") as f: + csvreader = csv.DictReader(f, fieldnames=("footprint", "value", "lcsc")) + next(csvreader) + for row in csvreader: + if self.parent.library.get_mapping_data( + row["footprint"], row["value"] + ): + self.parent.library.update_mapping_data( + row["footprint"], row["value"], row["lcsc"] + ) + else: + self.parent.library.insert_mapping_data( + row["footprint"], row["value"], row["lcsc"] + ) + self.populate_mapping_list() + + def _export_mappings(self, path): + """Export logic for mappings.""" + with open(path, "w", newline="", encoding="utf-8") as f: + csvwriter = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL) + csvwriter.writerow(["Footprint", "Part Value", "LCSC Part"]) + for m in self.parent.library.get_all_mapping_data(): + csvwriter.writerow([m[0], m[1], m[2]]) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector.py new file mode 100644 index 00000000..d0000436 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector.py @@ -0,0 +1,765 @@ +"""Contains the part selector modal window.""" + +import logging +import time + +import wx # pylint: disable=import-error +import wx.dataview as dv # pylint: disable=import-error + +from .datamodel import PartSelectorDataModel +from .derive_params import params_for_part # pylint: disable=import-error +from .events import AssignPartsEvent, UpdateSetting +from .helpers import HighResWxSize, loadBitmapScaled +from .partdetails import PartDetailsDialog +from .partselector_columns import DB_FIELDS, PARAMS_COLUMN_KEY, PARTSELECTOR_COLUMNS +from .partselector_highlight import HighlightedTextRenderer + +HIGHLIGHTED_COLUMN_KEYS = { + "lcsc", + "mfr_number", + "package", + "params", + "mfr", + "description", +} + + +def _format_duration(seconds: float) -> str: + """Format a duration as seconds or milliseconds for UI labels.""" + return f"{seconds:.2f}s" if seconds > 1 else f"{seconds * 1000.0:.0f}ms" + + +class PartSelectorDialog(wx.Dialog): + """The part selector window.""" + + def __init__(self, parent, parts): + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title="JLCPCB Library", + pos=wx.DefaultPosition, + size=HighResWxSize(parent.window, wx.Size(1400, 800)), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + + self.logger = logging.getLogger(__name__) + self.parent = parent + self.parts = parts + lcsc_selection = self.get_existing_selection(parts) + + self.search_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.search) + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + + # --------------------------------------------------------------------- + # --------------------------- Search bar ------------------------------ + # --------------------------------------------------------------------- + + keyword_label = wx.StaticText( + self, + wx.ID_ANY, + "Keywords", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + style=wx.ALIGN_RIGHT, + ) + self.keyword = wx.TextCtrl( + self, + wx.ID_ANY, + lcsc_selection, + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(800, 24)), + wx.TE_PROCESS_ENTER, + ) + self.keyword.SetHint("e.g. 10k 0603") + + self.ohm_button = wx.Button( + self, + wx.ID_ANY, + "Ω", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(20, -1)), + 0, + ) + self.ohm_button.SetToolTip("Append the Ω symbol to the search string") + + self.micro_button = wx.Button( + self, + wx.ID_ANY, + "µ", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(20, -1)), + 0, + ) + self.micro_button.SetToolTip("Append the µ symbol to the search string") + + manufacturer_label = wx.StaticText( + self, + wx.ID_ANY, + "Manufacturer", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.manufacturer = wx.TextCtrl( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + wx.TE_PROCESS_ENTER, + ) + self.manufacturer.SetHint("e.g. Vishay") + + package_label = wx.StaticText( + self, + wx.ID_ANY, + "Package", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.package = wx.TextCtrl( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + wx.TE_PROCESS_ENTER, + ) + self.package.SetHint("e.g. 0603") + + category_label = wx.StaticText( + self, + wx.ID_ANY, + "Category", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.category = wx.ComboBox( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + choices=parent.library.categories, + style=wx.CB_READONLY, + ) + self.category.SetHint("e.g. Resistors") + + part_no_label = wx.StaticText( + self, + wx.ID_ANY, + "Part number", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.part_no = wx.TextCtrl( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + wx.TE_PROCESS_ENTER, + ) + self.part_no.SetHint("e.g. DS2411") + + solder_joints_label = wx.StaticText( + self, + wx.ID_ANY, + "Solder joints", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.solder_joints = wx.TextCtrl( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + wx.TE_PROCESS_ENTER, + ) + self.solder_joints.SetHint("e.g. 2") + + subcategory_label = wx.StaticText( + self, + wx.ID_ANY, + "Subcategory", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.subcategory = wx.ComboBox( + self, + wx.ID_ANY, + "", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + style=wx.CB_READONLY, + ) + self.subcategory.SetHint("e.g. Variable Resistors") + + basic_label = wx.StaticText( + self, + wx.ID_ANY, + "Include basic parts", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.basic_checkbox = wx.CheckBox( + self, + wx.ID_ANY, + "Basic", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + 0, + name="basic", + ) + preferred_label = wx.StaticText( + self, + wx.ID_ANY, + "Include preferred parts", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.preferred_checkbox = wx.CheckBox( + self, + wx.ID_ANY, + "Preferred", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + 0, + name="preferred", + ) + extended_label = wx.StaticText( + self, + wx.ID_ANY, + "Include extended parts", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.extended_checkbox = wx.CheckBox( + self, + wx.ID_ANY, + "Extended", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + 0, + name="extended", + ) + stock_label = wx.StaticText( + self, + wx.ID_ANY, + "Only show parts in stock", + size=HighResWxSize(parent.window, wx.Size(150, 15)), + ) + self.assert_stock_checkbox = wx.CheckBox( + self, + wx.ID_ANY, + "in Stock", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(200, 24)), + 0, + name="stock", + ) + + self.basic_checkbox.SetValue( + self.parent.settings.get("partselector", {}).get("basic", True) + ) + self.extended_checkbox.SetValue( + self.parent.settings.get("partselector", {}).get("extended", True) + ) + self.preferred_checkbox.SetValue( + self.parent.settings.get("partselector", {}).get("preferred", True) + ) + self.assert_stock_checkbox.SetValue( + self.parent.settings.get("partselector", {}).get("stock", False) + ) + + self.basic_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings) + self.extended_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings) + self.preferred_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings) + self.assert_stock_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings) + + help_button = wx.Button( + self, + wx.ID_ANY, + "Help", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(100, -1)), + 0, + ) + + keyword_search_row1 = wx.BoxSizer(wx.HORIZONTAL) + keyword_search_row1.Add(keyword_label, 0, wx.ALL, 5) + keyword_search_row1.Add( + self.keyword, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + keyword_search_row1.Add( + self.ohm_button, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + keyword_search_row1.Add( + self.micro_button, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + search_sizer_one = wx.BoxSizer(wx.VERTICAL) + search_sizer_one.Add(manufacturer_label, 0, wx.ALL, 5) + search_sizer_one.Add( + self.manufacturer, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_one.Add(package_label, 0, wx.ALL, 5) + search_sizer_one.Add( + self.package, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + search_sizer_two = wx.BoxSizer(wx.VERTICAL) + search_sizer_two.Add(category_label, 0, wx.ALL, 5) + search_sizer_two.Add( + self.category, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_two.Add(part_no_label, 0, wx.ALL, 5) + search_sizer_two.Add( + self.part_no, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_two.Add(solder_joints_label, 0, wx.ALL, 5) + search_sizer_two.Add( + self.solder_joints, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + search_sizer_three = wx.BoxSizer(wx.VERTICAL) + search_sizer_three.Add(subcategory_label, 0, wx.ALL, 5) + search_sizer_three.Add( + self.subcategory, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + search_sizer_four = wx.BoxSizer(wx.VERTICAL) + search_sizer_four.Add(basic_label, 0, wx.ALL, 5) + search_sizer_four.Add( + self.basic_checkbox, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_four.Add(preferred_label, 0, wx.ALL, 5) + search_sizer_four.Add( + self.preferred_checkbox, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_four.Add(extended_label, 0, wx.ALL, 5) + search_sizer_four.Add( + self.extended_checkbox, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + search_sizer_four.Add(stock_label, 0, wx.ALL, 5) + search_sizer_four.Add( + self.assert_stock_checkbox, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + search_sizer_five = wx.BoxSizer(wx.VERTICAL) + search_sizer_five.Add( + help_button, + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM, + 5, + ) + + help_button.SetBitmap( + loadBitmapScaled( + "mdi-help-circle-outline.png", + self.parent.scale_factor, + ) + ) + help_button.SetBitmapMargins((2, 0)) + + search_sizer = wx.StaticBoxSizer(wx.VERTICAL, self, "Search") + + search_sizer.Add(keyword_search_row1) + + search_sizer_row2 = wx.StaticBoxSizer(wx.HORIZONTAL, self) + search_sizer_row2.Add(search_sizer_one, 0, wx.RIGHT, 20) + search_sizer_row2.Add(search_sizer_two, 0, wx.RIGHT, 20) + search_sizer_row2.Add(search_sizer_three, 0, wx.RIGHT, 20) + search_sizer_row2.Add(search_sizer_four, 0, wx.RIGHT, 20) + search_sizer_row2.Add(search_sizer_five, 0, wx.RIGHT, 20) + # search_sizer.Add(help_button, 0, wx.RIGHT, 20) + + search_sizer.Add(search_sizer_row2) + + self.keyword.Bind(wx.EVT_TEXT, self.search_dwell) + self.ohm_button.Bind(wx.EVT_BUTTON, self.add_ohm_symbol) + self.micro_button.Bind(wx.EVT_BUTTON, self.add_micro_symbol) + self.manufacturer.Bind(wx.EVT_TEXT, self.search_dwell) + self.package.Bind(wx.EVT_TEXT, self.search_dwell) + self.category.Bind(wx.EVT_COMBOBOX, self.update_subcategories) + self.category.Bind(wx.EVT_TEXT, self.update_subcategories) + self.part_no.Bind(wx.EVT_TEXT, self.search_dwell) + self.solder_joints.Bind(wx.EVT_TEXT, self.search_dwell) + help_button.Bind(wx.EVT_BUTTON, self.help) + + # --------------------------------------------------------------------- + # ------------------------ Result status line ------------------------- + # --------------------------------------------------------------------- + + self.result_count = wx.StaticText( + self, wx.ID_ANY, "0 Results", wx.DefaultPosition, wx.DefaultSize + ) + + result_sizer = wx.BoxSizer(wx.HORIZONTAL) + result_sizer.Add(self.result_count, 0, wx.LEFT | wx.TOP, 5) + + # --------------------------------------------------------------------- + # ------------------------- Result Part list -------------------------- + # --------------------------------------------------------------------- + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) + + table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL) + table_scroller.SetScrollRate(20, 20) + + self.part_list = dv.DataViewCtrl( + table_scroller, + style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_SINGLE, + ) + align_map = { + "left": wx.ALIGN_LEFT, + "center": wx.ALIGN_CENTER, + } + for idx, column in enumerate(PARTSELECTOR_COLUMNS): + if column.key in HIGHLIGHTED_COLUMN_KEYS: + renderer = HighlightedTextRenderer( + highlight_text_getter=self.get_highlight_text, + align=align_map[column.align], + ) + view_col = dv.DataViewColumn( + column.label, + renderer, + idx, + width=int(parent.scale_factor * column.width), + align=align_map[column.align], + ) + self.part_list.AppendColumn(view_col) + else: + view_col = self.part_list.AppendTextColumn( + column.label, + idx, + width=int(parent.scale_factor * column.width), + mode=dv.DATAVIEW_CELL_INERT, + align=align_map[column.align], + ) + if column.sortable: + view_col.SetSortable(True) + + self.part_list.Bind(dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnPartSelected) + self.part_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part) + scrolled_sizer = wx.BoxSizer(wx.VERTICAL) + scrolled_sizer.Add(self.part_list, 1, wx.EXPAND) + table_scroller.SetSizer(scrolled_sizer) + + table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------ Right side toolbar ------------------------- + # --------------------------------------------------------------------- + + self.select_part_button = wx.Button( + self, + wx.ID_ANY, + "Select part", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + self.part_details_button = wx.Button( + self, + wx.ID_ANY, + "Show part details", + wx.DefaultPosition, + HighResWxSize(parent.window, wx.Size(150, -1)), + 0, + ) + + self.select_part_button.Bind(wx.EVT_BUTTON, self.select_part) + self.part_details_button.Bind(wx.EVT_BUTTON, self.get_part_details) + + self.select_part_button.SetBitmap( + loadBitmapScaled( + "mdi-check.png", + self.parent.scale_factor, + ) + ) + self.select_part_button.SetBitmapMargins((2, 0)) + + self.part_details_button.SetBitmap( + loadBitmapScaled( + "mdi-text-box-search-outline.png", + self.parent.scale_factor, + ) + ) + self.part_details_button.SetBitmapMargins((2, 0)) + + tool_sizer = wx.BoxSizer(wx.VERTICAL) + tool_sizer.Add(self.select_part_button, 0, wx.ALL, 5) + tool_sizer.Add(self.part_details_button, 0, wx.ALL, 5) + table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5) + + # --------------------------------------------------------------------- + # ------------------------------ Sizers ------------------------------ + # --------------------------------------------------------------------- + + layout = wx.BoxSizer(wx.VERTICAL) + layout.Add(search_sizer, 1, wx.ALL, 5) + # layout.Add(self.search_button, 5, wx.ALL, 5) + layout.Add(result_sizer, 1, wx.LEFT, 5) + layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5) + + self.part_list_model = PartSelectorDataModel() + self.part_list.AssociateModel(self.part_list_model) + + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + self.enable_toolbar_buttons(False) + + # initiate the initial search now that the window has been constructed + self.search(None) + + def update_settings(self, event): + """Update the settings on change.""" + wx.PostEvent( + self.parent, + UpdateSetting( + section="partselector", + setting=event.GetEventObject().GetName(), + value=event.GetEventObject().GetValue(), + ), + ) + + # initiate a search now that settings have changed + self.search(None) + + @staticmethod + def get_existing_selection(parts): + """Check if exactly one LCSC part number is amongst the selected parts.""" + s = set(parts.values()) + if len(s) != 1: + return "" + return list(s)[0] + + def quit_dialog(self, *_): + """Close this window.""" + self.Destroy() + self.EndModal(0) + + def OnSortPartList(self, e): + """Set order_by to the clicked column and trigger list refresh.""" + self.parent.library.set_order_by(e.GetColumn()) + self.search(None) + + def OnPartSelected(self, *_): + """Enable the toolbar buttons when a selection was made.""" + if self.part_list.GetSelectedItemsCount() > 0: + self.enable_toolbar_buttons(True) + else: + self.enable_toolbar_buttons(False) + + def enable_toolbar_buttons(self, state): + """Control the state of all the buttons in toolbar on the right side.""" + for b in [ + self.select_part_button, + self.part_details_button, + ]: + b.Enable(bool(state)) + + def add_ohm_symbol(self, *_): + """Append the Ω symbol to the search string.""" + self.keyword.AppendText("Ω") + + def add_micro_symbol(self, *_): + """Append the µ symbol to the search string.""" + self.keyword.AppendText("µ") + + def search_dwell(self, *_): + """Initiate a search once the timeout expires. + + Used to avoid continuous searches + when input fields are still being changed by the user. + """ + self.search_timer.StartOnce(750) + + def search(self, *_): + """Search the library for parts that meet the search criteria.""" + parameters = { + "keyword": self.keyword.GetValue(), + "manufacturer": self.manufacturer.GetValue(), + "package": self.package.GetValue(), + "category": self.category.GetValue(), + "subcategory": self.subcategory.GetValue(), + "part_no": self.part_no.GetValue(), + "solder_joints": self.solder_joints.GetValue(), + "basic": self.basic_checkbox.GetValue(), + "extended": self.extended_checkbox.GetValue(), + "preferred": self.preferred_checkbox.GetValue(), + "stock": self.assert_stock_checkbox.GetValue(), + } + start = time.time() + result = self.parent.library.search(parameters) + self.logger.debug("len(result) %d", len(result)) + search_duration = time.time() - start + self.populate_part_list(result, search_duration) + + def get_highlight_text(self) -> str: + """Return the active keyword search text for result highlighting.""" + if not self.parent.settings.get("partselector", {}).get( + "highlight_matches", True + ): + return "" + return self.keyword.GetValue() + + def update_subcategories(self, *_): + """Update the possible subcategory selection.""" + self.subcategory.Clear() + if self.category.GetSelection() != wx.NOT_FOUND: + subcategories = self.parent.library.get_subcategories( + self.category.GetValue() + ) + self.subcategory.AppendItems(subcategories) + + # search now that categories might have changed + self.search(None) + + def get_price(self, quantity, prices) -> float: + """Find the price for the number of selected parts according to the price ranges.""" + price_ranges = prices.split(",") + if not price_ranges[0]: + return -1.0 + min_quantity = int(price_ranges[0].split("-")[0]) + if quantity <= min_quantity: + range, price = price_ranges[0].split(":") + return float(price) + for p in price_ranges: + range, price = p.split(":") + lower, upper = range.split("-") + if not upper: # upper bound of price ranges + return float(price) + lower = int(lower) + upper = int(upper) + if lower <= quantity < upper: + return float(price) + return -1.0 + + def populate_part_list(self, parts, search_duration): + """Populate the list with the result of the search.""" + search_duration_text = _format_duration(search_duration) + start = time.time() + self.part_list_model.RemoveAll() + if parts is None: + return + limit_text = " (limited)" if len(parts) >= 1000 else "" + for p in parts: + db_row = {field: str(value) for field, value in zip(DB_FIELDS, p)} + price = round(self.get_price(len(self.parts), db_row.get("Price", "")), 3) + if price > 0: + total_cost = round(price * len(self.parts), 3) + db_row["Price"] = ( + f"{len(self.parts)} parts: ${price} each / ${total_cost} total" + ) + else: + db_row["Price"] = "Error in price data" + params = params_for_part( + { + "description": db_row.get("Description", ""), + "category": db_row.get("First Category", ""), + "package": db_row.get("Package", ""), + } + ) + item = [] + for column in PARTSELECTOR_COLUMNS: + if column.key == PARAMS_COLUMN_KEY: + item.append(params) + elif column.db_field: + item.append(db_row.get(column.db_field, "")) + else: + item.append("") + self.part_list_model.AddEntry(item) + render_duration = time.time() - start + render_duration_text = _format_duration(render_duration) + result_count_label = ( + f"{len(parts)} parts {limit_text}." + f"Search in {search_duration_text}, " + f"Render in {render_duration_text}." + ) + self.result_count.SetLabel(result_count_label) + + def select_part(self, *_): + """Save the selected part number and close the modal.""" + if self.part_list.GetSelectedItemsCount() > 0: + item = self.part_list.GetSelection() + wx.PostEvent( + self.parent, + AssignPartsEvent( + lcsc=self.part_list_model.get_lcsc(item), + type=self.part_list_model.get_type(item), + stock=self.part_list_model.get_stock(item), + references=self.parts.keys(), + ), + ) + self.EndModal(wx.ID_OK) + + def get_part_details(self, *_): + """Fetch part details from LCSC and show them in a modal.""" + if self.part_list.GetSelectedItemsCount() > 0: + item = self.part_list.GetSelection() + busy_cursor = wx.BusyCursor() + dialog = PartDetailsDialog(self.parent, self.part_list_model.get_lcsc(item)) + del busy_cursor + dialog.ShowModal() + + def help(self, *_): + """Show message box with help instructions.""" + title = "Help" + text = """ + Use % as wildcard selector. \n + For example DS24% will match DS2411\n + %QFP% will match LQFP-64 as well as TQFP-32\n + The keyword search box is automatically post- and prefixed with wildcard operators. + The others are not by default.\n + The keyword search field is applied to "LCSC Part", "Description", "MFR.Part", + "Package" and "Manufacturer".\n + Searching occurs as input fields are changed.\n + The results are limited to 1000. + """ + wx.MessageBox(text, title, style=wx.ICON_INFORMATION) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py new file mode 100644 index 00000000..66ccdc94 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py @@ -0,0 +1,51 @@ +"""Shared column definitions for the part selector UI and queries.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PartSelectorColumn: + """Column metadata for the part selector list.""" + + key: str + label: str + db_field: str | None + width: int + align: str + sortable: bool = True + model_type: str = "string" + + +PARTSELECTOR_COLUMNS: list[PartSelectorColumn] = [ + PartSelectorColumn("lcsc", "LCSC", "LCSC Part", 60, "center"), + PartSelectorColumn("mfr_number", "MFR Number", "MFR.Part", 140, "left"), + PartSelectorColumn("package", "Package", "Package", 100, "left"), + PartSelectorColumn("type", "Type", "Library Type", 50, "left"), + PartSelectorColumn("params", "Params", None, 150, "center", sortable=False), + PartSelectorColumn("stock", "Stock", "Stock", 50, "center"), + PartSelectorColumn("mfr", "Manufacturer", "Manufacturer", 100, "left"), + PartSelectorColumn("description", "Description", "Description", 300, "left"), + PartSelectorColumn("price", "Price", "Price", 100, "left"), +] + +EXTRA_DB_FIELDS: list[str] = ["First Category"] + +DB_FIELDS: list[str] = [ + c.db_field for c in PARTSELECTOR_COLUMNS if c.db_field is not None +] + EXTRA_DB_FIELDS + +COLUMN_INDEX: dict[str, int] = { + column.key: idx for idx, column in enumerate(PARTSELECTOR_COLUMNS) +} + +SORTABLE_COLUMN_INDEX_TO_DB: dict[int, str] = { + idx: column.db_field + for idx, column in enumerate(PARTSELECTOR_COLUMNS) + if column.sortable and column.db_field is not None +} + +MODEL_COLUMN_TYPES: list[str] = [column.model_type for column in PARTSELECTOR_COLUMNS] + +PARAMS_COLUMN_KEY = "params" diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py new file mode 100644 index 00000000..c6517a60 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py @@ -0,0 +1,201 @@ +"""Highlight helpers and renderer for part selector search results.""" + +from __future__ import annotations + +from collections.abc import Callable + +try: + import wx # pylint: disable=import-error + import wx.dataview as dv # pylint: disable=import-error +except ImportError: # pragma: no cover - test environments may not have wx + wx = None # type: ignore[assignment] + dv = None # type: ignore[assignment] + + +_HIGHLIGHT_FG = (180, 120, 0) +_HIGHLIGHT_FG_SELECTED = (255, 215, 64) +_MIN_HIGHLIGHT_TERM_LENGTH = 2 + + +def normalize_highlight_terms(query: str) -> list[str]: + """Split keyword query into normalized terms suitable for highlighting.""" + terms = [] + for raw_term in query.split(): + term = raw_term.strip().strip("%") + if term: + lowered = term.casefold() + if lowered not in terms: + terms.append(lowered) + return terms + + +def find_highlight_spans(text: str, terms: list[str]) -> list[tuple[int, int]]: + """Return merged `(start, end)` spans for all term matches in `text`.""" + if not text or not terms: + return [] + + lowered_text = text.casefold() + spans: list[tuple[int, int]] = [] + + for term in terms: + start = 0 + while True: + match_start = lowered_text.find(term, start) + if match_start == -1: + break + match_end = match_start + len(term) + spans.append((match_start, match_end)) + start = match_end + + if not spans: + return [] + + spans.sort() + merged = [spans[0]] + for start, end in spans[1:]: + last_start, last_end = merged[-1] + if start <= last_end: + merged[-1] = (last_start, max(last_end, end)) + else: + merged.append((start, end)) + return merged + + +def filtered_highlight_terms(query: str) -> list[str]: + """Return normalized terms that are long enough to highlight.""" + return [ + t + for t in normalize_highlight_terms(query) + if len(t) >= _MIN_HIGHLIGHT_TERM_LENGTH + ] + + +class HighlightQueryCache: + """Cache normalized query terms and highlight spans for one active query.""" + + def __init__(self): + self._query = "" + self._terms: list[str] = [] + self._span_cache: dict[str, list[tuple[int, int]]] = {} + + def prepare(self, query: str): + """Prepare cache state for a query, resetting cached spans on change.""" + if query != self._query: + self._query = query + self._terms = filtered_highlight_terms(query) + self._span_cache.clear() + + def clear(self): + """Clear all cached query and span data.""" + self._query = "" + self._terms = [] + self._span_cache.clear() + + def get_terms(self) -> list[str]: + """Return terms prepared for the active query.""" + return self._terms + + def get_spans(self, text: str) -> list[tuple[int, int]]: + """Return cached spans for text, computing and storing on cache miss.""" + spans = self._span_cache.get(text) + if spans is None: + spans = find_highlight_spans(text, self._terms) + self._span_cache[text] = spans + return spans + + +if wx is not None and dv is not None: # pragma: no branch + + class HighlightedTextRenderer(dv.DataViewCustomRenderer): + """Simple text renderer that highlights keyword matches.""" + + def __init__(self, highlight_text_getter: Callable[[], str], align: int): + super().__init__("string", dv.DATAVIEW_CELL_INERT, align) + self._highlight_text_getter = highlight_text_getter + self._value = "" + # Cache lifetime is tied to this renderer instance (one dialog/session). + # It resets automatically when the query string changes via prepare(query). + # A new part selector dialog constructs new renderer instances and caches. + self._query_cache = HighlightQueryCache() + + def SetValue(self, value: str) -> bool: + """Store value to render for the current cell.""" + self._value = "" if value is None else str(value) + return True + + def GetValue(self) -> str: + """Return current cell value.""" + return self._value + + def GetSize(self): + """Return a best-effort size for the current text.""" + owner = self.GetOwner() + font = ( + owner.GetOwner().GetFont() + if owner is not None and owner.GetOwner() is not None + else wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT) + ) + dc = wx.ScreenDC() + dc.SetFont(font) + width, height = dc.GetTextExtent(self._value or "Hg") + return wx.Size(width + 8, height + 6) + + def Render(self, rect, dc, state): + """Draw the cell text and highlight search-term matches.""" + selected = bool(state & dv.DATAVIEW_CELL_SELECTED) + foreground = wx.SystemSettings.GetColour( + wx.SYS_COLOUR_HIGHLIGHTTEXT if selected else wx.SYS_COLOUR_LISTBOXTEXT + ) + highlight = wx.Colour( + *(_HIGHLIGHT_FG_SELECTED if selected else _HIGHLIGHT_FG) + ) + + dc.SetTextForeground(foreground) + dc.SetBackgroundMode(wx.TRANSPARENT) + + text = self._value + if not text: + return True + + query = self._highlight_text_getter() + self._query_cache.prepare(query) + terms = self._query_cache.get_terms() + if not terms: + text_height = dc.GetTextExtent("Hg")[1] + x = rect.x + 4 + y = rect.y + max(0, (rect.height - text_height) // 2) + dc.SetClippingRegion(rect) + try: + dc.DrawText(text, x, y) + finally: + dc.DestroyClippingRegion() + return True + + spans = self._query_cache.get_spans(text) + text_height = dc.GetTextExtent("Hg")[1] + x = rect.x + 4 + y = rect.y + max(0, (rect.height - text_height) // 2) + + dc.SetClippingRegion(rect) + try: + cursor = 0 + for start, end in spans: + if start > cursor: + segment = text[cursor:start] + dc.SetTextForeground(foreground) + dc.DrawText(segment, x, y) + x += dc.GetTextExtent(segment)[0] + + segment = text[start:end] + segment_width, _ = dc.GetTextExtent(segment) + dc.SetTextForeground(highlight) + dc.DrawText(segment, x, y) + x += segment_width + cursor = end + + if cursor < len(text): + dc.SetTextForeground(foreground) + dc.DrawText(text[cursor:], x, y) + finally: + dc.DestroyClippingRegion() + return True diff --git a/third_party/Bouni/kicad-jlcpcb-tools/plugin.py b/third_party/Bouni/kicad-jlcpcb-tools/plugin.py new file mode 100644 index 00000000..5072a4b3 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/plugin.py @@ -0,0 +1,30 @@ +"""Contains the Action Plugin.""" + +import os + +from pcbnew import ActionPlugin # pylint: disable=import-error + +from .mainwindow import JLCPCBTools + + +class JLCPCBPlugin(ActionPlugin): + """JLCPCBPlugin instance of ActionPlugin.""" + + def defaults(self): + """Define defaults.""" + # pylint: disable=attribute-defined-outside-init + self.name = "JLCPCB Tools" + self.category = "Fabrication data generation" + self.description = ( + "Generate JLCPCB-compatible Gerber, Excellon, BOM and CPL files" + ) + self.show_toolbar_button = True + path, _ = os.path.split(os.path.abspath(__file__)) + self.icon_file_name = os.path.join(path, "jlcpcb-icon.png") + self._pcbnew_frame = None + + def Run(self): + """Overwrite Run.""" + dialog = JLCPCBTools(None) + dialog.Center() + dialog.Show() diff --git a/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml b/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml new file mode 100644 index 00000000..5ed3f4ee --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml @@ -0,0 +1,156 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "Kicad-jlcpcb-tools" +version = "2025.09.02" +license = "MIT" +description = "Plugin to generate BOM + CPL files for JLCPCB." +readme = "README.md" +requires-python = ">=3.10" +keywords = ["kicad", "jlcpcb", "electronics", "pcb", "bom", "cpl"] +classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "click>=8.0", + "humanize>=4.0", + "requests>=2.28", + "cachetools>=5.0", + "ratelimit>=2.2.1", + "tqdm>=4.0", + "retry>=0.9.2", + "split_file_reader>=0.1.4", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", +] + +[project.urls] +Homepage = "https://github.com/bouni/kicad-jlcpcb-tools" +Repository = "https://github.com/bouni/kicad-jlcpcb-tools.git" +Issues = "https://github.com/bouni/kicad-jlcpcb-tools/issues" + +[tool.setuptools] +packages = ["db_build", "common"] + +[tool.ruff.lint] + +select = [ + "B002", # Python does not support the unary prefix increment + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B904", # Use raise from to specify exception cause + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "G", # flake8-logging-format + "I", # isort + "ICN001", # import concentions; {name} should be imported as {asname} + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "Q000", # Double quotes found but single quotes preferred + "RUF006", # Store a reference to the return value of asyncio.create_task + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM208", # Use {expr} instead of not (not {expr}) + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "TRY004", # Prefer TypeError exception for invalid type + "TRY203", # Remove exception handler; error is immediately re-raised + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "C901", # too complex + "D107", # Ignore missing Docstring in __init__ methods + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E722", # Do not use bare except, specify exception instead + "E731", # do not assign a lambda expression, use a def + "S608", # Ignore Possible SQL injection vector through string-based query construction + + # Ignore ignored, as the rule is now back in preview/nursery, which cannot + # be ignored anymore without warnings. + # https://github.com/astral-sh/ruff/issues/7491 + # "PLC1901", # Lots of false positives + + # False positives https://github.com/astral-sh/ruff/issues/5386 + "PLC0208", # Use a sequence type instead of a `set` when iterating over values + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "UP006", # keep type annotation style as is + "UP007", # keep type annotation style as is +] + + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.isort] +force-sort-within-sections = true +combine-as-imports = true +split-on-trailing-comma = false + +[tool.ruff.lint.per-file-ignores] +"db_build/jlcparts_db_convert.py" = ["T201"] +"common/filemgr.py" = ["T201"] +"common/partsdb.py" = ["T201"] +"common/progress.py" = ["T201"] + +[tool.ruff.lint.mccabe] +max-complexity = 25 diff --git a/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py b/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py new file mode 100644 index 00000000..c02de2f6 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py @@ -0,0 +1,278 @@ +"""Module for exporting LCSC data to schematic.""" + +import logging +import os +import os.path +import re + +from pcbnew import GetBuildVersion # pylint: disable=import-error + +from .core.version import is_version6, is_version7 + + +class SchematicExport: + """A class to export Schematic files.""" + + # This only works with KiCad v6/v7/v8 files, if the format changes, this will probably break + + def __init__(self, parent): + self.logger = logging.getLogger(__name__) + self.parent = parent + + def load_schematic(self, paths): + """Load schematic file.""" + if is_version6(GetBuildVersion()): + self.logger.info("Kicad 6...") + for path in paths: + self._update_schematic6(path) + elif is_version7(GetBuildVersion()): + self.logger.info("Kicad 7...") + for path in paths: + self._update_schematic7(path) + else: + self.logger.info("Kicad 8+...") + for path in paths: + self._update_schematic(path) + + def _update_schematic6(self, path): + """Only works with KiCad V6 files.""" + self.logger.info("Reading %s...", path) + # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it + # keep track of property ids and Reference property location to use with new LCSC property + propRx = re.compile( + '\\(property\\s\\"(.*)\\"\\s\\"(.*)\\"\\s\\(id\\s(\\d+)\\)\\s\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)' + ) + pinRx = re.compile('\\(pin\\s\\"(.*)\\"\\s\\(') + + store_parts = self.parent.store.read_all() + + lastID = -1 + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + + lines = [] + newlines = [] + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + if os.path.exists(path + "_old"): + os.remove(path + "_old") + os.rename(path, path + "_old") + partSection = False + + for line in lines: + inLine = line.rstrip() + outLine = inLine + if "(symbol (lib_id" in inLine: # skip library section + partSection = True + m = propRx.search(inLine) + if m and partSection: + key = m.group(1) + value = m.group(2) + lastID = int(m.group(3)) + + # found a LCSC property, so update it if needed + if key == "LCSC": + lastLcsc = value + if newLcsc not in (lastLcsc, ""): + self.logger.info("Updating %s on %s", newLcsc, lastRef) + outLine = outLine.replace( + '"' + lastLcsc + '"', '"' + newLcsc + '"' + ) + lastLcsc = newLcsc + + if key == "Reference": + lastLoc = m.group(4) + lastRef = value + for part in store_parts: + if value == part["reference"]: + newLcsc = part["lcsc"] + break + # if we hit the pin section without finding a LCSC property, add it + m = pinRx.search(inLine) + if m: + if lastLcsc == "" and newLcsc != "" and lastLoc != "" and lastID != -1: + self.logger.info("added %s to %s", newLcsc, lastRef) + newTxt = f' (property "LCSC" "{newLcsc}" (id {lastID + 1}) (at {lastLoc} 0)' + newlines.append(newTxt) + newlines.append(" (effects (font (size 1.27 1.27)) hide)") + newlines.append(" )") + lastID = -1 + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + newlines.append(outLine) + + with open(path, "w", encoding="utf-8") as f: + for line in newlines: + f.write(line + "\n") + self.logger.info("Added LCSC's to %s(maybe?)", path) + + def _update_schematic7(self, path): + """Only works with KiCad V7 files.""" + self.logger.info("Reading %s...", path) + # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it + # keep track of property ids and Reference property location to use with new LCSC property + propRx = re.compile( + '\\(property\\s\\"(.*)\\"\\s\\"(.*)\\"\\s\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)' + ) + pinRx = re.compile('\\(pin\\s\\"(.*)\\"\\s\\(') + + store_parts = self.parent.store.read_all() + + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + + lines = [] + newlines = [] + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + if os.path.exists(path + "_old"): + os.remove(path + "_old") + os.rename(path, path + "_old") + partSection = False + + for line in lines: + inLine = line.rstrip() + outLine = inLine + if "(symbol (lib_id" in inLine: # skip library section + partSection = True + m = propRx.search(inLine) + if m and partSection: + key = m.group(1) + value = m.group(2) + + # found a LCSC property, so update it if needed + if key == "LCSC": + lastLcsc = value + if newLcsc not in (lastLcsc, ""): + self.logger.info("Updating %s on %s", newLcsc, lastRef) + outLine = outLine.replace( + '"' + lastLcsc + '"', '"' + newLcsc + '"' + ) + lastLcsc = newLcsc + + if key == "Reference": + lastLoc = m.group(3) + lastRef = value + for part in store_parts: + if value == part["reference"]: + newLcsc = part["lcsc"] + break + # if we hit the pin section without finding a LCSC property, add it + m = pinRx.search(inLine) + if m: + if lastLcsc == "" and newLcsc != "" and lastLoc != "": + self.logger.info("added %s to %s", newLcsc, lastRef) + newTxt = f' (property "LCSC" "{newLcsc}" (at {lastLoc} 0)' + newlines.append(newTxt) + newlines.append(" (effects (font (size 1.27 1.27)) hide)") + newlines.append(" )") + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + newlines.append(outLine) + + with open(path, "w", encoding="utf-8") as f: + for line in newlines: + f.write(line + "\n") + self.logger.info("Added LCSC's to %s (maybe?)", path) + + def _update_schematic(self, path): + """Only works with KiCad V8+ files.""" + self.logger.info("Reading %s...", path) + # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it + # keep track of property ids and Reference property location to use with new LCSC property + propRx = re.compile('\\(property\\s\\"(.*)\\"\\s"(.*)\\"') + atRx = re.compile("\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)") + pinRx = re.compile('\\(pin\\s\\"(.*)\\"') + + store_parts = self.parent.store.read_all() + + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + + lines = [] + newlines = [] + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + partSection = False + files_seen = set() # keeps sheet files already processed. + + for i in range(0, len(lines) - 1): + inLine = lines[i].rstrip() + inLine2 = lines[i + 1].rstrip() + outLine = inLine + + if "(symbol" in inLine and "(lib_id" in inLine2: # skip library section + partSection = True + + # self.logger.info("line %d", i) + m = propRx.search(inLine) + m2 = atRx.search(inLine2) + if m and m2 and partSection: + key = m.group(1) + # self.logger.info("key %s", key) + # found a LCSC property, so update it if needed + if key in {"LCSC", "LCSC_PN", "JLC_PN"}: + value = m.group(2) + lastLcsc = value + if newLcsc not in (lastLcsc, ""): + self.logger.info( + "Updating %s on %s in %s", newLcsc, lastRef, path + ) + outLine = outLine.replace( + '"' + lastLcsc + '"', '"' + newLcsc + '"' + ) + lastLcsc = newLcsc + + if key == "Reference": + lastLoc = m2.group(1) + value = m.group(2) + # self.logger.info("value %s", value) + lastRef = value + for part in store_parts: + if value == part["reference"]: + newLcsc = part["lcsc"] + break + if key == "Sheetfile": + file_name = m.group(2) + if file_name not in files_seen: + files_seen.add(file_name) + dir_name = os.path.dirname(path) + self._update_schematic(os.path.join(dir_name, file_name)) + # if we hit the pin section without finding a LCSC property, add it + m3 = pinRx.search(inLine) + if m3 and partSection: + if lastLcsc == "" and newLcsc != "" and lastLoc != "": + self.logger.info("added %s to %s", newLcsc, lastRef) + newTxt = f'\t\t(property "LCSC" "{newLcsc}"\n\t\t\t(at {lastLoc} 0)' + newlines.append(newTxt) + newlines.append( + "\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t\t(hide yes)" + ) + newlines.append("\t\t\t)") + newlines.append("\t\t)") + lastLoc = "" + lastLcsc = "" + newLcsc = "" + lastRef = "" + newlines.append(outLine) + newlines.append(lines[len(lines) - 1].rstrip()) + if os.path.exists(path + "_old"): + os.remove(path + "_old") + os.rename(path, path + "_old") + with open(path, "w", encoding="utf-8") as f: + for line in newlines: + f.write(line + "\n") + self.logger.info("Added LCSC's to %s (maybe?)", path) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py b/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py new file mode 100644 index 00000000..5c9b4f50 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py @@ -0,0 +1,26 @@ +"""SQL escaping helpers for the parts library search.""" + + +def escape_like_term(term: str) -> str: + r"""Escape a term for use inside a SQL LIKE expression. + + Uses backslash as the escape character, so the caller must append + ``ESCAPE '\'`` to the LIKE predicate. Also escapes embedded single + quotes so the term is safe to inline into a SQL string literal. + """ + return ( + term.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + .replace("'", "''") + ) + + +def escape_fts_phrase(term: str) -> str: + r"""Escape a term for use inside an FTS5 double-quoted phrase token. + + Inside ``\"...\"`` phrase tokens a literal ``\"`` must be doubled. + The whole MATCH expression is embedded in a SQL ``'...'`` string so + a literal ``'`` must also be escaped as ``''``. + """ + return term.replace('"', '""').replace("'", "''") diff --git a/third_party/Bouni/kicad-jlcpcb-tools/settings.json b/third_party/Bouni/kicad-jlcpcb-tools/settings.json new file mode 100644 index 00000000..03086dc2 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/settings.json @@ -0,0 +1 @@ +{"partselector": {"basic": false, "extended": false, "preferred": true, "stock": false, "highlight_matches": true}, "gerber": {"tented_vias": true, "fill_zones": true, "force_drc": true, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": false, "order_number": true, "select_alike_auto": false}, "library": {"selected_library": "current-parts", "data_path": ""}} diff --git a/third_party/Bouni/kicad-jlcpcb-tools/settings.py b/third_party/Bouni/kicad-jlcpcb-tools/settings.py new file mode 100644 index 00000000..e40e1d46 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/settings.py @@ -0,0 +1,715 @@ +"""Contains the settings dialog.""" + +import logging + +import wx # pylint: disable=import-error + +# Import library configuration to populate choices +from .dblib import LIBRARY_CONFIGS +from .events import UpdateSetting +from .helpers import HighResWxSize, loadBitmapScaled + + +class SettingsDialog(wx.Dialog): + """Dialog for plugin settings.""" + + def __init__(self, parent): + wx.Dialog.__init__( + self, + parent, + id=wx.ID_ANY, + title="JLCPCB tools settings", + pos=wx.DefaultPosition, + size=HighResWxSize(parent.window, wx.Size(1300, 800)), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + + self.logger = logging.getLogger(__name__) + self.parent = parent + + # --------------------------------------------------------------------- + # ---------------------------- Hotkeys -------------------------------- + # --------------------------------------------------------------------- + quitid = wx.NewId() + self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid) + + entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()] + entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid) + entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid) + entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid) + accel = wx.AcceleratorTable(entries) + self.SetAcceleratorTable(accel) + + # --------------------------------------------------------------------- + # ------------------------- Change settings --------------------------- + # --------------------------------------------------------------------- + + ##### Tented vias ##### + + self.tented_vias_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Do not tent vias", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_tented_vias", + ) + + self.tented_vias_setting.SetToolTip( + wx.ToolTip("Whether vias should be coverd by soldermask or not") + ) + + self.tented_vias_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("tented.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.tented_vias_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + tented_vias_sizer = wx.BoxSizer(wx.HORIZONTAL) + tented_vias_sizer.Add(self.tented_vias_image, 10, wx.ALL | wx.EXPAND, 5) + tented_vias_sizer.Add(self.tented_vias_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Fill zones ##### + + self.fill_zones_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Fill zones", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_fill_zones", + ) + + self.fill_zones_setting.SetToolTip( + wx.ToolTip("Whether zones should be filled on gerber generation") + ) + + self.fill_zones_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("fill-zones.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.fill_zones_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + fill_zones_sizer = wx.BoxSizer(wx.HORIZONTAL) + fill_zones_sizer.Add(self.fill_zones_image, 10, wx.ALL | wx.EXPAND, 5) + fill_zones_sizer.Add(self.fill_zones_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Force DRC before Gerber export ##### + + self.force_drc_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Force DRC check before Gerber export - Saves board and fills zones!", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_force_drc", + ) + + self.force_drc_setting.SetToolTip( + wx.ToolTip( + "Run kicad-cli DRC with error severity before generating Gerbers (Saves board and fills zones!)" + ) + ) + + self.force_drc_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("bug-check-outline.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.force_drc_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + force_drc_sizer = wx.BoxSizer(wx.HORIZONTAL) + force_drc_sizer.Add(self.force_drc_image, 10, wx.ALL | wx.EXPAND, 5) + force_drc_sizer.Add(self.force_drc_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Plot values ##### + + self.plot_values_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Plot values", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_plot_values", + ) + + self.plot_values_setting.SetToolTip( + wx.ToolTip("Whether value should be plotted on gerber generation") + ) + + self.plot_values_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("plot_values.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.plot_values_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + plot_values_sizer = wx.BoxSizer(wx.HORIZONTAL) + plot_values_sizer.Add(self.plot_values_image, 10, wx.ALL | wx.EXPAND, 5) + plot_values_sizer.Add(self.plot_values_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Plot references ##### + + self.plot_references_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Plot references", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_plot_references", + ) + + self.plot_references_setting.SetToolTip( + wx.ToolTip("Whether value should be plotted on gerber generation") + ) + + self.plot_references_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("plot_refs.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.plot_references_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + plot_references_sizer = wx.BoxSizer(wx.HORIZONTAL) + plot_references_sizer.Add(self.plot_references_image, 10, wx.ALL | wx.EXPAND, 5) + plot_references_sizer.Add( + self.plot_references_setting, 100, wx.ALL | wx.EXPAND, 5 + ) + + ##### LCSC priority ##### + + self.lcsc_priority_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="LCSC number priority", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="general_lcsc_priority", + ) + + self.lcsc_priority_setting.SetToolTip( + wx.ToolTip( + "Whether LCSC number from schematic should overrule those in the database" + ) + ) + + self.lcsc_priority_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("schematic.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.lcsc_priority_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + lcsc_priority_sizer = wx.BoxSizer(wx.HORIZONTAL) + lcsc_priority_sizer.Add(self.lcsc_priority_image, 10, wx.ALL | wx.EXPAND, 5) + lcsc_priority_sizer.Add(self.lcsc_priority_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Only parts with LCSC number in BOM/CPL ##### + + self.lcsc_bom_cpl_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Add parts without LCSC numbers to BOM/CPL", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="gerber_lcsc_bom_cpl", + ) + + self.lcsc_bom_cpl_setting.SetToolTip( + wx.ToolTip("Whether parts wihout LCSC number should be added to BOM/CPL") + ) + + self.lcsc_bom_cpl_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("bom.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.lcsc_bom_cpl_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + lcsc_bom_cpl_sizer = wx.BoxSizer(wx.HORIZONTAL) + lcsc_bom_cpl_sizer.Add(self.lcsc_bom_cpl_image, 10, wx.ALL | wx.EXPAND, 5) + lcsc_bom_cpl_sizer.Add(self.lcsc_bom_cpl_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Check if order/serial number placeholder is present ##### + + self.order_number_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Check if an order/serial number placeholder is placed", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="general_order_number", + ) + + self.order_number_setting.SetToolTip( + wx.ToolTip("Is an order/serial number placeholder placed") + ) + + self.order_number_image = wx.StaticBitmap( + self, + wx.ID_ANY, + loadBitmapScaled("order_number.png", self.parent.scale_factor, static=True), + wx.DefaultPosition, + wx.DefaultSize, + 0, + ) + + self.order_number_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + order_number_sizer = wx.BoxSizer(wx.HORIZONTAL) + order_number_sizer.Add(self.order_number_image, 10, wx.ALL | wx.EXPAND, 5) + order_number_sizer.Add(self.order_number_setting, 100, wx.ALL | wx.EXPAND, 5) + + ##### Highlight text matches in part selector ###### + + highlight_matches_label = wx.StaticText( + self, + id=wx.ID_ANY, + label="Part selector highlighting", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + ) + + self.highlight_matches_setting = wx.CheckBox( + self, + id=wx.ID_ANY, + label="Highlight search matches", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=0, + name="partselector_highlight_matches", + ) + + self.highlight_matches_setting.SetToolTip( + wx.ToolTip("Highlight keyword matches in the part selector search results") + ) + + self.highlight_matches_setting.Bind(wx.EVT_CHECKBOX, self.update_settings) + + highlight_matches_sizer = wx.BoxSizer(wx.HORIZONTAL) + highlight_matches_sizer.Add( + highlight_matches_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5 + ) + highlight_matches_sizer.Add( + self.highlight_matches_setting, 0, wx.ALL | wx.EXPAND, 5 + ) + + ##### Library Selection ##### + + library_label = wx.StaticText( + self, + id=wx.ID_ANY, + label="Parts Library:", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + ) + + library_choices = [config.display_name for config in LIBRARY_CONFIGS.values()] + self.library_selected_setting = wx.ComboBox( + self, + id=wx.ID_ANY, + value="", + choices=library_choices, + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=wx.CB_READONLY, + name="library_selected_library", + ) + + self.library_selected_setting.SetToolTip( + wx.ToolTip("Select which parts library to use") + ) + + self.library_selected_setting.Bind(wx.EVT_COMBOBOX, self.update_settings) + + library_sizer = wx.BoxSizer(wx.HORIZONTAL) + library_sizer.Add(library_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) + library_sizer.Add(self.library_selected_setting, 1, wx.ALL | wx.EXPAND, 5) + + ##### Library Data Directory ##### + + library_data_path_label = wx.StaticText( + self, + id=wx.ID_ANY, + label="Database directory:", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + ) + + self.library_data_path_setting = wx.DirPickerCtrl( + self, + id=wx.ID_ANY, + path="", + message="Choose folder for global library database files", + pos=wx.DefaultPosition, + size=wx.DefaultSize, + style=wx.DIRP_DEFAULT_STYLE | wx.DIRP_USE_TEXTCTRL, + name="library_data_path", + ) + + self.library_data_path_setting.SetToolTip( + wx.ToolTip( + "Override where the global library database files are stored." + " If you change this, you may want to copy existing mapping and" + " corrections files from the old location to the new one to avoid" + " losing existing mappings and corrections." + ) + ) + + self.library_data_path_setting.Bind( + wx.EVT_DIRPICKER_CHANGED, self.update_settings + ) + + library_data_path_sizer = wx.BoxSizer(wx.HORIZONTAL) + library_data_path_sizer.Add( + library_data_path_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5 + ) + library_data_path_sizer.Add( + self.library_data_path_setting, 1, wx.ALL | wx.EXPAND, 5 + ) + + # --------------------------------------------------------------------- + # ---------------------- Main Layout Sizer ---------------------------- + # --------------------------------------------------------------------- + + layout = wx.GridSizer(12, 2, 0, 0) + layout.Add(tented_vias_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(fill_zones_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(force_drc_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(plot_values_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(plot_references_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(lcsc_priority_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(lcsc_bom_cpl_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(order_number_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(highlight_matches_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(library_sizer, 0, wx.ALL | wx.EXPAND, 5) + layout.Add(library_data_path_sizer, 0, wx.ALL | wx.EXPAND, 5) + self.SetSizer(layout) + self.Layout() + self.Centre(wx.BOTH) + + self.load_settings() + + def update_tented_vias(self, tented): + """Update settings dialog according to the settings.""" + if tented: + self.tented_vias_setting.SetValue(tented) + self.tented_vias_setting.SetLabel("Tented vias") + self.tented_vias_image.SetBitmap( + loadBitmapScaled("tented.png", self.parent.scale_factor, static=True) + ) + else: + self.tented_vias_setting.SetValue(tented) + self.tented_vias_setting.SetLabel("Untented vias") + self.tented_vias_image.SetBitmap( + loadBitmapScaled("untented.png", self.parent.scale_factor, static=True) + ) + + def update_fill_zones(self, fill): + """Update settings dialog according to the settings.""" + if fill: + self.fill_zones_setting.SetValue(fill) + self.fill_zones_setting.SetLabel("Fill zones") + self.fill_zones_image.SetBitmap( + loadBitmapScaled( + "fill-zones.png", self.parent.scale_factor, static=True + ) + ) + else: + self.fill_zones_setting.SetValue(fill) + self.fill_zones_setting.SetLabel("Don't fill zones") + self.fill_zones_image.SetBitmap( + loadBitmapScaled( + "unfill-zones.png", self.parent.scale_factor, static=True + ) + ) + + def build_force_drc_bitmap(self, enabled): + """Build the Force DRC icon, overlaying a red X when disabled.""" + bitmap = loadBitmapScaled( + "bug-check-outline.png", self.parent.scale_factor, static=True + ) + if enabled: + return bitmap + + return self.create_disabled_bitmap(bitmap) + + def create_disabled_bitmap(self, bitmap): + """Create a disabled-state bitmap by drawing a red X over it.""" + disabled_bitmap = bitmap.ConvertToImage().ConvertToBitmap() + memory_dc = wx.MemoryDC() + memory_dc.SelectObject(disabled_bitmap) + try: + pen_width = max(2, int(round(self.parent.scale_factor * 2))) + margin = max(2, int(round(self.parent.scale_factor * 3))) + width, height = disabled_bitmap.GetSize() + memory_dc.SetPen(wx.Pen(wx.Colour(220, 0, 0), width=pen_width)) + memory_dc.DrawLine(margin, margin, width - margin, height - margin) + memory_dc.DrawLine(margin, height - margin, width - margin, margin) + finally: + memory_dc.SelectObject(wx.NullBitmap) + + return disabled_bitmap + + def update_plot_values(self, plot_values): + """Update settings dialog according to the settings.""" + if plot_values: + self.plot_values_setting.SetValue(plot_values) + self.plot_values_setting.SetLabel("Plot values on silkscreen") + self.plot_values_image.SetBitmap( + loadBitmapScaled( + "plot_values.png", self.parent.scale_factor, static=True + ) + ) + else: + self.plot_values_setting.SetValue(plot_values) + self.plot_values_setting.SetLabel("Don't plot values on silkscreen") + self.plot_values_image.SetBitmap( + loadBitmapScaled("no_values.png", self.parent.scale_factor, static=True) + ) + + def update_force_drc(self, force_drc): + """Update settings dialog according to the settings.""" + self.force_drc_setting.SetValue(bool(force_drc)) + self.force_drc_image.SetBitmap(self.build_force_drc_bitmap(bool(force_drc))) + if force_drc: + self.force_drc_setting.SetLabel( + "Force DRC check before Gerber export - Saves board and fills zones!" + ) + self.update_fill_zones(True) + self.fill_zones_setting.Disable() + else: + self.force_drc_setting.SetLabel( + "Do not force DRC check before Gerber export" + ) + self.fill_zones_setting.Enable() + + def update_plot_references(self, plot_references): + """Update settings dialog according to the settings.""" + if plot_references: + self.plot_references_setting.SetValue(plot_references) + self.plot_references_setting.SetLabel("Plot references on silkscreen") + self.plot_references_image.SetBitmap( + loadBitmapScaled("plot_refs.png", self.parent.scale_factor, static=True) + ) + else: + self.plot_references_setting.SetValue(plot_references) + self.plot_references_setting.SetLabel("Don't plot references on silkscreen") + self.plot_references_image.SetBitmap( + loadBitmapScaled("no_refs.png", self.parent.scale_factor, static=True) + ) + + def update_lcsc_priority(self, priority): + """Update settings dialog according to the settings.""" + if priority: + self.lcsc_priority_setting.SetValue(priority) + self.lcsc_priority_setting.SetLabel( + "LCSC numbers from schematic have priority" + ) + self.lcsc_priority_image.SetBitmap( + loadBitmapScaled("schematic.png", self.parent.scale_factor, static=True) + ) + else: + self.lcsc_priority_setting.SetValue(priority) + self.lcsc_priority_setting.SetLabel( + "LCSC numbers from database have priority" + ) + self.lcsc_priority_image.SetBitmap( + loadBitmapScaled( + "database-outline.png", self.parent.scale_factor, static=True + ) + ) + + def update_lcsc_bom_cpl(self, add): + """Update settings dialog according to the settings.""" + if add: + self.lcsc_bom_cpl_setting.SetValue(add) + self.lcsc_bom_cpl_setting.SetLabel( + "Add parts without LCSC number to BOM/POS" + ) + self.lcsc_bom_cpl_image.SetBitmap( + loadBitmapScaled("bom.png", self.parent.scale_factor, static=True) + ) + else: + self.lcsc_bom_cpl_setting.SetValue(add) + self.lcsc_bom_cpl_setting.SetLabel( + "Don't add parts without LCSC number to BOM/POS" + ) + self.lcsc_bom_cpl_image.SetBitmap( + loadBitmapScaled("no_bom.png", self.parent.scale_factor, static=True) + ) + + def update_order_number(self, check): + """Update settings dialog according to the settings.""" + self.logger.debug(check) + if check: + self.order_number_setting.SetValue(check) + self.order_number_setting.SetLabel( + "Check if an order/serial number placeholder is placed" + ) + self.order_number_image.SetBitmap( + loadBitmapScaled( + "order_number.png", self.parent.scale_factor, static=True + ) + ) + else: + self.order_number_setting.SetValue(check) + self.order_number_setting.SetLabel( + "Don't check if an order/serial number placeholder is placed" + ) + self.order_number_image.SetBitmap( + loadBitmapScaled( + "no_order_number.png", self.parent.scale_factor, static=True + ) + ) + + def update_highlight_matches(self, enabled): + """Update settings dialog according to the settings.""" + self.highlight_matches_setting.SetValue(bool(enabled)) + if enabled: + self.highlight_matches_setting.SetLabel("Highlight search matches") + else: + self.highlight_matches_setting.SetLabel("Do not highlight search matches") + + def load_settings(self): + """Load settings and set checkboxes accordingly.""" + self.update_tented_vias( + self.parent.settings.get("gerber", {}).get("tented_vias", True) + ) + self.update_fill_zones( + self.parent.settings.get("gerber", {}).get("fill_zones", True) + ) + self.update_force_drc( + self.parent.settings.get("gerber", {}).get("force_drc", False) + ) + self.update_plot_values( + self.parent.settings.get("gerber", {}).get("plot_values", True) + ) + self.update_plot_references( + self.parent.settings.get("gerber", {}).get("plot_references", True) + ) + self.update_lcsc_priority( + self.parent.settings.get("general", {}).get("lcsc_priority", True) + ) + self.update_lcsc_bom_cpl( + self.parent.settings.get("gerber", {}).get("lcsc_bom_cpl", True) + ) + self.update_order_number( + self.parent.settings.get("general", {}).get("order_number", True) + ) + self.update_highlight_matches( + self.parent.settings.get("partselector", {}).get("highlight_matches", True) + ) + self.update_selected_library( + self.parent.settings.get("library", {}).get( + "selected_library", "current-parts" + ) + ) + self.update_data_path( + self.parent.settings.get("library", {}).get("data_path", "") + ) + + def update_selected_library(self, library_key): + """Update settings dialog according to the selected library.""" + if library_key in LIBRARY_CONFIGS: + display_name = LIBRARY_CONFIGS[library_key].display_name + self.library_selected_setting.SetStringSelection(display_name) + + def update_data_path(self, data_path): + """Update settings dialog according to the configured data path.""" + value = data_path.strip() if isinstance(data_path, str) else "" + effective_path = value if value else self.parent.library.datadir + self.library_data_path_setting.SetPath(effective_path) + + def update_settings(self, event): + """Update and persist a setting that was changed.""" + section, name = event.GetEventObject().GetName().split("_", 1) + if hasattr(event.GetEventObject(), "GetPath"): + value = event.GetEventObject().GetPath() + else: + value = event.GetEventObject().GetValue() + self.logger.debug(section) + self.logger.debug(name) + self.logger.debug(value) + + # Special handling for library selection: convert display name back to key + if section == "library" and name == "selected_library": + # Find the key for this display name + for key, config in LIBRARY_CONFIGS.items(): + if config.display_name == value: + self.logger.debug("Selected library key: %s", key) + value = key + break + + # If forced DRC is enabled, fill zones must stay enabled. + if ( + section == "gerber" + and name == "fill_zones" + and self.force_drc_setting.GetValue() + ): + value = True + + getattr(self, f"update_{name}")(value) + + # Turning on forced DRC implies enabling fill zones. + if section == "gerber" and name == "force_drc" and value: + wx.PostEvent( + self.parent, + UpdateSetting( + section="gerber", + setting="fill_zones", + value=True, + ), + ) + + wx.PostEvent( + self.parent, + UpdateSetting( + section=section, + setting=name, + value=value, + ), + ) + + def quit_dialog(self, *_): + """Close this dialog.""" + self.Destroy() + self.EndModal(0) diff --git a/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py b/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py new file mode 100644 index 00000000..f63d7bc1 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py @@ -0,0 +1,134 @@ +"""Stubs for standalone usage of the plugin.""" + + +class LIB_ID_Stub: + """Implementation of pcbnew.LIB_ID.""" + + def __init__(self, item_name): + self.item_name = item_name + + def GetLibItemName(self) -> str: + """Item name.""" + return self.item_name + + +class Field_Stub: + """Implementation of pcbnew.Field.""" + + def __init__(self, name, text): + self.name = name + self.text = text + + def GetName(self) -> str: + """Field name.""" + return self.name + + def GetText(self) -> str: + """Field text.""" + return self.text + + def SetVisible(self, visible): + """Set the field visibility.""" + pass + + +class Footprint_Stub: + """Implementation of pcbnew.Footprint.""" + + def __init__(self, reference, value, fpid): + self.reference = reference + self.value = value + self.fpid = fpid + + def GetReference(self) -> str: + """Retrieve the reference designator string.""" + return self.reference + + def GetValue(self) -> str: + """Value string.""" + return self.value + + def GetFPID(self) -> LIB_ID_Stub: + """Footprint LIB_ID.""" + return self.fpid + + def GetProperties(self) -> dict: + """Properties.""" + return {} + + def GetAttributes(self) -> int: + """Attributes.""" + return 0 + + def GetFields(self) -> list: + """Fields.""" + return [] + + def SetField(self, name, text): + """Set a field.""" + pass + + def GetFieldByName(self, name) -> Field_Stub: + """Get a field by name.""" + return Field_Stub(name, "stub") + + def GetLayer(self) -> int: + """Layer number.""" + # TODO: maybe this is defined in a python module we can import and reuse here? + return 3 # F_Cu, see https://docs.kicad.org/doxygen/layer__ids_8h.html#ae0ad6e574332a997f501d1b091c3f53f + + def SetSelected(self): + """Select this item.""" + + +class BoardStub: + """Implementation of pcbnew.Board.""" + + def __init__(self): + self.footprints = [] + self.footprints.append(Footprint_Stub("R1", "100", LIB_ID_Stub("resistors"))) + + def GetFileName(self): + """Board filename.""" + return "fake_test_board.kicad_pcb" + + def GetFootprints(self): + """Footprint list.""" + return self.footprints + + def FindFootprintByReference(self, reference): + """Get a list of footprints that match a reference.""" + return Footprint_Stub(reference, "stub", 100) + + +class PcbnewStub: + """Stub implementation of pcbnew.""" + + def __init__(self): + self.board = BoardStub() + + def GetBoard(self): + """Get the board.""" + return self.board + + def GetBuildVersion(self): + """Get the kicad build version.""" + return "8.0.1" + + def GetCurrentSelection(self): + """Get the currently selected board items.""" + return [] + + def Refresh(self): + """Redraw the screen.""" + + +class KicadStub: + """Stub implementation of Kicad.""" + + def __init__(self): + self.pcbnew = PcbnewStub() + + def get_pcbnew(self): + """Get the pcbnew stub.""" + return self.pcbnew diff --git a/third_party/Bouni/kicad-jlcpcb-tools/store.py b/third_party/Bouni/kicad-jlcpcb-tools/store.py new file mode 100644 index 00000000..3111a185 --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/store.py @@ -0,0 +1,262 @@ +"""Contains the data storge for a project.""" + +import contextlib +import csv +import logging +import os +from pathlib import Path +import sqlite3 +from typing import Union + +from .helpers import ( + dict_factory, + get_exclude_from_bom, + get_exclude_from_pos, + get_lcsc_value, + get_valid_footprints, + natural_sort_collation, +) + + +class Store: + """A storage class to get data from a sqlite database and write it back.""" + + def __init__(self, parent, project_path, board): + self.logger = logging.getLogger(__name__) + self.parent = parent + self.project_path = project_path + self.board = board + self.datadir = os.path.join(self.project_path, "jlcpcb") + self.dbfile = os.path.join(self.datadir, "project.db") + self.order_by = "reference" + self.order_dir = "ASC" + self.setup() + self.update_from_board() + + def setup(self): + """Check if folders and database exist, setup if not.""" + if not os.path.isdir(self.datadir): + self.logger.info( + "Data directory 'jlcpcb' does not exist and will be created." + ) + Path(self.datadir).mkdir(parents=True, exist_ok=True) + self.create_db() + + def set_order_by(self, n: int): + """Set which value we want to order by when getting data from the database.""" + if n > 7: + return + # The following two cases are just a temporary hack and will eventually be replaced by + # direct sorting via DataViewListCtrl rather than via SQL query + if n == 4: + return + if n > 4: + n = n - 1 + order_by = [ + "reference", + "value", + "footprint", + "lcsc", + "stock", + "exclude_from_bom", + "exclude_from_pos", + ] + if self.order_by == order_by[n] and self.order_dir == "ASC": + self.order_dir = "DESC" + else: + self.order_by = order_by[n] + self.order_dir = "ASC" + + def create_db(self): + """Create the sqlite database tables.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "CREATE TABLE IF NOT EXISTS part_info (" + "reference NOT NULL PRIMARY KEY," + "value TEXT NOT NULL," + "footprint TEXT NOT NULL," + "lcsc TEXT," + "stock NUMERIC," + "exclude_from_bom NUMERIC DEFAULT 0," + "exclude_from_pos NUMERIC DEFAULT 0" + ")", + ) + cur.commit() + + def read_all(self) -> dict: + """Read all parts from the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + con.create_collation("naturalsort", natural_sort_collation) + con.row_factory = dict_factory + return cur.execute( + f"SELECT * FROM part_info ORDER BY {self.order_by} COLLATE naturalsort {self.order_dir}" + ).fetchall() + + def read_bom_parts(self) -> dict: + """Read all parts that should be included in the BOM.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + con.row_factory = dict_factory + # Query all parts that are supposed to be in the BOM an have an lcsc number, group the references together + subquery = "SELECT value, reference, footprint, lcsc FROM part_info WHERE exclude_from_bom = '0' AND lcsc != '' ORDER BY lcsc, reference" + query = f"SELECT value, GROUP_CONCAT(reference) AS refs, footprint, lcsc FROM ({subquery}) GROUP BY value, lcsc" + a = cur.execute(query).fetchall() + # Query all parts that are supposed to be in the BOM but have no lcsc number + query = "SELECT value, reference AS refs, footprint, lcsc FROM part_info WHERE exclude_from_bom = '0' AND lcsc = ''" + b = cur.execute(query).fetchall() + return a + b + + def create_part(self, part: dict): + """Create a part in the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "INSERT INTO part_info VALUES (:reference, :value, :footprint, :lcsc, '', :exclude_from_bom, :exclude_from_pos)", + part, + ) + cur.commit() + + def update_part(self, part: dict): + """Update a part in the database, overwrite lcsc if supplied.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "UPDATE part_info set value = :value, footprint = :footprint, lcsc = :lcsc, exclude_from_bom = :exclude_from_bom, exclude_from_pos = :exclude_from_pos WHERE reference = :reference", + part, + ) + cur.commit() + + def get_part(self, ref: str) -> dict: + """Get a part from the database by its reference.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + con.row_factory = dict_factory + return cur.execute( + "SELECT * FROM part_info WHERE reference = :reference", + {"reference": ref}, + ).fetchone() + + def set_stock(self, ref: str, stock: Union[int, None]): + """Set the stock value for a part in the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "UPDATE part_info SET stock = :stock WHERE reference = :reference", + {"reference": ref, "stock": stock}, + ) + cur.commit() + + def set_bom(self, ref: str, state: int): + """Change the BOM attribute for a part in the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "UPDATE part_info SET exclude_from_bom = :state WHERE reference = :reference", + {"reference": ref, "state": state}, + ) + cur.commit() + + def set_pos(self, ref: str, state: int): + """Change the POS attribute for a part in the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "UPDATE part_info SET exclude_from_pos = :state WHERE reference = :reference", + {"reference": ref, "state": state}, + ) + cur.commit() + + def set_lcsc(self, ref: str, lcsc: str): + """Change the LCSC attribute for a part in the database.""" + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + "UPDATE part_info SET lcsc = :lcsc WHERE reference = :reference", + {"reference": ref, "lcsc": lcsc}, + ) + cur.commit() + + def update_from_board(self): + """Read all footprints from the board and insert them into the database if they do not exist.""" + for fp in get_valid_footprints(self.board): + board_part = { + "reference": fp.GetReference(), + "value": fp.GetValue(), + "footprint": str(fp.GetFPID().GetLibItemName()), + "lcsc": get_lcsc_value(fp), + "exclude_from_bom": get_exclude_from_bom(fp), + "exclude_from_pos": get_exclude_from_pos(fp), + } + db_part = self.get_part(board_part["reference"]) + # if part is not in the database yet, create it + if not db_part: + self.logger.debug( + "Part %s does not exist in the database and will be created from the board.", + board_part["reference"], + ) + self.create_part(board_part) + # if the board part matches the db_part except for the LCSC and the stock value + elif [ + board_part["reference"], + board_part["value"], + board_part["footprint"], + board_part["exclude_from_bom"], + board_part["exclude_from_pos"], + ] == [ + db_part["reference"], + db_part["value"], + db_part["footprint"], + bool(db_part["exclude_from_bom"]), + bool(db_part["exclude_from_pos"]), + ]: + # if part in the database, has no lcsc value the board part has a lcsc value, update including lcsc + if db_part and not db_part["lcsc"] and board_part["lcsc"]: + self.logger.debug( + "Part %s is already in the database but without lcsc value, so the value supplied from the board will be set.", + board_part["reference"], + ) + self.update_part(board_part) + # if part in the database, has a lcsc value + elif db_part and db_part["lcsc"] and board_part["lcsc"]: + # update lcsc value as well if setting is accordingly + if not self.parent.settings.get("general", {}).get( + "lcsc_priority", True + ): + self.logger.debug( + "Part %s is already in the database and has a lcsc value, the value supplied from the board will be ignored.", + board_part["reference"], + ) + board_part["lcsc"] = db_part["lcsc"] + else: + self.logger.debug( + "Part %s is already in the database and has a lcsc value, the value supplied from the board will overwrite that in the database.", + board_part["reference"], + ) + self.update_part(board_part) + else: + # If something changed, we overwrite the part and dump the lcsc value or use the one supplied by the board + self.logger.debug( + "Part %s is already in the database but value, footprint, bom or pos values changed in the board file, part will be updated, lcsc overwritten/cleared.", + board_part["reference"], + ) + self.update_part(board_part) + self.import_legacy_assignments() + self.clean_database() + + def clean_database(self): + """Delete all parts from the database that are no longer present on the board.""" + refs = [f"'{fp.GetReference()}'" for fp in get_valid_footprints(self.board)] + with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur: + cur.execute( + f"DELETE FROM part_info WHERE reference NOT IN ({','.join(refs)})" + ) + cur.commit() + + def import_legacy_assignments(self): + """Check if assignments of an old version are found and merge them into the database.""" + csv_file = os.path.join(self.project_path, "jlcpcb", "part_assignments.csv") + if os.path.isfile(csv_file): + with open(csv_file, encoding="utf-8") as f: + csvreader = csv.DictReader( + f, fieldnames=("reference", "lcsc", "bom", "pos") + ) + for row in csvreader: + self.set_lcsc(row["reference"], row["lcsc"]) + self.set_bom(row["reference"], int(row["bom"])) + self.set_pos(row["reference"], int(row["pos"])) + self.logger.debug( + "Update %s from legacy 'part_assignments.csv'", row["reference"] + ) + os.rename(csv_file, f"{csv_file}.backup") diff --git a/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py b/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py new file mode 100644 index 00000000..0ce7602c --- /dev/null +++ b/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py @@ -0,0 +1,66 @@ +#!/bin/env python3 +"""Module for unziping and merging split db zip file.""" + +import logging +import os +from zipfile import ZipFile + +import wx # pylint: disable=import-error + +from .events import ( + UnzipCombiningProgressEvent, + UnzipCombiningStartedEvent, + UnzipExtractingCompletedEvent, + UnzipExtractingProgressEvent, + UnzipExtractingStartedEvent, +) + + +def unzip_parts(parent, path, zipfile: str) -> None: + """Unzip and merge split zip file.""" + logger = logging.getLogger(__name__) + logger.debug("Combine zip chunks") + wx.PostEvent(parent, UnzipCombiningStartedEvent()) + # unzip (needs to go into download function finally) + # Set the name of the original file + db_zip_file = os.path.join(path, zipfile) + + # Open the original file for writing + with open(db_zip_file, "wb") as db: + # Get a list of the split files in the split directory + split_files = [f for f in os.listdir(path) if f.startswith(f"{zipfile}.")] + + # Sort the split files by their index + split_files.sort(key=lambda f: int(f.split(".")[-1])) + + # Iterate over the split files and append their contents to the original file + for i, split_file_name in enumerate(split_files, 1): + split_path = os.path.join(path, split_file_name) + # Open the split file + with open(split_path, "rb") as split_file: + # Read the file data + while file_data := split_file.read(1024 * 1024): + # Append the file data to the original file + db.write(file_data) + + # Delete the split file + os.unlink(split_path) + progress = 100 / len(split_files) * i + wx.PostEvent(parent, UnzipCombiningProgressEvent(value=progress)) + + with ZipFile(db_zip_file, "r") as zf: + logger.debug("Extract zip file") + wx.PostEvent(parent, UnzipExtractingStartedEvent()) + file_info = zf.infolist()[0] + file_size = file_info.file_size + with ( + zf.open(file_info) as source, + open(os.path.join(path, file_info.filename), "wb") as target, + ): + for chunk in iter(lambda: source.read(1024 * 1024), b""): + target.write(chunk) + progress = target.tell() / file_size * 100 + wx.PostEvent(parent, UnzipExtractingProgressEvent(value=progress)) + + os.unlink(db_zip_file) + wx.PostEvent(parent, UnzipExtractingCompletedEvent()) diff --git a/tools/third_party_sync/repos.lock.json b/tools/third_party_sync/repos.lock.json index 25979bfb..822a953e 100644 --- a/tools/third_party_sync/repos.lock.json +++ b/tools/third_party_sync/repos.lock.json @@ -1,3 +1,11 @@ { - "repositories": {} + "repositories": { + "kicad-jlcpcb-tools": { + "published_at": "2026-04-14T07:16:06Z", + "resolved_commit": "de777bc9c458070dc6177134825bb6233fcd8aa6", + "resolved_release": "2026.04.02", + "source": "release", + "tarball_url": "https://api.github.com/repos/Bouni/kicad-jlcpcb-tools/tarball/2026.04.02" + } + } }