diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..24c022b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# 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://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + target-branch: "dev" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..59e3299 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ dev ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ dev ] + schedule: + - cron: '17 19 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4271072 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,93 @@ +name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI + +on: push + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/PyExcelerate + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.gitignore b/.gitignore index 73d1baa..d2074be 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ nosetests.xml .project .pydevproject +# VS Code +.vscode + # Misc .*.swp *.xlsx diff --git a/.travis.yml b/.travis.yml index 7491c5f..8db9920 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,21 @@ language: python python: - - "2.6" - "2.7" - - "3.3" - "3.4" - "3.5" + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "nightly" # command to install dependencies install: - pip install -r requirements.txt - pip install numpy # optional reqs + - pip install pytz # time zone data - pip install xlrd xlsxwriter openpyxl # competitors + - pip install memory_profiler futures # benchmark dependencies - pip install coverage coveralls # test coverage # command to run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fe0ac..8d65e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +0.10.0 +* Add support for XLSM files from PR #101 (thanks @caffeinatedMike) + +0.9.0 +* Fix unintentional trimming of whitespace on strings +* Filter invalid XML characters to prevent corrupted Excel files from being saved + +0.8.0 +* Add ability to enable auto filters +* Add ability to write to file handle + +0.7.3 +* Performance optimizations +* Fix invalid function/formula references in third-party spreadsheet software +* Remove Python 2.6 support from test suite; Python 2.6 will no longer be supported in future 0.8.0 release + +0.7.2 (November 15, 2017) +* Strip tzinfo from datetime objects (issue #59) +* Minor bug fixes and performance improvements +* Remove support for Python 3.2 +* Add support for Python 3.6 + 0.7.1 (August 5, 2016) * Revert to hardcoded version string in setup.py diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..6cd11fd --- /dev/null +++ b/Pipfile @@ -0,0 +1,23 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +nose = "*" +openpyxl = "*" +xlsxwriter = "*" +memory-profiler = "*" +pytz = "*" +black = "*" +numpy = "*" + +[packages] +six = ">=1.4.0" +Jinja2 = "*" + +[scripts] +test = "nosetests" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..1f277e1 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,295 @@ +{ + "_meta": { + "hash": { + "sha256": "84bdc8b6da6abe86ee6a717ef92cb7bf7dc814914b19e79560ce16c0e208aebd" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "index": "pypi", + "version": "==3.1.2" + }, + "markupsafe": { + "hashes": [ + "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", + "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", + "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", + "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", + "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", + "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", + "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", + "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", + "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", + "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", + "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", + "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", + "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", + "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", + "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", + "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", + "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", + "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", + "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", + "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", + "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", + "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", + "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", + "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", + "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", + "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", + "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", + "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", + "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", + "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", + "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", + "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", + "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", + "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", + "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", + "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", + "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", + "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", + "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", + "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", + "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", + "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.3" + }, + "six": { + "hashes": [ + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.17.0" + } + }, + "develop": { + "black": { + "hashes": [ + "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", + "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", + "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", + "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", + "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", + "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", + "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", + "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", + "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", + "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", + "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", + "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", + "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", + "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", + "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", + "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", + "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", + "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", + "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", + "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", + "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", + "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==25.1.0" + }, + "click": { + "hashes": [ + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.8" + }, + "et-xmlfile": { + "hashes": [ + "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", + "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + ], + "markers": "python_version >= '3.6'", + "version": "==1.1.0" + }, + "memory-profiler": { + "hashes": [ + "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84", + "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0" + ], + "index": "pypi", + "version": "==0.61.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "index": "pypi", + "version": "==1.3.7" + }, + "numpy": { + "hashes": [ + "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", + "sha256:0d54974f9cf14acf49c60f0f7f4084b6579d24d439453d5fc5805d46a165b542", + "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", + "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", + "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", + "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", + "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", + "sha256:218f061d2faa73621fa23d6359442b0fc658d5b9a70801373625d958259eaca3", + "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", + "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", + "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", + "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", + "sha256:4ba5054787e89c59c593a4169830ab362ac2bee8a969249dc56e5d7d20ff8df9", + "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", + "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", + "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", + "sha256:7051ee569db5fbac144335e0f3b9c2337e0c8d5c9fee015f259a5bd70772b7e8", + "sha256:7716e4a9b7af82c06a2543c53ca476fa0b57e4d760481273e09da04b74ee6ee2", + "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", + "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", + "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", + "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", + "sha256:8146f3550d627252269ac42ae660281d673eb6f8b32f113538e0cc2a9aed42b9", + "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", + "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", + "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", + "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", + "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", + "sha256:a0258ad1f44f138b791327961caedffbf9612bfa504ab9597157806faa95194a", + "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", + "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", + "sha256:a84eda42bd12edc36eb5b53bbcc9b406820d3353f1994b6cfe453a33ff101775", + "sha256:ab2939cd5bec30a7430cbdb2287b63151b77cf9624de0532d629c9a1c59b1d5c", + "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", + "sha256:adf8c1d66f432ce577d0197dceaac2ac00c0759f573f28516246351c58a85020", + "sha256:b4adfbbc64014976d2f91084915ca4e626fbf2057fb81af209c1a6d776d23e3d", + "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", + "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", + "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", + "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", + "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", + "sha256:d0f35b19894a9e08639fd60a1ec1978cb7f5f7f1eace62f38dd36be8aecdef4d", + "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", + "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", + "sha256:df2f57871a96bbc1b69733cd4c51dc33bea66146b8c63cacbfed73eec0883017", + "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", + "sha256:e642d86b8f956098b564a45e6f6ce68a22c2c97a04f5acd3f221f57b8cb850ae", + "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", + "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", + "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", + "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", + "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", + "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", + "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", + "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==2.2.4" + }, + "openpyxl": { + "hashes": [ + "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", + "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==3.1.5" + }, + "packaging": { + "hashes": [ + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + ], + "markers": "python_version >= '3.8'", + "version": "==24.2" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" + ], + "markers": "python_version >= '3.8'", + "version": "==4.3.6" + }, + "psutil": { + "hashes": [ + "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d", + "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217", + "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4", + "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c", + "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f", + "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da", + "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4", + "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42", + "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5", + "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4", + "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9", + "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f", + "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30", + "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==5.9.5" + }, + "pytz": { + "hashes": [ + "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", + "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" + ], + "index": "pypi", + "version": "==2025.2" + }, + "xlsxwriter": { + "hashes": [ + "sha256:593f8296e8a91790c6d0378ab08b064f34a642b3feb787cf6738236bd0a4860d", + "sha256:ad6fd41bdcf1b885876b1f6b7087560aecc9ae5a9cc2ba97dcac7ab2e210d3d5" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.2.3" + } + } +} diff --git a/README.md b/README.md index 27979b6..71c80d9 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Accelerated Excel XLSX writing library for Python -master: [![build-status-master](https://travis-ci.org/kz26/PyExcelerate.svg?branch=master)](https://travis-ci.org/kz26/PyExcelerate) -dev: [![build-status-dev](https://travis-ci.org/kz26/PyExcelerate.svg?branch=dev)](https://travis-ci.org/kz26/PyExcelerate) -test coverage: [![coverage-status](https://coveralls.io/repos/kz26/PyExcelerate/badge.svg)](https://coveralls.io/r/kz26/PyExcelerate) +[![coverage-status](https://coveralls.io/repos/kz26/PyExcelerate/badge.svg)](https://coveralls.io/r/kz26/PyExcelerate) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyexcelerate) +![PyPI - Version](https://img.shields.io/pypi/v/pyexcelerate) * Authors: [Kevin Wang](https://github.com/kevmo314) and [Kevin Zhang](https://github.com/kz26) * Copyright 2015 Kevin Wang, Kevin Zhang. Portions copyright Google, Inc. @@ -13,6 +13,7 @@ test coverage: [![coverage-status](https://coveralls.io/repos/kz26/PyExcelerate/ * [PyPI page](https://pypi.python.org/pypi/PyExcelerate) ## Description + PyExcelerate is a Python for writing Excel-compatible XLSX spreadsheet files, with an emphasis on speed. @@ -42,7 +43,7 @@ Ubuntu 12.04 LTS, Core i5-3450, 8GB DDR3, Python 2.7.3 ## Installation -PyExcelerate is supported on Python 2.6, 2.7, 3.3, 3.4, and 3.5. +PyExcelerate is supported on Python 2.7, 3.4, 3.5, 3.6, 3.7, and 3.8. pip install pyexcelerate @@ -65,18 +66,27 @@ wb.save("output.xlsx") ### Writing bulk data to a range -PyExcelerate also permits you to write data to ranges directly, which is faster than writing cell-by-cell. +PyExcelerate also permits you to write data to ranges directly, which is faster than writing cell-by-cell. If writing a Pandas DataFrame, see the [note on compatibility](#Pandas-DataFrames). #### Fastest ```python from pyexcelerate import Workbook +wb = Workbook() +ws = wb.new_sheet("test", data=[[1, 2], [3, 4]]) +wb.save("output.xlsx") +``` + +#### Fast + +```python +from pyexcelerate import Workbook + wb = Workbook() ws = wb.new_sheet("test") ws.range("B2", "C3").value = [[1, 2], [3, 4]] wb.save("output.xlsx") - ``` ### Writing cell data @@ -94,7 +104,6 @@ ws.set_cell_value(1, 2, 20) ws.set_cell_value(1, 3, "=SUM(A1,B1)") # a formula ws.set_cell_value(1, 4, datetime.now()) # a date wb.save("output.xlsx") - ``` #### Fast @@ -110,7 +119,6 @@ ws[1][2].value = 20 ws[1][3].value = "=SUM(A1,B1)" # a formula ws[1][4].value = datetime.now() # a date wb.save("output.xlsx") - ``` ### Selecting cells by name @@ -122,7 +130,6 @@ wb = Workbook() ws = wb.new_sheet("sheet name") ws.cell("A1").value = 12 wb.save("output.xlsx") - ``` ### Merging cells @@ -135,7 +142,6 @@ ws = wb.new_sheet("sheet name") ws[1][1].value = 15 ws.range("A1", "B1").merge() wb.save("output.xlsx") - ``` ### Styling cells @@ -184,7 +190,6 @@ ws.get_cell_style(1, 1).fill.background = Color(0, 255, 0, 0) ws.set_cell_value(1, 2, datetime.now()) ws.get_cell_style(1, 1).format.format = 'mm/dd/yy' wb.save("output.xlsx") - ``` #### Fast @@ -203,7 +208,6 @@ ws[1][1].style.fill.background = Color(0, 255, 0, 0) ws[1][2].value = datetime.now() ws[1][2].style.format.format = 'mm/dd/yy' wb.save("output.xlsx") - ``` **Note** that `.style.format.format`'s repetition is due to planned support for conditional formatting and other related features. The formatting syntax may be improved in the future. @@ -237,7 +241,6 @@ wb = Workbook() ws = wb.new_sheet("sheet name") ws.set_row_style(1, Style(fill=Fill(background=Color(255,0,0,0)))) wb.save("output.xlsx") - ``` #### Faster @@ -263,7 +266,6 @@ wb = Workbook() ws = wb.new_sheet("sheet name") ws[1].style.fill.background = Color(255, 0, 0) wb.save("output.xlsx") - ``` ### Styling columns @@ -278,7 +280,6 @@ wb = Workbook() ws = wb.new_sheet("sheet name") ws.set_col_style(1, Style(fill=Fill(background=Color(255,0,0,0)))) wb.save("output.xlsx") - ``` ### Available style attributes @@ -298,6 +299,8 @@ ws[1][1].style.alignment.rotation = 90 ws[1][1].style.alignment.wrap_text = True ws[1][1].style.borders.top.color = Color(255, 0, 0) ws[1][1].style.borders.right.style = '-.' +ws[1][1].style.data_type = DataTypes.INLINE_STRING +ws[1][1].style.quote_prefix = True ``` Each attribute also has constructors for implementing via `set_cell_style()`. @@ -319,9 +322,8 @@ from datetime import datetime wb = Workbook() ws = wb.new_sheet("sheet name") -ws.set_col_style(2, Style(size=0))) +ws.set_col_style(2, Style(size=0)) wb.save("output.xlsx") - ``` ### Linked styles @@ -337,9 +339,18 @@ ws[1][1].value = 1 font = Font(bold=True, italic=True, underline=True, strikethrough=True) ws[1][1].style.font = font wb.save("output.xlsx") +``` + +## Pandas DataFrames +PyExcelerate does not support directly passing a Pandas DataFrame as the data argument to a new worksheet. If the sheet does not require having the headers rendered, the most efficient solution is: + +```python +ws = wb.new_sheet("sheet name", data=df.values.tolist()) ``` +Note that the conversion `.tolist()` is faster as PyExcelerate has some optimizations surrounding data that's provided in lists. If the sheet needs to have headers rendered, consider [asking the Pandas maintainers](https://github.com/pandas-dev/pandas/issues/4517) to integrate PyExcelerate, [use a transformation function](https://gist.github.com/mapa17/bc04be36e447cab0746a0ec8903cc49f), or convert your DataFrame to a list with the headers included. + ## Packaging with PyInstaller PyInstaller is the only packager officially supported by PyExcelerate. Copy hook-pyexcelerate.Writer.py to your PyInstaller hooks directory. diff --git a/README.rst b/README.rst index 8cd97fa..71c5a11 100644 --- a/README.rst +++ b/README.rst @@ -343,7 +343,7 @@ For example, to hide column B: wb = Workbook() ws = wb.new_sheet("sheet name") - ws.set_col_style(2, Style(size=0))) + ws.set_col_style(2, Style(size=0)) wb.save("output.xlsx") Linked styles @@ -363,6 +363,20 @@ attribute as well. This permits you to modify the style at a later time. ws[1][1].style.font = font wb.save("output.xlsx") +Hidden sheet +~~~~~~~~~~~~ + +PyExcelerate supports adding hidden sheets. Note that the first sheet cannot be hidden. + +:: + + from pyexcelerate import Workbook + + wb = Workbook() + ws = wb.new_sheet("visible sheet") + ws = wb.new_sheet("hidden sheet", hidden=True) + wb.save("output.xlsx") + Packaging with PyInstaller -------------------------- diff --git a/hook-pyexcelerate.Writer.py b/hook-pyexcelerate.Writer.py index bb522d7..d410965 100644 --- a/hook-pyexcelerate.Writer.py +++ b/hook-pyexcelerate.Writer.py @@ -1,18 +1,14 @@ -#----------------------------------------------------------------------------- -# Copyright (c) 2013, PyInstaller Development Team. +# ------------------------------------------------------------------ +# Copyright (c) 2020 PyInstaller Development Team. # -# Distributed under the terms of the GNU General Public License with exception -# for distributing bootloader. +# This file is distributed under the terms of the GNU General Public +# License (version 2.0 or later). # -# The full license is in the file COPYING.txt, distributed with this software. -#----------------------------------------------------------------------------- - -import os -from PyInstaller.hooks.hookutils import exec_statement - -template_path = exec_statement('from pyexcelerate.Writer import _TEMPLATE_PATH as tp; print tp') - -datas = [ - (os.path.join(template_path, '*'), os.path.join('pyexcelerate', 'templates')) -] +# The full license is available in LICENSE.GPL.txt, distributed with +# this software. +# +# SPDX-License-Identifier: GPL-2.0-or-later +# ------------------------------------------------------------------ +from PyInstaller.utils.hooks import collect_data_files +datas = collect_data_files('pyexcelerate') diff --git a/pyexcelerate/Alignment.py b/pyexcelerate/Alignment.py index b5d1f24..28f86a4 100644 --- a/pyexcelerate/Alignment.py +++ b/pyexcelerate/Alignment.py @@ -2,86 +2,116 @@ from . import Utility from . import Color + class Alignment(object): - def __init__(self, horizontal='left', vertical='bottom', rotation=0, wrap_text=False): - self._horizontal = horizontal - self._vertical = vertical - self._rotation = rotation - self._wrap_text = wrap_text - - @property - def wrap_text(self): - return self._wrap_text - - @wrap_text.setter - def wrap_text(self, value): - if value not in (True, False): - raise TypeError('Invalid wrap text alignment value. Expects either True or False.') - self._wrap_text = value - - @property - def horizontal(self): - return self._horizontal - - @horizontal.setter - def horizontal(self, value): - if value not in ('left', 'center', 'right'): - raise ValueError('Invalid horizontal alignment value. Expects either \'left\', \'center\', or \'right\'.') - self._horizontal = value - - @property - def vertical(self): - return self._vertical - - @vertical.setter - def vertical(self, value): - if value not in ('top', 'center', 'bottom'): - raise ValueError('Invalid vertical alignment value. Expects either \'top\', \'center\', or \'bottom\'.') - self._vertical = value - - @property - def rotation(self): - return self._rotation - - @rotation.setter - def rotation(self, value): - self._rotation = (value % 360) - - @property - def is_default(self): - return self._horizontal == 'left' and self._vertical == 'bottom' and self._rotation == 0 and not self._wrap_text - - def get_xml_string(self): - return "" % (self._horizontal, self._vertical, self._rotation, 1 if self._wrap_text else 0) - - def __or__(self, other): - return self._binary_operation(other, Utility.nonboolean_or) - - def __and__(self, other): - return self._binary_operation(other, Utility.nonboolean_and) - - def __xor__(self, other): - return self._binary_operation(other, Utility.nonboolean_xor) - - def _binary_operation(self, other, operation): - return Alignment( \ - horizontal = operation(self._horizontal, other._horizontal, 'left'), \ - vertical = operation(self._vertical, other._vertical, 'bottom'), \ - rotation = operation(self._rotation, other._rotation, 0), \ - wrap_text = operation(self._wrap_text, other._wrap_text, False) - ) - - def __eq__(self, other): - if other is None: - return self.is_default - elif Utility.YOLO: - return self._vertical == other._vertical and self._rotation == other._rotation - else: - return self._vertical == other._vertical and self._rotation == other._rotation and self._horizontal == other._horizontal and self._wrap_text == other._wrap_text - - def __hash__(self): - return hash((self._horizontal, self._wrap_text)) - - def __str__(self): - return "Align: %s %s %s" % (self._horizontal, self._vertical, self._rotation) - + __slots__ = ("_horizontal", "_vertical", "_rotation", "_wrap_text", "id") + + def __init__( + self, horizontal="left", vertical="bottom", rotation=0, wrap_text=False + ): + self._horizontal = horizontal + self._vertical = vertical + self._rotation = rotation + self._wrap_text = wrap_text + + @property + def wrap_text(self): + return self._wrap_text + + @wrap_text.setter + def wrap_text(self, value): + if value not in (True, False): + raise TypeError( + "Invalid wrap text alignment value. Expects either True or False." + ) + self._wrap_text = value + + @property + def horizontal(self): + return self._horizontal + + @horizontal.setter + def horizontal(self, value): + if value not in ("left", "center", "right"): + raise ValueError( + "Invalid horizontal alignment value. Expects either 'left', 'center', or 'right'." + ) + self._horizontal = value + + @property + def vertical(self): + return self._vertical + + @vertical.setter + def vertical(self, value): + if value not in ("top", "center", "bottom"): + raise ValueError( + "Invalid vertical alignment value. Expects either 'top', 'center', or 'bottom'." + ) + self._vertical = value + + @property + def rotation(self): + return self._rotation + + @rotation.setter + def rotation(self, value): + self._rotation = value % 360 + + @property + def is_default(self): + return ( + self._horizontal == "left" + and self._vertical == "bottom" + and self._rotation == 0 + and not self._wrap_text + ) + + def get_xml_string(self): + return ( + '' + % ( + self._horizontal, + self._vertical, + self._rotation, + 1 if self._wrap_text else 0, + ) + ) + + def __or__(self, other): + return self._binary_operation(other, Utility.nonboolean_or) + + def __and__(self, other): + return self._binary_operation(other, Utility.nonboolean_and) + + def __xor__(self, other): + return self._binary_operation(other, Utility.nonboolean_xor) + + def _binary_operation(self, other, operation): + return Alignment( + horizontal=operation(self._horizontal, other._horizontal, "left"), + vertical=operation(self._vertical, other._vertical, "bottom"), + rotation=operation(self._rotation, other._rotation, 0), + wrap_text=operation(self._wrap_text, other._wrap_text, False), + ) + + def __eq__(self, other): + if other is None: + return self.is_default + elif Utility.YOLO: + return ( + self._vertical == other._vertical and self._rotation == other._rotation + ) + else: + return ( + self._vertical == other._vertical + and self._rotation == other._rotation + and self._horizontal == other._horizontal + and self._wrap_text == other._wrap_text + ) + + def __hash__(self): + return hash((self._horizontal, self._wrap_text)) + + def __str__(self): + return "Align: %s %s %s" % (self._horizontal, self._vertical, self._rotation) diff --git a/pyexcelerate/Border.py b/pyexcelerate/Border.py index 0a704c9..d656c98 100644 --- a/pyexcelerate/Border.py +++ b/pyexcelerate/Border.py @@ -1,65 +1,68 @@ from . import Utility from . import Color + # # An object representing a single border # class Border(object): - STYLE_MAPPING = { \ - 'dashDot': ('.-', '-.', 'dash dot'), \ - 'dashDotDot': ('..-', '-..', 'dash dot dot'), \ - 'dashed': ('--'), \ - 'dotted': ('..', ':'), \ - 'double': ('='), \ - 'hair': ('hairline', '.'), \ - 'medium': (), \ - 'mediumDashDot': ('medium dash dot', 'medium -.', 'medium .-'), \ - 'mediumDashDotDot': ('medium dash dot dot', 'medium -..', 'medium ..-'), \ - 'mediumDashed': ('medium dashed', 'medium --'), \ - 'slantDashDot': ('/-.', 'slant dash dot'), \ - 'thick': (), \ - 'thin': ('_') \ - } - - def __init__(self, color=None, style='thin'): - self._color = color - self._style = Border.get_style_name(style) - - @property - def color(self): - return Utility.lazy_get(self, '_color', Color.Color(0, 0, 0)) - - @color.setter - def color(self, value): - Utility.lazy_set(self, '_color', None, value) - - @property - def style(self): - return self._style - - @style.setter - def style(self, value): - self._style = Border.get_style_name(value) - - @staticmethod - def get_style_name(style): - for key, values in Border.STYLE_MAPPING.items(): - if style == key or style in values: - return key - # TODO: warn the user? - return 'thin' - - @property - def is_default(self): - return self._color is None and self._style == 'thin' - - def __eq__(self, other): - if other is None: - return self.is_default - elif Utility.YOLO: - return self._color == other._color - else: - return self._color == other._color and self._style == other._style - - def __hash__(self): - return hash(self._style) + STYLE_MAPPING = { + "dashDot": (".-", "-.", "dash dot"), + "dashDotDot": ("..-", "-..", "dash dot dot"), + "dashed": ("--"), + "dotted": ("..", ":"), + "double": ("="), + "hair": ("hairline", "."), + "medium": (), + "mediumDashDot": ("medium dash dot", "medium -.", "medium .-"), + "mediumDashDotDot": ("medium dash dot dot", "medium -..", "medium ..-"), + "mediumDashed": ("medium dashed", "medium --"), + "slantDashDot": ("/-.", "slant dash dot"), + "thick": (), + "thin": ("_"), + } + + __slots__ = ("_color", "_style", "id") + + def __init__(self, color=None, style="thin"): + self._color = color + self._style = Border.get_style_name(style) + + @property + def color(self): + return Utility.lazy_get(self, "_color", Color.Color(0, 0, 0)) + + @color.setter + def color(self, value): + Utility.lazy_set(self, "_color", None, value) + + @property + def style(self): + return self._style + + @style.setter + def style(self, value): + self._style = Border.get_style_name(value) + + @staticmethod + def get_style_name(style): + for key, values in Border.STYLE_MAPPING.items(): + if style == key or style in values: + return key + # TODO: warn the user? + return "thin" + + @property + def is_default(self): + return self._color is None and self._style == "thin" + + def __eq__(self, other): + if other is None: + return self.is_default + elif Utility.YOLO: + return self._color == other._color + else: + return self._color == other._color and self._style == other._style + + def __hash__(self): + return hash(self._style) diff --git a/pyexcelerate/Borders.py b/pyexcelerate/Borders.py index 1476631..040f640 100644 --- a/pyexcelerate/Borders.py +++ b/pyexcelerate/Borders.py @@ -2,91 +2,111 @@ from . import Utility from . import Border + class Borders(object): - def __init__(self, left=None, right=None, top=None, bottom=None): - self._left = left - self._right = right - self._top = top - self._bottom = bottom - - @property - def left(self): - return Utility.lazy_get(self, '_left', Border.Border()) - - @left.setter - def left(self, value): - Utility.lazy_set(self, '_left', None, value) - - @property - def right(self): - return Utility.lazy_get(self, '_right', Border.Border()) - - @right.setter - def right(self, value): - Utility.lazy_set(self, '_right', None, value) - - @property - def top(self): - return Utility.lazy_get(self, '_top', Border.Border()) - - @top.setter - def top(self, value): - Utility.lazy_set(self, '_top', None, value) - - @property - def bottom(self): - return Utility.lazy_get(self, '_bottom', Border.Border()) - - @bottom.setter - def bottom(self, value): - Utility.lazy_set(self, '_bottom', None, value) - - @property - def is_default(self): - return not (self._left or self._right or self._top or self._bottom) - - def get_xml_string(self): - tokens = [''] - if self._left: - tokens.append("" % (self._left.style, self._left.color.hex)) - else: - tokens.append("") - if self._right: - tokens.append("" % (self._right.style, self._right.color.hex)) - else: - tokens.append("") - if self._top: - tokens.append("" % (self._top.style, self._top.color.hex)) - else: - tokens.append("") - if self._bottom: - tokens.append("" % (self._bottom.style, self._bottom.color.hex)) - else: - tokens.append("") - tokens.append("") - return ''.join(tokens) - - def __or__(self, other): - return self._binary_operation(other, Utility.nonboolean_or) - - def __and__(self, other): - return self._binary_operation(other, Utility.nonboolean_and) - - def __xor__(self, other): - return self._binary_operation(other, Utility.nonboolean_xor) - - def _binary_operation(self, other, operation): - return Borders( \ - top = operation(self._top, other._top, None), \ - left = operation(self._left, other._left, None), \ - right = operation(self._right, other._right, None), \ - bottom = operation(self._bottom, other._bottom, None) \ - ) - - def __eq__(self, other): - if other is None: - return self.is_default - return self._right == other._right and self._bottom == other._bottom and self._top == other._top and self._left == other._left - - def __hash__(self): - return hash((self._top, self._left)) + __slots__ = ("_left", "_right", "_top", "_bottom", "id") + + def __init__(self, left=None, right=None, top=None, bottom=None): + self._left = left + self._right = right + self._top = top + self._bottom = bottom + + @property + def left(self): + return Utility.lazy_get(self, "_left", Border.Border()) + + @left.setter + def left(self, value): + Utility.lazy_set(self, "_left", None, value) + + @property + def right(self): + return Utility.lazy_get(self, "_right", Border.Border()) + + @right.setter + def right(self, value): + Utility.lazy_set(self, "_right", None, value) + + @property + def top(self): + return Utility.lazy_get(self, "_top", Border.Border()) + + @top.setter + def top(self, value): + Utility.lazy_set(self, "_top", None, value) + + @property + def bottom(self): + return Utility.lazy_get(self, "_bottom", Border.Border()) + + @bottom.setter + def bottom(self, value): + Utility.lazy_set(self, "_bottom", None, value) + + @property + def is_default(self): + return not (self._left or self._right or self._top or self._bottom) + + def get_xml_string(self): + tokens = [""] + if self._left: + tokens.append( + '' + % (self._left.style, self._left.color.hex) + ) + else: + tokens.append("") + if self._right: + tokens.append( + '' + % (self._right.style, self._right.color.hex) + ) + else: + tokens.append("") + if self._top: + tokens.append( + '' + % (self._top.style, self._top.color.hex) + ) + else: + tokens.append("") + if self._bottom: + tokens.append( + '' + % (self._bottom.style, self._bottom.color.hex) + ) + else: + tokens.append("") + tokens.append("") + return "".join(tokens) + + def __or__(self, other): + return self._binary_operation(other, Utility.nonboolean_or) + + def __and__(self, other): + return self._binary_operation(other, Utility.nonboolean_and) + + def __xor__(self, other): + return self._binary_operation(other, Utility.nonboolean_xor) + + def _binary_operation(self, other, operation): + return Borders( + top=operation(self._top, other._top, None), + left=operation(self._left, other._left, None), + right=operation(self._right, other._right, None), + bottom=operation(self._bottom, other._bottom, None), + ) + + def __eq__(self, other): + if other is None: + return self.is_default + return ( + self._right == other._right + and self._bottom == other._bottom + and self._top == other._top + and self._left == other._left + ) + + def __hash__(self): + return hash((self._top, self._left)) diff --git a/pyexcelerate/Color.py b/pyexcelerate/Color.py index f7ccdc2..cc35956 100644 --- a/pyexcelerate/Color.py +++ b/pyexcelerate/Color.py @@ -1,24 +1,32 @@ class Color(object): - def __init__(self, r=255, g=255, b=255, a=255): - self.r = r - self.g = g - self.b = b - self.a = a + __slots__ = ("r", "g", "b", "a") - @property - def hex(self): - return '%0.2X%0.2X%0.2X%0.2X' % (self.a, self.r, self.g, self.b) - - def __hash__(self): - return (self.a << 24) + (self.r << 16) + (self.g << 8) + (self.b) - - def __eq__(self, other): - if not other: - return False - return self.r == other.r and self.g == other.g and self.b == other.b and self.a == other.a + def __init__(self, r=255, g=255, b=255, a=255): + self.r = r + self.g = g + self.b = b + self.a = a + + @property + def hex(self): + return "%0.2X%0.2X%0.2X%0.2X" % (self.a, self.r, self.g, self.b) + + def __hash__(self): + return (self.a << 24) + (self.r << 16) + (self.g << 8) + (self.b) + + def __eq__(self, other): + if not other: + return False + return ( + self.r == other.r + and self.g == other.g + and self.b == other.b + and self.a == other.a + ) + + def __str__(self): + return self.hex - def __str__(self): - return self.hex Color.WHITE = Color(255, 255, 255, 255) -Color.BLACK = Color(0, 0, 0, 255) \ No newline at end of file +Color.BLACK = Color(0, 0, 0, 255) diff --git a/pyexcelerate/DataTypes.py b/pyexcelerate/DataTypes.py index 18dc18d..5d4a4a0 100644 --- a/pyexcelerate/DataTypes.py +++ b/pyexcelerate/DataTypes.py @@ -1,63 +1,90 @@ -from datetime import datetime, date, time import decimal +import warnings +from datetime import date, datetime, time + import six + try: - import numpy as np - HAS_NUMPY = True + import numpy as np + + HAS_NUMPY = True except: - HAS_NUMPY = False + HAS_NUMPY = False + class DataTypes(object): - BOOLEAN = 0 - DATE = 1 - ERROR = 2 - INLINE_STRING = 3 - NUMBER = 4 - SHARED_STRING = 5 - STRING = 6 - FORMULA = 7 - EXCEL_BASE_DATE = datetime(1900, 1, 1, 0, 0, 0) - - _numberTypes = six.integer_types + (float, complex, decimal.Decimal) - - @staticmethod - def get_type(value): - # Using value.__class__ over isinstance for speed - if value.__class__ in six.string_types: - if len(value) > 0 and value[0] == '=': - return DataTypes.FORMULA - else: - return DataTypes.INLINE_STRING - # not using in (int, float, long, complex) for speed - elif value.__class__ == bool: - return DataTypes.BOOLEAN - elif value.__class__ in DataTypes._numberTypes: - return DataTypes.NUMBER - # fall back to the slower isinstance - elif isinstance(value, six.string_types): - if len(value) > 0 and value[0] == '=': - return DataTypes.FORMULA - else: - return DataTypes.INLINE_STRING - elif isinstance(value, bool): - return DataTypes.BOOLEAN - elif isinstance(value, DataTypes._numberTypes): - return DataTypes.NUMBER - elif HAS_NUMPY and isinstance(value, (np.floating, np.integer, np.complexfloating, np.unsignedinteger)): - return DataTypes.NUMBER - elif isinstance(value, (datetime, date, time)): - return DataTypes.DATE - else: - return DataTypes.ERROR - - @staticmethod - def to_excel_date(d): - if isinstance(d, datetime): - delta = d - DataTypes.EXCEL_BASE_DATE - excel_date = delta.days + (float(delta.seconds) + float(delta.microseconds) / 1E6) / (60 * 60 * 24) + 1 - return excel_date + (excel_date > 59) - elif isinstance(d, date): - # this is why python sucks >.< - return DataTypes.to_excel_date(datetime(*(d.timetuple()[:6]))) - elif isinstance(d, time): - return DataTypes.to_excel_date(datetime(*(DataTypes.EXCEL_BASE_DATE.timetuple()[:3]), hour=d.hour, minute=d.minute, second=d.second, microsecond=d.microsecond)) - 1 + BOOLEAN = 0 + DATE = 1 + ERROR = 2 + INLINE_STRING = 3 + NUMBER = 4 + SHARED_STRING = 5 + STRING = 6 + FORMULA = 7 + EXCEL_BASE_DATE = datetime(1900, 1, 1, 0, 0, 0) + + _numberTypes = six.integer_types + (float, complex, decimal.Decimal) + + @staticmethod + def get_type(value): + # Using value.__class__ over isinstance for speed + if value.__class__ in six.string_types: + if len(value) > 0 and value[0] == "=": + return DataTypes.FORMULA + else: + return DataTypes.INLINE_STRING + # not using in (int, float, long, complex) for speed + elif value.__class__ == bool: + return DataTypes.BOOLEAN + elif value.__class__ in DataTypes._numberTypes: + return DataTypes.NUMBER + # fall back to the slower isinstance + elif isinstance(value, six.string_types): + if len(value) > 0 and value[0] == "=": + return DataTypes.FORMULA + else: + return DataTypes.INLINE_STRING + elif isinstance(value, bool): + return DataTypes.BOOLEAN + elif isinstance(value, DataTypes._numberTypes): + return DataTypes.NUMBER + elif HAS_NUMPY and isinstance( + value, (np.floating, np.integer, np.complexfloating, np.unsignedinteger) + ): + return DataTypes.NUMBER + elif isinstance(value, (datetime, date, time)): + return DataTypes.DATE + else: + return DataTypes.ERROR + + @staticmethod + def to_excel_date(d): + if isinstance(d, datetime): + if d.tzinfo is not None: + warnings.warn( + "Excel does not support timestamps with time zone information. Time zones will be ignored." + ) + delta = d.replace(tzinfo=None) - DataTypes.EXCEL_BASE_DATE + excel_date = ( + delta.days + + (float(delta.seconds) + float(delta.microseconds) / 1e6) + / (60 * 60 * 24) + + 1 + ) + return excel_date + (excel_date > 59) + elif isinstance(d, date): + # this is why python sucks >.< + return DataTypes.to_excel_date(datetime(*(d.timetuple()[:6]))) + elif isinstance(d, time): + return ( + DataTypes.to_excel_date( + datetime( + *(DataTypes.EXCEL_BASE_DATE.timetuple()[:3]), + hour=d.hour, + minute=d.minute, + second=d.second, + microsecond=d.microsecond + ) + ) + - 1 + ) diff --git a/pyexcelerate/Fill.py b/pyexcelerate/Fill.py index 4a5e5ee..5d81c1d 100644 --- a/pyexcelerate/Fill.py +++ b/pyexcelerate/Fill.py @@ -1,47 +1,59 @@ from . import Utility from . import Color + class Fill(object): - def __init__(self, background=None): - self._background = background - - @property - def background(self): - return Utility.lazy_get(self, '_background', Color.Color()) - - @background.setter - def background(self, value): - Utility.lazy_set(self, '_background', None, value) - - @property - def is_default(self): - return self == Fill() - - def __eq__(self, other): - if other is None: - return self.is_default - return self._background == other._background - - def __hash__(self): - return hash(self.background) - - def get_xml_string(self): - if not self.background: - return '' - else: - return "" % self.background.hex - - def __or__(self, other): - return Fill(background=Utility.nonboolean_or(self._background, other._background, None)) - - def __and__(self, other): - return Fill(background=Utility.nonboolean_and(self._background, other._background, None)) - - def __xor__(self, other): - return Fill(background=Utility.nonboolean_xor(self._background, other._background, None)) - - def __str__(self): - return "Fill: #%s" % self.background.hex - - def __repr__(self): - return "<%s>" % self.__str__() \ No newline at end of file + __slots__ = ("_background", "id") + + def __init__(self, background=None): + self._background = background + + @property + def background(self): + return Utility.lazy_get(self, "_background", Color.Color()) + + @background.setter + def background(self, value): + Utility.lazy_set(self, "_background", None, value) + + @property + def is_default(self): + return self == Fill() + + def __eq__(self, other): + if other is None: + return self.is_default + return self._background == other._background + + def __hash__(self): + return hash(self.background) + + def get_xml_string(self): + if not self.background: + return '' + else: + return ( + '' + % self.background.hex + ) + + def __or__(self, other): + return Fill( + background=Utility.nonboolean_or(self._background, other._background, None) + ) + + def __and__(self, other): + return Fill( + background=Utility.nonboolean_and(self._background, other._background, None) + ) + + def __xor__(self, other): + return Fill( + background=Utility.nonboolean_xor(self._background, other._background, None) + ) + + def __str__(self): + return "Fill: #%s" % self.background.hex + + def __repr__(self): + return "<%s>" % self.__str__() diff --git a/pyexcelerate/Font.py b/pyexcelerate/Font.py index 1967d87..8e835a6 100644 --- a/pyexcelerate/Font.py +++ b/pyexcelerate/Font.py @@ -2,90 +2,122 @@ from . import Utility from . import Color + class Font(object): - def __init__(self, bold=False, italic=False, underline=False, strikethrough=False, family='Calibri', size=11, color=None): - self.bold = bold - self.italic = italic - self.underline = underline - self.strikethrough = strikethrough - self.family = family - self.size = size - self._color = color - - def get_xml_string(self): - tokens = ["" % (self.size, self.family)] - # sure, we could do this with an enum, but this is faster :D - if self.bold: - tokens.append('') - if self.italic: - tokens.append('') - if self.underline: - tokens.append('') - if self.strikethrough: - tokens.append('') - if self._color: - tokens.append("" % self._color.hex) - return "%s" % "".join(tokens) - - @property - def color(self): - return Utility.lazy_get(self, '_color', Color.Color()) - - @color.setter - def color(self, value): - Utility.lazy_set(self, '_color', None, value) - - @property - def is_default(self): - return self._to_tuple() == Font()._to_tuple() - - def __or__(self, other): - return self._binary_operation(other, Utility.nonboolean_or) - - def __and__(self, other): - return self._binary_operation(other, Utility.nonboolean_and) - - def __xor__(self, other): - return self._binary_operation(other, Utility.nonboolean_xor) - - def _binary_operation(self, other, operation): - return Font( \ - bold = operation(self.bold, other.bold), \ - italic = operation(self.italic, other.italic), \ - underline = operation(self.underline, other.underline), \ - strikethrough = operation(self.strikethrough, other.strikethrough), \ - family = operation(self.family, other.family, 'Calibri'), \ - size = operation(self.size, other.size, 11), \ - color = operation(self._color, other._color, None) \ - ) - - def __eq__(self, other): - if other is None: - return self.is_default - elif Utility.YOLO: - return (self.family, self.size, self._color) == (other.family, other.size, other._color) - else: - return self._to_tuple() == other._to_tuple() - - def __hash__(self): - return hash((self.bold, self.italic, self.underline, self.strikethrough)) - - def _to_tuple(self): - return (self.bold, self.italic, self.underline, self.strikethrough, self.family, self.size, self._color) - - def __str__(self): - tokens = ["%s, %dpt" % (self.family, self.size)] - # sure, we could do this with an enum, but this is faster :D - if self.bold: - tokens.append('b') - if self.italic: - tokens.append('i') - if self.underline: - tokens.append('u') - if self.strikethrough: - tokens.append('s') - return "Font: %s" % ' '.join(tokens) - - def __repr__(self): - return "<%s>" % self.__str__() - + __slots__ = ( + "bold", + "italic", + "underline", + "strikethrough", + "family", + "size", + "_color", + "id", + ) + + def __init__( + self, + bold=False, + italic=False, + underline=False, + strikethrough=False, + family="Calibri", + size=11, + color=None, + ): + self.bold = bold + self.italic = italic + self.underline = underline + self.strikethrough = strikethrough + self.family = family + self.size = size + self._color = color + + def get_xml_string(self): + tokens = ['' % (self.size, self.family)] + # sure, we could do this with an enum, but this is faster :D + if self.bold: + tokens.append("") + if self.italic: + tokens.append("") + if self.underline: + tokens.append("") + if self.strikethrough: + tokens.append("") + if self._color: + tokens.append('' % self._color.hex) + return "%s" % "".join(tokens) + + @property + def color(self): + return Utility.lazy_get(self, "_color", Color.Color()) + + @color.setter + def color(self, value): + Utility.lazy_set(self, "_color", None, value) + + @property + def is_default(self): + return self._to_tuple() == Font()._to_tuple() + + def __or__(self, other): + return self._binary_operation(other, Utility.nonboolean_or) + + def __and__(self, other): + return self._binary_operation(other, Utility.nonboolean_and) + + def __xor__(self, other): + return self._binary_operation(other, Utility.nonboolean_xor) + + def _binary_operation(self, other, operation): + return Font( + bold=operation(self.bold, other.bold), + italic=operation(self.italic, other.italic), + underline=operation(self.underline, other.underline), + strikethrough=operation(self.strikethrough, other.strikethrough), + family=operation(self.family, other.family, "Calibri"), + size=operation(self.size, other.size, 11), + color=operation(self._color, other._color, None), + ) + + def __eq__(self, other): + if other is None: + return self.is_default + elif Utility.YOLO: + return (self.family, self.size, self._color) == ( + other.family, + other.size, + other._color, + ) + else: + return self._to_tuple() == other._to_tuple() + + def __hash__(self): + return hash((self.bold, self.italic, self.underline, self.strikethrough)) + + def _to_tuple(self): + return ( + self.bold, + self.italic, + self.underline, + self.strikethrough, + self.family, + self.size, + self._color, + ) + + def __str__(self): + tokens = ["%s, %dpt" % (self.family, self.size)] + # sure, we could do this with an enum, but this is faster :D + if self.bold: + tokens.append("b") + if self.italic: + tokens.append("i") + if self.underline: + tokens.append("u") + if self.strikethrough: + tokens.append("s") + return "Font: %s" % " ".join(tokens) + + def __repr__(self): + return "<%s>" % self.__str__() diff --git a/pyexcelerate/Format.py b/pyexcelerate/Format.py index 929cfad..e49e796 100644 --- a/pyexcelerate/Format.py +++ b/pyexcelerate/Format.py @@ -1,42 +1,45 @@ from . import Utility import six + class Format(object): - def __init__(self, format=None): - self._id = 0 # autopopulated by workbook.py - self.format = format - - def __eq__(self, other): - if other is None: - return self.is_default - return self.format == other.format - - def __or__(self, other): - return Format(format=Utility.nonboolean_or(self.format, other.format, None)) - - def __and__(self, other): - return Format(format=Utility.nonboolean_and(self.format, other.format, None)) - - def __xor__(self, other): - return Format(format=Utility.nonboolean_xor(self.format, other.format, None)) - - def __hash__(self): - return hash(self.format) - - @property - def is_default(self): - return self == Format() - - @property - def id(self): - return self._id - - @id.setter - def id(self, value): - self._id = value + 1000 - - def get_xml_string(self): - return "" % (self.id, self.format) - - def __str__(self): - return "Format: %s" % self.format + __slots__ = ("_id", "format") + + def __init__(self, format=None): + self._id = 0 # autopopulated by workbook.py + self.format = format + + def __eq__(self, other): + if other is None: + return self.is_default + return self.format == other.format + + def __or__(self, other): + return Format(format=Utility.nonboolean_or(self.format, other.format, None)) + + def __and__(self, other): + return Format(format=Utility.nonboolean_and(self.format, other.format, None)) + + def __xor__(self, other): + return Format(format=Utility.nonboolean_xor(self.format, other.format, None)) + + def __hash__(self): + return hash(self.format) + + @property + def is_default(self): + return self == Format() + + @property + def id(self): + return self._id + + @id.setter + def id(self, value): + self._id = value + 1000 + + def get_xml_string(self): + return '' % (self.id, self.format) + + def __str__(self): + return "Format: %s" % self.format diff --git a/pyexcelerate/Panes.py b/pyexcelerate/Panes.py index f353f0a..e9fd939 100644 --- a/pyexcelerate/Panes.py +++ b/pyexcelerate/Panes.py @@ -3,27 +3,31 @@ class Panes(object): - def __init__(self, x=None, y=None, freeze=True): - self.x = x or 0 - self.y = y or 0 - self.freeze = freeze + __slots__ = ("x", "y", "freeze") - def __bool__(self): - return any((self.x, self.y)) + def __init__(self, x=None, y=None, freeze=True): + self.x = x or 0 + self.y = y or 0 + self.freeze = freeze - def __nonzero__(self): - return self.__bool__() + def __bool__(self): + return any((self.x, self.y)) - def __eq__(self, other): - return self.x == other.x and self.y == other.y and self.freeze == other.freeze + def __nonzero__(self): + return self.__bool__() - def get_xml(self): - attrs = {'topLeftCell': Range.Range.coordinate_to_string((self.y + 1, self.x + 1))} - if self.freeze: - attrs['state'] = 'frozen' - if self.x: - attrs['xSplit'] = self.x - if self.y: - attrs['ySplit'] = self.y - attr_str = " ".join('%s="%s"' % item for item in sorted(attrs.items())) - return to_unicode("" % attr_str) + def __eq__(self, other): + return self.x == other.x and self.y == other.y and self.freeze == other.freeze + + def get_xml(self): + attrs = { + "topLeftCell": Range.Range.coordinate_to_string((self.y + 1, self.x + 1)) + } + if self.freeze: + attrs["state"] = "frozen" + if self.x: + attrs["xSplit"] = self.x + if self.y: + attrs["ySplit"] = self.y + attr_str = " ".join('%s="%s"' % item for item in sorted(attrs.items())) + return to_unicode("" % attr_str) diff --git a/pyexcelerate/Range.py b/pyexcelerate/Range.py index 1488f5c..07b593d 100644 --- a/pyexcelerate/Range.py +++ b/pyexcelerate/Range.py @@ -1,6 +1,10 @@ -from . import DataTypes +import itertools +import re +import string +from collections import OrderedDict + import six -from . import Font, Fill, Format, Style +from . import Font, Fill, Format, Style, DataTypes from six.moves import reduce # @@ -8,210 +12,273 @@ # to be immutable. Please don't modify attributes after instantiation. :) # +# generate the list of columns name from "A" to "ZZZ" => mapping such that COORD2COLUMN[1] => "A" +COORD2COLUMN = ( + # remove duplicates in collection by taking list(dict.fromkeys( collection )) + list( + OrderedDict.fromkeys( + # joined the items together so that ["","","A"] => "A", ["","R","Z"] => "RZ", ... + map( + "".join, + # build iterator with all combination of 3 items in the list ["", "A", "B", ..., "Z"] + itertools.product([""] + list(string.ascii_uppercase), repeat=3), + ) + ) + ) +) +# reverse the previous mapping COORD2COLUMN to go from "A" to 1 +COLUMN2COORD = {col: i for i, col in enumerate(COORD2COLUMN)} +# regexp that splits an excel reference (e.g. "B23") into row/col +RE_COLUMN_ROW = re.compile(r"([A-Z]+)(\d*)") + + class Range(object): - A = ord('A') - Z = ord('Z') - def __init__(self, start, end, worksheet, validate=True): - self._start = (Range.string_to_coordinate(start) if validate and isinstance(start, six.string_types) else start) - self._end = (Range.string_to_coordinate(end) if validate and isinstance(end, six.string_types) else end) - # Following http://office.microsoft.com/en-ca/excel-help/excel-specifications-and-limits-HA103980614.aspx - if (not (1 <= self._start[0] <= 1048576) and self._start[0] != float('inf')) \ - or (not (1 <= self._end[0] <= 1048576) and self._end[0] != float('inf')): - raise IndexError("Row index out of bounds") - if (not (1 <= self._start[1] <= 16384) and self._start[1] != float('inf')) \ - or (not (1 <= self._end[1] <= 16384) and self._end[1] != float('inf')): - raise IndexError("Column index out of bounds") - self.worksheet = worksheet - self.is_cell = (self._start == self._end) - - self.is_row = (self._end[1] == float('inf') and self._start[0] == self._end[0] and self._start[1] == 1) - self.is_column = (self._end[0] == float('inf') and self._start[1] == self._end[1] and self._start[0] == 1) - - self.x = (self._start[0] if self.is_row or self.is_cell else None) - self.y = (self._start[1] if self.is_column or self.is_cell else None) - self.height = (self._end[0] - self._start[0] + 1) - self.width = (self._end[1] - self._start[1] + 1) - if self.is_cell: - worksheet._columns = max(worksheet._columns, self.y) - - @property - def coordinate(self): - if self.is_cell: - return self._start - else: - raise Exception("Non-singleton range selected") - - @property - def style(self): - if self.is_row: - return self.__get_attr(self.worksheet.get_cell_style, Range.AttributeInterceptor(self.worksheet.get_row_style(self.x), '')) - return self.__get_attr(self.worksheet.get_cell_style, Range.AttributeInterceptor(self, 'style')) - - @style.setter - def style(self, data): - self.__set_attr(self.worksheet.set_cell_style, data) - - @property - def value(self): - return self.__get_attr(self.worksheet.get_cell_value) - - @value.setter - def value(self, data): - self.__set_attr(self.worksheet.set_cell_value, data) - - # this class permits doing things like range().style.font.bold = True - class AttributeInterceptor(object): - def __init__(self, parent, attribute = ''): - self.__dict__['_parent'] = parent - self.__dict__['_attribute'] = attribute - def __getattr__(self, name): - if self._attribute == '': - return Range.AttributeInterceptor(self._parent, name) - return Range.AttributeInterceptor(self._parent, "%s.%s" % (self._attribute, name)) - def __setattr__(self, name, value): - if isinstance(self._parent, Style.Style): - setattr(reduce(getattr, self._attribute.split('.'), self._parent), name, value) - else: - for cell in self._parent: - setattr(reduce(getattr, self._attribute.split('.'), cell), name, value) - - # note that these are not the python __getattr__/__setattr__ - def __get_attr(self, method, default=None): - if self.is_cell: - for merge in self.worksheet.merges: - if self in merge: - return method(merge._start[0], merge._start[1]) - return method(self.x, self.y) - elif default: - return default - else: - raise Exception('Non-singleton range selected') - - def __set_attr(self, method, data): - if self.is_cell: - for merge in self.worksheet.merges: - if self in merge: - method(merge._start[0], merge._start[1], data) - return - method(self.x, self.y, data) - elif self.is_row and isinstance(data, Style.Style): - # Applying a row style - self.worksheet.set_row_style(self.x, data) - elif DataTypes.DataTypes.get_type(data) != DataTypes.DataTypes.ERROR: - # Attempt to apply in batch - for cell in self: - cell.__set_attr(method, data) - else: - if len(data) <= self.height: - for row in data: - if len(row) > self.width: - raise Exception("Row too large for range, row has %s columns, but range only has %s" % (len(row), self.width)) - for x, row in enumerate(data): - for y, value in enumerate(row): - method(x + self._start[0], y + self._start[1], value) - else: - raise Exception("Too many rows for range, data has %s rows, but range only has %s" % (len(data), self.height)) - - def intersection(self, range): - """ + __slots__ = ( + "_start", + "_end", + "worksheet", + "is_cell", + "is_row", + "is_column", + "x", + "y", + "height", + "width", + ) + + def __init__(self, start, end, worksheet, validate=True): + self._start = ( + Range.string_to_coordinate(start) + if validate and isinstance(start, six.string_types) + else start + ) + self._end = ( + Range.string_to_coordinate(end) + if validate and isinstance(end, six.string_types) + else end + ) + # Following http://office.microsoft.com/en-ca/excel-help/excel-specifications-and-limits-HA103980614.aspx + if ( + not (1 <= self._start[0] <= 1048576) and self._start[0] != float("inf") + ) or (not (1 <= self._end[0] <= 1048576) and self._end[0] != float("inf")): + raise IndexError("Row index out of bounds") + if (not (1 <= self._start[1] <= 16384) and self._start[1] != float("inf")) or ( + not (1 <= self._end[1] <= 16384) and self._end[1] != float("inf") + ): + raise IndexError("Column index out of bounds") + self.worksheet = worksheet + self.is_cell = self._start == self._end + + self.is_row = ( + self._end[1] == float("inf") + and self._start[0] == self._end[0] + and self._start[1] == 1 + ) + self.is_column = ( + self._end[0] == float("inf") + and self._start[1] == self._end[1] + and self._start[0] == 1 + ) + + self.x = self._start[0] if self.is_row or self.is_cell else None + self.y = self._start[1] if self.is_column or self.is_cell else None + self.height = self._end[0] - self._start[0] + 1 + self.width = self._end[1] - self._start[1] + 1 + if self.is_cell: + worksheet._columns = max(worksheet._columns, self.y) + + @property + def coordinate(self): + if self.is_cell: + return self._start + else: + raise Exception("Non-singleton range selected") + + @property + def style(self): + if self.is_row: + return self.__get_attr( + self.worksheet.get_cell_style, + Range.AttributeInterceptor(self.worksheet.get_row_style(self.x), ""), + ) + return self.__get_attr( + self.worksheet.get_cell_style, Range.AttributeInterceptor(self, "style") + ) + + @style.setter + def style(self, data): + self.__set_attr(self.worksheet.set_cell_style, data) + + @property + def value(self): + return self.__get_attr(self.worksheet.get_cell_value) + + @value.setter + def value(self, data): + self.__set_attr(self.worksheet.set_cell_value, data) + + # this class permits doing things like range().style.font.bold = True + class AttributeInterceptor(object): + def __init__(self, parent, attribute=""): + self.__dict__["_parent"] = parent + self.__dict__["_attribute"] = attribute + + def __getattr__(self, name): + if self._attribute == "": + return Range.AttributeInterceptor(self._parent, name) + return Range.AttributeInterceptor( + self._parent, "%s.%s" % (self._attribute, name) + ) + + def __setattr__(self, name, value): + if isinstance(self._parent, Style.Style): + setattr( + reduce(getattr, self._attribute.split("."), self._parent), + name, + value, + ) + else: + for cell in self._parent: + setattr( + reduce(getattr, self._attribute.split("."), cell), name, value + ) + + # note that these are not the python __getattr__/__setattr__ + def __get_attr(self, method, default=None): + if self.is_cell: + for merge in self.worksheet.merges: + if self in merge: + return method(merge._start[0], merge._start[1]) + return method(self.x, self.y) + elif default: + return default + else: + raise Exception("Non-singleton range selected") + + def __set_attr(self, method, data): + if self.is_cell: + for merge in self.worksheet.merges: + if self in merge: + method(merge._start[0], merge._start[1], data) + return + method(self.x, self.y, data) + elif self.is_row and isinstance(data, Style.Style): + # Applying a row style + self.worksheet.set_row_style(self.x, data) + elif DataTypes.DataTypes.get_type(data) != DataTypes.DataTypes.ERROR: + # Attempt to apply in batch + for cell in self: + cell.__set_attr(method, data) + else: + if len(data) <= self.height: + for row in data: + if len(row) > self.width: + raise Exception( + "Row too large for range, row has %s columns, but range only has %s" + % (len(row), self.width) + ) + for x, row in enumerate(data): + for y, value in enumerate(row): + method(x + self._start[0], y + self._start[1], value) + else: + raise Exception( + "Too many rows for range, data has %s rows, but range only has %s" + % (len(data), self.height) + ) + + def intersection(self, range): + """ Calculates the intersection with another range object """ - if self.worksheet != range.worksheet: - # Different worksheet - return None - start = (max(self._start[0], range._start[0]), max(self._start[1], range._start[1])) - end = (min(self._end[0], range._end[0]), min(self._end[1], range._end[1])) - if end[0] < start[0] or end[1] < start[1]: - return None - return Range(start, end, self.worksheet, validate=False) - - __and__ = intersection - - def intersects(self, range): - return self.intersection(range) is not None - - def merge(self): - self.worksheet.add_merge(self) - - def __iter__(self): - if self.is_row or self.is_column: - raise Exception('Can\'t iterate over an infinite row/column') - for x in range(self._start[0], self._end[0] + 1): - for y in range(self._start[1], self._end[1] + 1): - yield Range((x, y), (x, y), self.worksheet, validate=False) - - def __contains__(self, item): - return self.intersection(item) == item - - def __hash__(self): - def hash(val): - return val[0] << 8 + val[1] - return hash(self._start) << 24 + hash(self._end) - - def __str__(self): - return Range.coordinate_to_string(self._start) + ":" + Range.coordinate_to_string(self._end) - - def __len__(self): - if self._start[0] == self._end[0]: - return self.width - else: - return self.height - - def __eq__(self, other): - if other is None: - return False - return self._start == other._start and self._end == other._end - - def __ne__(self, other): - return not (self == other) - - def __getitem__(self, key): - if self.is_row: - # return the key'th column - if isinstance(key, six.string_types): - key = Range.string_to_coordinate(key) - return Range((self.x, key), (self.x, key), self.worksheet, validate=False) - elif self.is_column: - #return the key'th row - return Range((key, self.y), (key, self.y), self.worksheet, validate=False) - else: - raise Exception("Selection not valid") - - def __setitem__(self, key, value): - if self.is_row: - self.worksheet.set_cell_value(self.x, key, value) - else: - raise Exception("Couldn't set that") - - @staticmethod - def string_to_coordinate(s): - # Convert a base-26 name to integer - y = 0 - l = len(s) - for index, c in enumerate(s): - if ord(c) < Range.A or ord(c) > Range.Z: - s = s[index:] - break - y *= 26 - y += ord(c) - Range.A + 1 - if len(s) == l: - return y - else: - return (int(s), y) - - _cts_cache = {} - @staticmethod - def coordinate_to_string(coord): - if coord[1] == float('inf'): - return 'IV%s' % str(coord[0]) - - # convert an integer to base-26 name - y = coord[1] - 1 - if y not in Range._cts_cache: - s = [] - while y >= 0: - s.append(chr((y % 26) + Range.A)) - y = int(y / 26) - 1 - s.reverse() - Range._cts_cache[y] = ''.join(s) - return Range._cts_cache[y] + str(coord[0]) + if self.worksheet != range.worksheet: + # Different worksheet + return None + start = ( + max(self._start[0], range._start[0]), + max(self._start[1], range._start[1]), + ) + end = (min(self._end[0], range._end[0]), min(self._end[1], range._end[1])) + if end[0] < start[0] or end[1] < start[1]: + return None + return Range(start, end, self.worksheet, validate=False) + + __and__ = intersection + + def intersects(self, range): + return self.intersection(range) is not None + + def merge(self): + self.worksheet.add_merge(self) + + def __iter__(self): + if self.is_row or self.is_column: + raise Exception("Can't iterate over an infinite row/column") + for x in range(self._start[0], self._end[0] + 1): + for y in range(self._start[1], self._end[1] + 1): + yield Range((x, y), (x, y), self.worksheet, validate=False) + + def __contains__(self, item): + return self.intersection(item) == item + + def __hash__(self): + def hash(val): + return val[0] << 8 + val[1] + + return hash(self._start) << 24 + hash(self._end) + + def __str__(self): + return ( + Range.coordinate_to_string(self._start) + + ":" + + Range.coordinate_to_string(self._end) + ) + + def __len__(self): + if self._start[0] == self._end[0]: + return self.width + else: + return self.height + + def __eq__(self, other): + if other is None: + return False + return self._start == other._start and self._end == other._end + + def __ne__(self, other): + return not (self == other) + + def __getitem__(self, key): + if self.is_row: + # return the key'th column + if isinstance(key, six.string_types): + key = Range.string_to_coordinate(key) + return Range((self.x, key), (self.x, key), self.worksheet, validate=False) + elif self.is_column: + # return the key'th row + return Range((key, self.y), (key, self.y), self.worksheet, validate=False) + else: + raise Exception("Selection not valid") + + def __setitem__(self, key, value): + if self.is_row: + self.worksheet.set_cell_value(self.x, key, value) + else: + raise Exception("Couldn't set that") + + @staticmethod + def string_to_coordinate(s): + # Convert a base-26 name to a coordinate (or integer if column) + col, num = RE_COLUMN_ROW.match(s).groups() + if num: + return (int(num), COLUMN2COORD[col]) + else: + return COLUMN2COORD[col] + + @staticmethod + def coordinate_to_string(coord): + # Convert a coordinate to a base-26 name + row, col = coord + try: + return "%s%s" % (COORD2COLUMN[col], row) + except (IndexError, TypeError): + return "%s%s" % (COORD2COLUMN[256], row) diff --git a/pyexcelerate/Style.py b/pyexcelerate/Style.py index f761b9b..1b83d02 100644 --- a/pyexcelerate/Style.py +++ b/pyexcelerate/Style.py @@ -2,113 +2,170 @@ from . import Utility import six + class Style(object): - def __init__(self, font=None, fill=None, format=None, alignment=None, borders=None, size=None): - self._font = font - self._fill = fill - self._format = format - self._alignment = alignment - self._borders = borders - self._size = size - - @property - def size(self): - return self._size - - @property - def is_default(self): - return not (self._font or self._fill or self._format or self._alignment or self._borders or self._size is not None) - - @property - def borders(self): - return Utility.lazy_get(self, '_borders', Borders.Borders()) - - @borders.setter - def borders(self, value): - Utility.lazy_set(self, '_borders', None, value) - - @property - def alignment(self): - return Utility.lazy_get(self, '_alignment', Alignment.Alignment()) - - @alignment.setter - def alignment(self, value): - Utility.lazy_set(self, '_alignment', None, value) - - @property - def format(self): - # don't use default because default should be const - return Utility.lazy_get(self, '_format', Format.Format()) - - @format.setter - def format(self, value): - Utility.lazy_set(self, '_format', None, value) - - @property - def font(self): - return Utility.lazy_get(self, '_font', Font.Font()) - - @font.setter - def font(self, value): - Utility.lazy_set(self, '_font', None, value) - - @property - def fill(self): - return Utility.lazy_get(self, '_fill', Fill.Fill()) - - @fill.setter - def fill(self, value): - Utility.lazy_set(self, '_fill', None, value) - - def get_xml_string(self): - # Precondition: Workbook._align_styles has been run. - # Be careful when using this function as id's may be inaccurate if precondition not met. - tag = [] - if not self._format is None: - tag.append("numFmtId=\"%d\"" % self._format.id) - if not self._font is None: - tag.append("applyFont=\"1\" fontId=\"%d\"" % (self._font.id)) - if not self._fill is None: - tag.append("applyFill=\"1\" fillId=\"%d\"" % (self._fill.id + 1)) - if not self._borders is None: - tag.append("applyBorder=\"1\" borderId=\"%d\"" % (self._borders.id)) - if self._alignment is None: - return "" % (" ".join(tag)) - else: - return "%s" % (" ".join(tag), self._alignment.get_xml_string()) - - def __hash__(self): - return hash((self._font, self._fill, self._format, self._alignment)) - - def __eq__(self, other): - if other is None: - return self.is_default - return self._to_tuple() == other._to_tuple() - - def __or__(self, other): - return self._binary_operation(other, Utility.nonboolean_or) - - def __and__(self, other): - return self._binary_operation(other, Utility.nonboolean_and) - - def __xor__(self, other): - return self._binary_operation(other, Utility.nonboolean_xor) - - def _binary_operation(self, other, operation): - return Style( \ - font=operation(self.font, other.font), \ - fill=operation(self.fill, other.fill), \ - format=operation(self.format, other.format), \ - alignment=operation(self.alignment, other.alignment), \ - borders=operation(self.borders, other.borders) \ - ) - - def _to_tuple(self): - return (self._font, self._fill, self._format, self._alignment, self._borders, self._size) - - - def __str__(self): - return "%s %s %s %s" % (self.font, self.fill, self.format, self.alignment) - - def __repr__(self): - return "<%s>" % self.__str__() + __slots__ = ("_font", "_fill", "_format", "_alignment", "_borders", "_size", "_data_type", "_quote_prefix", "id") + + def __init__( + self, font=None, fill=None, format=None, alignment=None, borders=None, size=None, data_type=None, quote_prefix=None + ): + self._font = font + self._fill = fill + self._format = format + self._alignment = alignment + self._borders = borders + self._size = size + self._data_type = data_type + self._quote_prefix = quote_prefix + + @property + def data_type(self): + return self._data_type + + @data_type.setter + def data_type(self, value): + Utility.lazy_set(self, "_data_type", None, value) + + @property + def quote_prefix(self): + return self._quote_prefix + + @quote_prefix.setter + def quote_prefix(self, value): + if value not in (True, False): + raise TypeError( + "Invalid quote prefix value. Expects either True or False." + ) + Utility.lazy_set(self, "_quote_prefix", None, value) + + @property + def size(self): + return self._size + + @size.setter + def size(self, value): + Utility.lazy_set(self, "_size", None, value) + + @property + def is_default(self): + return not ( + self._font + or self._fill + or self._format + or self._alignment + or self._borders + or self._size is not None + or self._data_type is not None + or self._quote_prefix is not None + ) + + @property + def borders(self): + return Utility.lazy_get(self, "_borders", Borders.Borders()) + + @borders.setter + def borders(self, value): + Utility.lazy_set(self, "_borders", None, value) + + @property + def alignment(self): + return Utility.lazy_get(self, "_alignment", Alignment.Alignment()) + + @alignment.setter + def alignment(self, value): + Utility.lazy_set(self, "_alignment", None, value) + + @property + def format(self): + # don't use default because default should be const + return Utility.lazy_get(self, "_format", Format.Format()) + + @format.setter + def format(self, value): + Utility.lazy_set(self, "_format", None, value) + + @property + def font(self): + return Utility.lazy_get(self, "_font", Font.Font()) + + @font.setter + def font(self, value): + Utility.lazy_set(self, "_font", None, value) + + @property + def fill(self): + return Utility.lazy_get(self, "_fill", Fill.Fill()) + + @fill.setter + def fill(self, value): + Utility.lazy_set(self, "_fill", None, value) + + def get_xml_string(self): + # Precondition: Workbook._align_styles has been run. + # Be careful when using this function as id's may be inaccurate if precondition not met. + tag = [] + if not self._format is None: + tag.append('numFmtId="%d"' % self._format.id) + if not self._font is None: + tag.append('applyFont="1" fontId="%d"' % (self._font.id)) + if not self._fill is None: + tag.append('applyFill="1" fillId="%d"' % (self._fill.id + 1)) + if not self._borders is None: + tag.append('applyBorder="1" borderId="%d"' % (self._borders.id)) + if not self._quote_prefix is None: + tag.append('quotePrefix="%d"' % (1 if self._quote_prefix else 0)) + if self._alignment is None: + return '' % (" ".join(tag)) + else: + return '%s' % ( + " ".join(tag), + self._alignment.get_xml_string(), + ) + + def __hash__(self): + return hash((self._font, self._fill, self._format, self._alignment, + self._size, self._data_type, self._quote_prefix)) + + def __eq__(self, other): + if other is None: + return self.is_default + return self._to_tuple() == other._to_tuple() + + def __or__(self, other): + return self._binary_operation(other, Utility.nonboolean_or) + + def __and__(self, other): + return self._binary_operation(other, Utility.nonboolean_and) + + def __xor__(self, other): + return self._binary_operation(other, Utility.nonboolean_xor) + + def _binary_operation(self, other, operation): + return Style( + font=operation(self.font, other.font), + fill=operation(self.fill, other.fill), + format=operation(self.format, other.format), + alignment=operation(self.alignment, other.alignment), + borders=operation(self.borders, other.borders), + size=operation(self.size, other.size, None), + data_type=operation(self.data_type, other.data_type, None), + quote_prefix=operation(self.quote_prefix, other.quote_prefix, None), + ) + + def _to_tuple(self): + return ( + self._font, + self._fill, + self._format, + self._alignment, + self._borders, + self._size, + self._data_type, + self._quote_prefix, + ) + + def __str__(self): + return "%s %s %s %s" % (self.font, self.fill, self.format, self.alignment) + + def __repr__(self): + return "<%s>" % self.__str__() diff --git a/pyexcelerate/Utility.py b/pyexcelerate/Utility.py index 0bb85a9..f5a16b2 100644 --- a/pyexcelerate/Utility.py +++ b/pyexcelerate/Utility.py @@ -1,52 +1,65 @@ import six + def nonboolean_or(left, right, default=False): - if default == False: - return left | right - if left == default: - return right - if right == default or left == right: - return left - return left | right # this scenario doesn't actually make sense, but it might be implemented + if default == False: + return left | right + if left == default: + return right + if right == default or left == right: + return left + return ( + left | right + ) # this scenario doesn't actually make sense, but it might be implemented + def nonboolean_and(left, right, default=False): - if default == False: - return left & right - if left == right: - return left - return default - + if default == False: + return left & right + if left == right: + return left + return default + + def nonboolean_xor(left, right, default=False): - if default == False: - return left ^ right - if left == default: - return right - if right == default: - return left - return default - + if default == False: + return left ^ right + if left == default: + return right + if right == default: + return left + return default + + def lazy_get(self, attribute, default): - value = getattr(self, attribute) - if not value: - setattr(self, attribute, default) - return default - else: - return value - + value = getattr(self, attribute) + if not value: + setattr(self, attribute, default) + return default + else: + return value + + def lazy_set(self, attribute, default, value): - if value == default: - setattr(self, attribute, default) - else: - setattr(self, attribute, value) + if value == default: + setattr(self, attribute, default) + else: + setattr(self, attribute, value) + if six.PY2: - def to_unicode(s): - if type(s) == unicode: - return s - else: - return s.decode('utf-8') + + def to_unicode(s): + if type(s) == unicode: + return s + else: + return unicode(s, "utf-8") + + else: - def to_unicode(s): - return s -YOLO = False # are we aligning? + def to_unicode(s): + return s + + +YOLO = False # are we aligning? diff --git a/pyexcelerate/Workbook.py b/pyexcelerate/Workbook.py index f01f712..96b1a98 100644 --- a/pyexcelerate/Workbook.py +++ b/pyexcelerate/Workbook.py @@ -1,82 +1,105 @@ from . import Worksheet from .Writer import Writer from . import Utility +import six import time + class Workbook(object): - # map for attribute sets => style attribute id's - STYLE_ATTRIBUTE_MAP = {'fonts':'_font', 'fills':'_fill', 'num_fmts':'_format', 'borders':'_borders'} - STYLE_ID_ATTRIBUTE = 'id' - def __init__(self, encoding='utf-8'): - self._worksheets = [] - self._styles = [] - self._items = {} #dictionary containing lists of fonts, fills, etc. - self._encoding = encoding - self._writer = Writer(self) + # map for attribute sets => style attribute id's + STYLE_ATTRIBUTE_MAP = { + "fonts": "_font", + "fills": "_fill", + "num_fmts": "_format", + "borders": "_borders", + } + STYLE_ID_ATTRIBUTE = "id" + __slots__ = ("_worksheets", "_styles", "_items", "_has_macros", "_encoding", "_writer") + + def __init__(self, encoding="utf-8"): + self._worksheets = [] + self._styles = [] + self._items = {} # dictionary containing lists of fonts, fills, etc. + self._has_macros = False + self._encoding = encoding + self._writer = Writer(self) + + def add_sheet(self, worksheet): + for sheet in self._worksheets: + if sheet.name == worksheet.name: + raise Exception( + "There is already a worksheet with the name '%s'. Duplicate worksheet names are not permitted." + % worksheet.name + ) + self._worksheets.append(worksheet) + + def new_sheet(self, sheet_name, data=None, force_name=False, hidden=False): + worksheet = Worksheet.Worksheet(sheet_name, self, data, force_name, hidden) + self.add_sheet(worksheet) + return worksheet + + def add_style(self, style): + # keep them all, even if they're deleted. compress later. + self._styles.append(style) - def add_sheet(self, worksheet): - for sheet in self._worksheets: - if sheet.name == worksheet.name: - raise Exception("There is already a worksheet with the name '%s'. Duplicate worksheet names are not permitted." % worksheet.name) - self._worksheets.append(worksheet) - - def new_sheet(self, sheet_name, data=None, force_name=False): - worksheet = Worksheet.Worksheet(sheet_name, self, data, force_name) - self.add_sheet(worksheet) - return worksheet + @property + def has_styles(self): + return len(self._styles) > 0 - def add_style(self, style): - # keep them all, even if they're deleted. compress later. - self._styles.append(style) - - @property - def has_styles(self): - return len(self._styles) > 0 + @property + def styles(self): + self._align_styles() + return self._styles + + @property + def has_macros(self): + return self._has_macros - @property - def styles(self): - self._align_styles() - return self._styles + def get_xml_data(self): + self._align_styles() # because it will be used by the worksheets later + for index, ws in enumerate(self._worksheets, start=1): + yield (index, ws) - def get_xml_data(self): - self._align_styles() # because it will be used by the worksheets later - for index, ws in enumerate(self._worksheets, start=1): - yield (index, ws) + def _align_styles(self): + Utility.YOLO = True + items = dict([(x, {}) for x in Workbook.STYLE_ATTRIBUTE_MAP.keys()]) + styles = {} + for index, style in enumerate(self._styles): + # compress style + if not style.is_default: + styles[style] = styles.get(style, len(styles) + 1) + setattr(style, Workbook.STYLE_ID_ATTRIBUTE, styles[style]) + for style in styles.keys(): + # compress individual attributes + for attr, attr_id in Workbook.STYLE_ATTRIBUTE_MAP.items(): + obj = getattr(style, attr_id) + if ( + obj and not obj.is_default + ): # we only care about it if it's not default + items[attr][obj] = items[attr].get(obj, len(items[attr]) + 1) + obj.id = items[attr][obj] # apply + for k, v in items.items(): + # ensure it's sorted properly + items[k] = [tup[0] for tup in sorted(v.items(), key=lambda x: x[1])] + self._items = items + self._styles = [tup[0] for tup in sorted(styles.items(), key=lambda x: x[1])] + Utility.YOLO = False - def _align_styles(self): - Utility.YOLO = True - items = dict([(x, {}) for x in Workbook.STYLE_ATTRIBUTE_MAP.keys()]) - styles = {} - for index, style in enumerate(self._styles): - # compress style - if not style.is_default: - styles[style] = styles.get(style, len(styles) + 1) - setattr(style, Workbook.STYLE_ID_ATTRIBUTE, styles[style]) - for style in styles.keys(): - # compress individual attributes - for attr, attr_id in Workbook.STYLE_ATTRIBUTE_MAP.items(): - obj = getattr(style, attr_id) - if obj and not obj.is_default: # we only care about it if it's not default - items[attr][obj] = items[attr].get(obj, len(items[attr]) + 1) - obj.id = items[attr][obj] # apply - for k, v in items.items(): - # ensure it's sorted properly - items[k] = [tup[0] for tup in sorted(v.items(), key=lambda x: x[1])] - self._items = items - self._styles = [tup[0] for tup in sorted(styles.items(), key=lambda x: x[1])] - Utility.YOLO = False - - def __getattr__(self, name): - self._align_styles() - return self._items[name] + def __getattr__(self, name): + self._align_styles() + return self._items[name] - def __len__(self): - return len(self._worksheets) + def __len__(self): + return len(self._worksheets) - def _save(self, file_handle): - self._align_styles() - self._writer.save(file_handle) + def _save(self, file_handle): + self._align_styles() + self._writer.save(file_handle) - def save(self, filename): - with open(filename, 'wb') as fp: - self._save(fp) + def save(self, filename_or_filehandle, has_macros=False): + self._has_macros = has_macros + if isinstance(filename_or_filehandle, six.string_types): + with open(filename_or_filehandle, "wb") as fp: + self._save(fp) + else: + self._save(filename_or_filehandle) diff --git a/pyexcelerate/Worksheet.py b/pyexcelerate/Worksheet.py index 89fdb5e..ac0a980 100644 --- a/pyexcelerate/Worksheet.py +++ b/pyexcelerate/Worksheet.py @@ -1,266 +1,380 @@ -from . import Panes -from . import Range -from . import Style -from . import Format -from .DataTypes import DataTypes -from .Utility import to_unicode -import six +# -*- coding: utf-8 -*- + +import collections +import itertools import math +import re +import sys from datetime import datetime from xml.sax.saxutils import escape +import six + +from . import Format, Panes, Range, Style +from .DataTypes import DataTypes +from .Utility import to_unicode + +# From https://stackoverflow.com/a/22273639/86433 +_illegal_unichrs = [ + (0x00, 0x08), + (0x0B, 0x0C), + (0x0E, 0x1F), + (0x7F, 0x84), + (0x86, 0x9F), + (0xFDD0, 0xFDDF), + (0xFFFE, 0xFFFF), +] +if sys.maxunicode >= 0x10000: # not narrow build + _illegal_unichrs.extend( + [ + (0x1FFFE, 0x1FFFF), + (0x2FFFE, 0x2FFFF), + (0x3FFFE, 0x3FFFF), + (0x4FFFE, 0x4FFFF), + (0x5FFFE, 0x5FFFF), + (0x6FFFE, 0x6FFFF), + (0x7FFFE, 0x7FFFF), + (0x8FFFE, 0x8FFFF), + (0x9FFFE, 0x9FFFF), + (0xAFFFE, 0xAFFFF), + (0xBFFFE, 0xBFFFF), + (0xCFFFE, 0xCFFFF), + (0xDFFFE, 0xDFFFF), + (0xEFFFE, 0xEFFFF), + (0xFFFFE, 0xFFFFF), + (0x10FFFE, 0x10FFFF), + ] + ) +_illegal_ranges = [ + "%s-%s" % (six.unichr(low), six.unichr(high)) for (low, high) in _illegal_unichrs +] +_illegal_xml_chars_RE = re.compile(u"[%s]" % u"".join(_illegal_ranges)) + class Worksheet(object): - def __init__(self, name, workbook, data=None, force_name=False): - self._columns = 0 # cache this for speed - if len(name) > 31 and not force_name: # http://stackoverflow.com/questions/3681868/is-there-a-limit-on-an-excel-worksheets-name-length - raise Exception('Excel does not permit worksheet names longer than 31 characters. Set force_name=True to disable this restriction.') - self._name = name - self._cells = {} - self._styles = {} - self._row_styles = {} - self._col_styles = {} - self._parent = workbook - self._merges = [] # list of Range objects - self._attributes = {} - self._panes = Panes.Panes() - self._show_grid_lines = True - if data is not None: - for x, row in enumerate(data, 1): - for y, cell in enumerate(row, 1): - if x not in self._cells: - self._cells[x] = {} - self._cells[x][y] = cell - self._columns = max(self._columns, y) - - def __getitem__(self, key): - if isinstance(key, slice): - if key.step is not None and key.step > 1: - raise Exception("PyExcelerate doesn't support slicing with steps") - else: - return Range.Range((key.start or 1, 1), (key.stop or float('inf'), float('inf')), self) - else: - if key not in self._cells: - self._cells[key] = {} - return Range.Range((key, 1), (key, float('inf')), self) # return a row range - - @property - def panes(self): - return self._panes - - @panes.setter - def panes(self, panes): - if not isinstance(panes, Panes.Panes): - raise TypeError("Worksheet.panes must be of type Panes") - self._panes = panes - - @property - def stylesheet(self): - return self._stylesheet - - @property - def col_styles(self): - return self._col_styles.items() - - @property - def name(self): - return self._name - - @property - def merges(self): - return self._merges - - @property - def num_rows(self): - if len(self._cells) > 0: - return max(self._cells.keys()) - else: - return 1 - - @property - def num_columns(self): - return max(1, self._columns) - - @property - def show_grid_lines(self): - return self._show_grid_lines - - @show_grid_lines.setter - def show_grid_lines(self, show_grid_lines): - self._show_grid_lines = show_grid_lines - - def cell(self, name): - # convenience method - return self.range(name, name) - - def range(self, start, end): - # convenience method - return Range.Range(start, end, self) - - def add_merge(self, range): - for merge in self._merges: - if range.intersects(merge): - raise Exception("Invalid merge, intersects existing") - self._merges.append(range) - - def get_cell_value(self, x, y): - if x not in self._cells: - self._cells[x] = {} - if y not in self._cells[x]: - return None - type = DataTypes.get_type(self._cells[x][y]) - if type == DataTypes.FORMULA: - # remove the equals sign - return self._cells[x][y][:1] - elif type == DataTypes.INLINE_STRING and self._cells[x][y][2:] == '\'=': - return self._cells[x][y][:1] - else: - return self._cells[x][y] - - def set_cell_value(self, x, y, value): - if x not in self._cells: - self._cells[x] = {} - if DataTypes.get_type(value) == DataTypes.DATE: - self.get_cell_style(x, y).format = Format.Format('yyyy-mm-dd') - self._cells[x][y] = value - - def get_cell_style(self, x, y): - if x not in self._styles: - self._styles[x] = {} - if y not in self._styles[x]: - self.set_cell_style(x, y, Style.Style()) - return self._styles[x][y] - - def set_cell_style(self, x, y, value): - if x not in self._styles: - self._styles[x] = {} - self._styles[x][y] = value - self._parent.add_style(value) - if self.get_cell_value(x, y) is None: - self.set_cell_value(x, y, '') - - def get_row_style(self, row): - if row not in self._row_styles: - self.set_row_style(row, Style.Style()) - return self._row_styles[row] - - def set_row_style(self, row, value): - if hasattr(row, "__iter__"): - for r in row: - self.set_row_style(r, value) - else: - self._row_styles[row] = value - self.workbook.add_style(value) - - def get_col_style(self, col): - if col not in self._col_styles: - self.set_col_style(col, Style.Style()) - return self._col_styles[col] - - def set_col_style(self, col, value): - if hasattr(col, "__iter__"): - for c in col: - self.set_col_style(c, value) - else: - self._col_styles[col] = value - self.workbook.add_style(value) - - @property - def workbook(self): - return self._parent - - def __get_cell_data(self, cell, x, y, style): - if cell is None: - return "" # no cell data - # boolean values are treated oddly in dictionaries, manually override - type = DataTypes.get_type(cell) - - if type == DataTypes.NUMBER: - if math.isnan(cell): - z = '" t="e">#NUM!' - elif math.isinf(cell): - z = '" t="e">#DIV/0!' - else: - z = '">%.15g' % (cell) - elif type == DataTypes.INLINE_STRING: - z = '" t="inlineStr">%s' % escape(to_unicode(cell)) - elif type == DataTypes.DATE: - z = '">%s' % (DataTypes.to_excel_date(cell)) - elif type == DataTypes.FORMULA: - z = '">%s' % (cell) - elif type == DataTypes.BOOLEAN: - z = '" t="b">%d' % (cell) - - if style: - return "' + elif math.isinf(cell): + z = '" t="e">#DIV/0!' + else: + z = '">%.15g' % (cell) + elif type == DataTypes.INLINE_STRING or type == DataTypes.ERROR: + # Also serialize errors to string, we'll try our best... + z = '" t="inlineStr">%s' % escape( + _illegal_xml_chars_RE.sub(u"\uFFFD", to_unicode(cell if isinstance(cell, six.string_types) else str(cell))) + ) + elif type == DataTypes.DATE: + z = '">%s' % (DataTypes.to_excel_date(cell)) + elif type == DataTypes.FORMULA: + z = '">%s' % (cell[1:]) # Remove equals sign. + elif type == DataTypes.BOOLEAN: + z = '" t="b">%d' % (cell) + + if style and hasattr(style, "id"): + return '' % (col, col) + style = self._col_styles[col] + if style.size == -1: + size = 0 + + def get_size(v): + if isinstance(v, six.string_types): + v = to_unicode(v) + else: + v = six.text_type(v) + return (len(v) * 7 + 5) / 7 + + for row in self._dense_cells[1:]: + if col < len(row): + size = max(get_size(row[col]), size) + for row in six.itervalues(self._sparse_cells): + if col in row: + size = max(get_size(row[col]), size) + elif DataTypes.get_type(style.size) == DataTypes.NUMBER: + size = style.size + else: + return '' % ( + col, + col, + style.id, + ) + return ( + '' + % ( + col, + col, + 1 if style.size == 0 else 0, # hidden + 1 if style.size == -1 else 0, # best fit + 1 if style.size is not None else 0, # customWidth + size, + style.id, + ) + ) + + def get_row_xml_string(self, row): + if row in self._row_styles and not self._row_styles[row].is_default: + style = self._row_styles[row] + if style.size == -1: + size = 0 + dense_rows = ( + enumerate(self._dense_cells[row][1:]) + if row < len(self._dense_cells) + else [] + ) + for y, cell in itertools.chain( + dense_rows, + six.iteritems(self._sparse_cells[row]) + if row in self._sparse_cells + else [], + ): + try: + font_size = self._styles[row][y].font.size + except: + font_size = 11 + lines = ( + cell.count("\n") + if DataTypes.get_type(style.size) == DataTypes.STRING + else 1 + ) + size = max(font_size * (lines + 1) * 4 / 3, size) + else: + size = style.size if style.size else 15 + return ( + '