[
  {
    "path": ".gitchangelog.rc",
    "content": "# -*- coding: utf-8; mode: python -*-\n##\n## Format\n##\n##   ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...]\n##\n## Description\n##\n##   ACTION is one of 'chg', 'fix', 'new'\n##\n##       Is WHAT the change is about.\n##\n##       'chg' is for refactor, small improvement, cosmetic changes...\n##       'fix' is for bug fixes\n##       'new' is for new features, big improvement\n##\n##   AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc'\n##\n##       Is WHO is concerned by the change.\n##\n##       'dev'  is for developpers (API changes, refactors...)\n##       'usr'  is for final users (UI changes)\n##       'pkg'  is for packagers   (packaging changes)\n##       'test' is for testers     (test only related changes)\n##       'doc'  is for doc guys    (doc only changes)\n##\n##   COMMIT_MSG is ... well ... the commit message itself.\n##\n##   TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic'\n##\n##       They are preceded with a '!' or a '@' (prefer the former, as the\n##       latter is wrongly interpreted in github.) Commonly used tags are:\n##\n##       'refactor' is obviously for refactoring code only\n##       'minor' is for a very meaningless change (a typo, adding a comment)\n##       'cosmetic' is for cosmetic driven change (re-indentation, 80-col...)\n##       'wip' is for partial functionality but complete subfunctionality.\n##\n## Example:\n##\n##   new: usr: support of bazaar implemented\n##   chg: re-indentend some lines !cosmetic\n##   new: dev: updated code to be compatible with last version of killer lib.\n##   fix: pkg: updated year of licence coverage.\n##   new: test: added a bunch of test around user usability of feature X.\n##   fix: typo in spelling my name in comment. !minor\n##\n##   Please note that multi-line commit message are supported, and only the\n##   first line will be considered as the \"summary\" of the commit message. So\n##   tags, and other rules only applies to the summary.  The body of the commit\n##   message will be displayed in the changelog without reformatting.\n\n\n##\n## ``ignore_regexps`` is a line of regexps\n##\n## Any commit having its full commit message matching any regexp listed here\n## will be ignored and won't be reported in the changelog.\n##\nignore_regexps = [\n    r'@minor', r'!minor',\n    r'@cosmetic', r'!cosmetic',\n    r'@refactor', r'!refactor',\n    r'@wip', r'!wip',\n    r'^([cC]hg|[fF]ix|[nN]ew)\\s*:\\s*[p|P]kg:',\n    r'^([cC]hg|[fF]ix|[nN]ew)\\s*:\\s*[d|D]ev:',\n    r'^(.{3,3}\\s*:)?\\s*[fF]irst commit.?\\s*$',\n    r'^$',  ## ignore commits with empty messages\n]\n\n\n## ``section_regexps`` is a list of 2-tuples associating a string label and a\n## list of regexp\n##\n## Commit messages will be classified in sections thanks to this. Section\n## titles are the label, and a commit is classified under this section if any\n## of the regexps associated is matching.\n##\n## Please note that ``section_regexps`` will only classify commits and won't\n## make any changes to the contents. So you'll probably want to go check\n## ``subject_process`` (or ``body_process``) to do some changes to the subject,\n## whenever you are tweaking this variable.\n##\nsection_regexps = [\n    ('New', [\n        r'^[nN]ew\\s*:\\s*((dev|use?r|pkg|test|doc)\\s*:\\s*)?([^\\n]*)$',\n     ]),\n    ('Changes', [\n        r'^[cC]hg\\s*:\\s*((dev|use?r|pkg|test|doc)\\s*:\\s*)?([^\\n]*)$',\n     ]),\n    ('Fix', [\n        r'^[fF]ix\\s*:\\s*((dev|use?r|pkg|test|doc)\\s*:\\s*)?([^\\n]*)$',\n     ]),\n\n    ('Other', None ## Match all lines\n     ),\n\n]\n\n\n## ``body_process`` is a callable\n##\n## This callable will be given the original body and result will\n## be used in the changelog.\n##\n## Available constructs are:\n##\n##   - any python callable that take one txt argument and return txt argument.\n##\n##   - ReSub(pattern, replacement): will apply regexp substitution.\n##\n##   - Indent(chars=\"  \"): will indent the text with the prefix\n##     Please remember that template engines gets also to modify the text and\n##     will usually indent themselves the text if needed.\n##\n##   - Wrap(regexp=r\"\\n\\n\"): re-wrap text in separate paragraph to fill 80-Columns\n##\n##   - noop: do nothing\n##\n##   - ucfirst: ensure the first letter is uppercase.\n##     (usually used in the ``subject_process`` pipeline)\n##\n##   - final_dot: ensure text finishes with a dot\n##     (usually used in the ``subject_process`` pipeline)\n##\n##   - strip: remove any spaces before or after the content of the string\n##\n##   - SetIfEmpty(msg=\"No commit message.\"): will set the text to\n##     whatever given ``msg`` if the current text is empty.\n##\n## Additionally, you can `pipe` the provided filters, for instance:\n#body_process = Wrap(regexp=r'\\n(?=\\w+\\s*:)') | Indent(chars=\"  \")\n#body_process = Wrap(regexp=r'\\n(?=\\w+\\s*:)')\n#body_process = noop\nbody_process = ReSub(r'((^|\\n)[A-Z]\\w+(-\\w+)*: .*(\\n\\s+.*)*)+$', r'') | strip\n\n\n## ``subject_process`` is a callable\n##\n## This callable will be given the original subject and result will\n## be used in the changelog.\n##\n## Available constructs are those listed in ``body_process`` doc.\nsubject_process = (strip |\n    ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\\s*:\\s*((dev|use?r|pkg|test|doc)\\s*:\\s*)?([^\\n@]*)(@[a-z]+\\s+)*$', r'\\4') |\n    SetIfEmpty(\"No commit message.\") | ucfirst | final_dot)\n\n\n## ``tag_filter_regexp`` is a regexp\n##\n## Tags that will be used for the changelog must match this regexp.\n##\ntag_filter_regexp = r'^v[0-9]+\\.[0-9]+(\\.[0-9]+)?$'\n\n\n## ``unreleased_version_label`` is a string or a callable that outputs a string\n##\n## This label will be used as the changelog Title of the last set of changes\n## between last valid tag and HEAD if any.\nunreleased_version_label = \"(unreleased)\"\n\n\n## ``output_engine`` is a callable\n##\n## This will change the output format of the generated changelog file\n##\n## Available choices are:\n##\n##   - rest_py\n##\n##        Legacy pure python engine, outputs ReSTructured text.\n##        This is the default.\n##\n##   - mustache(<template_name>)\n##\n##        Template name could be any of the available templates in\n##        ``templates/mustache/*.tpl``.\n##        Requires python package ``pystache``.\n##        Examples:\n##           - mustache(\"markdown\")\n##           - mustache(\"restructuredtext\")\n##\n##   - makotemplate(<template_name>)\n##\n##        Template name could be any of the available templates in\n##        ``templates/mako/*.tpl``.\n##        Requires python package ``mako``.\n##        Examples:\n##           - makotemplate(\"restructuredtext\")\n##\noutput_engine = rest_py\n#output_engine = mustache(\"restructuredtext\")\n#output_engine = mustache(\"markdown\")\n#output_engine = makotemplate(\"restructuredtext\")\n\n\n## ``include_merge`` is a boolean\n##\n## This option tells git-log whether to include merge commits in the log.\n## The default is to include them.\ninclude_merge = True\n\n\n## ``log_encoding`` is a string identifier\n##\n## This option tells gitchangelog what encoding is outputed by ``git log``.\n## The default is to be clever about it: it checks ``git config`` for\n## ``i18n.logOutputEncoding``, and if not found will default to git's own\n## default: ``utf-8``.\n#log_encoding = 'utf-8'\n\n\n## ``publish`` is a callable\n##\n## Sets what ``gitchangelog`` should do with the output generated by\n## the output engine. ``publish`` is a callable taking one argument\n## that is an interator on lines from the output engine.\n##\n## Some helper callable are provided:\n##\n## Available choices are:\n##\n##   - stdout\n##\n##        Outputs directly to standard output\n##        (This is the default)\n##\n##   - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start())\n##\n##        Creates a callable that will parse given file for the given\n##        regex pattern and will insert the output in the file.\n##        ``idx`` is a callable that receive the matching object and\n##        must return a integer index point where to insert the\n##        the output in the file. Default is to return the position of\n##        the start of the matched string.\n##\n##   - FileRegexSubst(file, pattern, replace, flags)\n##\n##        Apply a replace inplace in the given file. Your regex pattern must\n##        take care of everything and might be more complex. Check the README\n##        for a complete copy-pastable example.\n##\n# publish = FileInsertIntoFirstRegexMatch(\n#     \"CHANGELOG.rst\",\n#     r'/(?P<rev>[0-9]+\\.[0-9]+(\\.[0-9]+)?)\\s+\\([0-9]+-[0-9]{2}-[0-9]{2}\\)\\n--+\\n/',\n#     idx=lambda m: m.start(1)\n# )\n#publish = stdout\n\n\n## ``revs`` is a list of callable or a list of string\n##\n## callable will be called to resolve as strings and allow dynamical\n## computation of these. The result will be used as revisions for\n## gitchangelog (as if directly stated on the command line). This allows\n## to filter exaclty which commits will be read by gitchangelog.\n##\n## To get a full documentation on the format of these strings, please\n## refer to the ``git rev-list`` arguments. There are many examples.\n##\n## Using callables is especially useful, for instance, if you\n## are using gitchangelog to generate incrementally your changelog.\n##\n## Some helpers are provided, you can use them::\n##\n##   - FileFirstRegexMatch(file, pattern): will return a callable that will\n##     return the first string match for the given pattern in the given file.\n##     If you use named sub-patterns in your regex pattern, it'll output only\n##     the string matching the regex pattern named \"rev\".\n##\n##   - Caret(rev): will return the rev prefixed by a \"^\", which is a\n##     way to remove the given revision and all its ancestor.\n##\n## Please note that if you provide a rev-list on the command line, it'll\n## replace this value (which will then be ignored).\n##\n## If empty, then ``gitchangelog`` will act as it had to generate a full\n## changelog.\n##\n## The default is to use all commits to make the changelog.\n#revs = [\"^1.0.3\", ]\n#revs = [\n#    Caret(\n#        FileFirstRegexMatch(\n#            \"CHANGELOG.rst\",\n#            r\"(?P<rev>[0-9]+\\.[0-9]+(\\.[0-9]+)?)\\s+\\([0-9]+-[0-9]{2}-[0-9]{2}\\)\\n--+\\n\")),\n#    \"HEAD\"\n#]\nrevs = []\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**Version and Environment**\n*Version of package*: X.X.X\n*Python interpreter*: CPython, PyPy, etc.\n*OS*: xxx\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/workflows/python-package.yml",
    "content": "# This workflow will install Python dependencies, run tests and lint with a variety of Python versions\n# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python\n\nname: Build and Test\n\non: push\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      max-parallel: 1\n      matrix:\n        python-version: [\"3.8\", \"3.13\"]\n\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v3\n      with:\n        python-version: ${{ matrix.python-version }}\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        python -m pip install build tox tox-gh-actions\n    - name: Build sdist and wheel\n      run: |\n        python -m build \n    - name: Test with tox\n      run: |\n        echo \"${GSHEETS_CREDENTIALS}\" > creds.json\n        echo \"${TESTS_CONFIG}\" > tests.config\n        tox -v\n      env:\n          GSHEETS_CREDENTIALS: ${{secrets.GSHEETS_CREDENTIALS}}\n          TESTS_CONFIG: ${{secrets.TESTS_CONFIG}}\n\n\n"
  },
  {
    "path": ".gitignore",
    "content": "/.travis.secrets.tar.gz\n/creds.json\n/tests.config\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# pyenv\n.python-version\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n\n"
  },
  {
    "path": "CHANGELOG.rst",
    "content": "Changelog\n=========\n\n\nv1.2.1 (2025-03-07)\n-------------------\n- Bump to v1.2.1. [Robin Thomas]\n- Test coverage for fix of #59. [Robin Thomas]\n- TextFormatRun.format now optional, has empty default (#60) [Robin\n  Thomas]\n\n  Fixes #59.\n\n\nv1.2.0 (2024-06-12)\n-------------------\n- Bump to v1.2.0. [Robin Thomas]\n- Add TextFormatRun support (#57) [Robin Thomas]\n\n  Fixes #54.\n- Remove unneeded badges. [Robin Thomas]\n- Fix Google Sheets docs URL in README (#55) [Matt Black]\n- Formatting correction in README. [Robin Thomas]\n\n\nv1.1.2 (2022-12-03)\n-------------------\n- Bump to v1.1.2. [Robin Thomas]\n- Added test coverage of include_column_header=False case. [Robin\n  Thomas]\n- Fix exception when include_column_header=False. [pomcho555]\n\n  UnboundLocalError would occur, because freeze_args is defined in the wrong place. Fixed.\n- Fixed formatting issue with RTL section of README. [Robin Thomas]\n\n\nv1.1.1 (2022-09-27)\n-------------------\n- Bump to v1.1.1. [Robin Thomas]\n- Add README documentation for right-to-left support. [Robin Thomas]\n\n\nv1.1.0 (2022-09-27)\n-------------------\n- Bump to v1.1.0. [Robin Thomas]\n- Adds `get_right_to_left` and `set_right_to_left` functions. (#44)\n  [Robin Thomas]\n\n  * Adds `get_right_to_left` and `set_right_to_left` functions.\n  (`set_right_to_left` is also available in the batch updater.)\n  Fixes #43.\n\n\nv1.0.6 (2022-02-09)\n-------------------\n- Bump to v1.0.6. [Robin Thomas]\n- Enforce type on items added to conditional format rules sequence\n  object. [Robin Thomas]\n\n\nv1.0.5 (2021-11-27)\n-------------------\n- Bump to v1.0.5. [Robin Thomas]\n- Fixes #38. Avoids import errors, and also adapts test code to avoid\n  that Spreadsheet.__init__ calls fetch_sheet_metadata, working with new\n  release 5.0.0 of gspread. [Robin Thomas]\n\n\nv1.0.4 (2021-08-12)\n-------------------\n- Bump to v1.0.4. [Robin Thomas]\n- Fixes #35. Allows for bare RelativeDate objects in the values list to\n  BooleanCondition, transforming to ConditionValue objects with\n  relativeDate. [Robin Thomas]\n\n\nv1.0.3 (2021-07-13)\n-------------------\n- Bump to v1.0.3. [Robin Thomas]\n- Fixes #34. Cells with no formatting or data validation rules were\n  causing KeyError exceptions in get_effective_format() and similar\n  functions. These functions now properly return None without raising an\n  exception. [Robin Thomas]\n\n\nv1.0.2 (2021-07-01)\n-------------------\n- Bump to v1.0.2. [Robin Thomas]\n- Compare model classes to json schema from discovery URI, using new\n  script; remove foregroundColorStyle from CellFormat class as it's not\n  in json schema. (Border.width is in json schema but is deprecated and\n  thus Border model class is correctly coded.) [Robin Thomas]\n\n\nv1.0.1 (2021-06-30)\n-------------------\n- Bump to v1.0.1. [Robin Thomas]\n- Fixes #33 -- 'link' property of TextFormat now supported. [Robin\n  Thomas]\n\n\nv1.0.0 (2021-05-13)\n-------------------\n- Bump to v1.0.0. [Robin Thomas]\n- Fix for #31 (#32) [Robin Thomas]\n\n  Fixes #31. Allows for Sheets API's tendency to include empty objects\n  for default color values in API responses.\n- No longer CI with pypy. [Robin Thomas]\n- Revert \"Attempt to constrain use of old rsa pkg to 2.7 CI build, and\n  also\" [Robin Thomas]\n\n  This reverts commit 5cc83c67036ba5d004de997b613a34e9a8550f24.\n- Revert \"Attempt to constrain use of old rsa pkg to 2.7 CI build, and\n  also\" [Robin Thomas]\n\n  This reverts commit 5cc83c67036ba5d004de997b613a34e9a8550f24.\n- Revert \"Try TravisCI conditional use of rsa<=4.1 again\" [Robin Thomas]\n\n  This reverts commit b8ecaad65f0876385a1585237d756ee1fd450fb0.\n- Oops use rsa<4.1. [Robin Thomas]\n- Try TravisCI conditional use of rsa<=4.1 again. [Robin Thomas]\n- Attempt to constrain use of old rsa pkg to 2.7 CI build, and also\n  avoid the github dependabot alert. [Robin Thomas]\n- Pin rsa to < 4.1 so that Python 2.7 CI can still run. [Robin Thomas]\n- Added paranoid test of absent sheetId in GridRange props, to prevent\n  accidental regression. [Robin Thomas]\n- Improved, more concise code for Color.fromHex and .toHex(). [Robin\n  Thomas]\n- Tighten up travis install. [Robin Thomas]\n- Try explicit directory caching to make pip cache work as expected for\n  pandas wheel. [Robin Thomas]\n- Add 3.9 to travis. [Robin Thomas]\n- Pin six to >=1.12.0 in travis to avoid weird environmental dependency\n  problem. [Robin Thomas]\n- Move to travis-ci.com. [Robin Thomas]\n\n\nv0.3.7 (2020-11-23)\n-------------------\n- Bump to v0.3.7. [Robin Thomas]\n- Corrected error in conditional format rules example code in README.\n  [Robin Thomas]\n- Fixed typo in README. [Robin Thomas]\n- Fixed typos in batch call documentation. [Robin Thomas]\n\n\nv0.3.6 (2020-11-12)\n-------------------\n- Bump to v0.3.6. [Robin Thomas]\n- Allow for absent sheetId property in GridRange objects coming from API\n  (suspected abrupt change in Sheets API behavior!) [Robin Thomas]\n- Added extra example for clearing data validation rule with None.\n  [Robin Thomas]\n\n\nv0.3.5 (2020-11-10)\n-------------------\n- Bump to v0.3.5. [Robin Thomas]\n- Fixes #26. Allows `None` as rule parameter to\n  set_data_validation_rule* functions, which will clear data validation\n  rule for the relevant cells. [Robin Thomas]\n\n\nv0.3.4 (2020-10-22)\n-------------------\n- Bump to v0.3.4. [Robin Thomas]\n- More informative exception message when BooleanCondition receives non-\n  list/tuple for values parameter. [Robin Thomas]\n- Increased already-high test coverage. [Robin Thomas]\n- Removed dead link to now-inlined conditional formatting doc. [Robin\n  Thomas]\n- Correct doc/sphinx annoyances. [Robin Thomas]\n\n\nv0.3.3 (2020-09-24)\n-------------------\n- Bump to version v0.3.3. [Robin Thomas]\n- Fixes #24. [Robin Thomas]\n\n  A certain set of functions that exist both in batch and standalone mode\n  are dynamically bound as local names in the functions subpackage. That makes\n  them undiscoverable by IDEs like PyCharm. Adding a straightforward import\n  statement for these function names -- even though the names are re-bound\n  immediately with wrapped standalone versions of the functions -- makes\n  the function names visible to PyCharm.\n\n\nv0.3.2 (2020-09-16)\n-------------------\n- Bump to v0.3.2. [Robin Thomas]\n- Fixes #23. Test coverage added. [Robin Thomas]\n- Support InterpolationPoint.colorStyle. [Robin Thomas]\n\n\nv0.3.1 (2020-09-07)\n-------------------\n- Bump to 0.3.1. [Robin Thomas]\n- Consolidated CONDITIONALS.rst into README.rst. [Robin Thomas]\n- Let setup.cfg handle long_description and append conditionals doc.\n  [Robin Thomas]\n- Better short desc. [Robin Thomas]\n- Added PyPy and CPython implementation classifications to setup.py.\n  [Robin Thomas]\n- Remove unused _wrap_as_standalone_function duplicate. [Robin Thomas]\n- Indicate PyPy and PyPy3 support in README. (PyPy3 Travis build\n  stumbles on Pandas install problems; my local PyPy3 environment (which\n  required special NumPy source install with OpenBLAS config) shows a\n  successful test suite. [Robin Thomas]\n- Remove pypy3 travis target until pandas install problems can be fixed.\n  [Robin Thomas]\n\n\nv0.3.0 (2020-08-14)\n-------------------\n- Bump to version 0.3.0. [Robin Thomas]\n- Include pypy and pypy3 in travis builds. [Robin Thomas]\n- Add \"batch updater\" object (#21) [Robin Thomas]\n\n  * Added batch capability to all formatting functions as well as format_with_dataframe.\n  Minimal test coverage.\n\n  * use \"del listobj[:]\" for 2.7 compatbility\n\n  * Additional batch-updater tests; added batch updater docs to README.\n\n\nv0.2.5 (2020-07-17)\n-------------------\n- Bump to version 0.2.5. [Robin Thomas]\n- Fixes #20: BooleanCondition objects returned by API endpoints may lack\n  a 'values' field instead of having a present 'values' field with an\n  empty list of values. Allow for this in BooleanCondition constructor.\n  Test coverage added for round-trip test of Boolean. [Robin Thomas]\n- Argh no 3.9-dev yet. [Robin Thomas]\n- Corrected version reference in sphinx docs. [Robin Thomas]\n- Removed 3.6, added 3.9-dev to travis build` [Robin Thomas]\n- Make collections.abc import 3.9-compatible. [Robin Thomas]\n- Use full version string in sphnix docs. [Robin Thomas]\n- Add docs badge to README. [Robin Thomas]\n- Fix title in index.rst. [Robin Thomas]\n- Try adding conditionals rst to docs. [Robin Thomas]\n- Preserve original conditional rules for effective replacement of rules\n  in one API call. [Robin Thomas]\n- Add downloads badge. [Robin Thomas]\n\n\nv0.2.4 (2020-05-04)\n-------------------\n- Bump to v0.2.4. [Robin Thomas]\n- Make new Color.fromHex() and toHex() 2.7-compatible. [Robin Thomas]\n\n\nv0.2.3 (2020-05-04)\n-------------------\n- Bump to v0.2.3. [Robin Thomas]\n- Color model import and export as hex color (#17) [Sam Korn]\n\n  * Add toHex function to Color model\n\n  * tohex and fromhex functions for Color model\n\n  * Use classmethod for hexstring constructor\n\n  * tests for hex colors, additional checks for malformed hex inputs\n- Results of check-manifest added to MANIFEST.in. [Robin Thomas]\n\n\nv0.2.2 (2020-04-19)\n-------------------\n- Bump to v0.2.2. [Robin Thomas]\n- Add MANIFEST.in to add VERSION file to sdist. [Robin Thomas]\n\n\nv0.2.1 (2020-04-02)\n-------------------\n- Bump to v0.2.1. [Robin Thomas]\n- Added support in DataFrame formatting for MultiIndex, either as index\n  or as the columns object of the DataFrame. [Robin Thomas]\n- Added docs/ to start sphinx autodoc generation. [Robin Thomas]\n- Add wheel dep for bdist_wheel support. [Robin Thomas]\n\n\nv0.2.0 (2020-03-31)\n-------------------\n- Bump to v0.2.0. [Robin Thomas]\n- Fixes #10 (support setting row height or column width). [Robin Thomas]\n- Added unbounded col and row ranges in format_cell_ranges test to\n  ensure that formatting calls (not just _range_to_gridrange_object)\n  succeed. [Robin Thomas]\n\n\nv0.1.1 (2020-02-28)\n-------------------\n- Bump to v0.1.1. [Robin Thomas]\n- Bare column row 14 (#15) [Robin Thomas]\n\n  Fixes #14 -- support range strings that are unbounded on row dimension\n  or column dimenstion.\n- Oops typo. [Robin Thomas]\n- Improve README intro and conditional docs text; attempt to include all\n  .rst in package so that PyPI and others can see the other doc files.\n  [Robin Thomas]\n\n\nv0.1.0 (2020-02-11)\n-------------------\n- Bump to 0.1.0 for conditional formatting rules release. [Robin Thomas]\n- Added doc about rule mutation and save() [Robin Thomas]\n- Added conditional format rules documentation. [Robin Thomas]\n- Added tests on effective cell format after conditional format rules\n  apply. [Robin Thomas]\n- Py2.7 MutableSequence does not mixin clear() [Robin Thomas]\n- Tightened up add/delete of cond format rules, testing deletion of\n  multiple rules. [Robin Thomas]\n- Forbid illegal BooleanCondition.type values for data validation and\n  conditional formatting ,respectively. [Robin Thomas]\n- Realized that collections.abc is hoisted into collections module for\n  backward compatibility already. [Robin Thomas]\n- Add 2-3 compat for collections abc imports. [Robin Thomas]\n- Final draft of conditional formatting implementation; test added,\n  tests pass. Documentation not yet written. [Robin Thomas]\n- Update README.rst. [Robin Thomas]\n\n\nv0.0.9 (2020-02-09)\n-------------------\n- Bump to 0.0.9. [Robin Thomas]\n- Data validation and prerequesites for conditional formatting 8 (#13)\n  [Robin Thomas]\n\n  * objects for conditional formatting added to data model\n\n  * Implements data-validation feature requested in robin900/gspread-formatting#8.\n\n  Test coverage included.\n\n  * added GridRange object to models, ConditionalFormatRule class.\n\n  * factored test code to allow Travis-style ssecret injection\n\n  * merged in v0.0.8 changes from master; added full documentation for data validation;\n  conditional format rules have all models in place, but no functions and no\n  documentation in README.\n\n  * add travis yml!\n\n  * added requirements-test.txt so we can hopefully run tests in Travis\n\n  * 2-3 compatible StringIO import in test\n\n  * encrypt secrets files rather than env var approach to credentials and config\n\n  * try encrypted files again\n\n  * tighten up py versions in travis\n\n  * make .tar.gz for travis secrets\n\n  * bundle up secrets for travis ci\n\n  * 2.7 compatible config reading\n\n  * try a pip cache\n\n  * fewer py builds\n\n\nv0.0.8 (2020-02-06)\n-------------------\n- Fixes #12. Adds support for ColorStyle and all fields in which this\n  object is now expected in the Sheets v4 API. See the Python or C# API\n  documentation for reference, since the main REST API documentation\n  still lacks mention of ColorStyle. [Robin Thomas]\n\n\nv0.0.7 (2019-08-20)\n-------------------\n- Fixed setup.py problem that missed package contents. [Robin Thomas]\n- Merge branch 'master' of github.com:robin900/gspread-formatting.\n  [Robin Thomas]\n- Update issue templates. [Robin Thomas]\n\n  Added bug report template\n- Bump to 0.0.7. [Robin Thomas]\n- Add gspread-dataframe as dev req. [Robin Thomas]\n\n\nv0.0.6 (2019-04-30)\n-------------------\n- Handle from_props cases where a format component is an empty dict of\n  properties, so that comparing format objects round-trip works as\n  expected, and so that format objects are as sparse as possible. [Robin\n  Thomas]\n\n\nv0.0.5 (2019-04-30)\n-------------------\n- Bump to 0.0.5. [Robin Thomas]\n- Merge pull request #5 from robin900/fix-issue-4. [Robin Thomas]\n\n  Conversion of API response's CellFormat properties failed for\n- Conversion of API response's CellFormat properties failed for certain\n  nested format components such as borders.bottom. Added test coverage\n  to trigger bug, and code changes to solve the bug. Also added support\n  of deprecated width= attribute for Border format component. [Robin\n  Thomas]\n\n  Fixes #4.\n\n\nv0.0.4 (2019-03-26)\n-------------------\n- Bump VERSION to 0.0.4. [Robin Thomas]\n- Merge pull request #2 from robin900/rthomas-dataframe-formatting.\n  [Robin Thomas]\n\n  Rthomas dataframe formatting\n- Added docs and tests. [Robin Thomas]\n- Working dataframe formatting, with test in test suite. Lacks complete\n  documentation. [Robin Thomas]\n- Added date-format test in response to user email; test confirms that\n  package is working as expected. [Robin Thomas]\n- Clean up of test suite, and provided instructions for dev and testing\n  in README. [Robin Thomas]\n\n\nv0.0.3 (2018-08-24)\n-------------------\n- Bump to 0.0.3, which fixes issue #1. [Robin Thomas]\n- Fixed reference problem with NumberFormat.TYPES and Border.STYLES.\n  [Robin Thomas]\n- Added pypi badge. [Robin Thomas]\n- Added format_cell_ranges, plus tests and documentation. [Robin Thomas]\n\n\nv0.0.2 (2018-07-23)\n-------------------\n- Added get/set for frozen row and column counts. Bumped release to\n  0.0.2. [Robin Thomas]\n\n\nv0.0.1 (2018-07-20)\n-------------------\n- Tests pass; ready for version 0.0.1. [Robin Thomas]\n- Initial commit. [Robin Thomas]\n\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018 Robin Thomas\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include VERSION\ninclude *.example\ninclude *.py\ninclude *.rc\ninclude *.rst\ninclude *.txt\nrecursive-include docs *.bat\nrecursive-include docs *.py\nrecursive-include docs *.rst\nrecursive-include docs Makefile\n"
  },
  {
    "path": "README.rst",
    "content": "gspread-formatting\n------------------\n\n.. image:: https://badge.fury.io/py/gspread-formatting.svg\n    :target: https://badge.fury.io/py/gspread-formatting\n\n.. image:: https://github.com/robin900/gspread-formatting/actions/workflows/python-package.yml/badge.svg?branch=master\n    :target: https://github.com/robin900/gspread-formatting/actions/workflows/python-package.yml\n\n.. image:: https://img.shields.io/pypi/dm/gspread-formatting.svg\n    :target: https://pypi.org/project/gspread-formatting\n\nThis package provides complete cell formatting for Google spreadsheets\nusing the popular ``gspread`` package, along with a few related features such as setting\n\"frozen\" rows and columns in a worksheet. Both basic and conditional formatting operations\nare supported.\n\nThe package also offers graceful formatting of Google spreadsheets using a Pandas DataFrame.\nSee the section below for usage and details.\n\nUsage\n~~~~~\n\nBasic formatting of a range of cells in a worksheet is offered by the ``format_cell_range`` function. \nAll basic formatting components of the v4 Sheets API's ``CellFormat`` are present as classes \nin the ``gspread_formatting`` module, available both by ``InitialCaps`` names and ``camelCase`` names: \nfor example, the background color class is ``BackgroundColor`` but is also available as \n``backgroundColor``, while the color class is ``Color`` but available also as ``color``. \nAttributes of formatting components are best specified as keyword arguments using ``camelCase`` \nnaming, e.g. ``backgroundColor=...``. Complex formats may be composed easily, by nesting the calls to the classes.  \n\nSee `the CellFormat page of the Sheets API documentation <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat>`_\nto learn more about each formatting component.::\n\n    from gspread_formatting import *\n\n    fmt = cellFormat(\n        backgroundColor=color(1, 0.9, 0.9),\n        textFormat=textFormat(bold=True, foregroundColor=color(1, 0, 1)),\n        horizontalAlignment='CENTER'\n        )\n\n    format_cell_range(worksheet, 'A1:J1', fmt)\n\nThe ``format_cell_ranges`` function allows for formatting multiple ranges with corresponding formats,\nall in one function call and Sheets API operation::\n\n    fmt = cellFormat(\n        backgroundColor=color(1, 0.9, 0.9),\n        textFormat=textFormat(bold=True, foregroundColor=color(1, 0, 1)),\n        horizontalAlignment='CENTER'\n        )\n\n    fmt2 = cellFormat(\n        backgroundColor=color(0.9, 0.9, 0.9),\n        horizontalAlignment='RIGHT'\n        )\n\n    format_cell_ranges(worksheet, [('A1:J1', fmt), ('K1:K200', fmt2)])\n\nSpecifying Cell Ranges\n~~~~~~~~~~~~~~~~~~~~~~\n\nThe `format_cell_range` function and friends allow a string to specify a cell range using the \"A1\" convention\nto name a column-and-row cell address with column letter and row number; in addition, one may specify\nan entire column or column range with unbounded rows, or an entire row or row range with unbounded columns,\nor a combination thereof. Here are some examples::\n\n    A1     # column A row 1\n    A1:A2  # column A, rows 1-2\n    A      # entire column A, rows unbounded\n    A:A    # entire column A, rows unbounded\n    A:C    # entire columns A through C\n    A:B100 # columns A and B, unbounded start through row 100\n    A100:B # columns A and B, from row 100 with unbounded end \n    1:3    # entire rows 1 through 3, all columns\n    1      # entire row 1\n\n\nRetrieving, Comparing, and Composing CellFormats\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nA Google spreadsheet's own default format, as a CellFormat object, is available via ``get_default_format(spreadsheet)``.\n``get_effective_format(worksheet, label)`` and ``get_user_entered_format(worksheet, label)`` also will return\nfor any provided cell label either a CellFormat object (if any formatting is present) or None.\n\n``CellFormat`` objects are comparable with ``==`` and ``!=``, and are mutable at all times; \nthey can be safely copied with Python's ``copy.deepcopy`` function. ``CellFormat`` objects can be combined\ninto a new ``CellFormat`` object using the ``add`` method (or ``+`` operator). ``CellFormat`` objects also offer \n``difference`` and ``intersection`` methods, as well as the corresponding\noperators ``-`` (for difference) and ``&`` (for intersection).::\n\n    >>> default_format = CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True))\n    >>> user_format = CellFormat(textFormat=textFormat(italic=True))\n    >>> effective_format = default_format + user_format\n    >>> effective_format\n    CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True, italic=True))\n    >>> effective_format - user_format \n    CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True))\n    >>> effective_format - user_format == default_format\n    True\n\nFrozen Rows and Columns\n~~~~~~~~~~~~~~~~~~~~~~~\n\nThe following functions get or set \"frozen\" row or column counts for a worksheet::\n\n    get_frozen_row_count(worksheet)\n    get_frozen_column_count(worksheet)\n    set_frozen(worksheet, rows=1)\n    set_frozen(worksheet, cols=1)\n    set_frozen(worksheet, rows=1, cols=0)\n\nSetting Row Heights and Column Widths\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe following functions set the height (in pixels) of rows or width (in pixels) of columns::\n\n    set_row_height(worksheet, 1, 42)\n    set_row_height(worksheet, '1:100', 42)\n    set_row_heights(worksheet, [ ('1:100', 42), ('101:', 22) ])\n    set_column_width(worksheet, 'A', 190)\n    set_column_width(worksheet, 'A:D', 100)\n    set_column_widths(worksheet, [ ('A', 200), ('B:', 100) ])\n\nWorking with Right-to-Left Language Alphabets\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe following example shows the functions to get or set the `rightToLeft` property of a worksheet::\n\n    get_right_to_left(worksheet)\n    set_right_to_left(worksheet, True)\n\nAlso note the presence of the argument `textDirection=` to `CellFormat`: set it to `'RIGHT_TO_LEFT'`\nin order to use right-to-left text in an individual cell in an otherwise left-to-right worksheet.\n\nGetting and Setting Data Validation Rules for Cells and Cell Ranges\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThe following functions get or set the \"data validation rule\" for a cell or cell range::\n\n    get_data_validation_rule(worksheet, label)\n    set_data_validation_for_cell_range(worksheet, range, rule)\n    set_data_validation_for_cell_ranges(worksheet, ranges)\n\nThe full functionality of data validation rules is supported: all of ``BooleanCondition``. \nSee `the API documentation <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule>`_\nfor more information. Here's a short example::\n\n    validation_rule = DataValidationRule(\n        BooleanCondition('ONE_OF_LIST', ['1', '2', '3', '4']),\n        showCustomUi=True\n    )\n    set_data_validation_for_cell_range(worksheet, 'A2:D2', validation_rule)\n    # data validation for A2\n    eff_rule = get_data_validation_rule(worksheet, 'A2')\n    eff_rule.condition.type\n    >>> 'ONE_OF_LIST'\n    eff_rule.showCustomUi\n    >>> True\n    # No data validation for A1\n    eff_rule = get_data_validation_rule(worksheet, 'A1')\n    eff_rule\n    >>> None\n    # Clear data validation rule by using None\n    set_data_validation_for_cell_range(worksheet, 'A2', None)\n    eff_rule = get_data_validation_rule(worksheet, 'A2')\n    eff_rule\n    >>> None\n\n\nFormatting a Worksheet Using a Pandas DataFrame\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nIf you are using Pandas DataFrames to provide data to a Google spreadsheet -- using perhaps\nthe ``gspread-dataframe`` package `available on PyPI <https://pypi.org/project/gspread-dataframe/>`_ --\nthe ``format_with_dataframe`` function in ``gspread_formatting.dataframe`` allows you to use that same \nDataFrame object and specify formatting for a worksheet. There is a ``DEFAULT_FORMATTER`` in the module,\nwhich will be used if no formatter object is provided to ``format_with_dataframe``::\n\n    from gspread_formatting.dataframe import format_with_dataframe, BasicFormatter\n    from gspread_formatting import Color\n\n    # uses DEFAULT_FORMATTER\n    format_with_dataframe(worksheet, dataframe, include_index=True, include_column_header=True)\n\n    formatter = BasicFormatter(\n        header_background_color=Color(0,0,0), \n        header_text_color=Color(1,1,1),\n        decimal_format='#,##0.00'\n    )\n\n    format_with_dataframe(worksheet, dataframe, formatter, include_index=False, include_column_header=True)\n\n\nBatch Mode for API Call Efficiency\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis package offers a \"batch updater\" object, with methods having the same names and parameters as the \nformatting functions in the package. The batch updater will gather all formatting requests generated \nby calling these methods, and send them all to the Google Sheets API in a single ``batchUpdate`` \nrequest when ``.execute()`` is invoked on the batch updater. Alternately, you can use the batch updater\nas a context manager in a ``with:`` block, which will automate the call to ``.execute()``::\n\n    from gspread_formatting import batch_updater\n\n    sheet = some_gspread_worksheet\n\n    # Option 1: call execute() directly\n    batch = batch_updater(sheet.spreadsheet)\n    batch.format_cell_range(sheet, '1', cellFormat(textFormat=textFormat(bold=True)))\n    batch.set_row_height(sheet, '1', 32)\n    batch.execute()\n\n    # Option 2: use with: block\n    with batch_updater(sheet.spreadsheet) as batch:\n        batch.format_cell_range(sheet, '1', cellFormat(textFormat=textFormat(bold=True)))\n        batch.set_row_height(sheet, '1', 32)\n\n\nConditional Format Rules\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nA conditional format rule allows you to specify a cell format that (additively) applies to cells in certain ranges\nonly when the value of the cell meets a certain condition. \nThe `ConditionalFormatRule documentation <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#ConditionalFormatRule>`_ for the Sheets API describes the two kinds of rules allowed:\na ``BooleanRule`` in which the `CellFormat` is applied to the cell if the value meets the specified boolean\ncondition; or a ``GradientRule`` in which the ``Color`` or ``ColorStyle`` of the cell varies depending on the numeric\nvalue of the cell or cells. \n\nYou can specify multiple rules for each worksheet present in a Google spreadsheet. To add or remove rules,\nuse the ``get_conditional_format_rules(worksheet)`` function, which returns a list-like object which you can\nmodify as you would modify a list, and then call ``.save()`` to store the rule changes you've made.\n\nHere is an example that applies bold text and a bright red color to cells in column A if the cell value\nis numeric and greater than 100::\n\n    from gspread_formatting import *\n\n    worksheet = some_spreadsheet.worksheet('My Worksheet')\n\n    rule = ConditionalFormatRule(\n        ranges=[GridRange.from_a1_range('A1:A2000', worksheet)],\n        booleanRule=BooleanRule(\n            condition=BooleanCondition('NUMBER_GREATER', ['100']), \n            format=CellFormat(textFormat=textFormat(bold=True), backgroundColor=Color(1,0,0))\n        )\n    )\n\n    rules = get_conditional_format_rules(worksheet)\n    rules.append(rule)\n    rules.save()\n\n    # or, to replace any existing rules with just your single rule:\n    rules.clear()\n    rules.append(rule)\n    rules.save()\n\nAn important note: A ``ConditionalFormatRule`` is, like all other objects provided by this package,\nmutable in all of its fields. Mutating a ``ConditionalFormatRule`` object in place will not automatically\nstore the changes via the Sheets API; but calling `.save()` on the list-like rules object will store\nthe mutated rule as expected.\n\n\nInstallation\n------------\n\nRequirements\n~~~~~~~~~~~~\n\n* Python 3.x, PyPy and PyPy3\n* Python 2.7 support for releases prior to 2.0.0\n* gspread >= 3.0.0\n\nFrom PyPI\n~~~~~~~~~\n\n::\n\n    pip install gspread-formatting\n\nFrom GitHub\n~~~~~~~~~~~\n\n::\n\n    git clone https://github.com/robin900/gspread-formatting.git\n    cd gspread-formatting\n    python setup.py install\n\nDevelopment and Testing\n-----------------------\n\nInstall packages listed in ``requirements-dev.txt``. To run the test suite\nin ``test.py`` you will need to:\n\n* Authorize as the Google account you wish to use as a test, and download\n  a JSON file containing the credentials. Name the file ``creds.json``\n  and locate it in the top-level folder of the repository.\n* Set up a ``tests.config`` file using the ``tests.config.example`` file as a template.\n  Specify the ID of a spreadsheet that the Google account you are using\n  can access with write privileges.\n"
  },
  {
    "path": "VERSION",
    "content": "2.0.0b1\n"
  },
  {
    "path": "diffs_to_discovery.py",
    "content": "import requests\nimport gspread_formatting.models\nfrom gspread_formatting.util import _underlower\n\nimport inspect\nimport pprint\n\nr = requests.get(\"https://sheets.googleapis.com/$discovery/rest?version=v4\")\nj = r.json()\n\nschemas = j['schemas']\n\nclasses = gspread_formatting.models._CLASSES\n\nBASE_TYPES = {'boolean', 'string', 'number', 'integer'}\n\ndef resolve_schema_property(sch_prop):\n    if '$ref' in sch_prop:\n        return resolve_schema_property(schemas[sch_prop['$ref']])\n    else:\n        return sch_prop\n\ndef resolve_class_field(fields, field_name):\n    if isinstance(fields, dict):\n        field_ref = (fields[field_name] or field_name) if (field_name in fields) else None\n    else:\n        field_ref = field_name if (field_name in fields) else None\n    if field_ref is None:\n        return None\n    ref_class = classes.get(_underlower(field_ref))\n    if ref_class is not None:\n        return ref_class\n    if ref_class in BASE_TYPES:\n        return {'type': ref_class}\n    else:\n        return {'type': 'unknown'}\n\ndef compare_property(name, sch_prop, cls_prop):\n    errors = []\n    sch_type = sch_prop['type']\n    cls_type = None\n    if inspect.isclass(cls_prop):\n        cls_type = 'object' \n    elif isinstance(cls_prop, dict):\n        cls_type = cls_prop['type']\n    if sch_type != cls_type: \n        errors.append( (name, 'schema and class property type differs', '%r != %r' % (sch_type, cls_type)) )\n    elif sch_type == 'object':\n        errors.extend( compare_object(sch_prop, cls_prop) )\n    return errors\n\ndef compare_object(schema, cls):\n    errors = []\n    # 1. names must match\n    schema_name = schema['id']\n    cls_name = cls.__name__\n    if schema_name != cls_name:\n        errors.append( (schema_name, 'class name differs', '%r != %r' % (sch_name, cls_name)) )\n        \n    # 2. report extraneous properties in cls\n    for cls_propname in cls._FIELDS:\n        if cls_propname not in schema['properties']:\n            errors.append( (schema_name, 'extraneous property in class', cls_propname) )\n\n    # 3. report missing properties in cls\n    for sch_propname, sch_prop in schema['properties'].items():\n        cls_field = resolve_class_field(cls._FIELDS, sch_propname)\n        if cls_field is None:\n            errors.append( (schema_name, 'property missing in class', sch_propname) )\n        # 4. each property must be of correct type\n        errors.extend( \n            compare_property(\"%s.%s\" % (schema_name, sch_propname), resolve_schema_property(sch_prop), cls_field) \n        )\n\n    # dedupe\n    return sorted({e for e in errors})\n    \n\ndiffs = compare_object(schemas['CellFormat'], classes['cellFormat'])\npprint.pprint(diffs, width=120)\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nSPHINXPROJ    = gspread-formatting\nSOURCEDIR     = .\nBUILDDIR      = _build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n"
  },
  {
    "path": "docs/conf.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n#\n# gspread-formatting documentation build configuration file, created by\n# sphinx-quickstart on Fri Mar 10 22:46:18 2017.\n#\n# This file is execfile()d with the current directory set to its\n# containing dir.\n#\n# Note that not all possible configuration values are present in this\n# autogenerated file.\n#\n# All configuration values have a default; values that are commented out\n# serve to show the default.\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n#\n# import os\n# import sys\n# sys.path.insert(0, os.path.abspath('.'))\n\n\n# -- General configuration ------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n#\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = ['sphinx.ext.autodoc']\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = ['_templates']\n\n# The suffix(es) of source filenames.\n# You can specify multiple suffix as a list of string:\n#\n# source_suffix = ['.rst', '.md']\nsource_suffix = '.rst'\n\n# The master toctree document.\nmaster_doc = 'index'\n\n# General information about the project.\nproject = 'gspread-formatting'\ncopyright = '2017, Robin Thomas'\nauthor = 'Robin Thomas'\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\nimport os.path\nimport re\n\nwith open(os.path.join(os.path.dirname(__file__), '../VERSION'), 'r') as f:\n    # The full version, including alpha/beta/rc tags.\n    version = f.read().strip()\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n#\n# This is also used if you do content translation via gettext catalogs.\n# Usually you set \"language\" from the command line for these cases.\nlanguage = None\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This patterns also effect to html_static_path and html_extra_path\nexclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = 'sphinx'\n\n# If true, `todo` and `todoList` produce output, else they produce nothing.\ntodo_include_todos = False\n\n\n# -- Options for HTML output ----------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\nhtml_theme = 'alabaster'\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\n#\n# html_theme_options = {}\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = ['_static']\n\n\n# -- Options for HTMLHelp output ------------------------------------------\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = 'gspread-formattingdoc'\n\n\n# -- Options for LaTeX output ---------------------------------------------\n\nlatex_elements = {\n    # The paper size ('letterpaper' or 'a4paper').\n    #\n    # 'papersize': 'letterpaper',\n\n    # The font size ('10pt', '11pt' or '12pt').\n    #\n    # 'pointsize': '10pt',\n\n    # Additional stuff for the LaTeX preamble.\n    #\n    # 'preamble': '',\n\n    # Latex figure (float) alignment\n    #\n    # 'figure_align': 'htbp',\n}\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title,\n#  author, documentclass [howto, manual, or own class]).\nlatex_documents = [\n    (master_doc, 'gspread-formatting.tex', 'gspread-formatting Documentation',\n     'Robin Thomas', 'manual'),\n]\n\n\n# -- Options for manual page output ---------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [\n    (master_doc, 'gspread-formatting', 'gspread-formatting Documentation',\n     [author], 1)\n]\n\n\n# -- Options for Texinfo output -------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (master_doc, 'gspread-formatting', 'gspread-formatting Documentation',\n     author, 'gspread-formatting', 'Format gspread worksheets using Sheets v4 formatting features.',\n     'Miscellaneous'),\n]\n\n\n\n"
  },
  {
    "path": "docs/index.rst",
    "content": ".. gspread-formatting documentation master file, created by\n   sphinx-quickstart on Fri Mar 10 22:46:18 2017.\n   You can adapt this file completely to your liking, but it should at least\n   contain the root `toctree` directive.\n\nWelcome to gspread-formatting's documentation!\n==============================================\n\n.. toctree::\n   :maxdepth: 2\n   :caption: Contents:\n\n.. include:: ../README.rst\n\n.. include:: ../CONDITIONALS.rst\n\nModule Documentation - Version |version|\n----------------------------------------\n\n.. automodule:: gspread_formatting.functions\n   :members:\n\n.. automodule:: gspread_formatting.models\n   :members:\n\n.. automodule:: gspread_formatting.conditionals\n   :members:\n\n.. automodule:: gspread_formatting.dataframe\n   :members:\n\n\n\nIndices and tables\n==================\n\n* :ref:`genindex`\n* :ref:`modindex`\n* :ref:`search`\n"
  },
  {
    "path": "docs/make.bat",
    "content": "@ECHO OFF\r\n\r\npushd %~dp0\r\n\r\nREM Command file for Sphinx documentation\r\n\r\nif \"%SPHINXBUILD%\" == \"\" (\r\n\tset SPHINXBUILD=sphinx-build\r\n)\r\nset SOURCEDIR=.\r\nset BUILDDIR=_build\r\nset SPHINXPROJ=gspread-formatting\r\n\r\nif \"%1\" == \"\" goto help\r\n\r\n%SPHINXBUILD% >NUL 2>NUL\r\nif errorlevel 9009 (\r\n\techo.\r\n\techo.The 'sphinx-build' command was not found. Make sure you have Sphinx\r\n\techo.installed, then set the SPHINXBUILD environment variable to point\r\n\techo.to the full path of the 'sphinx-build' executable. Alternatively you\r\n\techo.may add the Sphinx directory to PATH.\r\n\techo.\r\n\techo.If you don't have Sphinx installed, grab it from\r\n\techo.http://sphinx-doc.org/\r\n\texit /b 1\r\n)\r\n\r\n%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%\r\ngoto end\r\n\r\n:help\r\n%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%\r\n\r\n:end\r\npopd\r\n"
  },
  {
    "path": "gspread_formatting/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\nfrom .functions import *\nfrom .models import *\nfrom .conditionals import *\nfrom .batch import *\n"
  },
  {
    "path": "gspread_formatting/batch.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport gspread_formatting.functions\nimport gspread_formatting.dataframe\n\nfrom functools import wraps\n\n__all__ = ('batch_updater', 'SpreadsheetBatchUpdater')\n\ndef batch_updater(spreadsheet):\n    return SpreadsheetBatchUpdater(spreadsheet)\n\nclass SpreadsheetBatchUpdater(object):\n    def __init__(self, spreadsheet):\n        self.spreadsheet = spreadsheet\n        self.requests = []\n\n    def __enter__(self):\n        if self.requests:\n            raise IOError(\"BatchUpdater has un-executed requests pending, cannot be __enter__ed\")\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        self.execute()\n        return False\n\n    def execute(self):\n        resps = self.spreadsheet.batch_update({'requests': self.requests})\n        del self.requests[:]\n        return resps\n\ndef _wrap_for_batch_updater(func):\n    @wraps(func)\n    def f(self, worksheet, *args, **kwargs):\n        if worksheet.spreadsheet != self.spreadsheet:\n            raise ValueError(\n                \"Worksheet %r belongs to spreadsheet %r, not batch updater's spreadsheet %r\" \n                % (worksheet, worksheet.spreadsheet, self.spreadsheet)\n            )\n        self.requests.append( func(worksheet, *args, **kwargs) )\n        return self\n    return f\n\nfor fname in gspread_formatting.batch_update_requests.__all__:\n    func = getattr(gspread_formatting.batch_update_requests, fname)\n    setattr(SpreadsheetBatchUpdater, fname, _wrap_for_batch_updater(func))\n\nsetattr(\n    SpreadsheetBatchUpdater, \n    'format_with_dataframe', \n    _wrap_for_batch_updater(gspread_formatting.dataframe._format_with_dataframe)\n)\n\n"
  },
  {
    "path": "gspread_formatting/batch_update_requests.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"\nThis module provides functions that generate request objects compatible with the\n\"batchUpdate\" API call in Google Sheets. Both the ``.functions`` and\n``.batch`` modules make use of these request functions, wrapping them\nin functions that make the API call or calls using the generated request objects.\n\"\"\"\n\nfrom .util import _build_repeat_cell_request, _range_to_dimensionrange_object\n\nfrom functools import wraps\n\n__all__ = (\n    'format_cell_ranges', 'format_cell_range', 'set_frozen', 'set_right_to_left',\n    'set_data_validation_for_cell_range', 'set_data_validation_for_cell_ranges',\n    'set_text_format_runs',\n    'set_row_height', 'set_row_heights',\n    'set_column_width', 'set_column_widths'\n)\n\n\ndef set_row_heights(worksheet, ranges):\n    \"\"\"Update a row or range of rows in the given ``Worksheet`` \n    to have the specified height in pixels.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param ranges: An iterable whose elements are pairs of:\n        a string with row range value in A1 notation, e.g. '1' or '1:50',\n        and a integer specifying height in pixels.\n    \"\"\"\n\n    return [\n        { \n            'updateDimensionProperties': { \n                'range': _range_to_dimensionrange_object(range, worksheet.id), \n                'properties': { 'pixelSize': height }, \n                'fields': 'pixelSize' \n            } \n        }\n        for range, height in ranges\n    ]\n\n\ndef set_row_height(worksheet, label, height):\n    \"\"\"Update a row or range of rows in the given ``Worksheet`` \n    to have the specified height in pixels.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param label: string representing a single row or range of rows, e.g. ``1`` or ``3:400``.\n    :param height: An integer greater than or equal to 0.\n\n    \"\"\"\n    return set_row_heights(worksheet, [(label, height)])\n \n\ndef set_column_widths(worksheet, ranges):\n    \"\"\"Update a column or range of columns in the given ``Worksheet`` \n    to have the specified width in pixels.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param ranges: An iterable whose elements are pairs of:\n                   a string with column range value in A1 notation, e.g. 'A:C',\n                   and a integer specifying width in pixels.\n\n    \"\"\"\n\n    return [\n        { \n            'updateDimensionProperties': { \n                'range': _range_to_dimensionrange_object(range, worksheet.id), \n                'properties': { 'pixelSize': width }, \n                'fields': 'pixelSize' \n            } \n        }\n        for range, width in ranges\n    ]\n\n\ndef set_column_width(worksheet, label, width):\n    \"\"\"Update a column or range of columns in the given ``Worksheet`` \n    to have the specified width in pixels.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param label: string representing a single column or range of columns, e.g. ``A`` or ``B:D``.\n    :param height: An integer greater than or equal to 0.\n\n    \"\"\"\n\n    return set_column_widths(worksheet, [(label, width)])\n\n\ndef set_text_format_runs(worksheet, label, runs):\n    \"\"\"For the given cell (or cell range)\n\n    :param worksheet: The ``Worksheet`` object.\n    :param label: string representing a single cell or range of cells, e.g. ``A1`` or ``A2:B7``.\n    :param runs: A list (possibly empty) of TextFormatRun objects\n    \"\"\"\n    return _build_repeat_cell_request(worksheet, label, runs, 'textFormatRuns')\n\n\ndef format_cell_ranges(worksheet, ranges):\n    \"\"\"Update a list of Cell object ranges of :class:`Cell` objects\n    in the given ``Worksheet`` to have the accompanying ``CellFormat``.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param ranges: An iterable whose elements are pairs of:\n        a string with range value in A1 notation, e.g. 'A1:A5',\n        and a ``CellFormat`` object).\n\n    \"\"\"\n\n    return [\n        _build_repeat_cell_request(worksheet, range, cell_format)\n        for range, cell_format in ranges\n    ]\n\n\ndef format_cell_range(worksheet, name, cell_format):\n    \"\"\"Update a range of :class:`Cell` objects in the given Worksheet\n    to have the specified ``CellFormat``.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param name: A string with range value in A1 notation, e.g. 'A1:A5'.\n    :param cell_format: A ``CellFormat`` object.\n\n    \"\"\"\n\n    return format_cell_ranges(worksheet, [(name, cell_format)])\n\n\ndef set_data_validation_for_cell_ranges(worksheet, ranges):\n    \"\"\"Update a list of Cell object ranges of :class:`Cell` objects\n    in the given ``Worksheet`` to have the accompanying ``DataValidationRule``.\n\n    :param worksheet: The ``Worksheet`` object.\n    :param ranges: An iterable whose elements are pairs of:\n                   a string with range value in A1 notation, e.g. 'A1:A5',\n                   and a ``DataValidationRule`` object or None to clear the data\n                   validation rule).\n\n    \"\"\"\n\n    return [\n        _build_repeat_cell_request(worksheet, range, data_validation_rule, 'dataValidation')\n        for range, data_validation_rule in ranges\n    ]\n\n\ndef set_data_validation_for_cell_range(worksheet, range, rule):\n    \"\"\"Update a Cell range in the given ``Worksheet``\n    to have the accompanying ``DataValidationRule`` (or no rule).\n\n    :param worksheet: The ``Worksheet`` object.\n    :param range: A string with range value in A1 notation, e.g. 'A1:A5'.\n    :param rule: A DataValidationRule object, or None to remove data validation rule for cells..\n\n    \"\"\"\n\n    return set_data_validation_for_cell_ranges(worksheet, [(range, rule)])\n\n\ndef set_right_to_left(worksheet, right_to_left):\n    right_to_left = bool(right_to_left)\n    return [{\n        'updateSheetProperties': {\n            'properties': {\n                'sheetId': worksheet.id,\n                'rightToLeft': right_to_left\n            },\n            'fields': 'rightToLeft'\n        }\n    }]\n\n\ndef set_frozen(worksheet, rows=None, cols=None):\n    if rows is None and cols is None:\n        raise ValueError(\"Must specify at least one of rows and cols\")\n    grid_properties = {}\n    if rows is not None:\n        grid_properties['frozenRowCount'] = rows\n    if cols is not None:\n        grid_properties['frozenColumnCount'] = cols\n    fields = ','.join(\n        'gridProperties.%s' % p for p in grid_properties.keys()\n    )\n    return [{\n        'updateSheetProperties': {\n            'properties': {\n                'sheetId': worksheet.id,\n                'gridProperties': grid_properties\n            },\n            'fields': fields\n        }\n    }]\n\n"
  },
  {
    "path": "gspread_formatting/conditionals.py",
    "content": "# -*- coding: utf-8 -*-\n\nfrom .util import _parse_string_enum, _underlower, _enforce_type\nfrom .models import FormattingComponent, GridRange, _CLASSES\n\ntry:\n    from collections.abc import MutableSequence, Iterable\nexcept ImportError:\n    from collections import MutableSequence, Iterable\n\n\ndef get_conditional_format_rules(worksheet):\n    resp = worksheet.spreadsheet.fetch_sheet_metadata()\n    rules = []\n    for sheet in resp['sheets']:\n        if sheet['properties']['sheetId'] == worksheet.id:\n            rules = [ ConditionalFormatRule.from_props(p) for p in sheet.get('conditionalFormats', []) ]\n            break\n    return ConditionalFormatRules(worksheet, rules)\n\ndef _make_delete_rule_request(worksheet, rule, ruleIndex):\n   return {\n       'deleteConditionalFormatRule': {\n           'sheetId': worksheet.id,\n           'index': ruleIndex\n       }\n   }\n\ndef _make_add_rule_request(worksheet, rule, ruleIndex):\n   return {\n       'addConditionalFormatRule': {\n           'rule': rule.to_props(),\n           'index': ruleIndex\n       }\n   }\n\nclass ConditionalFormatRules(MutableSequence):\n    def __init__(self, worksheet, *rules):\n        self.worksheet = worksheet\n        if len(rules) == 1 and isinstance(rules[0], Iterable):\n            rules = rules[0]\n        self.rules = list(rules)\n        self._original_rules = list(rules)\n\n    def __getitem__(self, idx):\n        return self.rules[idx]\n\n    def __setitem__(self, idx, value):\n        self.rules[idx] = _enforce_type('rule', ConditionalFormatRule, value, True)\n\n    def __delitem__(self, idx):\n        del self.rules[idx]\n\n    def __len__(self):\n        return len(self.rules)\n\n    # py2.7 MutableSequence does not offer clear()\n    def clear(self):\n        del self.rules[:]\n\n    def insert(self, idx, value):\n        return self.rules.insert(idx, _enforce_type('rule', ConditionalFormatRule, value, True))\n\n    def save(self):\n        # ideally, we would determine the longest \"increasing\" subsequence\n        # between the original and new rule lists, then determine the add/upd/del\n        # operations to position the remaining items.\n        # But until I implement that correctly, we are just going to delete all rules\n        # and re-add them.\n        delete_requests = [ \n            _make_delete_rule_request(self.worksheet, r, idx) for idx, r in enumerate(self._original_rules) \n        ]\n        # want to delete from highest index to lowest...\n        delete_requests.reverse()\n        add_requests = [ \n            _make_add_rule_request(self.worksheet, r, idx) for idx, r in enumerate(self.rules) \n        ]\n        if not delete_requests and not add_requests:\n            return\n        body = {\n            'requests': delete_requests + add_requests\n        }\n        resp = self.worksheet.spreadsheet.batch_update(body)\n        self._original_rules = list(self.rules)\n        return resp\n\n\n        \nclass ConditionalFormattingComponent(FormattingComponent):\n    pass\n\nclass BooleanRule(ConditionalFormattingComponent):\n    _FIELDS = {\n        'condition': 'booleanCondition', \n        'format': 'cellFormat'\n    }\n\n    def __init__(self, condition=None, format=None):\n        self.condition = condition\n        self.format = format\n\nclass BooleanCondition(ConditionalFormattingComponent):\n\n    illegal_types_for_data_validation = { \n        'TEXT_STARTS_WITH', \n        'TEXT_ENDS_WITH', \n        'BLANK', \n        'NOT_BLANK' \n    }\n\n    illegal_types_for_conditional_formatting = { \n        'TEXT_IS_EMAIL',\n        'TEXT_IS_URL',\n        'DATE_ON_OR_BEFORE',\n        'DATE_ON_OR_AFTER',\n        'DATE_BETWEEN',\n        'DATE_NOT_BETWEEN',\n        'DATE_IS_VALID',\n        'ONE_OF_RANGE',\n        'ONE_OF_LIST'\n        'BOOLEAN' \n    }\n\n    _FIELDS = ('type', 'values')\n\n    TYPES = {\n        'NUMBER_GREATER': 1,\n        'NUMBER_GREATER_THAN_EQ': 1,\n        'NUMBER_LESS': 1,\n        'NUMBER_LESS_THAN_EQ': 1,\n        'NUMBER_EQ': 1,\n        'NUMBER_NOT_EQ': 1,\n        'NUMBER_BETWEEN': 2,\n        'NUMBER_NOT_BETWEEN': 2,\n        'TEXT_CONTAINS': 1,\n        'TEXT_NOT_CONTAINS': 1,\n        'TEXT_STARTS_WITH': 1,\n        'TEXT_ENDS_WITH': 1,\n        'TEXT_EQ': 1,\n        'TEXT_IS_EMAIL': 0,\n        'TEXT_IS_URL': 0,\n        'DATE_EQ': 1,\n        'DATE_BEFORE': 1,\n        'DATE_AFTER': 1,\n        'DATE_ON_OR_BEFORE': 1,\n        'DATE_ON_OR_AFTER': 1,\n        'DATE_BETWEEN': 2,\n        'DATE_NOT_BETWEEN': 2,\n        'DATE_IS_VALID': 0,\n        'ONE_OF_RANGE': 1,\n        'ONE_OF_LIST': (lambda x: isinstance(x, (list, tuple)) and len(x) > 0),\n        'BLANK': 0,\n        'NOT_BLANK': 0,\n        'CUSTOM_FORMULA': 1,\n        'BOOLEAN': (lambda x: isinstance(x, (list, tuple)) and len(x) >= 0 and len(x) <= 2)\n    }\n\n    def __init__(self, type=None, values=()):\n        self.type = _parse_string_enum(\"type\", type, BooleanCondition.TYPES, True)\n        validator = BooleanCondition.TYPES[self.type]\n        if not isinstance(values, (list, tuple)):\n            raise ValueError(\"values parameter must always be list/tuple of values, even for a single element\")\n        valid = validator(values) if callable(validator) else len(values) == validator\n        if not valid:\n            raise ValueError(\n                \"BooleanCondition.values has inappropriate \"\n                \"length/content for condition type %s\" % self.type\n            )\n        # values are either RelativeDate enum values or user-entered values\n        self.values = [ \n            v if isinstance(v, ConditionValue) else (\n                ConditionValue.from_props(v) \n                if isinstance(v, dict) \n                else (\n                    ConditionValue(relativeDate=v) \n                    if isinstance(v, RelativeDate)\n                    else ConditionValue(userEnteredValue=v)\n                )\n            )\n            for v in values \n        ]\n\n    def to_props(self):\n        return {\n            'type': self.type,\n            'values': [ v.to_props() for v in self.values ]\n        }\n\nclass RelativeDate(FormattingComponent):\n    VALUES = set(['PAST_YEAR', 'PAST_MONTH', 'PAST_WEEK', 'YESTERDAY', 'TODAY', 'TOMORROW'])\n\n    def __init__(self, value=None):\n        self.value = _parse_string_enum(\"value\", value, RelativeDate.VALUES, True)\n\n    def to_props(self):\n        return self.value\n\nclass ConditionValue(ConditionalFormattingComponent):\n    _FIELDS = ('relativeDate', 'userEnteredValue')\n\n    def __init__(self, relativeDate=None, userEnteredValue=None):\n        self.relativeDate = relativeDate\n        self.userEnteredValue = userEnteredValue\n\nclass InterpolationPoint(ConditionalFormattingComponent):\n    _FIELDS = ('color', 'colorStyle', 'type', 'value')\n\n    TYPES = set(['MIN', 'MAX', 'NUMBER', 'PERCENT', 'PERCENTILE'])\n\n    def __init__(self, color=None, colorStyle=None, type=None, value=None):\n        self.color = color\n        self.colorStyle = colorStyle\n        self.type = _parse_string_enum(\"type\", type, InterpolationPoint.TYPES, required=True)\n        if value is None and self.type not in set(['MIN', 'MAX']):\n            raise ValueError((\"InterpolationPoint.type %s requires a value of MIN or MAX \"\n                \"if no value specified\") % self.type)\n        self.value = value\n\nclass GradientRule(ConditionalFormattingComponent):\n    _FIELDS = {\n        'minpoint': 'interpolationPoint', \n        'midpoint': 'interpolationPoint', \n        'maxpoint': 'interpolationPoint'\n    }\n\n    def __init__(self, minpoint=None, maxpoint=None, midpoint=None):\n        self.minpoint = _enforce_type(\"minpoint\", InterpolationPoint, minpoint, required=True)\n        self.midpoint = _enforce_type(\"midpoint\", InterpolationPoint, midpoint, required=False)\n        self.maxpoint = _enforce_type(\"maxpoint\", InterpolationPoint, maxpoint, required=True)\n\nclass ConditionalFormatRule(ConditionalFormattingComponent):\n    _FIELDS = ('ranges', 'booleanRule', 'gradientRule')\n\n    def __init__(self, ranges=None, booleanRule=None, gradientRule=None):\n        self.booleanRule = _enforce_type(\"booleanRule\", BooleanRule, booleanRule, required=False)\n        if self.booleanRule:\n            if self.booleanRule.condition.type in BooleanCondition.illegal_types_for_conditional_formatting:\n                raise ValueError(\n                    \"BooleanCondition.type for conditional formatting must not be one of: %s\" % \n                    BooleanCondition.illegal_types_for_conditional_formatting\n                )\n        self.gradientRule = _enforce_type(\"gradientRule\", GradientRule, gradientRule, required=False)\n        if len([x for x in (self.booleanRule, self.gradientRule) if x is not None]) != 1:\n            raise ValueError(\"Must specify exactly one of: booleanRule, gradientRule\")\n        # values are either GridRange objects or bare properties \n        self.ranges = [ \n            v if isinstance(v, GridRange) else GridRange.from_props(v)\n            for v in ranges \n        ] if ranges else []\n\n    def to_props(self):\n        p = {\n            'ranges': [ v.to_props() for v in self.ranges ]\n        }\n        if self.booleanRule:\n            p['booleanRule'] = self.booleanRule.to_props()\n        if self.gradientRule:\n            p['gradientRule'] = self.gradientRule.to_props()\n        return p\n\nclass DataValidationRule(FormattingComponent):\n    _FIELDS = {\n        'condition': 'booleanCondition', \n        'inputMessage': str, \n        'strict': bool, \n        'showCustomUi': bool\n    }\n\n    def __init__(self, condition=None, inputMessage=None, strict=None, showCustomUi=None):\n        self.condition = _enforce_type(\"condition\", BooleanCondition, condition, True)\n        if self.condition.type in BooleanCondition.illegal_types_for_data_validation:\n            raise ValueError(\n                \"BooleanCondition.type for data validation must not be one of: %s\" % \n                BooleanCondition.illegal_types_for_data_validation\n            )\n        self.inputMessage = _enforce_type(\"inputMessage\", str, inputMessage, False)\n        self.strict = _enforce_type(\"strict\", bool, strict, False)\n        self.showCustomUi = _enforce_type(\"showCustomUi\", bool, showCustomUi, False)\n\n# provide camelCase aliases for all component classes.\n\nfor _c in [ \n        obj for name, obj in locals().items() \n        if isinstance(obj, type) \n        and issubclass(obj, FormattingComponent) \n    ]:\n    _k = _underlower(_c.__name__)\n    _CLASSES[_k] = _c\n    locals()[_k] = _c\n"
  },
  {
    "path": "gspread_formatting/dataframe.py",
    "content": "# -*- coding: utf-8 -*-\n\ntry:\n    from itertools import zip_longest\nexcept ImportError:\n    from itertools import izip_longest as zip_longest\n\nfrom gspread_formatting.batch_update_requests import format_cell_ranges, set_frozen\nfrom gspread_formatting.models import cellFormat, numberFormat, Color, textFormat\n\nfrom gspread.utils import rowcol_to_a1\n\nfrom functools import wraps\n\n__all__ = (\n    'format_with_dataframe', \n    'DataFrameFormatter', \n    'BasicFormatter', \n    'DEFAULT_FORMATTER', \n    'DEFAULT_HEADER_BACKGROUND_COLOR'\n)\n\nDEFAULT_HEADER_BACKGROUND_COLOR = Color(0.8980392, 0.8980392, 0.8980392)\n\ndef _determine_index_or_columns_size(obj):\n    if hasattr(obj, 'levshape'):\n        return len(obj.levshape)\n    return 1\n        \ndef _format_with_dataframe(worksheet,\n                          dataframe,\n                          formatter=None,\n                          row=1,\n                          col=1,\n                          include_index=False,\n                          include_column_header=True):\n    \"\"\"\n    Modifies the cell formatting of an area of the provided Worksheet, using\n    the provided DataFrame to determine the area to be formatted and the formats\n    to be used.\n\n    :param worksheet: the gspread worksheet to set with content of DataFrame.\n    :param dataframe: the DataFrame.\n    :param formatter: an optional instance of ``DataFrameFormatter`` class, which\n                      will examine the contents of the DataFrame and\n                      assemble a set of ``gspread_formatter`` operations\n                      to be performed after the DataFrame contents \n                      are written to the given Worksheet. The formatting\n                      operations are performed after the contents are written\n                      and before this function returns. Defaults to \n                      ``DEFAULT_FORMATTER``.\n    :param row: number of row at which to begin formatting. Defaults to 1.\n    :param col: number of column at which to begin formatting. Defaults to 1.\n    :param include_index: if True, include the DataFrame's index as an\n            additional column when performing formatting. Defaults to False.\n    :param include_column_header: if True, format a header row before data.\n            Defaults to True.\n    \"\"\"\n    if not formatter:\n        formatter = DEFAULT_FORMATTER\n\n    formatting_ranges = []\n\n    columns = [ dataframe[c] for c in dataframe.columns ]\n    index_column_size = _determine_index_or_columns_size(dataframe.index)\n    column_header_size = _determine_index_or_columns_size(dataframe.columns)\n\n    if include_index:\n        # allow for multi-index index\n        if index_column_size > 1:\n            reset_df = dataframe.reset_index()\n            index_elts = [ reset_df[c] for c in list(reset_df.columns)[:index_column_size] ]\n        else:\n            index_elts = [ dataframe.index ]\n        columns = index_elts + columns\n\n    for idx, column in enumerate(columns):\n        column_fmt = formatter.format_for_column(column, col + idx, dataframe)\n        if not column_fmt or not column_fmt.to_props():\n            continue\n        range = '{}:{}'.format(\n            rowcol_to_a1(row, col + idx), \n            rowcol_to_a1(row + dataframe.shape[0], col + idx)\n        )\n        formatting_ranges.append( (range, column_fmt) )\n\n    freeze_args = {}\n    if include_column_header:\n        # TODO allow for multi-index columns object\n        elts = list(dataframe.columns)\n        if include_index:\n            # allow for multi-index index\n            if index_column_size > 1:\n                index_names = list(dataframe.index.names)\n            else:\n                index_names = [ dataframe.index.name ]\n            elts = index_names + elts\n            header_fmt = formatter.format_for_header(dataframe.index, dataframe)\n            if header_fmt:\n                formatting_ranges.append( \n                    (\n                        '{}:{}'.format(\n                            rowcol_to_a1(row, col), \n                            rowcol_to_a1(row + dataframe.shape[0], col + index_column_size - 1)\n                        ), \n                        header_fmt\n                    )\n                )\n\n        header_fmt = formatter.format_for_header(elts, dataframe)\n        if header_fmt:\n            formatting_ranges.append( \n                (\n                    '{}:{}'.format(\n                        rowcol_to_a1(row, col), \n                        rowcol_to_a1(row + column_header_size - 1, col + len(elts) - 1)\n                    ), \n                    header_fmt\n                )\n            )\n\n        if row == 1 and formatter.should_freeze_header(elts, dataframe):\n            freeze_args['rows'] = column_header_size\n\n        if include_index and col == 1 and formatter.should_freeze_header(dataframe.index, dataframe):\n            freeze_args['cols'] = index_column_size\n\n        row += column_header_size\n\n\n    values = []\n    for value_row, index_value in zip_longest(dataframe.values, dataframe.index):\n        if include_index:\n            if index_column_size > 1:\n                index_values = list(index_value)\n            else:\n                index_values = [index_value]\n            value_row = index_values + list(value_row)\n        values.append(value_row)\n    for y_idx, value_row in enumerate(values):\n        for x_idx, cell_value in enumerate(value_row):\n            cell_fmt = formatter.format_for_cell(cell_value, y_idx+row, x_idx+col, dataframe)\n            if cell_fmt:\n                formatting_ranges.append((rowcol_to_a1(y_idx+row, x_idx+col), cell_fmt))\n        row_fmt = formatter.format_for_data_row(values, y_idx+row, dataframe)\n        if row_fmt:\n            formatting_ranges.append(\n                (\n                    '{}:{}'.format(\n                        rowcol_to_a1(y_idx+row, col), \n                        rowcol_to_a1(y_idx+row, col+dataframe.shape[1])\n                    ), \n                row_fmt\n                )\n            )\n\n    requests = []\n\n    if formatting_ranges:\n        formatting_ranges = [ r for r in formatting_ranges if r[1] and r[1].to_props() ]\n        requests.extend(format_cell_ranges(worksheet, formatting_ranges))\n\n    if freeze_args:\n        requests.extend(set_frozen(worksheet, **freeze_args))\n\n    return requests\n\n@wraps(_format_with_dataframe)\ndef format_with_dataframe(worksheet, *args, **kwargs):\n    return worksheet.spreadsheet.batch_update(\n        {'requests': _format_with_dataframe(worksheet, *args, **kwargs)}\n    )\n\nclass DataFrameFormatter(object):\n    \"\"\"\n    An abstract base class defining the interface for producing formats\n    for a worksheet based on a given DataFrame.\n    \"\"\"\n    @classmethod\n    def resolve_number_format(cls, value, type=None):\n        \"\"\"\n        A utility class method that resolves a value to a ``NumberFormat`` object,\n        whether that value is a ``NumberFormat`` object or a pattern string.\n        Optional ``type`` parameter is to specify ``NumberFormat`` enum value.\n        \"\"\"\n        if value is None:\n            return None\n        elif isinstance(value, numberFormat):\n            return value\n        elif isinstance(value, str):\n            return numberFormat(type, value) \n        else:\n            raise ValueError(value)\n\n    def format_with_dataframe(self, worksheet, dataframe, row=1, col=1, include_index=False, include_column_header=True):\n        \"\"\"\n        Convenience method that will call this module's ``format_with_dataframe`` function with\n        this ``DataFrameFormatter`` object as the formatter.\n        \"\"\"\n        return format_with_dataframe(\n            worksheet, \n            dataframe, \n            self, \n            row=row, \n            col=col, \n            include_index=include_index, \n            include_column_header=include_column_header\n        )\n\n    def format_for_header(self, series, dataframe):\n        \"\"\"\n        Called by ``format_with_dataframe`` once for each header row (if ``include_column_header``\n        parameter is ``True``) or column (if ``include_index`` parameter is also ``True``)..\n\n        :param series: A sequence of elements representing the values in the row or column.\n                 Can be a simple list, or a ``pandas.Series`` or ``pandas.Index`` object.\n        :param dataframe: The ``pandas.DataFrame`` object, as additional context.\n\n        :return: Either a ``CellFormat`` object or ``None``.\n        \"\"\"\n        raise NotImplementedError()\n\n    def format_for_column(self, column, col_number, dataframe):\n        \"\"\"\n        Called by ``format_with_dataframe`` once for each column in the dataframe.\n\n        :param column: A ``pandas.Series`` object representing the column.\n        :param col_number: The index (starting with 1) of the column in the worksheet.\n        :param dataframe: The ``pandas.DataFrame`` object, as additional context.\n\n        :return: Either a ``CellFormat`` object or ``None``.\n        \"\"\"\n        raise NotImplementedError()\n\n    def format_for_data_row(self, values, row_number, dataframe):\n        \"\"\"\n        Called by ``format_with_dataframe`` once for each data row in the dataframe.\n        Allows for row-specific additional formatting to complement any\n        column-based formatting.\n\n        :param values: The values in the row, obtained directly from the ``DataFrame``.\n                 If ``include_index`` parameter to ``format_with_dataframe`` is ``True``,\n                 then the first element in this sequence is the index value for the row.\n        :param row_number: The index (starting with 1) of the row in the worksheet.\n        :param dataframe: The ``pandas.DataFrame`` object, as additional context.\n\n        :return: Either a ``CellFormat`` object or ``None``.\n        \"\"\"\n        raise NotImplementedError()\n\n    def format_for_cell(self, value, row_number, col_number, dataframe):\n        \"\"\"\n        Called by ``format_with_dataframe`` once for each cell in the dataframe.\n        Allows for cell-specific additional formatting to complement any column\n        or row formatting.\n\n        :param value: The value of the cell, obtained directly from the ``DataFrame``.\n        :param row_number: The index (starting with 1) of the row in the worksheet.\n        :param col_number: The index (starting with 1) of the column in the worksheet.\n        :param dataframe: The ``pandas.DataFrame`` object, as additional context.\n\n        :return: Either a ``CellFormat`` object or ``None``.\n        \"\"\"\n        raise NotImplementedError()\n\n    def should_freeze_header(self, series, dataframe):\n        \"\"\"\n        Called by ``format_with_dataframe`` once for each header row or column.\n\n        :param series: A sequence of elements representing the values in the row or column.\n                 Can be a simple list, or a ``pandas.Series`` or ``pandas.Index`` object.\n        :param dataframe: The ``pandas.DataFrame`` object, as additional context.\n\n        :return: boolean value\n        \"\"\"\n        raise NotImplementedError()\n\nclass BasicFormatter(DataFrameFormatter):\n    \"\"\"\n    A basic formatter class that offers: selection of format based on\n    inferred data type of each column; bold headers with a custom background color;\n    frozen header row (and column if index is included); and column-specific\n    override formats.\n    \"\"\"\n\n    @classmethod\n    def with_defaults(cls,\n        header_background_color=None, \n        header_text_color=None,\n        date_format=None,\n        decimal_format=None,\n        integer_format=None,\n        freeze_headers=None,\n        column_formats=None):\n        \"\"\"\n        Returns an instance of this class, with any unspecified parameters\n        being substituted with this package's default values for the parameters.\n        Instantiate the class directly if you want unspecified parameters to be ``None``\n        and thus always be omitted from formatting operations.\n        \"\"\"\n        return cls(\n            (header_background_color or DEFAULT_HEADER_BACKGROUND_COLOR),\n            header_text_color,\n            date_format,\n            decimal_format,\n            integer_format,\n            freeze_headers,\n            column_formats\n        )\n\n    def __init__(self, \n        header_background_color=None, \n        header_text_color=None,\n        date_format=None,\n        decimal_format=None,\n        integer_format=None,\n        freeze_headers=None,\n        column_formats=None):\n        self.header_background_color = header_background_color\n        self.header_text_color = header_text_color\n        self.date_format = BasicFormatter.resolve_number_format(date_format or '', 'DATE')\n        self.decimal_format = BasicFormatter.resolve_number_format(decimal_format or '', 'NUMBER')\n        self.integer_format = BasicFormatter.resolve_number_format(integer_format or '', 'NUMBER')\n        self.freeze_headers = bool(freeze_headers)\n        self.column_formats = column_formats or {}\n\n    def format_for_header(self, series, dataframe):\n        return cellFormat(\n            backgroundColor=self.header_background_color, \n            textFormat=textFormat(bold=True, foregroundColor=self.header_text_color)\n        )\n\n    def format_for_column(self, column, col_number, dataframe):\n        if column.name in self.column_formats:\n            return self.column_formats[column.name]\n        dtype = column.dtype\n        if dtype.kind == 'O' and hasattr(column, 'infer_objects'):\n            dtype = column.infer_objects().dtype\n        if dtype.kind == 'f':\n            return cellFormat(numberFormat=self.decimal_format, horizontalAlignment='RIGHT')\n        elif dtype.kind == 'i':\n            return cellFormat(numberFormat=self.integer_format, horizontalAlignment='RIGHT')\n        elif dtype.kind == 'M':\n            return cellFormat(numberFormat=self.date_format, horizontalAlignment='CENTER')\n        else:\n            return cellFormat(horizontalAlignment=('LEFT' if col_number == 1 else 'CENTER'))\n\n    def format_for_cell(self, value, row_number, col_number, dataframe):\n        return None\n\n    def format_for_data_row(self, values, row_number, dataframe):\n        return None\n\n    def should_freeze_header(self, series, dataframe):\n        return self.freeze_headers\n\nDEFAULT_FORMATTER = BasicFormatter.with_defaults()\n"
  },
  {
    "path": "gspread_formatting/functions.py",
    "content": "# -*- coding: utf-8 -*-\n\nfrom .util import _fetch_with_updated_properties, _range_to_dimensionrange_object\nfrom .models import CellFormat, TextFormatRun\nfrom .conditionals import DataValidationRule\n# These imports allow IDEs like PyCharm to verify the existence of these functions, \n# even though we will rebind the names below with wrapped versions of the functions\nfrom gspread_formatting.batch_update_requests import * \nimport gspread_formatting.batch_update_requests\n\nfrom gspread.utils import a1_to_rowcol, rowcol_to_a1, finditem\nfrom gspread import Spreadsheet\nfrom gspread.urls import SPREADSHEET_URL\n\nfrom functools import wraps\n\n__all__ = (\n    'get_default_format', 'get_effective_format', 'get_user_entered_format',\n    'get_frozen_row_count', 'get_frozen_column_count', 'get_right_to_left',\n    'get_data_validation_rule', 'get_text_format_runs'\n) + gspread_formatting.batch_update_requests.__all__\n\n\ndef _wrap_as_standalone_function(func):\n    @wraps(func)\n    def f(worksheet, *args, **kwargs):\n        return worksheet.spreadsheet.batch_update({'requests': func(worksheet, *args, **kwargs)})\n    return f\n\nfor _fname in gspread_formatting.batch_update_requests.__all__:\n    locals()[_fname] = _wrap_as_standalone_function(locals()[_fname])\n\n\ndef get_data_validation_rule(worksheet, label):\n    \"\"\"Returns a DataValidationRule object or None representing the\n    data validation in effect for the cell identified by ``label``.\n\n    :param worksheet: Worksheet object containing the cell whose data\n                      validation rule is desired.\n    :param label: String with cell label in common format, e.g. 'B1'.\n                  Letter case is ignored.\n\n    Example:\n    >>> get_data_validation_rule(worksheet, 'A1')\n    <DataValidationRule condition=(bold=True)>\n    >>> get_data_validation_rule(worksheet, 'A2')\n    None\n    \"\"\"\n    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))\n\n    resp = worksheet.spreadsheet.fetch_sheet_metadata({\n        'includeGridData': True,\n        'ranges': [label],\n        'fields': 'sheets.data.rowData.values.effectiveFormat,sheets.data.rowData.values.dataValidation'\n    })\n    data = resp['sheets'][0]['data'][0]\n    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('dataValidation')\n    return DataValidationRule.from_props(props) if props else None\n\n\ndef get_default_format(spreadsheet):\n    \"\"\"Return Default CellFormat for spreadsheet, or None if no default formatting was specified.\"\"\"\n    fmt = _fetch_with_updated_properties(spreadsheet, 'defaultFormat')\n    return CellFormat.from_props(fmt) if fmt else None\n\n\ndef get_effective_format(worksheet, label):\n    \"\"\"Returns a CellFormat object or None representing the effective formatting directives,\n    if any, for the cell; that is a combination of default formatting, user-entered formatting,\n    and conditional formatting.\n\n    :param worksheet: Worksheet object containing the cell whose format is desired.\n    :param label: String with cell label in common format, e.g. 'B1'.\n                  Letter case is ignored.\n\n    Example:\n\n    >>> get_effective_format(worksheet, 'A1')\n    <CellFormat textFormat=(bold=True)>\n    >>> get_effective_format(worksheet, 'A2')\n    None\n    \"\"\"\n    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))\n\n    resp = worksheet.spreadsheet.fetch_sheet_metadata({\n        'includeGridData': True,\n        'ranges': [label],\n        'fields': 'sheets.data.rowData.values.effectiveFormat'\n    })\n    data = resp['sheets'][0]['data'][0]\n    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('effectiveFormat')\n    return CellFormat.from_props(props) if props else None\n\n\ndef get_user_entered_format(worksheet, label):\n    \"\"\"Returns a CellFormat object or None representing the user-entered formatting directives,\n    if any, for the cell.\n\n    :param worksheet: Worksheet object containing the cell whose format is desired.\n    :param label: String with cell label in common format, e.g. 'B1'.\n                  Letter case is ignored.\n\n    Example:\n\n    >>> get_user_entered_format(worksheet, 'A1')\n    <CellFormat textFormat=(bold=True)>\n    >>> get_user_entered_format(worksheet, 'A2')\n    None\n    \"\"\"\n    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))\n\n    resp = worksheet.spreadsheet.fetch_sheet_metadata({\n        'includeGridData': True,\n        'ranges': [label],\n        'fields': 'sheets.data.rowData.values.userEnteredFormat'\n    })\n    data = resp['sheets'][0]['data'][0]\n    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('userEnteredFormat')\n    return CellFormat.from_props(props) if props else None\n\n\ndef get_text_format_runs(worksheet, label):\n    \"\"\"Returns a list of TextFormatRun objects for the cell. List will be empty\n    if no TextFormatRuns exist for the cell.\n\n    :param worksheet: Worksheet object containing the cell whose format is desired.\n    :param label: String with cell label in common format, e.g. 'B1'.\n                  Letter case is ignored.\n\n    Example:\n\n    >>> get_text_format_runs(worksheet, 'A1')\n    [<TextFormatRun startIndex=0 textFormat=(bold=True)>, <TextFormatRun startIndex=10 textFormat=(italic=True)>]\n    >>> get_text_format_runs(worksheet, 'A2')\n    []\n    \"\"\"\n    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))\n\n    resp = worksheet.spreadsheet.fetch_sheet_metadata({\n        'includeGridData': True,\n        'ranges': [label],\n        'fields': 'sheets.data.rowData.values.textFormatRuns'\n    })\n    data = resp['sheets'][0]['data'][0]\n    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('textFormatRuns', [])\n    return [TextFormatRun.from_props(item) for item in props]\n\n\ndef get_frozen_row_count(worksheet):\n    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})\n    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])\n    grid_props = sheet_data['properties']['gridProperties']\n    return grid_props.get('frozenRowCount')\n\n\ndef get_frozen_column_count(worksheet):\n    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})\n    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])\n    grid_props = sheet_data['properties']['gridProperties']\n    return grid_props.get('frozenColumnCount')\n\ndef get_right_to_left(worksheet):\n    \"\"\"Returns True or False (never None) if worksheet is rightToLeft.\"\"\"\n    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})\n    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])\n    pr = sheet_data['properties']\n    return bool(pr.get('rightToLeft'))\n\n# monkey-patch Spreadsheet class\n\ndef fetch_sheet_metadata(self, params=None):\n    if params is None:\n        params = {'includeGridData': 'false'}\n    url = SPREADSHEET_URL % self.id\n    r = self.client.request('get', url, params=params)\n    return r.json()\n\nSpreadsheet.fetch_sheet_metadata = fetch_sheet_metadata\ndel fetch_sheet_metadata\n\n"
  },
  {
    "path": "gspread_formatting/models.py",
    "content": "# -*- coding: utf-8 -*-\n\nfrom .util import _props_to_component, _extract_props, _extract_fieldrefs, \\\n    _parse_string_enum, _underlower, _range_to_gridrange_object\n\nimport abc\nimport re\n                  \nclass FormattingComponent(abc.ABC):\n    _FIELDS = ()\n    _DEFAULTS = {}\n\n    @classmethod\n    def from_props(cls, props):\n        return _props_to_component(_CLASSES, _underlower(cls.__name__), props)\n\n    def __repr__(self):\n        return '<' + self.__class__.__name__ + ' ' + str(self) + '>'\n\n    def __str__(self):\n        p = []\n        for a in self._FIELDS:\n            v = getattr(self, a)\n            if v is not None:\n                if isinstance(v, FormattingComponent):\n                    p.append( (a, \"(\" + str(v) + \")\") )\n                else:\n                    p.append( (a, str(v)) )\n        return \";\".join([\"%s=%s\" % (k, v) for k, v in p])\n\n    def to_props(self):\n        p = {}\n        for a in self._FIELDS:\n            v = getattr(self, a, None)\n            if v is None:\n                v = self._DEFAULTS.get(a)\n            if v is not None:\n                p[a] = _extract_props(v)\n        return p\n\n    def affected_fields(self, prefix):\n        fields = []\n        for a in self._FIELDS:\n            v = getattr(self, a, None)\n            if v is None:\n                v = self._DEFAULTS.get(a)\n            if v is not None:\n                fields.extend( _extract_fieldrefs(a, v, prefix) )\n        return fields\n\n    def __eq__(self, other):\n        if not isinstance(other, self.__class__):\n            return False\n        for a in self._FIELDS:\n            self_v = getattr(self, a, None)\n            if self_v == None:\n                self_v = self._DEFAULTS.get(a)\n            other_v = getattr(other, a, None)\n            if other_v == None:\n                other_v = other._DEFAULTS.get(a)\n            if self_v != other_v:\n                return False\n        return True\n\n    def __ne__(self, other):\n        return not self.__eq__(other)\n\n    def add(self, other):\n        new_props = {}\n        for a in self._FIELDS:\n            self_v = getattr(self, a, None)\n            other_v = getattr(other, a, None)\n            if isinstance(self_v, CellFormatComponent):\n                this_v = self_v.add(other_v)\n            elif other_v is not None:\n                this_v = other_v\n            else:\n                this_v = self_v\n            if this_v is not None:\n                new_props[a] = _extract_props(this_v)\n        return self.__class__.from_props(new_props)\n\n    __add__ = add\n\n    def intersection(self, other):\n        new_props = {}\n        for a in self._FIELDS:\n            self_v = getattr(self, a, None)\n            other_v = getattr(other, a, None)\n            this_v = None\n            if isinstance(self_v, CellFormatComponent):\n                this_v = self_v.intersection(other_v)\n            elif self_v == other_v:\n                this_v = self_v\n            if this_v is not None:\n                new_props[a] = _extract_props(this_v)\n        return self.__class__.from_props(new_props) if new_props else None\n\n    __and__ = intersection\n\n    def difference(self, other):\n        new_props = {}\n        for a in self._FIELDS:\n            self_v = getattr(self, a, None)\n            other_v = getattr(other, a, None)\n            this_v = None\n            if isinstance(self_v, CellFormatComponent):\n                this_v = self_v.difference(other_v)\n            elif other_v != self_v:\n                this_v = self_v\n            if this_v is not None:\n                new_props[a] = _extract_props(this_v)\n        return self.__class__.from_props(new_props) if new_props else None\n\n    __sub__ = difference\n\nclass GridRange(FormattingComponent):\n    _FIELDS = ('sheetId', 'startRowIndex', 'endRowIndex', 'startColumnIndex', 'endColumnIndex')\n\n    @classmethod\n    def from_a1_range(cls, range, worksheet):\n        return GridRange.from_props(_range_to_gridrange_object(range, worksheet.id))\n\n    def __init__(self, sheetId=None, startRowIndex=None, endRowIndex=None, startColumnIndex=None, endColumnIndex=None):\n        self.sheetId = (0 if sheetId is None else sheetId)\n        self.startRowIndex = startRowIndex\n        self.endRowIndex = endRowIndex\n        self.startColumnIndex = startColumnIndex\n        self.endColumnIndex = endColumnIndex\n\nclass CellFormatComponent(FormattingComponent, abc.ABC):\n    pass\n\nclass CellFormat(CellFormatComponent):\n    _FIELDS = {\n        'numberFormat': None,\n        'backgroundColor': 'color',\n        'borders': None,\n        'padding': None,\n        'horizontalAlignment': None,\n        'verticalAlignment': None,\n        'wrapStrategy': None,\n        'textDirection': None,\n        'textFormat': None,\n        'hyperlinkDisplayType': None,\n        'textRotation': None,\n        'backgroundColorStyle': 'colorStyle'\n    }\n\n    def __init__(self,\n        numberFormat=None,\n        backgroundColor=None,\n        borders=None,\n        padding=None,\n        horizontalAlignment=None,\n        verticalAlignment=None,\n        wrapStrategy=None,\n        textDirection=None,\n        textFormat=None,\n        hyperlinkDisplayType=None,\n        textRotation=None,\n        backgroundColorStyle=None\n        ):\n        self.numberFormat = numberFormat\n        self.backgroundColor = backgroundColor\n        self.borders = borders\n        self.padding = padding\n        self.horizontalAlignment = _parse_string_enum('horizontalAlignment', horizontalAlignment, set(['LEFT', 'CENTER', 'RIGHT']))\n        self.verticalAlignment = _parse_string_enum('verticalAlignment', verticalAlignment, set(['TOP', 'MIDDLE', 'BOTTOM']))\n        self.wrapStrategy = _parse_string_enum('wrapStrategy', wrapStrategy, set(['OVERFLOW_CELL', 'LEGACY_WRAP', 'CLIP', 'WRAP']))\n        self.textDirection = _parse_string_enum('textDirection', textDirection, set(['LEFT_TO_RIGHT', 'RIGHT_TO_LEFT']))\n        self.textFormat = textFormat\n        self.hyperlinkDisplayType = _parse_string_enum('hyperlinkDisplayType', hyperlinkDisplayType, set(['LINKED', 'PLAIN_TEXT']))\n        self.textRotation = textRotation\n        self.backgroundColorStyle = backgroundColorStyle\n\nclass NumberFormat(CellFormatComponent):\n    _FIELDS = ('type', 'pattern')\n\n    TYPES = set(['TEXT', 'NUMBER', 'PERCENT', 'CURRENCY', 'DATE', 'TIME', 'DATE_TIME', 'SCIENTIFIC'])\n\n    def __init__(self, type=None, pattern=None):\n        self.type = _parse_string_enum('type', type, NumberFormat.TYPES, True)\n        self.pattern = pattern\n\nclass ColorStyle(CellFormatComponent):\n    _FIELDS = {\n        'themeColor': None,\n        'rgbColor': 'color'\n    }\n\n    def __init__(self, themeColor=None, rgbColor=None):\n        self.themeColor = themeColor\n        self.rgbColor = rgbColor\n\nclass Color(CellFormatComponent):\n    _HEX_PATTERN = re.compile(r'^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$', re.IGNORECASE)\n    _FIELDS = ('red', 'green', 'blue', 'alpha')\n    _DEFAULTS = {\n        'red': 0.0,\n        'green': 0.0,\n        'blue': 0.0,\n        'alpha': 1.0\n    }\n\n    def __init__(self, red=None, green=None, blue=None, alpha=None):\n        self.red = red\n        self.green = green\n        self.blue = blue\n        self.alpha = alpha\n\n    @classmethod\n    def fromHex(cls,hexcolor):\n        match = cls._HEX_PATTERN.search(hexcolor)\n        if not match:\n            raise ValueError('Color string given: %s: Hex string must be of the form: \"#RRGGBB\" or \"#RRGGBBAA\"' % hexcolor)\n        # Convert Hex range 0-255 to 0-1.0 for red, green, blue, alpha\n        return cls(*[int(a, 16)/255.0 if a else None for a in match.groups()])\n\n    def toHex(self):\n        RR = format(int((self.red if self.red else 0) * 255), '02x')\n        GG = format(int((self.green if self.green else 0) * 255), '02x')\n        BB = format(int((self.blue if self.blue else 0) * 255), '02x')\n        AA = format(int((self.alpha if self.alpha else 0) * 255), '02x')\n        return '#{0}{1}{2}{3}'.format(RR, GG, BB, (AA if self.alpha != None else ''))\n\nclass Border(CellFormatComponent):\n    # Note: 'width' field is deprecated and we wish never to serialize it.\n    _FIELDS = ('style', 'color', 'colorStyle')\n\n    STYLES = set(['DOTTED', 'DASHED', 'SOLID', 'SOLID_MEDIUM', 'SOLID_THICK', 'NONE', 'DOUBLE'])\n\n    def __init__(self, style=None, color=None, width=None, colorStyle=None):\n        self.style = _parse_string_enum('style', style, Border.STYLES, True)\n        self.width = width\n        self.color = color\n        self.colorStyle = colorStyle\n\nclass Borders(CellFormatComponent):\n    _FIELDS = {\n        'top': 'border',\n        'bottom': 'border',\n        'left': 'border',\n        'right': 'border'\n    }\n\n    def __init__(self, top=None, bottom=None, left=None, right=None):\n        self.top = top\n        self.bottom = bottom\n        self.left = left\n        self.right = right\n\nclass Padding(CellFormatComponent):\n    _FIELDS = ('top', 'right', 'bottom', 'left')\n\n    def __init__(self, top=None, right=None, bottom=None, left=None):\n        self.top = top\n        self.right = right\n        self.bottom = bottom\n        self.left = left\n\nclass Link(CellFormatComponent):\n    _FIELDS = ('uri',)\n\n    def __init__(self, uri=None):\n        self.uri = uri\n\nclass TextFormat(CellFormatComponent):\n    _FIELDS = {\n        'foregroundColor': 'color',\n        'fontFamily': None,\n        'fontSize': None,\n        'bold': None,\n        'italic': None,\n        'strikethrough': None,\n        'underline': None,\n        'foregroundColorStyle': 'colorStyle',\n        'link': None\n    }\n\n    def __init__(self,\n        foregroundColor=None,\n        fontFamily=None,\n        fontSize=None,\n        bold=None,\n        italic=None,\n        strikethrough=None,\n        underline=None,\n        foregroundColorStyle=None,\n        link=None\n        ):\n        self.foregroundColor = foregroundColor\n        self.fontFamily = fontFamily\n        self.fontSize = fontSize\n        self.bold = bold\n        self.italic = italic\n        self.strikethrough = strikethrough\n        self.underline = underline\n        self.foregroundColorStyle = foregroundColorStyle\n        self.link = link\n\nclass TextFormatRun(FormattingComponent):\n    _FIELDS = {'format': 'textFormat', 'startIndex': None}\n\n    def __init__(self, format=None, startIndex=0):\n        self.startIndex = startIndex\n        self.format = format if format is not None else TextFormat()\n\nclass TextRotation(CellFormatComponent):\n    _FIELDS = ('angle', 'vertical')\n\n    def __init__(self, angle=None, vertical=None):\n        if len([expr for expr in (angle is not None, vertical is not None) if expr]) != 1:\n            raise ValueError(\"Either angle or vertical must be specified, not both or neither\")\n        self.angle = angle\n        self.vertical = vertical\n\n# provide camelCase aliases for all component classes.\n\n_CLASSES = {}\nfor _c in [ obj for name, obj in locals().items() if isinstance(obj, type) and issubclass(obj, FormattingComponent)]:\n    _k = _underlower(_c.__name__)\n    _CLASSES[_k] = _c\n    locals()[_k] = _c\n"
  },
  {
    "path": "gspread_formatting/util.py",
    "content": "# -*- coding: utf-8 -*-\nfrom functools import reduce\nfrom operator import or_\nimport re \n\ndef _convert_to_properties(fobj):\n    if isinstance(fobj, list):\n        return [i.to_props() for i in fobj]\n    elif fobj != None:\n        return fobj.to_props()\n    else:\n        return None\n\ndef _affected_fields_for(fobj, field_name):\n    if isinstance(fobj, list):\n        return list(reduce(or_, [set(i.affected_fields(field_name)) for i in fobj]))\n    elif fobj != None:\n        return fobj.affected_fields(field_name)\n    else:\n        return [field_name]\n\ndef _build_repeat_cell_request(worksheet, range, formatting_object, celldata_field='userEnteredFormat'):\n    return {\n        'repeatCell': {\n            'range': _range_to_gridrange_object(range, worksheet.id),\n            'cell': { celldata_field: _convert_to_properties(formatting_object) },\n            'fields': \",\".join(_affected_fields_for(formatting_object, celldata_field))\n        }\n    }\n\ndef _fetch_with_updated_properties(spreadsheet, key, params=None):\n    try:\n        return spreadsheet._properties[key]\n    except KeyError:\n        metadata = spreadsheet.fetch_sheet_metadata(params)\n        spreadsheet._properties.update(metadata['properties'])\n        return spreadsheet._properties[key]\n\n_MAGIC_NUMBER = 64\n_CELL_ADDR_RE = re.compile(r'([A-Za-z]+)?([1-9]\\d*)?')\n\ndef _a1_to_rowcol(label):\n    if not label:\n        raise ValueError(label)\n    m = _CELL_ADDR_RE.match(label)\n    if m:\n        column_label = m.group(1).upper() if m.group(1) else None\n        row = int(m.group(2)) if m.group(2) else None\n\n        if column_label is not None:\n            col = 0\n            for i, c in enumerate(reversed(column_label)):\n                col += (ord(c) - _MAGIC_NUMBER) * (26 ** i)\n        else:\n            col = None\n        return (row, col)\n    raise ValueError(label)\n\n\ndef _range_to_dimensionrange_object(range, worksheet_id):\n    gridrange = _range_to_gridrange_object(range, worksheet_id)\n    is_row_range = ('startRowIndex' in gridrange or 'endRowIndex' in gridrange)\n    is_column_range = ('startColumnIndex' in gridrange or 'endColumnIndex' in gridrange)\n    if is_row_range and is_column_range:\n        raise ValueError(\"Range for dimension must specify only column(s) or only row(s), not both: %s\" % range)\n    obj = { 'sheetId': worksheet_id }\n    if is_row_range:\n        obj['dimension'] = 'ROWS'\n        if 'endRowIndex' in gridrange:\n            obj['endIndex'] = gridrange['endRowIndex']\n        if 'startRowIndex' in gridrange:\n            obj['startIndex'] = gridrange['startRowIndex']\n    if is_column_range:\n        obj['dimension'] = 'COLUMNS'\n        if 'endColumnIndex' in gridrange:\n            obj['endIndex'] = gridrange['endColumnIndex']\n        if 'startColumnIndex' in gridrange:\n            obj['startIndex'] = gridrange['startColumnIndex']\n    return obj\n\ndef _range_to_gridrange_object(range, worksheet_id):\n    parts = range.split(':')\n    start = parts[0]\n    end = parts[1] if len(parts) > 1 else ''\n    row_offset, column_offset = _a1_to_rowcol(start)\n    last_row, last_column = _a1_to_rowcol(end) if end else (row_offset, column_offset)\n    # check for illegal ranges\n    if (row_offset is not None and last_row is not None and row_offset > last_row):\n        raise ValueError(range)\n    if (column_offset is not None and last_column is not None and column_offset > last_column):\n        raise ValueError(range)\n    obj = {\n        'sheetId': worksheet_id\n    }\n    if row_offset is not None:\n        obj['startRowIndex'] = row_offset-1\n    if last_row is not None:\n        obj['endRowIndex'] = last_row\n    if column_offset is not None:\n        obj['startColumnIndex'] = column_offset-1\n    if last_column is not None:\n        obj['endColumnIndex'] = last_column\n    return obj\n\ndef _props_to_component(class_registry, class_alias, value, none_if_empty=False):\n    if class_alias not in class_registry:\n        raise ValueError(\"No format component named '%s'\" % class_alias)\n    cls = class_registry[class_alias]\n    kwargs = {}\n    for k, v in value.items():\n        if isinstance(v, dict):\n            if isinstance(cls._FIELDS, dict) and cls._FIELDS.get(k) is not None:\n                item_alias = cls._FIELDS[k]\n            else:\n                item_alias = k\n            v = _props_to_component(class_registry, item_alias, v, True)\n        if v is not None:\n            kwargs[k] = v\n    # if our kwargs are empty and there are default values defined\n    # for properties in the class, it means to apply all the default values\n    # as kwargs.\n    if not kwargs and cls._DEFAULTS:\n        kwargs = { k: v for k, v in cls._DEFAULTS.items() }\n    rv = cls(**kwargs) if (kwargs or not none_if_empty) else None\n    return rv\n\ndef _ul_repl(m):\n    return '_' + m.group(1).lower()\n\ndef _underlower(name):\n    return name[0].lower() + name[1:]\n\ndef _parse_string_enum(name, value, set_of_values, required=False):\n    if value is None and required:\n        raise ValueError(\"%s value is required\" % name)\n    if value is not None and value.upper() not in set_of_values:\n        raise ValueError(\"%s value must be one of: %s\" % (name, set_of_values))\n    return value.upper() if value is not None else None\n\ndef _enforce_type(name, cls, value, required=False):\n    if value is None and required:\n        raise ValueError(\"%s value is required\" % name)\n    if value is not None and not isinstance(value, cls):\n        raise ValueError(\"%s value must be instance of: %s\" % (name, cls))\n    return value\n\ndef _extract_props(value):\n    if hasattr(value, 'to_props'):\n        return value.to_props()\n    return value\n\ndef _extract_fieldrefs(name, value, prefix):\n    if hasattr(value, 'affected_fields'):\n        return value.affected_fields(\".\".join([prefix, name]))\n    elif value is not None:\n        return [\".\".join([prefix, name])]\n    else:\n        return []\n\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\" \n\n[project]\nname = \"gspread-formatting\" \ndynamic = [\"version\"]\ndescription = \"Complete Google Sheets formatting support for gspread worksheets\"\nreadme = \"README.rst\"\nrequires-python = \">=3.0\"\nlicense = { file = \"LICENSE\" }\nkeywords = [\"spreadsheets\", \"google-spreadsheets\", \"formatting\", \"cell-format\"]\nauthors = [{ name = \"Robin Thomas\", email = \"rthomas900@gmail.com\" }]\nmaintainers = [{ name = \"Robin Thomas\", email = \"rthomas900@gmail.com\" }]\n\nclassifiers = [\n  \"Development Status :: 5 - Production/Stable\",\n  \"Intended Audience :: Developers\",\n  \"Intended Audience :: Science/Research\",\n  \"Topic :: Office/Business :: Financial :: Spreadsheet\",\n  \"Topic :: Software Development :: Libraries :: Python Modules\",\n  \"License :: OSI Approved :: MIT License\",\n  \"Programming Language :: Python :: 3\"\n]\n\ndependencies = [\"gspread>=3.0.0\"]\n\n[project.optional-dependencies]\ndev = [\n\"gitchangelog\",\n\"Sphinx\",\n\"Sphinx-PyPI-upload3\",\n\"twine\",\n\"pytest\",\n\"oauth2client\",\n\"pandas\",\n\"gspread-dataframe\",\n]\n\ntest = [\n\"pytest\",\n\"oauth2client\",\n\"pandas\",\n\"gspread-dataframe\",\n\"tox\"\n]\n\n[project.urls]\n\"Homepage\" = \"https://github.com/robin900/gspread-formatting\"\n\"Bug Reports\" = \"https://github.com/robin900/gspread-formatting/issues\"\n\"Source\" = \"https://github.com/robin900/gspread-formatting/\"\n\n[tool.setuptools.dynamic]\nversion = {file = \"VERSION\"}\n\n[tool.coverage.report]\nfail_under = 95\nshow_missing = true\nexclude_lines = [\n    'pragma: no cover',\n    '\\.\\.\\.',\n    'if TYPE_CHECKING:',\n    \"if __name__ == '__main__':\",\n]\n\n"
  },
  {
    "path": "test.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport os\nimport re\nimport random\nimport unittest\nimport itertools\nimport uuid\nfrom datetime import datetime, date\nimport pandas as pd\nfrom gspread_dataframe import set_with_dataframe\n\ntry:\n    from StringIO import StringIO\nexcept ImportError:\n    from io import StringIO\n\ntry:\n    import ConfigParser\nexcept ImportError:\n    import configparser as ConfigParser\n\nfrom oauth2client.service_account import ServiceAccountCredentials\n\nimport gspread\nfrom gspread import utils\nfrom gspread_formatting import *\nfrom gspread_formatting.dataframe import *\nfrom gspread_formatting.util import _range_to_gridrange_object, _range_to_dimensionrange_object\n\ntry:\n    unicode\nexcept NameError:\n    basestring = unicode = str\n\n# making a Worksheet object without fetching it from API changed in 6.0.0.\n\nif gspread.__version__ < '6.0.0':\n    def make_worksheet_object(spreadsheet, props):\n        return gspread.Worksheet(spreadsheet, props)\nelse:\n    def make_worksheet_object(spreadsheet, props):\n        return gspread.Worksheet(spreadsheet, props, spreadsheet.id, spreadsheet.client)\n\n\nCONFIG_FILENAME = os.path.join(os.path.dirname(__file__), 'tests.config')\nCREDS_FILENAME = os.path.join(os.path.dirname(__file__), 'creds.json')\nSCOPE = [\n    'https://spreadsheets.google.com/feeds',\n    'https://www.googleapis.com/auth/drive.file'\n]\n\n\nI18N_STR = u'Iñtërnâtiônàlizætiøn'  # .encode('utf8')\n\n\ndef read_config():\n    config = ConfigParser.ConfigParser()\n    envconfig = os.environ.get('GSPREAD_FORMATTING_CONFIG')\n    if envconfig:\n        fp = StringIO(envconfig)\n    else:\n        fp = open(CONFIG_FILENAME)\n    if hasattr(config, 'read_file'):\n       read_func = config.read_file\n    else:\n       read_func = config.readfp\n    try:\n        read_func(fp)\n    finally:\n        fp.close()\n    return config\n\ndef read_credentials():\n    credjson = os.environ.get('GSPREAD_FORMATTING_CREDENTIALS')\n    if credjson:\n        return ServiceAccountCredentials.from_json_keyfile_dict(json.loads(credjson), SCOPE)\n    else:\n        return ServiceAccountCredentials.from_json_keyfile_name(CREDS_FILENAME, SCOPE)\n\n\ndef gen_value(prefix=None):\n    if prefix:\n        return u'%s %s' % (prefix, gen_value())\n    else:\n        return unicode(uuid.uuid4())\n\nTEST_WORKSHEET_NAME = f'wksht_test{gen_value()}'\n\n\nclass RangeConversionTest(unittest.TestCase):\n    RANGES = {\n        'A': {'startColumnIndex': 0, 'endColumnIndex': 1},\n        'A:C': {'startColumnIndex': 0, 'endColumnIndex': 3},\n        'A5:B': {'startRowIndex': 4, 'startColumnIndex': 0, 'endColumnIndex': 2},\n        '3': {'startRowIndex': 2, 'endRowIndex': 3},\n        '3:100': {'startRowIndex': 2, 'endRowIndex': 100}\n    }\n\n    ILLEGAL_RANGES = (\n        'B:A',\n        'A100:A1',\n        'C1:A20',\n        'AA1:A1',\n        ''\n    )\n\n    DIMENSION_RANGES = {\n        'A': {'dimension': 'COLUMNS', 'startIndex': 0, 'endIndex': 1},\n        'A:C': {'dimension': 'COLUMNS', 'startIndex': 0, 'endIndex': 3},\n        '3': {'dimension': 'ROWS', 'startIndex': 2, 'endIndex': 3},\n        '3:100': {'dimension': 'ROWS', 'startIndex': 2, 'endIndex': 100}\n    }\n\n    ILLEGAL_DIMENSION_RANGES = ( 'A5:B', '1:C3', 'A1:D5' )\n\n    def test_ranges(self):\n        worksheet_id = 0\n        for range, gridrange_obj in self.RANGES.items():\n            gridrange_obj['sheetId'] = worksheet_id\n            self.assertEqual(gridrange_obj, _range_to_gridrange_object(range, worksheet_id))\n        pass\n\n    def test_illegal_ranges(self):\n        for range in self.ILLEGAL_RANGES:\n            exc = None\n            try:\n                _range_to_gridrange_object(range, 0)\n            except Exception as e:\n                exc = e\n            self.assertTrue(isinstance(exc, ValueError))\n\n    def test_dimension_ranges(self):\n        worksheet_id = 0\n        for range, range_obj in self.DIMENSION_RANGES.items():\n            range_obj['sheetId'] = worksheet_id\n            self.assertEqual(range_obj, _range_to_dimensionrange_object(range, worksheet_id))\n        pass\n\n    def test_illegal_dimension_ranges(self):\n        for range in self.ILLEGAL_DIMENSION_RANGES:\n            exc = None\n            try:\n                _range_to_dimensionrange_object(range, 0)\n            except Exception as e:\n                exc = e\n            self.assertTrue(isinstance(exc, ValueError))\n\nclass GspreadTest(unittest.TestCase):\n    maxDiff = None\n    config = None\n    gc = None\n\n    @classmethod\n    def setUpClass(cls):\n        try:\n            cls.config = read_config()\n            credentials = read_credentials()\n            cls.gc = gspread.authorize(credentials)\n        except IOError as e:\n            msg = \"Can't find %s for reading test configuration. \"\n            raise Exception(msg % e.filename)\n\n    def setUp(self):\n        if self.__class__.gc is None:\n            self.__class__.setUpClass()\n        self.assertTrue(isinstance(self.gc, gspread.client.Client))\n\nclass WorksheetTest(GspreadTest):\n    \"\"\"Test for gspread.Worksheet.\"\"\"\n    spreadsheet = None\n\n    @classmethod\n    def setUpClass(cls):\n        super(WorksheetTest, cls).setUpClass()\n        ss_id = cls.config.get('Spreadsheet', 'id')\n        cls.spreadsheet = cls.gc.open_by_key(ss_id)\n        cls.spreadsheet.batch_update(\n            {\n                \"requests\": [\n                    {\n                        \"updateSpreadsheetProperties\": {\n                            \"properties\": {\"locale\": \"en_US\"},\n                            \"fields\": \"locale\",\n                        }\n                    }\n                ]\n            }\n        )\n        try:\n            test_sheet = cls.spreadsheet.worksheet(TEST_WORKSHEET_NAME)\n            if test_sheet:\n                # somehow left over from interrupted test, remove.\n                cls.spreadsheet.del_worksheet(test_sheet)\n        except gspread.exceptions.WorksheetNotFound:\n            pass # expected\n\n    def setUp(self):\n        super(WorksheetTest, self).setUp()\n        if self.__class__.spreadsheet is None:\n            self.__class__.setUpClass()\n        try:\n            test_sheet = self.spreadsheet.worksheet(TEST_WORKSHEET_NAME)\n            if test_sheet:\n                # somehow left over from interrupted test, remove.\n                self.spreadsheet.del_worksheet(test_sheet)\n        except gspread.exceptions.WorksheetNotFound:\n            pass # expected\n        self.sheet = self.spreadsheet.add_worksheet(TEST_WORKSHEET_NAME, 20, 20)\n\n    def tearDown(self):\n        try:\n            test_sheet = self.spreadsheet.worksheet(TEST_WORKSHEET_NAME)\n            if test_sheet:\n                self.spreadsheet.del_worksheet(test_sheet)\n        except gspread.exceptions.WorksheetNotFound:\n            # it's ok if the worksheet is absent\n            pass\n        self.sheet = None\n\n    def test_some_format_constructors(self):\n        f = numberFormat('TEXT', '###0')\n        f = border('DOTTED', color(0.2, 0.2, 0.2))\n\n    def test_bottom_attribute(self):\n        f = padding(bottom=1.1)\n        f = borders(bottom=border('SOLID'))\n\n    def test_format_range(self):\n        rows = [[\"\", \"\", \"\", \"\"],\n                [\"\", \"\", \"\", \"\"],\n                [\"A1\", \"B1\", \"\", \"D1\"],\n                [1, \"b2\", 1.45, \"\"],\n                [\"\", \"\", \"\", \"\"],\n                [\"A4\", 0.4, \"\", 4]]\n\n        def_fmt = get_default_format(self.spreadsheet)\n        cell_list = self.sheet.range('A1:D6')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list)\n\n        fmt = cellFormat(textFormat=textFormat(bold=True), backgroundColorStyle=ColorStyle(rgbColor=Color(1,0,0)))\n        format_cell_ranges(self.sheet, [('A:A', fmt), ('B1:B6', fmt), ('C1:D6', fmt), ('2', fmt)])\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.textFormat.bold, True)\n        # userEnteredFormat will not have backgroundColorStyle...\n        eff_fmt = get_effective_format(self.sheet, 'A1')\n        self.assertEqual(eff_fmt.textFormat.bold, True)\n        # effectiveFormat will have backgroundColorStyle...\n        self.assertEqual(eff_fmt.backgroundColorStyle.rgbColor.red, 1)\n        self.assertEqual(eff_fmt.textFormat.bold, True)\n        fmt2 = cellFormat(textFormat=textFormat(italic=True))\n        format_cell_range(self.sheet, 'A:D', fmt2)\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.textFormat.italic, True)\n        eff_fmt = get_effective_format(self.sheet, 'A1')\n        self.assertEqual(eff_fmt.textFormat.italic, True)\n\n    def test_bottom_formatting(self):\n        rows = [[\"\", \"\", \"\", \"\"],\n                [\"\", \"\", \"\", \"\"],\n                [\"A1\", \"B1\", \"\", \"D1\"],\n                [1, \"b2\", 1.45, \"\"],\n                [\"\", \"\", \"\", \"\"],\n                [\"A4\", 0.4, \"\", 4]]\n\n        def_fmt = get_default_format(self.spreadsheet)\n        cell_list = self.sheet.range('A1:D6')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list)\n        fmt = cellFormat(textFormat=textFormat(bold=True))\n        format_cell_ranges(self.sheet, [('A1:B6', fmt), ('C1:D6', fmt)])\n\n        orig_fmt = get_user_entered_format(self.sheet, 'A1')\n        new_fmt = cellFormat(borders=borders(bottom=border('SOLID')), padding=padding(bottom=3))\n        format_cell_range(self.sheet, 'A1:A1', new_fmt)\n        # Sheets API bug: user entered format will now contain default color and colorStyle.rgbColor\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(new_fmt.borders.bottom.style, ue_fmt.borders.bottom.style)\n        self.assertEqual(new_fmt.padding.bottom, ue_fmt.padding.bottom)\n        eff_fmt = get_effective_format(self.sheet, 'A1')\n        self.assertEqual(new_fmt.borders.bottom.style, eff_fmt.borders.bottom.style)\n        self.assertEqual(new_fmt.padding.bottom, eff_fmt.padding.bottom)\n\n    def test_frozen_rows_cols_bad_args(self):\n        with self.assertRaises(ValueError):\n            set_frozen(self.sheet)\n\n    def test_frozen_rows_cols(self):\n        set_frozen(self.sheet, rows=1, cols=1)\n        fresh = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})\n        item = utils.finditem(lambda x: x['properties']['title'] == self.sheet.title, fresh['sheets'])\n        pr = item['properties']['gridProperties']\n        self.assertEqual(pr.get('frozenRowCount'), 1)\n        self.assertEqual(pr.get('frozenColumnCount'), 1)\n        self.assertEqual(get_frozen_row_count(self.sheet), 1)\n        self.assertEqual(get_frozen_column_count(self.sheet), 1)\n\n    def test_right_to_left(self):\n        set_right_to_left(self.sheet, True)\n        self.assertEqual(get_right_to_left(self.sheet), True)\n        set_right_to_left(self.sheet, False)\n        # Important! Sheets API will omit rightToLeft from sheet properies when it's False\n        # but our function guarantees boolean return\n        self.assertEqual(get_right_to_left(self.sheet), False)\n        fresh = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})\n        item = utils.finditem(lambda x: x['properties']['title'] == self.sheet.title, fresh['sheets'])\n        # but underneath, property is absent\n        pr = item['properties']\n        self.assertTrue('rightToLeft' not in pr)\n\n    def test_format_props_roundtrip(self):\n        fmt = cellFormat(backgroundColor=Color(1,0,1),textFormat=textFormat(italic=False))\n        fmt_roundtrip = CellFormat.from_props(fmt.to_props())\n        self.assertEqual(fmt, fmt_roundtrip)\n\n    def test_formats_equality_and_arithmetic(self):\n        def_fmt = cellFormat(backgroundColor=Color(1,0,1),textFormat=textFormat(italic=False))\n        fmt = cellFormat(textFormat=textFormat(bold=True))\n        effective_format = def_fmt + fmt\n        self.assertEqual(effective_format.textFormat.bold, True)\n        effective_format2 = def_fmt + fmt\n        self.assertEqual(effective_format, effective_format2)\n        self.assertEqual(effective_format - fmt, def_fmt)\n        self.assertEqual(effective_format.difference(fmt), def_fmt)\n        self.assertEqual(effective_format.intersection(effective_format), effective_format)\n        self.assertEqual(effective_format & effective_format, effective_format)\n        self.assertEqual(effective_format - effective_format, None)\n\n    def test_date_formatting_roundtrip(self):\n        rows = [\n            [\"9/1/2018\", \"1/2/2017\", \"4/4/2014\", \"4/4/2019\"],\n            [\"10/2/2019\", \"2/4/2000\", \"5/5/1994\", \"7/7/1979\"]\n        ]\n        def_fmt = get_default_format(self.spreadsheet)\n        cell_list = self.sheet.range('A1:D2')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')\n        fmt = cellFormat(\n                numberFormat=numberFormat('DATE', ' DD MM YYYY'),\n                backgroundColor=color(0.8, 0.9, 1),\n                horizontalAlignment='RIGHT',\n                textFormat=textFormat(bold=False))\n        format_cell_range(self.sheet, 'A1:D2', fmt)\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.numberFormat.type, 'DATE')\n        self.assertEqual(ue_fmt.numberFormat.pattern, ' DD MM YYYY')\n        eff_fmt = get_effective_format(self.sheet, 'A1')\n        self.assertEqual(eff_fmt.numberFormat.type, 'DATE')\n        self.assertEqual(eff_fmt.numberFormat.pattern, ' DD MM YYYY')\n        dt = self.sheet.acell('A1').value\n        self.assertEqual(dt, ' 01 09 2018')\n\n    def test_blank_color_as_black(self):\n        rows = [\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"1\", \"2\", \"3\", \"4\"],\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"TRUE\", \"FALSE\", \"FALSE\", \"TRUE\"],\n        ]\n        cell_list = self.sheet.range('A1:D4')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')\n        fmt = CellFormat(backgroundColor=Color(0,0,0,1))\n        format_cell_range(self.sheet, '1:1', fmt)\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.backgroundColor, Color(0,0,0,1))\n        self.assertEqual(ue_fmt.backgroundColor, Color())\n        fmt = CellFormat(backgroundColor=Color(red=1))\n        format_cell_range(self.sheet, '1:1', fmt)\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.backgroundColor, Color(1,0,0,1))\n        self.assertEqual(ue_fmt.backgroundColor, Color(red=1))\n        fmt = CellFormat(backgroundColor=Color())\n        format_cell_range(self.sheet, '1:1', fmt)\n        ue_fmt = get_user_entered_format(self.sheet, 'A1')\n        eff_fmt = get_effective_format(self.sheet, 'A1')\n        self.assertEqual(ue_fmt.backgroundColor, Color(0,0,0,1))\n        self.assertEqual(ue_fmt.backgroundColor, Color())\n\n\n    def test_empty_cell_formatting(self):\n        self.assertEqual(get_user_entered_format(self.sheet, 'A1'), None)\n        self.assertEqual(get_effective_format(self.sheet, 'A1'), None)\n        self.assertEqual(get_data_validation_rule(self.sheet, 'A1'), None)\n        \n\n    def test_data_validation_rule(self):\n        rows = [\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"1\", \"2\", \"3\", \"4\"],\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"TRUE\", \"FALSE\", \"FALSE\", \"TRUE\"],\n        ]\n        cell_list = self.sheet.range('A1:D4')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')\n        validation_rule = DataValidationRule(\n            BooleanCondition('ONE_OF_LIST', ['1', '2', '3', '4']), \n            showCustomUi=True\n        )\n        set_data_validation_for_cell_range(self.sheet, 'A2:D2', validation_rule)\n        # No data validation for A1\n        eff_rule = get_data_validation_rule(self.sheet, 'A1')\n        self.assertEqual(eff_rule, None)\n        # data validation for A2 should be equal to validation_rule\n        eff_rule = get_data_validation_rule(self.sheet, 'A2')\n        self.assertEqual(eff_rule.condition.type, 'ONE_OF_LIST')\n        self.assertEqual([ x.userEnteredValue for x in eff_rule.condition.values ], ['1', '2', '3', '4'])\n        self.assertEqual(eff_rule.showCustomUi, True)\n        self.assertEqual(eff_rule.strict, None)\n        self.assertEqual(eff_rule, validation_rule)\n\n        boolean_validation_rule = DataValidationRule(\n            BooleanCondition('BOOLEAN', [])\n        )\n        set_data_validation_for_cell_range(self.sheet, 'A4:D4', boolean_validation_rule)\n        eff_rule = get_data_validation_rule(self.sheet, 'A4')\n        self.assertEqual([ x.userEnteredValue for x in eff_rule.condition.values ], [])\n        self.assertEqual(eff_rule.showCustomUi, None)\n        self.assertEqual(eff_rule.strict, None)\n        self.assertEqual(eff_rule, boolean_validation_rule)\n\n        set_data_validation_for_cell_range(self.sheet, 'A4:D4', None)\n        eff_rule = get_data_validation_rule(self.sheet, 'A4')\n        self.assertEqual(eff_rule, None)\n\n    def test_boolean_condition(self):\n        with self.assertRaises(ValueError):\n            BooleanCondition('TEXT_EQ', 'foo')\n        with self.assertRaises(ValueError):\n            BooleanCondition('ONE_OF_LIST', 'foo')\n\n    def test_conditional_format_rules(self):\n        rows = [\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"1\", \"2\", \"3\", \"4\"]\n        ]\n        cell_list = self.sheet.range('A1:D2')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')\n\n        current_rules = get_conditional_format_rules(self.sheet)\n        self.assertEqual(list(current_rules), [])\n\n        with self.assertRaises(ValueError):\n            current_rules.append([])\n\n        new_rule = ConditionalFormatRule(\n            ranges=[GridRange.from_a1_range('A1:D1', self.sheet)],\n            booleanRule=BooleanRule(\n                condition=BooleanCondition('TEXT_CONTAINS', ['A']), \n                format=CellFormat(textFormat=TextFormat(bold=True))\n            )\n        )\n        new_rule_2 = ConditionalFormatRule(\n            ranges=[GridRange.from_a1_range('A2:D2', self.sheet)],\n            gradientRule=GradientRule(\n                maxpoint=InterpolationPoint(colorStyle=ColorStyle(themeColor='BACKGROUND'), type='MAX'),\n                minpoint=InterpolationPoint(colorStyle=ColorStyle(themeColor='TEXT'), type='NUMBER', value='1')\n            )\n        )\n        new_rule_3 = ConditionalFormatRule(\n            ranges=[GridRange.from_a1_range('A2:D2', self.sheet)],\n            booleanRule=BooleanRule(\n                condition=BooleanCondition('DATE_AFTER', [RelativeDate('PAST_WEEK')]),\n                format=CellFormat(textFormat=TextFormat(italic=True))\n            )\n        )\n        current_rules.append(new_rule)\n        current_rules.append(new_rule_2)\n        current_rules.append(new_rule_3)\n        self.assertNotEqual(current_rules.save(), None)\n        # re-saving _always_ sends a request to API, even if no local changes made\n        self.assertNotEqual(current_rules.save(), None)\n        current_rules = get_conditional_format_rules(self.sheet)\n        self.assertEqual(\n            current_rules.rules[0].booleanRule.format.textFormat.bold, \n            new_rule.booleanRule.format.textFormat.bold\n        )\n        self.assertEqual(\n            current_rules.rules[1].gradientRule.maxpoint.colorStyle.themeColor, \n            new_rule_2.gradientRule.maxpoint.colorStyle.themeColor, \n        )\n        self.assertEqual(\n            current_rules.rules[2].booleanRule.format.textFormat.italic,\n            new_rule_3.booleanRule.format.textFormat.italic\n        )\n        current_rules[0] = new_rule_2\n        del current_rules[1]\n        current_rules.append(new_rule)\n        self.assertNotEqual(current_rules.save(), None)\n        current_rules = get_conditional_format_rules(self.sheet)\n        self.assertEqual(\n            current_rules.rules[0].gradientRule.maxpoint.colorStyle.themeColor, \n            new_rule_2.gradientRule.maxpoint.colorStyle.themeColor, \n        )\n        self.assertEqual(\n            current_rules.rules[1].booleanRule.format.textFormat.italic,\n            new_rule_3.booleanRule.format.textFormat.italic\n        )\n        self.assertEqual(\n            current_rules.rules[2].booleanRule.format.textFormat.bold, \n            new_rule.booleanRule.format.textFormat.bold\n        )\n\n        bold_fmt = get_effective_format(self.sheet, 'A1')\n        normal_fmt = get_effective_format(self.sheet, 'C1')\n        self.assertEqual(bold_fmt.textFormat.bold, True)\n        self.assertEqual(bool(normal_fmt.textFormat.bold), False)\n        self.assertEqual(bool(normal_fmt.textFormat.italic), False)\n\n        current_rules.clear()\n        current_rules.append(new_rule_3)\n        current_rules.save()\n        current_rules = get_conditional_format_rules(self.sheet)\n        self.assertEqual(len(current_rules), 1)\n        self.assertEqual(\n            current_rules[0].booleanRule.format.textFormat.italic, \n            new_rule_3.booleanRule.format.textFormat.italic, \n        )\n\n        current_rules.clear()\n        current_rules.save()\n        current_rules = get_conditional_format_rules(self.sheet)\n        self.assertEqual(list(current_rules), [])\n        \n    def test_conditionals_issue_31(self):\n        rules = [\n            ConditionalFormatRule(\n                ranges=[GridRange(self.sheet.id, 1, 1, 1, 2)],\n                booleanRule=BooleanRule(\n                    BooleanCondition('NUMBER_EQ', ['1']),\n                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex(\"#000000\")))\n                )\n            ),\n            ConditionalFormatRule(\n                ranges=[GridRange(self.sheet.id, 2, 3, 2, 3)],\n                booleanRule=BooleanRule(\n                    BooleanCondition('NUMBER_EQ', ['1']),\n                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex(\"#00FFFF\")))\n                )\n           ),\n            ConditionalFormatRule(\n                ranges=[GridRange(self.sheet.id, 1, 2, 1, 3)],\n                booleanRule=BooleanRule(\n                    BooleanCondition('NUMBER_EQ', ['1']),\n                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex(\"#FFFF00\")))\n                )\n            )\n        ]\n        rows = [\n            [\"A\", \"B\", \"C\", \"D\"],\n            [\"1\", \"2\", \"3\", \"4\"]\n        ]\n        cell_list = self.sheet.range('A1:D2')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        current_rules = get_conditional_format_rules(self.sheet)\n        current_rules.extend(rules)\n        self.assertNotEqual(current_rules.save(), None)\n        current_rules_fetched = get_conditional_format_rules(self.sheet)\n        self.assertEqual(\n            current_rules_fetched.rules[0].booleanRule.format.textFormat.foregroundColor,\n            current_rules.rules[0].booleanRule.format.textFormat.foregroundColor\n        )\n        self.assertEqual(\n            current_rules_fetched.rules[1].booleanRule.format.textFormat.foregroundColor,\n            current_rules.rules[1].booleanRule.format.textFormat.foregroundColor\n        )\n        self.assertEqual(\n            current_rules_fetched.rules[2].booleanRule.format.textFormat.foregroundColor,\n            current_rules.rules[2].booleanRule.format.textFormat.foregroundColor\n        )\n\n\n    def test_dataframe_formatter(self):\n        rows = [  \n            {\n                'i': i,\n                'j': i * 2,\n                'A': 'Label ' + str(i), \n                'B': i * 100 + 2.34, \n                'C': date(2019, 3, i % 31 + 1), \n                'D': datetime(2019, 3, i % 31 + 1, i % 24, i % 60, i % 60),\n                'E': i * 1000 + 7.8001, \n            } \n            for i in range(200) \n        ]\n        df = pd.DataFrame.from_records(rows, index=['i', 'j'])\n        set_with_dataframe(self.sheet, df, include_index=True)\n        format_with_dataframe(\n            self.sheet, \n            df, \n            formatter=BasicFormatter.with_defaults(\n                freeze_headers=True, \n                column_formats={\n                    'C': cellFormat(\n                            numberFormat=numberFormat(type='DATE', pattern='yyyy mmmmmm dd'), \n                            horizontalAlignment='CENTER'\n                        ),\n                    'E': cellFormat(\n                            numberFormat=numberFormat(type='NUMBER', pattern='[Color23][>40000]\"HIGH\";[Color43][<=10000]\"LOW\";0000'), \n                            horizontalAlignment='CENTER'\n                        )\n                }\n            ), \n            include_index=True,\n        )\n        for cell_range, expected_uef in [\n            ('A2:A201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), \n            ('B2:B201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), \n            ('C2:C201', cellFormat(horizontalAlignment='CENTER')), \n            ('D2:D201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), \n            ('E2:E201', \n                cellFormat(\n                    numberFormat=numberFormat(type='DATE', pattern='yyyy mmmmmm dd'), \n                    horizontalAlignment='CENTER'\n                )\n            ), \n            ('F2:F201', cellFormat(numberFormat=numberFormat(type='DATE'), horizontalAlignment='CENTER')), \n            ('G2:G201', \n                cellFormat(\n                    numberFormat=numberFormat(\n                        type='NUMBER', \n                        pattern='[Color23][>40000]\"HIGH\";[Color43][<=10000]\"LOW\";0000'\n                    ), \n                    horizontalAlignment='CENTER'\n                )\n            ), \n            ('A1:B201', \n                cellFormat(\n                    backgroundColor=DEFAULT_HEADER_BACKGROUND_COLOR,\n                    textFormat=textFormat(bold=True)\n                )\n            ), \n            ('A1:G1', \n                cellFormat(\n                    backgroundColor=DEFAULT_HEADER_BACKGROUND_COLOR,\n                    textFormat=textFormat(bold=True)\n                )\n            )\n            ]:\n            start_cell, end_cell = cell_range.split(':')\n            for cell in (start_cell, end_cell):\n                actual_uef = get_user_entered_format(self.sheet, cell)\n                # actual_uef must be a superset of expected_uef\n                self.assertTrue(\n                    actual_uef & expected_uef == expected_uef, \n                    \"%s range expected format %s, got %s\" % (cell_range, expected_uef, actual_uef)\n                )\n        self.assertEqual(1, get_frozen_row_count(self.sheet))\n        self.assertEqual(2, get_frozen_column_count(self.sheet))\n\n    def test_dataframe_formatter_no_column_header(self):\n        rows = [  \n            {\n                'i': i,\n                'j': i * 2,\n                'A': 'Label ' + str(i), \n                'B': i * 100 + 2.34, \n                'C': date(2019, 3, i % 31 + 1), \n                'D': datetime(2019, 3, i % 31 + 1, i % 24, i % 60, i % 60),\n                'E': i * 1000 + 7.8001, \n            } \n            for i in range(200) \n        ]\n        df = pd.DataFrame.from_records(rows, index=['i', 'j'])\n        set_with_dataframe(self.sheet, df, include_index=True, include_column_header=False)\n        format_with_dataframe(\n            self.sheet, \n            df, \n            formatter=DEFAULT_FORMATTER,\n            include_index=True,\n            include_column_header=False\n        )\n\n    def test_row_height_and_column_width(self):\n        set_row_height(self.sheet, '1:5', 42)\n        set_column_width(self.sheet, 'A', 187)\n        metadata = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': 'true'})\n        sheet_md = [ s for s in metadata['sheets'] if s['properties']['sheetId'] == self.sheet.id ][0]\n        row_md = sheet_md['data'][0]['rowMetadata']\n        col_md = sheet_md['data'][0]['columnMetadata']\n        for row in row_md[0:4]:\n            self.assertEqual(42, row['pixelSize'])\n        for col in col_md[0:1]:\n            self.assertEqual(187, col['pixelSize'])\n\n    def test_row_height_and_column_width_batch(self):\n        with batch_updater(self.sheet.spreadsheet) as batch:\n            batch.set_row_height(self.sheet, '1:5', 42)\n            batch.set_column_width(self.sheet, 'A', 187)\n        metadata = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': 'true'})\n        sheet_md = [ s for s in metadata['sheets'] if s['properties']['sheetId'] == self.sheet.id ][0]\n        row_md = sheet_md['data'][0]['rowMetadata']\n        col_md = sheet_md['data'][0]['columnMetadata']\n        for row in row_md[0:4]:\n            self.assertEqual(42, row['pixelSize'])\n        for col in col_md[0:1]:\n            self.assertEqual(187, col['pixelSize'])\n\n    def test_text_format_runs(self):\n        rows = [\n            [\"Label A\", \"B\", \"C\", \"D\"],\n            [\"1\", \"2\", \"3\", \"4\"]\n        ]\n        cell_list = self.sheet.range('A1:D2')\n        for cell, value in zip(cell_list, itertools.chain(*rows)):\n            cell.value = value\n        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')\n\n        runs = [TextFormatRun(startIndex=0, format=TextFormat(bold=True)), TextFormatRun(startIndex=6, format=TextFormat(italic=True))]\n        with batch_updater(self.sheet.spreadsheet) as batch:\n            batch.set_text_format_runs(self.sheet, 'A1', runs)\n        fetched_runs = get_text_format_runs(self.sheet, 'A1')\n        self.assertEqual(runs, fetched_runs)\n        fetched_runs = get_text_format_runs(self.sheet, 'A2')\n        self.assertEqual([], fetched_runs)\n\n        # no args should succeed\n        TextFormatRun()\n\n    def test_batch_updater_different_spreadsheet(self):\n        batch = batch_updater(self.sheet.spreadsheet)\n        other_spread = gspread.Spreadsheet.__new__(gspread.Spreadsheet)\n        other_spread.client = self.sheet.spreadsheet.client\n        other_spread._properties = {'id': 'blech', 'title': 'Other sheet'}\n        other_sheet = make_worksheet_object(other_spread, {'sheetId': 4, 'title': 'Bleh'})\n        batch.set_row_height(self.sheet, '1:5', 42)\n        with self.assertRaises(ValueError):\n            batch.set_row_height(other_sheet, '1:5', 42)\n\n    def test_batch_updater_context(self):\n        batch = batch_updater(self.sheet.spreadsheet)\n        batch.set_row_height(self.sheet, '1:5', 42)\n        batch.set_column_width(self.sheet, 'A', 187)\n        self.assertEqual(2, len(batch.requests))\n        try:\n            with batch:\n                batch.set_row_height(self.sheet, '1:5', 40)\n        except Exception as e:\n            self.assertIsInstance(e, IOError)\n        self.assertEqual(2, len(batch.requests))\n        batch.execute()\n        self.assertEqual(0, len(batch.requests))\n\n\nclass ColorTest(unittest.TestCase):\n\n    SAMPLE_HEXSTRINGS_NOALPHA = ['#230ac7','#9ec08b','#037778','#134d70','#f1f974','#0997b6','#42da14','#be5ee8']\n    SAMPLE_HEXSTRINGS_ALPHA = ['#b7d90600','#0a29f321','#33db6a48','#4134a467','#7d172388','#58fe5fa1','#2ea14ecc','#c18de9f8']\n    SAMPLE_HEXSTRING_CAPS = ['#DDEEFF','#EEFFAABB','#1A2B3C4E','#A1F2B3']\n    # [NO_POUND_SIGN, NO_POUND_SIGN_ALPHA, INVALID_HEX_CHAR, INVALID_HEX_CHAR_ALPHA, SPECIAL_INVALID_CHAR, TOO_FEW_CHARS, TOO_MANY_CHARS]\n    SAMPLE_HEXSTRINGS_BAD = ['230ac7','9ec08b9b','#Adbeye','#1122ccgg','#11$100FF', '#11678','#867530910']\n\n    def test_color_roundtrip(self):\n        for hexstring in self.SAMPLE_HEXSTRINGS_NOALPHA:\n            self.assertEqual(hexstring, Color.fromHex(hexstring).toHex())\n        for hexstring in self.SAMPLE_HEXSTRINGS_ALPHA:\n            self.assertEqual(hexstring, Color.fromHex(hexstring).toHex())\n        for hexstring in self.SAMPLE_HEXSTRING_CAPS:\n            # Check equality with lowercase version of string\n            self.assertEqual(hexstring.lower(), Color.fromHex(hexstring).toHex())\n\n    def test_color_malformed(self):\n        for hexstring in self.SAMPLE_HEXSTRINGS_BAD:\n            with self.assertRaises(ValueError):\n                Color.fromHex(hexstring)\n\n\nclass FormattingComponentTest(unittest.TestCase):\n\n    def test_repr_and_equality(self):\n        comp = TextFormat(bold=True, italic=True)\n        comp2 = TextFormat(bold=True)\n        self.assertEqual('<TextFormat bold=True;italic=True>', repr(comp))\n        self.assertNotEqual(comp, comp2)\n        self.assertNotEqual(comp, None)\n        self.assertEqual(comp, comp)\n\n    def test_number_format_types(self):\n        for type_ in NumberFormat.TYPES:\n            f = NumberFormat(type_)\n            self.assertEqual(f, f)\n            self.assertEqual(type_, f.type)\n        with self.assertRaises(ValueError):\n            NumberFormat('BAD_TYPE')\n\n    def test_border_styles(self):\n        for style in Border.STYLES:\n            f = Border(style)\n            self.assertEqual(f, f)\n            self.assertEqual(style, f.style)\n        with self.assertRaises(ValueError):\n            Border('BAD_STYLE')\n\n    def test_text_format_link(self):\n        TextFormat(link=None)\n        TextFormat(link=Link(\"https://foo.com/\"))\n        tf = TextFormat(link=Link(uri=\"https://foo.com/\"))\n        self.assertEqual(\"https://foo.com/\", tf.link.uri)\n        tf2 = TextFormat.from_props(tf.to_props())\n        self.assertEqual(tf, tf2)\n\n    def test_text_rotation_exclusion(self):\n        TextRotation(angle=1)\n        TextRotation(vertical=True)\n        with self.assertRaises(ValueError):\n            TextRotation(angle=1, vertical=True)\n        with self.assertRaises(ValueError):\n            TextRotation()\n\n    def test_condition_with_relative_date_value(self):\n        c = BooleanCondition('DATE_AFTER', [RelativeDate('PAST_WEEK')])\n        self.assertEqual('DATE_AFTER', c.type)\n        self.assertEqual('PAST_WEEK', c.values[0].relativeDate.value)\n\n\n\nclass GridRangeTest(unittest.TestCase):\n\n    def test_absent_sheet_id(self):\n        gr = GridRange.from_props({'startRowIndex': 1})\n        self.assertEqual(0, gr.sheetId)\n        self.assertEqual(1, gr.startRowIndex)\n"
  },
  {
    "path": "tests.config.example",
    "content": "[Spreadsheet]\nid: 1P3rdCDxfO760TJdE-cbi0k_yy9vmC-joapjuGw9vNjc\n"
  },
  {
    "path": "tox.ini",
    "content": "[tox]\nenv_list =\n    3.8\n    3.13\nminversion = 4.24.2\n\n[testenv]\ndescription = run the tests with pytest\npackage = wheel\nwheel_build_env = .pkg\ndeps =\n    pytest>=6\n    coverage\n    oauth2client\n    pandas\n    gspread-dataframe\ncommands = \n  coverage erase\n  coverage run -m pytest {tty:--color=yes} test.py {posargs}\n  coverage report --omit=test.py\n\n[gh-actions]\npython = \n  3.8: py38\n  3.13: py313\n"
  }
]