Full Code of robin900/gspread-formatting for AI

master 1a724c480633 cached
26 files
141.9 KB
35.8k tokens
188 symbols
1 requests
Download .txt
Repository: robin900/gspread-formatting
Branch: master
Commit: 1a724c480633
Files: 26
Total size: 141.9 KB

Directory structure:
gitextract_5v4gfxc8/

├── .gitchangelog.rc
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   └── bug_report.md
│   └── workflows/
│       └── python-package.yml
├── .gitignore
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── VERSION
├── diffs_to_discovery.py
├── docs/
│   ├── Makefile
│   ├── conf.py
│   ├── index.rst
│   └── make.bat
├── gspread_formatting/
│   ├── __init__.py
│   ├── batch.py
│   ├── batch_update_requests.py
│   ├── conditionals.py
│   ├── dataframe.py
│   ├── functions.py
│   ├── models.py
│   └── util.py
├── pyproject.toml
├── test.py
├── tests.config.example
└── tox.ini

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitchangelog.rc
================================================
# -*- coding: utf-8; mode: python -*-
##
## Format
##
##   ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...]
##
## Description
##
##   ACTION is one of 'chg', 'fix', 'new'
##
##       Is WHAT the change is about.
##
##       'chg' is for refactor, small improvement, cosmetic changes...
##       'fix' is for bug fixes
##       'new' is for new features, big improvement
##
##   AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc'
##
##       Is WHO is concerned by the change.
##
##       'dev'  is for developpers (API changes, refactors...)
##       'usr'  is for final users (UI changes)
##       'pkg'  is for packagers   (packaging changes)
##       'test' is for testers     (test only related changes)
##       'doc'  is for doc guys    (doc only changes)
##
##   COMMIT_MSG is ... well ... the commit message itself.
##
##   TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic'
##
##       They are preceded with a '!' or a '@' (prefer the former, as the
##       latter is wrongly interpreted in github.) Commonly used tags are:
##
##       'refactor' is obviously for refactoring code only
##       'minor' is for a very meaningless change (a typo, adding a comment)
##       'cosmetic' is for cosmetic driven change (re-indentation, 80-col...)
##       'wip' is for partial functionality but complete subfunctionality.
##
## Example:
##
##   new: usr: support of bazaar implemented
##   chg: re-indentend some lines !cosmetic
##   new: dev: updated code to be compatible with last version of killer lib.
##   fix: pkg: updated year of licence coverage.
##   new: test: added a bunch of test around user usability of feature X.
##   fix: typo in spelling my name in comment. !minor
##
##   Please note that multi-line commit message are supported, and only the
##   first line will be considered as the "summary" of the commit message. So
##   tags, and other rules only applies to the summary.  The body of the commit
##   message will be displayed in the changelog without reformatting.


##
## ``ignore_regexps`` is a line of regexps
##
## Any commit having its full commit message matching any regexp listed here
## will be ignored and won't be reported in the changelog.
##
ignore_regexps = [
    r'@minor', r'!minor',
    r'@cosmetic', r'!cosmetic',
    r'@refactor', r'!refactor',
    r'@wip', r'!wip',
    r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:',
    r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:',
    r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$',
    r'^$',  ## ignore commits with empty messages
]


## ``section_regexps`` is a list of 2-tuples associating a string label and a
## list of regexp
##
## Commit messages will be classified in sections thanks to this. Section
## titles are the label, and a commit is classified under this section if any
## of the regexps associated is matching.
##
## Please note that ``section_regexps`` will only classify commits and won't
## make any changes to the contents. So you'll probably want to go check
## ``subject_process`` (or ``body_process``) to do some changes to the subject,
## whenever you are tweaking this variable.
##
section_regexps = [
    ('New', [
        r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
     ]),
    ('Changes', [
        r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
     ]),
    ('Fix', [
        r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
     ]),

    ('Other', None ## Match all lines
     ),

]


## ``body_process`` is a callable
##
## This callable will be given the original body and result will
## be used in the changelog.
##
## Available constructs are:
##
##   - any python callable that take one txt argument and return txt argument.
##
##   - ReSub(pattern, replacement): will apply regexp substitution.
##
##   - Indent(chars="  "): will indent the text with the prefix
##     Please remember that template engines gets also to modify the text and
##     will usually indent themselves the text if needed.
##
##   - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns
##
##   - noop: do nothing
##
##   - ucfirst: ensure the first letter is uppercase.
##     (usually used in the ``subject_process`` pipeline)
##
##   - final_dot: ensure text finishes with a dot
##     (usually used in the ``subject_process`` pipeline)
##
##   - strip: remove any spaces before or after the content of the string
##
##   - SetIfEmpty(msg="No commit message."): will set the text to
##     whatever given ``msg`` if the current text is empty.
##
## Additionally, you can `pipe` the provided filters, for instance:
#body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars="  ")
#body_process = Wrap(regexp=r'\n(?=\w+\s*:)')
#body_process = noop
body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip


## ``subject_process`` is a callable
##
## This callable will be given the original subject and result will
## be used in the changelog.
##
## Available constructs are those listed in ``body_process`` doc.
subject_process = (strip |
    ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') |
    SetIfEmpty("No commit message.") | ucfirst | final_dot)


## ``tag_filter_regexp`` is a regexp
##
## Tags that will be used for the changelog must match this regexp.
##
tag_filter_regexp = r'^v[0-9]+\.[0-9]+(\.[0-9]+)?$'


## ``unreleased_version_label`` is a string or a callable that outputs a string
##
## This label will be used as the changelog Title of the last set of changes
## between last valid tag and HEAD if any.
unreleased_version_label = "(unreleased)"


## ``output_engine`` is a callable
##
## This will change the output format of the generated changelog file
##
## Available choices are:
##
##   - rest_py
##
##        Legacy pure python engine, outputs ReSTructured text.
##        This is the default.
##
##   - mustache(<template_name>)
##
##        Template name could be any of the available templates in
##        ``templates/mustache/*.tpl``.
##        Requires python package ``pystache``.
##        Examples:
##           - mustache("markdown")
##           - mustache("restructuredtext")
##
##   - makotemplate(<template_name>)
##
##        Template name could be any of the available templates in
##        ``templates/mako/*.tpl``.
##        Requires python package ``mako``.
##        Examples:
##           - makotemplate("restructuredtext")
##
output_engine = rest_py
#output_engine = mustache("restructuredtext")
#output_engine = mustache("markdown")
#output_engine = makotemplate("restructuredtext")


## ``include_merge`` is a boolean
##
## This option tells git-log whether to include merge commits in the log.
## The default is to include them.
include_merge = True


## ``log_encoding`` is a string identifier
##
## This option tells gitchangelog what encoding is outputed by ``git log``.
## The default is to be clever about it: it checks ``git config`` for
## ``i18n.logOutputEncoding``, and if not found will default to git's own
## default: ``utf-8``.
#log_encoding = 'utf-8'


## ``publish`` is a callable
##
## Sets what ``gitchangelog`` should do with the output generated by
## the output engine. ``publish`` is a callable taking one argument
## that is an interator on lines from the output engine.
##
## Some helper callable are provided:
##
## Available choices are:
##
##   - stdout
##
##        Outputs directly to standard output
##        (This is the default)
##
##   - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start())
##
##        Creates a callable that will parse given file for the given
##        regex pattern and will insert the output in the file.
##        ``idx`` is a callable that receive the matching object and
##        must return a integer index point where to insert the
##        the output in the file. Default is to return the position of
##        the start of the matched string.
##
##   - FileRegexSubst(file, pattern, replace, flags)
##
##        Apply a replace inplace in the given file. Your regex pattern must
##        take care of everything and might be more complex. Check the README
##        for a complete copy-pastable example.
##
# publish = FileInsertIntoFirstRegexMatch(
#     "CHANGELOG.rst",
#     r'/(?P<rev>[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/',
#     idx=lambda m: m.start(1)
# )
#publish = stdout


## ``revs`` is a list of callable or a list of string
##
## callable will be called to resolve as strings and allow dynamical
## computation of these. The result will be used as revisions for
## gitchangelog (as if directly stated on the command line). This allows
## to filter exaclty which commits will be read by gitchangelog.
##
## To get a full documentation on the format of these strings, please
## refer to the ``git rev-list`` arguments. There are many examples.
##
## Using callables is especially useful, for instance, if you
## are using gitchangelog to generate incrementally your changelog.
##
## Some helpers are provided, you can use them::
##
##   - FileFirstRegexMatch(file, pattern): will return a callable that will
##     return the first string match for the given pattern in the given file.
##     If you use named sub-patterns in your regex pattern, it'll output only
##     the string matching the regex pattern named "rev".
##
##   - Caret(rev): will return the rev prefixed by a "^", which is a
##     way to remove the given revision and all its ancestor.
##
## Please note that if you provide a rev-list on the command line, it'll
## replace this value (which will then be ignored).
##
## If empty, then ``gitchangelog`` will act as it had to generate a full
## changelog.
##
## The default is to use all commits to make the changelog.
#revs = ["^1.0.3", ]
#revs = [
#    Caret(
#        FileFirstRegexMatch(
#            "CHANGELOG.rst",
#            r"(?P<rev>[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")),
#    "HEAD"
#]
revs = []


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**Version and Environment**
*Version of package*: X.X.X
*Python interpreter*: CPython, PyPy, etc.
*OS*: xxx

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
 - Version [e.g. 22]

**Smartphone (please complete the following information):**
 - Device: [e.g. iPhone6]
 - OS: [e.g. iOS8.1]
 - Browser [e.g. stock browser, safari]
 - Version [e.g. 22]

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/workflows/python-package.yml
================================================
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Build and Test

on: push

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        python-version: ["3.8", "3.13"]

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v3
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        python -m pip install build tox tox-gh-actions
    - name: Build sdist and wheel
      run: |
        python -m build 
    - name: Test with tox
      run: |
        echo "${GSHEETS_CREDENTIALS}" > creds.json
        echo "${TESTS_CONFIG}" > tests.config
        tox -v
      env:
          GSHEETS_CREDENTIALS: ${{secrets.GSHEETS_CREDENTIALS}}
          TESTS_CONFIG: ${{secrets.TESTS_CONFIG}}




================================================
FILE: .gitignore
================================================
/.travis.secrets.tar.gz
/creds.json
/tests.config

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# pyenv
.python-version

# celery beat schedule file
celerybeat-schedule

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/



================================================
FILE: CHANGELOG.rst
================================================
Changelog
=========


v1.2.1 (2025-03-07)
-------------------
- Bump to v1.2.1. [Robin Thomas]
- Test coverage for fix of #59. [Robin Thomas]
- TextFormatRun.format now optional, has empty default (#60) [Robin
  Thomas]

  Fixes #59.


v1.2.0 (2024-06-12)
-------------------
- Bump to v1.2.0. [Robin Thomas]
- Add TextFormatRun support (#57) [Robin Thomas]

  Fixes #54.
- Remove unneeded badges. [Robin Thomas]
- Fix Google Sheets docs URL in README (#55) [Matt Black]
- Formatting correction in README. [Robin Thomas]


v1.1.2 (2022-12-03)
-------------------
- Bump to v1.1.2. [Robin Thomas]
- Added test coverage of include_column_header=False case. [Robin
  Thomas]
- Fix exception when include_column_header=False. [pomcho555]

  UnboundLocalError would occur, because freeze_args is defined in the wrong place. Fixed.
- Fixed formatting issue with RTL section of README. [Robin Thomas]


v1.1.1 (2022-09-27)
-------------------
- Bump to v1.1.1. [Robin Thomas]
- Add README documentation for right-to-left support. [Robin Thomas]


v1.1.0 (2022-09-27)
-------------------
- Bump to v1.1.0. [Robin Thomas]
- Adds `get_right_to_left` and `set_right_to_left` functions. (#44)
  [Robin Thomas]

  * Adds `get_right_to_left` and `set_right_to_left` functions.
  (`set_right_to_left` is also available in the batch updater.)
  Fixes #43.


v1.0.6 (2022-02-09)
-------------------
- Bump to v1.0.6. [Robin Thomas]
- Enforce type on items added to conditional format rules sequence
  object. [Robin Thomas]


v1.0.5 (2021-11-27)
-------------------
- Bump to v1.0.5. [Robin Thomas]
- Fixes #38. Avoids import errors, and also adapts test code to avoid
  that Spreadsheet.__init__ calls fetch_sheet_metadata, working with new
  release 5.0.0 of gspread. [Robin Thomas]


v1.0.4 (2021-08-12)
-------------------
- Bump to v1.0.4. [Robin Thomas]
- Fixes #35. Allows for bare RelativeDate objects in the values list to
  BooleanCondition, transforming to ConditionValue objects with
  relativeDate. [Robin Thomas]


v1.0.3 (2021-07-13)
-------------------
- Bump to v1.0.3. [Robin Thomas]
- Fixes #34. Cells with no formatting or data validation rules were
  causing KeyError exceptions in get_effective_format() and similar
  functions. These functions now properly return None without raising an
  exception. [Robin Thomas]


v1.0.2 (2021-07-01)
-------------------
- Bump to v1.0.2. [Robin Thomas]
- Compare model classes to json schema from discovery URI, using new
  script; remove foregroundColorStyle from CellFormat class as it's not
  in json schema. (Border.width is in json schema but is deprecated and
  thus Border model class is correctly coded.) [Robin Thomas]


v1.0.1 (2021-06-30)
-------------------
- Bump to v1.0.1. [Robin Thomas]
- Fixes #33 -- 'link' property of TextFormat now supported. [Robin
  Thomas]


v1.0.0 (2021-05-13)
-------------------
- Bump to v1.0.0. [Robin Thomas]
- Fix for #31 (#32) [Robin Thomas]

  Fixes #31. Allows for Sheets API's tendency to include empty objects
  for default color values in API responses.
- No longer CI with pypy. [Robin Thomas]
- Revert "Attempt to constrain use of old rsa pkg to 2.7 CI build, and
  also" [Robin Thomas]

  This reverts commit 5cc83c67036ba5d004de997b613a34e9a8550f24.
- Revert "Attempt to constrain use of old rsa pkg to 2.7 CI build, and
  also" [Robin Thomas]

  This reverts commit 5cc83c67036ba5d004de997b613a34e9a8550f24.
- Revert "Try TravisCI conditional use of rsa<=4.1 again" [Robin Thomas]

  This reverts commit b8ecaad65f0876385a1585237d756ee1fd450fb0.
- Oops use rsa<4.1. [Robin Thomas]
- Try TravisCI conditional use of rsa<=4.1 again. [Robin Thomas]
- Attempt to constrain use of old rsa pkg to 2.7 CI build, and also
  avoid the github dependabot alert. [Robin Thomas]
- Pin rsa to < 4.1 so that Python 2.7 CI can still run. [Robin Thomas]
- Added paranoid test of absent sheetId in GridRange props, to prevent
  accidental regression. [Robin Thomas]
- Improved, more concise code for Color.fromHex and .toHex(). [Robin
  Thomas]
- Tighten up travis install. [Robin Thomas]
- Try explicit directory caching to make pip cache work as expected for
  pandas wheel. [Robin Thomas]
- Add 3.9 to travis. [Robin Thomas]
- Pin six to >=1.12.0 in travis to avoid weird environmental dependency
  problem. [Robin Thomas]
- Move to travis-ci.com. [Robin Thomas]


v0.3.7 (2020-11-23)
-------------------
- Bump to v0.3.7. [Robin Thomas]
- Corrected error in conditional format rules example code in README.
  [Robin Thomas]
- Fixed typo in README. [Robin Thomas]
- Fixed typos in batch call documentation. [Robin Thomas]


v0.3.6 (2020-11-12)
-------------------
- Bump to v0.3.6. [Robin Thomas]
- Allow for absent sheetId property in GridRange objects coming from API
  (suspected abrupt change in Sheets API behavior!) [Robin Thomas]
- Added extra example for clearing data validation rule with None.
  [Robin Thomas]


v0.3.5 (2020-11-10)
-------------------
- Bump to v0.3.5. [Robin Thomas]
- Fixes #26. Allows `None` as rule parameter to
  set_data_validation_rule* functions, which will clear data validation
  rule for the relevant cells. [Robin Thomas]


v0.3.4 (2020-10-22)
-------------------
- Bump to v0.3.4. [Robin Thomas]
- More informative exception message when BooleanCondition receives non-
  list/tuple for values parameter. [Robin Thomas]
- Increased already-high test coverage. [Robin Thomas]
- Removed dead link to now-inlined conditional formatting doc. [Robin
  Thomas]
- Correct doc/sphinx annoyances. [Robin Thomas]


v0.3.3 (2020-09-24)
-------------------
- Bump to version v0.3.3. [Robin Thomas]
- Fixes #24. [Robin Thomas]

  A certain set of functions that exist both in batch and standalone mode
  are dynamically bound as local names in the functions subpackage. That makes
  them undiscoverable by IDEs like PyCharm. Adding a straightforward import
  statement for these function names -- even though the names are re-bound
  immediately with wrapped standalone versions of the functions -- makes
  the function names visible to PyCharm.


v0.3.2 (2020-09-16)
-------------------
- Bump to v0.3.2. [Robin Thomas]
- Fixes #23. Test coverage added. [Robin Thomas]
- Support InterpolationPoint.colorStyle. [Robin Thomas]


v0.3.1 (2020-09-07)
-------------------
- Bump to 0.3.1. [Robin Thomas]
- Consolidated CONDITIONALS.rst into README.rst. [Robin Thomas]
- Let setup.cfg handle long_description and append conditionals doc.
  [Robin Thomas]
- Better short desc. [Robin Thomas]
- Added PyPy and CPython implementation classifications to setup.py.
  [Robin Thomas]
- Remove unused _wrap_as_standalone_function duplicate. [Robin Thomas]
- Indicate PyPy and PyPy3 support in README. (PyPy3 Travis build
  stumbles on Pandas install problems; my local PyPy3 environment (which
  required special NumPy source install with OpenBLAS config) shows a
  successful test suite. [Robin Thomas]
- Remove pypy3 travis target until pandas install problems can be fixed.
  [Robin Thomas]


v0.3.0 (2020-08-14)
-------------------
- Bump to version 0.3.0. [Robin Thomas]
- Include pypy and pypy3 in travis builds. [Robin Thomas]
- Add "batch updater" object (#21) [Robin Thomas]

  * Added batch capability to all formatting functions as well as format_with_dataframe.
  Minimal test coverage.

  * use "del listobj[:]" for 2.7 compatbility

  * Additional batch-updater tests; added batch updater docs to README.


v0.2.5 (2020-07-17)
-------------------
- Bump to version 0.2.5. [Robin Thomas]
- Fixes #20: BooleanCondition objects returned by API endpoints may lack
  a 'values' field instead of having a present 'values' field with an
  empty list of values. Allow for this in BooleanCondition constructor.
  Test coverage added for round-trip test of Boolean. [Robin Thomas]
- Argh no 3.9-dev yet. [Robin Thomas]
- Corrected version reference in sphinx docs. [Robin Thomas]
- Removed 3.6, added 3.9-dev to travis build` [Robin Thomas]
- Make collections.abc import 3.9-compatible. [Robin Thomas]
- Use full version string in sphnix docs. [Robin Thomas]
- Add docs badge to README. [Robin Thomas]
- Fix title in index.rst. [Robin Thomas]
- Try adding conditionals rst to docs. [Robin Thomas]
- Preserve original conditional rules for effective replacement of rules
  in one API call. [Robin Thomas]
- Add downloads badge. [Robin Thomas]


v0.2.4 (2020-05-04)
-------------------
- Bump to v0.2.4. [Robin Thomas]
- Make new Color.fromHex() and toHex() 2.7-compatible. [Robin Thomas]


v0.2.3 (2020-05-04)
-------------------
- Bump to v0.2.3. [Robin Thomas]
- Color model import and export as hex color (#17) [Sam Korn]

  * Add toHex function to Color model

  * tohex and fromhex functions for Color model

  * Use classmethod for hexstring constructor

  * tests for hex colors, additional checks for malformed hex inputs
- Results of check-manifest added to MANIFEST.in. [Robin Thomas]


v0.2.2 (2020-04-19)
-------------------
- Bump to v0.2.2. [Robin Thomas]
- Add MANIFEST.in to add VERSION file to sdist. [Robin Thomas]


v0.2.1 (2020-04-02)
-------------------
- Bump to v0.2.1. [Robin Thomas]
- Added support in DataFrame formatting for MultiIndex, either as index
  or as the columns object of the DataFrame. [Robin Thomas]
- Added docs/ to start sphinx autodoc generation. [Robin Thomas]
- Add wheel dep for bdist_wheel support. [Robin Thomas]


v0.2.0 (2020-03-31)
-------------------
- Bump to v0.2.0. [Robin Thomas]
- Fixes #10 (support setting row height or column width). [Robin Thomas]
- Added unbounded col and row ranges in format_cell_ranges test to
  ensure that formatting calls (not just _range_to_gridrange_object)
  succeed. [Robin Thomas]


v0.1.1 (2020-02-28)
-------------------
- Bump to v0.1.1. [Robin Thomas]
- Bare column row 14 (#15) [Robin Thomas]

  Fixes #14 -- support range strings that are unbounded on row dimension
  or column dimenstion.
- Oops typo. [Robin Thomas]
- Improve README intro and conditional docs text; attempt to include all
  .rst in package so that PyPI and others can see the other doc files.
  [Robin Thomas]


v0.1.0 (2020-02-11)
-------------------
- Bump to 0.1.0 for conditional formatting rules release. [Robin Thomas]
- Added doc about rule mutation and save() [Robin Thomas]
- Added conditional format rules documentation. [Robin Thomas]
- Added tests on effective cell format after conditional format rules
  apply. [Robin Thomas]
- Py2.7 MutableSequence does not mixin clear() [Robin Thomas]
- Tightened up add/delete of cond format rules, testing deletion of
  multiple rules. [Robin Thomas]
- Forbid illegal BooleanCondition.type values for data validation and
  conditional formatting ,respectively. [Robin Thomas]
- Realized that collections.abc is hoisted into collections module for
  backward compatibility already. [Robin Thomas]
- Add 2-3 compat for collections abc imports. [Robin Thomas]
- Final draft of conditional formatting implementation; test added,
  tests pass. Documentation not yet written. [Robin Thomas]
- Update README.rst. [Robin Thomas]


v0.0.9 (2020-02-09)
-------------------
- Bump to 0.0.9. [Robin Thomas]
- Data validation and prerequesites for conditional formatting 8 (#13)
  [Robin Thomas]

  * objects for conditional formatting added to data model

  * Implements data-validation feature requested in robin900/gspread-formatting#8.

  Test coverage included.

  * added GridRange object to models, ConditionalFormatRule class.

  * factored test code to allow Travis-style ssecret injection

  * merged in v0.0.8 changes from master; added full documentation for data validation;
  conditional format rules have all models in place, but no functions and no
  documentation in README.

  * add travis yml!

  * added requirements-test.txt so we can hopefully run tests in Travis

  * 2-3 compatible StringIO import in test

  * encrypt secrets files rather than env var approach to credentials and config

  * try encrypted files again

  * tighten up py versions in travis

  * make .tar.gz for travis secrets

  * bundle up secrets for travis ci

  * 2.7 compatible config reading

  * try a pip cache

  * fewer py builds


v0.0.8 (2020-02-06)
-------------------
- Fixes #12. Adds support for ColorStyle and all fields in which this
  object is now expected in the Sheets v4 API. See the Python or C# API
  documentation for reference, since the main REST API documentation
  still lacks mention of ColorStyle. [Robin Thomas]


v0.0.7 (2019-08-20)
-------------------
- Fixed setup.py problem that missed package contents. [Robin Thomas]
- Merge branch 'master' of github.com:robin900/gspread-formatting.
  [Robin Thomas]
- Update issue templates. [Robin Thomas]

  Added bug report template
- Bump to 0.0.7. [Robin Thomas]
- Add gspread-dataframe as dev req. [Robin Thomas]


v0.0.6 (2019-04-30)
-------------------
- Handle from_props cases where a format component is an empty dict of
  properties, so that comparing format objects round-trip works as
  expected, and so that format objects are as sparse as possible. [Robin
  Thomas]


v0.0.5 (2019-04-30)
-------------------
- Bump to 0.0.5. [Robin Thomas]
- Merge pull request #5 from robin900/fix-issue-4. [Robin Thomas]

  Conversion of API response's CellFormat properties failed for
- Conversion of API response's CellFormat properties failed for certain
  nested format components such as borders.bottom. Added test coverage
  to trigger bug, and code changes to solve the bug. Also added support
  of deprecated width= attribute for Border format component. [Robin
  Thomas]

  Fixes #4.


v0.0.4 (2019-03-26)
-------------------
- Bump VERSION to 0.0.4. [Robin Thomas]
- Merge pull request #2 from robin900/rthomas-dataframe-formatting.
  [Robin Thomas]

  Rthomas dataframe formatting
- Added docs and tests. [Robin Thomas]
- Working dataframe formatting, with test in test suite. Lacks complete
  documentation. [Robin Thomas]
- Added date-format test in response to user email; test confirms that
  package is working as expected. [Robin Thomas]
- Clean up of test suite, and provided instructions for dev and testing
  in README. [Robin Thomas]


v0.0.3 (2018-08-24)
-------------------
- Bump to 0.0.3, which fixes issue #1. [Robin Thomas]
- Fixed reference problem with NumberFormat.TYPES and Border.STYLES.
  [Robin Thomas]
- Added pypi badge. [Robin Thomas]
- Added format_cell_ranges, plus tests and documentation. [Robin Thomas]


v0.0.2 (2018-07-23)
-------------------
- Added get/set for frozen row and column counts. Bumped release to
  0.0.2. [Robin Thomas]


v0.0.1 (2018-07-20)
-------------------
- Tests pass; ready for version 0.0.1. [Robin Thomas]
- Initial commit. [Robin Thomas]




================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2018 Robin Thomas

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: MANIFEST.in
================================================
include VERSION
include *.example
include *.py
include *.rc
include *.rst
include *.txt
recursive-include docs *.bat
recursive-include docs *.py
recursive-include docs *.rst
recursive-include docs Makefile


================================================
FILE: README.rst
================================================
gspread-formatting
------------------

.. image:: https://badge.fury.io/py/gspread-formatting.svg
    :target: https://badge.fury.io/py/gspread-formatting

.. image:: https://github.com/robin900/gspread-formatting/actions/workflows/python-package.yml/badge.svg?branch=master
    :target: https://github.com/robin900/gspread-formatting/actions/workflows/python-package.yml

.. image:: https://img.shields.io/pypi/dm/gspread-formatting.svg
    :target: https://pypi.org/project/gspread-formatting

This package provides complete cell formatting for Google spreadsheets
using the popular ``gspread`` package, along with a few related features such as setting
"frozen" rows and columns in a worksheet. Both basic and conditional formatting operations
are supported.

The package also offers graceful formatting of Google spreadsheets using a Pandas DataFrame.
See the section below for usage and details.

Usage
~~~~~

Basic formatting of a range of cells in a worksheet is offered by the ``format_cell_range`` function. 
All basic formatting components of the v4 Sheets API's ``CellFormat`` are present as classes 
in the ``gspread_formatting`` module, available both by ``InitialCaps`` names and ``camelCase`` names: 
for example, the background color class is ``BackgroundColor`` but is also available as 
``backgroundColor``, while the color class is ``Color`` but available also as ``color``. 
Attributes of formatting components are best specified as keyword arguments using ``camelCase`` 
naming, e.g. ``backgroundColor=...``. Complex formats may be composed easily, by nesting the calls to the classes.  

See `the CellFormat page of the Sheets API documentation <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat>`_
to learn more about each formatting component.::

    from gspread_formatting import *

    fmt = cellFormat(
        backgroundColor=color(1, 0.9, 0.9),
        textFormat=textFormat(bold=True, foregroundColor=color(1, 0, 1)),
        horizontalAlignment='CENTER'
        )

    format_cell_range(worksheet, 'A1:J1', fmt)

The ``format_cell_ranges`` function allows for formatting multiple ranges with corresponding formats,
all in one function call and Sheets API operation::

    fmt = cellFormat(
        backgroundColor=color(1, 0.9, 0.9),
        textFormat=textFormat(bold=True, foregroundColor=color(1, 0, 1)),
        horizontalAlignment='CENTER'
        )

    fmt2 = cellFormat(
        backgroundColor=color(0.9, 0.9, 0.9),
        horizontalAlignment='RIGHT'
        )

    format_cell_ranges(worksheet, [('A1:J1', fmt), ('K1:K200', fmt2)])

Specifying Cell Ranges
~~~~~~~~~~~~~~~~~~~~~~

The `format_cell_range` function and friends allow a string to specify a cell range using the "A1" convention
to name a column-and-row cell address with column letter and row number; in addition, one may specify
an entire column or column range with unbounded rows, or an entire row or row range with unbounded columns,
or a combination thereof. Here are some examples::

    A1     # column A row 1
    A1:A2  # column A, rows 1-2
    A      # entire column A, rows unbounded
    A:A    # entire column A, rows unbounded
    A:C    # entire columns A through C
    A:B100 # columns A and B, unbounded start through row 100
    A100:B # columns A and B, from row 100 with unbounded end 
    1:3    # entire rows 1 through 3, all columns
    1      # entire row 1


Retrieving, Comparing, and Composing CellFormats
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A Google spreadsheet's own default format, as a CellFormat object, is available via ``get_default_format(spreadsheet)``.
``get_effective_format(worksheet, label)`` and ``get_user_entered_format(worksheet, label)`` also will return
for any provided cell label either a CellFormat object (if any formatting is present) or None.

``CellFormat`` objects are comparable with ``==`` and ``!=``, and are mutable at all times; 
they can be safely copied with Python's ``copy.deepcopy`` function. ``CellFormat`` objects can be combined
into a new ``CellFormat`` object using the ``add`` method (or ``+`` operator). ``CellFormat`` objects also offer 
``difference`` and ``intersection`` methods, as well as the corresponding
operators ``-`` (for difference) and ``&`` (for intersection).::

    >>> default_format = CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True))
    >>> user_format = CellFormat(textFormat=textFormat(italic=True))
    >>> effective_format = default_format + user_format
    >>> effective_format
    CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True, italic=True))
    >>> effective_format - user_format 
    CellFormat(backgroundColor=color(1,1,1), textFormat=textFormat(bold=True))
    >>> effective_format - user_format == default_format
    True

Frozen Rows and Columns
~~~~~~~~~~~~~~~~~~~~~~~

The following functions get or set "frozen" row or column counts for a worksheet::

    get_frozen_row_count(worksheet)
    get_frozen_column_count(worksheet)
    set_frozen(worksheet, rows=1)
    set_frozen(worksheet, cols=1)
    set_frozen(worksheet, rows=1, cols=0)

Setting Row Heights and Column Widths
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following functions set the height (in pixels) of rows or width (in pixels) of columns::

    set_row_height(worksheet, 1, 42)
    set_row_height(worksheet, '1:100', 42)
    set_row_heights(worksheet, [ ('1:100', 42), ('101:', 22) ])
    set_column_width(worksheet, 'A', 190)
    set_column_width(worksheet, 'A:D', 100)
    set_column_widths(worksheet, [ ('A', 200), ('B:', 100) ])

Working with Right-to-Left Language Alphabets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following example shows the functions to get or set the `rightToLeft` property of a worksheet::

    get_right_to_left(worksheet)
    set_right_to_left(worksheet, True)

Also note the presence of the argument `textDirection=` to `CellFormat`: set it to `'RIGHT_TO_LEFT'`
in order to use right-to-left text in an individual cell in an otherwise left-to-right worksheet.

Getting and Setting Data Validation Rules for Cells and Cell Ranges
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following functions get or set the "data validation rule" for a cell or cell range::

    get_data_validation_rule(worksheet, label)
    set_data_validation_for_cell_range(worksheet, range, rule)
    set_data_validation_for_cell_ranges(worksheet, ranges)

The full functionality of data validation rules is supported: all of ``BooleanCondition``. 
See `the API documentation <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule>`_
for more information. Here's a short example::

    validation_rule = DataValidationRule(
        BooleanCondition('ONE_OF_LIST', ['1', '2', '3', '4']),
        showCustomUi=True
    )
    set_data_validation_for_cell_range(worksheet, 'A2:D2', validation_rule)
    # data validation for A2
    eff_rule = get_data_validation_rule(worksheet, 'A2')
    eff_rule.condition.type
    >>> 'ONE_OF_LIST'
    eff_rule.showCustomUi
    >>> True
    # No data validation for A1
    eff_rule = get_data_validation_rule(worksheet, 'A1')
    eff_rule
    >>> None
    # Clear data validation rule by using None
    set_data_validation_for_cell_range(worksheet, 'A2', None)
    eff_rule = get_data_validation_rule(worksheet, 'A2')
    eff_rule
    >>> None


Formatting a Worksheet Using a Pandas DataFrame
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you are using Pandas DataFrames to provide data to a Google spreadsheet -- using perhaps
the ``gspread-dataframe`` package `available on PyPI <https://pypi.org/project/gspread-dataframe/>`_ --
the ``format_with_dataframe`` function in ``gspread_formatting.dataframe`` allows you to use that same 
DataFrame object and specify formatting for a worksheet. There is a ``DEFAULT_FORMATTER`` in the module,
which will be used if no formatter object is provided to ``format_with_dataframe``::

    from gspread_formatting.dataframe import format_with_dataframe, BasicFormatter
    from gspread_formatting import Color

    # uses DEFAULT_FORMATTER
    format_with_dataframe(worksheet, dataframe, include_index=True, include_column_header=True)

    formatter = BasicFormatter(
        header_background_color=Color(0,0,0), 
        header_text_color=Color(1,1,1),
        decimal_format='#,##0.00'
    )

    format_with_dataframe(worksheet, dataframe, formatter, include_index=False, include_column_header=True)


Batch Mode for API Call Efficiency
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This package offers a "batch updater" object, with methods having the same names and parameters as the 
formatting functions in the package. The batch updater will gather all formatting requests generated 
by calling these methods, and send them all to the Google Sheets API in a single ``batchUpdate`` 
request when ``.execute()`` is invoked on the batch updater. Alternately, you can use the batch updater
as a context manager in a ``with:`` block, which will automate the call to ``.execute()``::

    from gspread_formatting import batch_updater

    sheet = some_gspread_worksheet

    # Option 1: call execute() directly
    batch = batch_updater(sheet.spreadsheet)
    batch.format_cell_range(sheet, '1', cellFormat(textFormat=textFormat(bold=True)))
    batch.set_row_height(sheet, '1', 32)
    batch.execute()

    # Option 2: use with: block
    with batch_updater(sheet.spreadsheet) as batch:
        batch.format_cell_range(sheet, '1', cellFormat(textFormat=textFormat(bold=True)))
        batch.set_row_height(sheet, '1', 32)


Conditional Format Rules
~~~~~~~~~~~~~~~~~~~~~~~~

A conditional format rule allows you to specify a cell format that (additively) applies to cells in certain ranges
only when the value of the cell meets a certain condition. 
The `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:
a ``BooleanRule`` in which the `CellFormat` is applied to the cell if the value meets the specified boolean
condition; or a ``GradientRule`` in which the ``Color`` or ``ColorStyle`` of the cell varies depending on the numeric
value of the cell or cells. 

You can specify multiple rules for each worksheet present in a Google spreadsheet. To add or remove rules,
use the ``get_conditional_format_rules(worksheet)`` function, which returns a list-like object which you can
modify as you would modify a list, and then call ``.save()`` to store the rule changes you've made.

Here is an example that applies bold text and a bright red color to cells in column A if the cell value
is numeric and greater than 100::

    from gspread_formatting import *

    worksheet = some_spreadsheet.worksheet('My Worksheet')

    rule = ConditionalFormatRule(
        ranges=[GridRange.from_a1_range('A1:A2000', worksheet)],
        booleanRule=BooleanRule(
            condition=BooleanCondition('NUMBER_GREATER', ['100']), 
            format=CellFormat(textFormat=textFormat(bold=True), backgroundColor=Color(1,0,0))
        )
    )

    rules = get_conditional_format_rules(worksheet)
    rules.append(rule)
    rules.save()

    # or, to replace any existing rules with just your single rule:
    rules.clear()
    rules.append(rule)
    rules.save()

An important note: A ``ConditionalFormatRule`` is, like all other objects provided by this package,
mutable in all of its fields. Mutating a ``ConditionalFormatRule`` object in place will not automatically
store the changes via the Sheets API; but calling `.save()` on the list-like rules object will store
the mutated rule as expected.


Installation
------------

Requirements
~~~~~~~~~~~~

* Python 3.x, PyPy and PyPy3
* Python 2.7 support for releases prior to 2.0.0
* gspread >= 3.0.0

From PyPI
~~~~~~~~~

::

    pip install gspread-formatting

From GitHub
~~~~~~~~~~~

::

    git clone https://github.com/robin900/gspread-formatting.git
    cd gspread-formatting
    python setup.py install

Development and Testing
-----------------------

Install packages listed in ``requirements-dev.txt``. To run the test suite
in ``test.py`` you will need to:

* Authorize as the Google account you wish to use as a test, and download
  a JSON file containing the credentials. Name the file ``creds.json``
  and locate it in the top-level folder of the repository.
* Set up a ``tests.config`` file using the ``tests.config.example`` file as a template.
  Specify the ID of a spreadsheet that the Google account you are using
  can access with write privileges.


================================================
FILE: VERSION
================================================
2.0.0b1


================================================
FILE: diffs_to_discovery.py
================================================
import requests
import gspread_formatting.models
from gspread_formatting.util import _underlower

import inspect
import pprint

r = requests.get("https://sheets.googleapis.com/$discovery/rest?version=v4")
j = r.json()

schemas = j['schemas']

classes = gspread_formatting.models._CLASSES

BASE_TYPES = {'boolean', 'string', 'number', 'integer'}

def resolve_schema_property(sch_prop):
    if '$ref' in sch_prop:
        return resolve_schema_property(schemas[sch_prop['$ref']])
    else:
        return sch_prop

def resolve_class_field(fields, field_name):
    if isinstance(fields, dict):
        field_ref = (fields[field_name] or field_name) if (field_name in fields) else None
    else:
        field_ref = field_name if (field_name in fields) else None
    if field_ref is None:
        return None
    ref_class = classes.get(_underlower(field_ref))
    if ref_class is not None:
        return ref_class
    if ref_class in BASE_TYPES:
        return {'type': ref_class}
    else:
        return {'type': 'unknown'}

def compare_property(name, sch_prop, cls_prop):
    errors = []
    sch_type = sch_prop['type']
    cls_type = None
    if inspect.isclass(cls_prop):
        cls_type = 'object' 
    elif isinstance(cls_prop, dict):
        cls_type = cls_prop['type']
    if sch_type != cls_type: 
        errors.append( (name, 'schema and class property type differs', '%r != %r' % (sch_type, cls_type)) )
    elif sch_type == 'object':
        errors.extend( compare_object(sch_prop, cls_prop) )
    return errors

def compare_object(schema, cls):
    errors = []
    # 1. names must match
    schema_name = schema['id']
    cls_name = cls.__name__
    if schema_name != cls_name:
        errors.append( (schema_name, 'class name differs', '%r != %r' % (sch_name, cls_name)) )
        
    # 2. report extraneous properties in cls
    for cls_propname in cls._FIELDS:
        if cls_propname not in schema['properties']:
            errors.append( (schema_name, 'extraneous property in class', cls_propname) )

    # 3. report missing properties in cls
    for sch_propname, sch_prop in schema['properties'].items():
        cls_field = resolve_class_field(cls._FIELDS, sch_propname)
        if cls_field is None:
            errors.append( (schema_name, 'property missing in class', sch_propname) )
        # 4. each property must be of correct type
        errors.extend( 
            compare_property("%s.%s" % (schema_name, sch_propname), resolve_schema_property(sch_prop), cls_field) 
        )

    # dedupe
    return sorted({e for e in errors})
    

diffs = compare_object(schemas['CellFormat'], classes['cellFormat'])
pprint.pprint(diffs, width=120)


================================================
FILE: docs/Makefile
================================================
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line.
SPHINXOPTS    =
SPHINXBUILD   = sphinx-build
SPHINXPROJ    = gspread-formatting
SOURCEDIR     = .
BUILDDIR      = _build

# Put it first so that "make" without argument is like "make help".
help:
	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)


================================================
FILE: docs/conf.py
================================================
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# gspread-formatting documentation build configuration file, created by
# sphinx-quickstart on Fri Mar 10 22:46:18 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))


# -- General configuration ------------------------------------------------

# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['sphinx.ext.autodoc']

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'

# The master toctree document.
master_doc = 'index'

# General information about the project.
project = 'gspread-formatting'
copyright = '2017, Robin Thomas'
author = 'Robin Thomas'

# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
import os.path
import re

with open(os.path.join(os.path.dirname(__file__), '../VERSION'), 'r') as f:
    # The full version, including alpha/beta/rc tags.
    version = f.read().strip()

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'

# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False


# -- Options for HTML output ----------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'

# Theme options are theme-specific and customize the look and feel of a theme
# further.  For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']


# -- Options for HTMLHelp output ------------------------------------------

# Output file base name for HTML help builder.
htmlhelp_basename = 'gspread-formattingdoc'


# -- Options for LaTeX output ---------------------------------------------

latex_elements = {
    # The paper size ('letterpaper' or 'a4paper').
    #
    # 'papersize': 'letterpaper',

    # The font size ('10pt', '11pt' or '12pt').
    #
    # 'pointsize': '10pt',

    # Additional stuff for the LaTeX preamble.
    #
    # 'preamble': '',

    # Latex figure (float) alignment
    #
    # 'figure_align': 'htbp',
}

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
#  author, documentclass [howto, manual, or own class]).
latex_documents = [
    (master_doc, 'gspread-formatting.tex', 'gspread-formatting Documentation',
     'Robin Thomas', 'manual'),
]


# -- Options for manual page output ---------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    (master_doc, 'gspread-formatting', 'gspread-formatting Documentation',
     [author], 1)
]


# -- Options for Texinfo output -------------------------------------------

# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
#  dir menu entry, description, category)
texinfo_documents = [
    (master_doc, 'gspread-formatting', 'gspread-formatting Documentation',
     author, 'gspread-formatting', 'Format gspread worksheets using Sheets v4 formatting features.',
     'Miscellaneous'),
]





================================================
FILE: docs/index.rst
================================================
.. gspread-formatting documentation master file, created by
   sphinx-quickstart on Fri Mar 10 22:46:18 2017.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

Welcome to gspread-formatting's documentation!
==============================================

.. toctree::
   :maxdepth: 2
   :caption: Contents:

.. include:: ../README.rst

.. include:: ../CONDITIONALS.rst

Module Documentation - Version |version|
----------------------------------------

.. automodule:: gspread_formatting.functions
   :members:

.. automodule:: gspread_formatting.models
   :members:

.. automodule:: gspread_formatting.conditionals
   :members:

.. automodule:: gspread_formatting.dataframe
   :members:



Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`


================================================
FILE: docs/make.bat
================================================
@ECHO OFF

pushd %~dp0

REM Command file for Sphinx documentation

if "%SPHINXBUILD%" == "" (
	set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set SPHINXPROJ=gspread-formatting

if "%1" == "" goto help

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
	echo.
	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
	echo.installed, then set the SPHINXBUILD environment variable to point
	echo.to the full path of the 'sphinx-build' executable. Alternatively you
	echo.may add the Sphinx directory to PATH.
	echo.
	echo.If you don't have Sphinx installed, grab it from
	echo.http://sphinx-doc.org/
	exit /b 1
)

%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end

:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%

:end
popd


================================================
FILE: gspread_formatting/__init__.py
================================================
# -*- coding: utf-8 -*-

from .functions import *
from .models import *
from .conditionals import *
from .batch import *


================================================
FILE: gspread_formatting/batch.py
================================================
# -*- coding: utf-8 -*-

import gspread_formatting.functions
import gspread_formatting.dataframe

from functools import wraps

__all__ = ('batch_updater', 'SpreadsheetBatchUpdater')

def batch_updater(spreadsheet):
    return SpreadsheetBatchUpdater(spreadsheet)

class SpreadsheetBatchUpdater(object):
    def __init__(self, spreadsheet):
        self.spreadsheet = spreadsheet
        self.requests = []

    def __enter__(self):
        if self.requests:
            raise IOError("BatchUpdater has un-executed requests pending, cannot be __enter__ed")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.execute()
        return False

    def execute(self):
        resps = self.spreadsheet.batch_update({'requests': self.requests})
        del self.requests[:]
        return resps

def _wrap_for_batch_updater(func):
    @wraps(func)
    def f(self, worksheet, *args, **kwargs):
        if worksheet.spreadsheet != self.spreadsheet:
            raise ValueError(
                "Worksheet %r belongs to spreadsheet %r, not batch updater's spreadsheet %r" 
                % (worksheet, worksheet.spreadsheet, self.spreadsheet)
            )
        self.requests.append( func(worksheet, *args, **kwargs) )
        return self
    return f

for fname in gspread_formatting.batch_update_requests.__all__:
    func = getattr(gspread_formatting.batch_update_requests, fname)
    setattr(SpreadsheetBatchUpdater, fname, _wrap_for_batch_updater(func))

setattr(
    SpreadsheetBatchUpdater, 
    'format_with_dataframe', 
    _wrap_for_batch_updater(gspread_formatting.dataframe._format_with_dataframe)
)



================================================
FILE: gspread_formatting/batch_update_requests.py
================================================
# -*- coding: utf-8 -*-
"""
This module provides functions that generate request objects compatible with the
"batchUpdate" API call in Google Sheets. Both the ``.functions`` and
``.batch`` modules make use of these request functions, wrapping them
in functions that make the API call or calls using the generated request objects.
"""

from .util import _build_repeat_cell_request, _range_to_dimensionrange_object

from functools import wraps

__all__ = (
    'format_cell_ranges', 'format_cell_range', 'set_frozen', 'set_right_to_left',
    'set_data_validation_for_cell_range', 'set_data_validation_for_cell_ranges',
    'set_text_format_runs',
    'set_row_height', 'set_row_heights',
    'set_column_width', 'set_column_widths'
)


def set_row_heights(worksheet, ranges):
    """Update a row or range of rows in the given ``Worksheet`` 
    to have the specified height in pixels.

    :param worksheet: The ``Worksheet`` object.
    :param ranges: An iterable whose elements are pairs of:
        a string with row range value in A1 notation, e.g. '1' or '1:50',
        and a integer specifying height in pixels.
    """

    return [
        { 
            'updateDimensionProperties': { 
                'range': _range_to_dimensionrange_object(range, worksheet.id), 
                'properties': { 'pixelSize': height }, 
                'fields': 'pixelSize' 
            } 
        }
        for range, height in ranges
    ]


def set_row_height(worksheet, label, height):
    """Update a row or range of rows in the given ``Worksheet`` 
    to have the specified height in pixels.

    :param worksheet: The ``Worksheet`` object.
    :param label: string representing a single row or range of rows, e.g. ``1`` or ``3:400``.
    :param height: An integer greater than or equal to 0.

    """
    return set_row_heights(worksheet, [(label, height)])
 

def set_column_widths(worksheet, ranges):
    """Update a column or range of columns in the given ``Worksheet`` 
    to have the specified width in pixels.

    :param worksheet: The ``Worksheet`` object.
    :param ranges: An iterable whose elements are pairs of:
                   a string with column range value in A1 notation, e.g. 'A:C',
                   and a integer specifying width in pixels.

    """

    return [
        { 
            'updateDimensionProperties': { 
                'range': _range_to_dimensionrange_object(range, worksheet.id), 
                'properties': { 'pixelSize': width }, 
                'fields': 'pixelSize' 
            } 
        }
        for range, width in ranges
    ]


def set_column_width(worksheet, label, width):
    """Update a column or range of columns in the given ``Worksheet`` 
    to have the specified width in pixels.

    :param worksheet: The ``Worksheet`` object.
    :param label: string representing a single column or range of columns, e.g. ``A`` or ``B:D``.
    :param height: An integer greater than or equal to 0.

    """

    return set_column_widths(worksheet, [(label, width)])


def set_text_format_runs(worksheet, label, runs):
    """For the given cell (or cell range)

    :param worksheet: The ``Worksheet`` object.
    :param label: string representing a single cell or range of cells, e.g. ``A1`` or ``A2:B7``.
    :param runs: A list (possibly empty) of TextFormatRun objects
    """
    return _build_repeat_cell_request(worksheet, label, runs, 'textFormatRuns')


def format_cell_ranges(worksheet, ranges):
    """Update a list of Cell object ranges of :class:`Cell` objects
    in the given ``Worksheet`` to have the accompanying ``CellFormat``.

    :param worksheet: The ``Worksheet`` object.
    :param ranges: An iterable whose elements are pairs of:
        a string with range value in A1 notation, e.g. 'A1:A5',
        and a ``CellFormat`` object).

    """

    return [
        _build_repeat_cell_request(worksheet, range, cell_format)
        for range, cell_format in ranges
    ]


def format_cell_range(worksheet, name, cell_format):
    """Update a range of :class:`Cell` objects in the given Worksheet
    to have the specified ``CellFormat``.

    :param worksheet: The ``Worksheet`` object.
    :param name: A string with range value in A1 notation, e.g. 'A1:A5'.
    :param cell_format: A ``CellFormat`` object.

    """

    return format_cell_ranges(worksheet, [(name, cell_format)])


def set_data_validation_for_cell_ranges(worksheet, ranges):
    """Update a list of Cell object ranges of :class:`Cell` objects
    in the given ``Worksheet`` to have the accompanying ``DataValidationRule``.

    :param worksheet: The ``Worksheet`` object.
    :param ranges: An iterable whose elements are pairs of:
                   a string with range value in A1 notation, e.g. 'A1:A5',
                   and a ``DataValidationRule`` object or None to clear the data
                   validation rule).

    """

    return [
        _build_repeat_cell_request(worksheet, range, data_validation_rule, 'dataValidation')
        for range, data_validation_rule in ranges
    ]


def set_data_validation_for_cell_range(worksheet, range, rule):
    """Update a Cell range in the given ``Worksheet``
    to have the accompanying ``DataValidationRule`` (or no rule).

    :param worksheet: The ``Worksheet`` object.
    :param range: A string with range value in A1 notation, e.g. 'A1:A5'.
    :param rule: A DataValidationRule object, or None to remove data validation rule for cells..

    """

    return set_data_validation_for_cell_ranges(worksheet, [(range, rule)])


def set_right_to_left(worksheet, right_to_left):
    right_to_left = bool(right_to_left)
    return [{
        'updateSheetProperties': {
            'properties': {
                'sheetId': worksheet.id,
                'rightToLeft': right_to_left
            },
            'fields': 'rightToLeft'
        }
    }]


def set_frozen(worksheet, rows=None, cols=None):
    if rows is None and cols is None:
        raise ValueError("Must specify at least one of rows and cols")
    grid_properties = {}
    if rows is not None:
        grid_properties['frozenRowCount'] = rows
    if cols is not None:
        grid_properties['frozenColumnCount'] = cols
    fields = ','.join(
        'gridProperties.%s' % p for p in grid_properties.keys()
    )
    return [{
        'updateSheetProperties': {
            'properties': {
                'sheetId': worksheet.id,
                'gridProperties': grid_properties
            },
            'fields': fields
        }
    }]



================================================
FILE: gspread_formatting/conditionals.py
================================================
# -*- coding: utf-8 -*-

from .util import _parse_string_enum, _underlower, _enforce_type
from .models import FormattingComponent, GridRange, _CLASSES

try:
    from collections.abc import MutableSequence, Iterable
except ImportError:
    from collections import MutableSequence, Iterable


def get_conditional_format_rules(worksheet):
    resp = worksheet.spreadsheet.fetch_sheet_metadata()
    rules = []
    for sheet in resp['sheets']:
        if sheet['properties']['sheetId'] == worksheet.id:
            rules = [ ConditionalFormatRule.from_props(p) for p in sheet.get('conditionalFormats', []) ]
            break
    return ConditionalFormatRules(worksheet, rules)

def _make_delete_rule_request(worksheet, rule, ruleIndex):
   return {
       'deleteConditionalFormatRule': {
           'sheetId': worksheet.id,
           'index': ruleIndex
       }
   }

def _make_add_rule_request(worksheet, rule, ruleIndex):
   return {
       'addConditionalFormatRule': {
           'rule': rule.to_props(),
           'index': ruleIndex
       }
   }

class ConditionalFormatRules(MutableSequence):
    def __init__(self, worksheet, *rules):
        self.worksheet = worksheet
        if len(rules) == 1 and isinstance(rules[0], Iterable):
            rules = rules[0]
        self.rules = list(rules)
        self._original_rules = list(rules)

    def __getitem__(self, idx):
        return self.rules[idx]

    def __setitem__(self, idx, value):
        self.rules[idx] = _enforce_type('rule', ConditionalFormatRule, value, True)

    def __delitem__(self, idx):
        del self.rules[idx]

    def __len__(self):
        return len(self.rules)

    # py2.7 MutableSequence does not offer clear()
    def clear(self):
        del self.rules[:]

    def insert(self, idx, value):
        return self.rules.insert(idx, _enforce_type('rule', ConditionalFormatRule, value, True))

    def save(self):
        # ideally, we would determine the longest "increasing" subsequence
        # between the original and new rule lists, then determine the add/upd/del
        # operations to position the remaining items.
        # But until I implement that correctly, we are just going to delete all rules
        # and re-add them.
        delete_requests = [ 
            _make_delete_rule_request(self.worksheet, r, idx) for idx, r in enumerate(self._original_rules) 
        ]
        # want to delete from highest index to lowest...
        delete_requests.reverse()
        add_requests = [ 
            _make_add_rule_request(self.worksheet, r, idx) for idx, r in enumerate(self.rules) 
        ]
        if not delete_requests and not add_requests:
            return
        body = {
            'requests': delete_requests + add_requests
        }
        resp = self.worksheet.spreadsheet.batch_update(body)
        self._original_rules = list(self.rules)
        return resp


        
class ConditionalFormattingComponent(FormattingComponent):
    pass

class BooleanRule(ConditionalFormattingComponent):
    _FIELDS = {
        'condition': 'booleanCondition', 
        'format': 'cellFormat'
    }

    def __init__(self, condition=None, format=None):
        self.condition = condition
        self.format = format

class BooleanCondition(ConditionalFormattingComponent):

    illegal_types_for_data_validation = { 
        'TEXT_STARTS_WITH', 
        'TEXT_ENDS_WITH', 
        'BLANK', 
        'NOT_BLANK' 
    }

    illegal_types_for_conditional_formatting = { 
        'TEXT_IS_EMAIL',
        'TEXT_IS_URL',
        'DATE_ON_OR_BEFORE',
        'DATE_ON_OR_AFTER',
        'DATE_BETWEEN',
        'DATE_NOT_BETWEEN',
        'DATE_IS_VALID',
        'ONE_OF_RANGE',
        'ONE_OF_LIST'
        'BOOLEAN' 
    }

    _FIELDS = ('type', 'values')

    TYPES = {
        'NUMBER_GREATER': 1,
        'NUMBER_GREATER_THAN_EQ': 1,
        'NUMBER_LESS': 1,
        'NUMBER_LESS_THAN_EQ': 1,
        'NUMBER_EQ': 1,
        'NUMBER_NOT_EQ': 1,
        'NUMBER_BETWEEN': 2,
        'NUMBER_NOT_BETWEEN': 2,
        'TEXT_CONTAINS': 1,
        'TEXT_NOT_CONTAINS': 1,
        'TEXT_STARTS_WITH': 1,
        'TEXT_ENDS_WITH': 1,
        'TEXT_EQ': 1,
        'TEXT_IS_EMAIL': 0,
        'TEXT_IS_URL': 0,
        'DATE_EQ': 1,
        'DATE_BEFORE': 1,
        'DATE_AFTER': 1,
        'DATE_ON_OR_BEFORE': 1,
        'DATE_ON_OR_AFTER': 1,
        'DATE_BETWEEN': 2,
        'DATE_NOT_BETWEEN': 2,
        'DATE_IS_VALID': 0,
        'ONE_OF_RANGE': 1,
        'ONE_OF_LIST': (lambda x: isinstance(x, (list, tuple)) and len(x) > 0),
        'BLANK': 0,
        'NOT_BLANK': 0,
        'CUSTOM_FORMULA': 1,
        'BOOLEAN': (lambda x: isinstance(x, (list, tuple)) and len(x) >= 0 and len(x) <= 2)
    }

    def __init__(self, type=None, values=()):
        self.type = _parse_string_enum("type", type, BooleanCondition.TYPES, True)
        validator = BooleanCondition.TYPES[self.type]
        if not isinstance(values, (list, tuple)):
            raise ValueError("values parameter must always be list/tuple of values, even for a single element")
        valid = validator(values) if callable(validator) else len(values) == validator
        if not valid:
            raise ValueError(
                "BooleanCondition.values has inappropriate "
                "length/content for condition type %s" % self.type
            )
        # values are either RelativeDate enum values or user-entered values
        self.values = [ 
            v if isinstance(v, ConditionValue) else (
                ConditionValue.from_props(v) 
                if isinstance(v, dict) 
                else (
                    ConditionValue(relativeDate=v) 
                    if isinstance(v, RelativeDate)
                    else ConditionValue(userEnteredValue=v)
                )
            )
            for v in values 
        ]

    def to_props(self):
        return {
            'type': self.type,
            'values': [ v.to_props() for v in self.values ]
        }

class RelativeDate(FormattingComponent):
    VALUES = set(['PAST_YEAR', 'PAST_MONTH', 'PAST_WEEK', 'YESTERDAY', 'TODAY', 'TOMORROW'])

    def __init__(self, value=None):
        self.value = _parse_string_enum("value", value, RelativeDate.VALUES, True)

    def to_props(self):
        return self.value

class ConditionValue(ConditionalFormattingComponent):
    _FIELDS = ('relativeDate', 'userEnteredValue')

    def __init__(self, relativeDate=None, userEnteredValue=None):
        self.relativeDate = relativeDate
        self.userEnteredValue = userEnteredValue

class InterpolationPoint(ConditionalFormattingComponent):
    _FIELDS = ('color', 'colorStyle', 'type', 'value')

    TYPES = set(['MIN', 'MAX', 'NUMBER', 'PERCENT', 'PERCENTILE'])

    def __init__(self, color=None, colorStyle=None, type=None, value=None):
        self.color = color
        self.colorStyle = colorStyle
        self.type = _parse_string_enum("type", type, InterpolationPoint.TYPES, required=True)
        if value is None and self.type not in set(['MIN', 'MAX']):
            raise ValueError(("InterpolationPoint.type %s requires a value of MIN or MAX "
                "if no value specified") % self.type)
        self.value = value

class GradientRule(ConditionalFormattingComponent):
    _FIELDS = {
        'minpoint': 'interpolationPoint', 
        'midpoint': 'interpolationPoint', 
        'maxpoint': 'interpolationPoint'
    }

    def __init__(self, minpoint=None, maxpoint=None, midpoint=None):
        self.minpoint = _enforce_type("minpoint", InterpolationPoint, minpoint, required=True)
        self.midpoint = _enforce_type("midpoint", InterpolationPoint, midpoint, required=False)
        self.maxpoint = _enforce_type("maxpoint", InterpolationPoint, maxpoint, required=True)

class ConditionalFormatRule(ConditionalFormattingComponent):
    _FIELDS = ('ranges', 'booleanRule', 'gradientRule')

    def __init__(self, ranges=None, booleanRule=None, gradientRule=None):
        self.booleanRule = _enforce_type("booleanRule", BooleanRule, booleanRule, required=False)
        if self.booleanRule:
            if self.booleanRule.condition.type in BooleanCondition.illegal_types_for_conditional_formatting:
                raise ValueError(
                    "BooleanCondition.type for conditional formatting must not be one of: %s" % 
                    BooleanCondition.illegal_types_for_conditional_formatting
                )
        self.gradientRule = _enforce_type("gradientRule", GradientRule, gradientRule, required=False)
        if len([x for x in (self.booleanRule, self.gradientRule) if x is not None]) != 1:
            raise ValueError("Must specify exactly one of: booleanRule, gradientRule")
        # values are either GridRange objects or bare properties 
        self.ranges = [ 
            v if isinstance(v, GridRange) else GridRange.from_props(v)
            for v in ranges 
        ] if ranges else []

    def to_props(self):
        p = {
            'ranges': [ v.to_props() for v in self.ranges ]
        }
        if self.booleanRule:
            p['booleanRule'] = self.booleanRule.to_props()
        if self.gradientRule:
            p['gradientRule'] = self.gradientRule.to_props()
        return p

class DataValidationRule(FormattingComponent):
    _FIELDS = {
        'condition': 'booleanCondition', 
        'inputMessage': str, 
        'strict': bool, 
        'showCustomUi': bool
    }

    def __init__(self, condition=None, inputMessage=None, strict=None, showCustomUi=None):
        self.condition = _enforce_type("condition", BooleanCondition, condition, True)
        if self.condition.type in BooleanCondition.illegal_types_for_data_validation:
            raise ValueError(
                "BooleanCondition.type for data validation must not be one of: %s" % 
                BooleanCondition.illegal_types_for_data_validation
            )
        self.inputMessage = _enforce_type("inputMessage", str, inputMessage, False)
        self.strict = _enforce_type("strict", bool, strict, False)
        self.showCustomUi = _enforce_type("showCustomUi", bool, showCustomUi, False)

# provide camelCase aliases for all component classes.

for _c in [ 
        obj for name, obj in locals().items() 
        if isinstance(obj, type) 
        and issubclass(obj, FormattingComponent) 
    ]:
    _k = _underlower(_c.__name__)
    _CLASSES[_k] = _c
    locals()[_k] = _c


================================================
FILE: gspread_formatting/dataframe.py
================================================
# -*- coding: utf-8 -*-

try:
    from itertools import zip_longest
except ImportError:
    from itertools import izip_longest as zip_longest

from gspread_formatting.batch_update_requests import format_cell_ranges, set_frozen
from gspread_formatting.models import cellFormat, numberFormat, Color, textFormat

from gspread.utils import rowcol_to_a1

from functools import wraps

__all__ = (
    'format_with_dataframe', 
    'DataFrameFormatter', 
    'BasicFormatter', 
    'DEFAULT_FORMATTER', 
    'DEFAULT_HEADER_BACKGROUND_COLOR'
)

DEFAULT_HEADER_BACKGROUND_COLOR = Color(0.8980392, 0.8980392, 0.8980392)

def _determine_index_or_columns_size(obj):
    if hasattr(obj, 'levshape'):
        return len(obj.levshape)
    return 1
        
def _format_with_dataframe(worksheet,
                          dataframe,
                          formatter=None,
                          row=1,
                          col=1,
                          include_index=False,
                          include_column_header=True):
    """
    Modifies the cell formatting of an area of the provided Worksheet, using
    the provided DataFrame to determine the area to be formatted and the formats
    to be used.

    :param worksheet: the gspread worksheet to set with content of DataFrame.
    :param dataframe: the DataFrame.
    :param formatter: an optional instance of ``DataFrameFormatter`` class, which
                      will examine the contents of the DataFrame and
                      assemble a set of ``gspread_formatter`` operations
                      to be performed after the DataFrame contents 
                      are written to the given Worksheet. The formatting
                      operations are performed after the contents are written
                      and before this function returns. Defaults to 
                      ``DEFAULT_FORMATTER``.
    :param row: number of row at which to begin formatting. Defaults to 1.
    :param col: number of column at which to begin formatting. Defaults to 1.
    :param include_index: if True, include the DataFrame's index as an
            additional column when performing formatting. Defaults to False.
    :param include_column_header: if True, format a header row before data.
            Defaults to True.
    """
    if not formatter:
        formatter = DEFAULT_FORMATTER

    formatting_ranges = []

    columns = [ dataframe[c] for c in dataframe.columns ]
    index_column_size = _determine_index_or_columns_size(dataframe.index)
    column_header_size = _determine_index_or_columns_size(dataframe.columns)

    if include_index:
        # allow for multi-index index
        if index_column_size > 1:
            reset_df = dataframe.reset_index()
            index_elts = [ reset_df[c] for c in list(reset_df.columns)[:index_column_size] ]
        else:
            index_elts = [ dataframe.index ]
        columns = index_elts + columns

    for idx, column in enumerate(columns):
        column_fmt = formatter.format_for_column(column, col + idx, dataframe)
        if not column_fmt or not column_fmt.to_props():
            continue
        range = '{}:{}'.format(
            rowcol_to_a1(row, col + idx), 
            rowcol_to_a1(row + dataframe.shape[0], col + idx)
        )
        formatting_ranges.append( (range, column_fmt) )

    freeze_args = {}
    if include_column_header:
        # TODO allow for multi-index columns object
        elts = list(dataframe.columns)
        if include_index:
            # allow for multi-index index
            if index_column_size > 1:
                index_names = list(dataframe.index.names)
            else:
                index_names = [ dataframe.index.name ]
            elts = index_names + elts
            header_fmt = formatter.format_for_header(dataframe.index, dataframe)
            if header_fmt:
                formatting_ranges.append( 
                    (
                        '{}:{}'.format(
                            rowcol_to_a1(row, col), 
                            rowcol_to_a1(row + dataframe.shape[0], col + index_column_size - 1)
                        ), 
                        header_fmt
                    )
                )

        header_fmt = formatter.format_for_header(elts, dataframe)
        if header_fmt:
            formatting_ranges.append( 
                (
                    '{}:{}'.format(
                        rowcol_to_a1(row, col), 
                        rowcol_to_a1(row + column_header_size - 1, col + len(elts) - 1)
                    ), 
                    header_fmt
                )
            )

        if row == 1 and formatter.should_freeze_header(elts, dataframe):
            freeze_args['rows'] = column_header_size

        if include_index and col == 1 and formatter.should_freeze_header(dataframe.index, dataframe):
            freeze_args['cols'] = index_column_size

        row += column_header_size


    values = []
    for value_row, index_value in zip_longest(dataframe.values, dataframe.index):
        if include_index:
            if index_column_size > 1:
                index_values = list(index_value)
            else:
                index_values = [index_value]
            value_row = index_values + list(value_row)
        values.append(value_row)
    for y_idx, value_row in enumerate(values):
        for x_idx, cell_value in enumerate(value_row):
            cell_fmt = formatter.format_for_cell(cell_value, y_idx+row, x_idx+col, dataframe)
            if cell_fmt:
                formatting_ranges.append((rowcol_to_a1(y_idx+row, x_idx+col), cell_fmt))
        row_fmt = formatter.format_for_data_row(values, y_idx+row, dataframe)
        if row_fmt:
            formatting_ranges.append(
                (
                    '{}:{}'.format(
                        rowcol_to_a1(y_idx+row, col), 
                        rowcol_to_a1(y_idx+row, col+dataframe.shape[1])
                    ), 
                row_fmt
                )
            )

    requests = []

    if formatting_ranges:
        formatting_ranges = [ r for r in formatting_ranges if r[1] and r[1].to_props() ]
        requests.extend(format_cell_ranges(worksheet, formatting_ranges))

    if freeze_args:
        requests.extend(set_frozen(worksheet, **freeze_args))

    return requests

@wraps(_format_with_dataframe)
def format_with_dataframe(worksheet, *args, **kwargs):
    return worksheet.spreadsheet.batch_update(
        {'requests': _format_with_dataframe(worksheet, *args, **kwargs)}
    )

class DataFrameFormatter(object):
    """
    An abstract base class defining the interface for producing formats
    for a worksheet based on a given DataFrame.
    """
    @classmethod
    def resolve_number_format(cls, value, type=None):
        """
        A utility class method that resolves a value to a ``NumberFormat`` object,
        whether that value is a ``NumberFormat`` object or a pattern string.
        Optional ``type`` parameter is to specify ``NumberFormat`` enum value.
        """
        if value is None:
            return None
        elif isinstance(value, numberFormat):
            return value
        elif isinstance(value, str):
            return numberFormat(type, value) 
        else:
            raise ValueError(value)

    def format_with_dataframe(self, worksheet, dataframe, row=1, col=1, include_index=False, include_column_header=True):
        """
        Convenience method that will call this module's ``format_with_dataframe`` function with
        this ``DataFrameFormatter`` object as the formatter.
        """
        return format_with_dataframe(
            worksheet, 
            dataframe, 
            self, 
            row=row, 
            col=col, 
            include_index=include_index, 
            include_column_header=include_column_header
        )

    def format_for_header(self, series, dataframe):
        """
        Called by ``format_with_dataframe`` once for each header row (if ``include_column_header``
        parameter is ``True``) or column (if ``include_index`` parameter is also ``True``)..

        :param series: A sequence of elements representing the values in the row or column.
                 Can be a simple list, or a ``pandas.Series`` or ``pandas.Index`` object.
        :param dataframe: The ``pandas.DataFrame`` object, as additional context.

        :return: Either a ``CellFormat`` object or ``None``.
        """
        raise NotImplementedError()

    def format_for_column(self, column, col_number, dataframe):
        """
        Called by ``format_with_dataframe`` once for each column in the dataframe.

        :param column: A ``pandas.Series`` object representing the column.
        :param col_number: The index (starting with 1) of the column in the worksheet.
        :param dataframe: The ``pandas.DataFrame`` object, as additional context.

        :return: Either a ``CellFormat`` object or ``None``.
        """
        raise NotImplementedError()

    def format_for_data_row(self, values, row_number, dataframe):
        """
        Called by ``format_with_dataframe`` once for each data row in the dataframe.
        Allows for row-specific additional formatting to complement any
        column-based formatting.

        :param values: The values in the row, obtained directly from the ``DataFrame``.
                 If ``include_index`` parameter to ``format_with_dataframe`` is ``True``,
                 then the first element in this sequence is the index value for the row.
        :param row_number: The index (starting with 1) of the row in the worksheet.
        :param dataframe: The ``pandas.DataFrame`` object, as additional context.

        :return: Either a ``CellFormat`` object or ``None``.
        """
        raise NotImplementedError()

    def format_for_cell(self, value, row_number, col_number, dataframe):
        """
        Called by ``format_with_dataframe`` once for each cell in the dataframe.
        Allows for cell-specific additional formatting to complement any column
        or row formatting.

        :param value: The value of the cell, obtained directly from the ``DataFrame``.
        :param row_number: The index (starting with 1) of the row in the worksheet.
        :param col_number: The index (starting with 1) of the column in the worksheet.
        :param dataframe: The ``pandas.DataFrame`` object, as additional context.

        :return: Either a ``CellFormat`` object or ``None``.
        """
        raise NotImplementedError()

    def should_freeze_header(self, series, dataframe):
        """
        Called by ``format_with_dataframe`` once for each header row or column.

        :param series: A sequence of elements representing the values in the row or column.
                 Can be a simple list, or a ``pandas.Series`` or ``pandas.Index`` object.
        :param dataframe: The ``pandas.DataFrame`` object, as additional context.

        :return: boolean value
        """
        raise NotImplementedError()

class BasicFormatter(DataFrameFormatter):
    """
    A basic formatter class that offers: selection of format based on
    inferred data type of each column; bold headers with a custom background color;
    frozen header row (and column if index is included); and column-specific
    override formats.
    """

    @classmethod
    def with_defaults(cls,
        header_background_color=None, 
        header_text_color=None,
        date_format=None,
        decimal_format=None,
        integer_format=None,
        freeze_headers=None,
        column_formats=None):
        """
        Returns an instance of this class, with any unspecified parameters
        being substituted with this package's default values for the parameters.
        Instantiate the class directly if you want unspecified parameters to be ``None``
        and thus always be omitted from formatting operations.
        """
        return cls(
            (header_background_color or DEFAULT_HEADER_BACKGROUND_COLOR),
            header_text_color,
            date_format,
            decimal_format,
            integer_format,
            freeze_headers,
            column_formats
        )

    def __init__(self, 
        header_background_color=None, 
        header_text_color=None,
        date_format=None,
        decimal_format=None,
        integer_format=None,
        freeze_headers=None,
        column_formats=None):
        self.header_background_color = header_background_color
        self.header_text_color = header_text_color
        self.date_format = BasicFormatter.resolve_number_format(date_format or '', 'DATE')
        self.decimal_format = BasicFormatter.resolve_number_format(decimal_format or '', 'NUMBER')
        self.integer_format = BasicFormatter.resolve_number_format(integer_format or '', 'NUMBER')
        self.freeze_headers = bool(freeze_headers)
        self.column_formats = column_formats or {}

    def format_for_header(self, series, dataframe):
        return cellFormat(
            backgroundColor=self.header_background_color, 
            textFormat=textFormat(bold=True, foregroundColor=self.header_text_color)
        )

    def format_for_column(self, column, col_number, dataframe):
        if column.name in self.column_formats:
            return self.column_formats[column.name]
        dtype = column.dtype
        if dtype.kind == 'O' and hasattr(column, 'infer_objects'):
            dtype = column.infer_objects().dtype
        if dtype.kind == 'f':
            return cellFormat(numberFormat=self.decimal_format, horizontalAlignment='RIGHT')
        elif dtype.kind == 'i':
            return cellFormat(numberFormat=self.integer_format, horizontalAlignment='RIGHT')
        elif dtype.kind == 'M':
            return cellFormat(numberFormat=self.date_format, horizontalAlignment='CENTER')
        else:
            return cellFormat(horizontalAlignment=('LEFT' if col_number == 1 else 'CENTER'))

    def format_for_cell(self, value, row_number, col_number, dataframe):
        return None

    def format_for_data_row(self, values, row_number, dataframe):
        return None

    def should_freeze_header(self, series, dataframe):
        return self.freeze_headers

DEFAULT_FORMATTER = BasicFormatter.with_defaults()


================================================
FILE: gspread_formatting/functions.py
================================================
# -*- coding: utf-8 -*-

from .util import _fetch_with_updated_properties, _range_to_dimensionrange_object
from .models import CellFormat, TextFormatRun
from .conditionals import DataValidationRule
# These imports allow IDEs like PyCharm to verify the existence of these functions, 
# even though we will rebind the names below with wrapped versions of the functions
from gspread_formatting.batch_update_requests import * 
import gspread_formatting.batch_update_requests

from gspread.utils import a1_to_rowcol, rowcol_to_a1, finditem
from gspread import Spreadsheet
from gspread.urls import SPREADSHEET_URL

from functools import wraps

__all__ = (
    'get_default_format', 'get_effective_format', 'get_user_entered_format',
    'get_frozen_row_count', 'get_frozen_column_count', 'get_right_to_left',
    'get_data_validation_rule', 'get_text_format_runs'
) + gspread_formatting.batch_update_requests.__all__


def _wrap_as_standalone_function(func):
    @wraps(func)
    def f(worksheet, *args, **kwargs):
        return worksheet.spreadsheet.batch_update({'requests': func(worksheet, *args, **kwargs)})
    return f

for _fname in gspread_formatting.batch_update_requests.__all__:
    locals()[_fname] = _wrap_as_standalone_function(locals()[_fname])


def get_data_validation_rule(worksheet, label):
    """Returns a DataValidationRule object or None representing the
    data validation in effect for the cell identified by ``label``.

    :param worksheet: Worksheet object containing the cell whose data
                      validation rule is desired.
    :param label: String with cell label in common format, e.g. 'B1'.
                  Letter case is ignored.

    Example:
    >>> get_data_validation_rule(worksheet, 'A1')
    <DataValidationRule condition=(bold=True)>
    >>> get_data_validation_rule(worksheet, 'A2')
    None
    """
    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))

    resp = worksheet.spreadsheet.fetch_sheet_metadata({
        'includeGridData': True,
        'ranges': [label],
        'fields': 'sheets.data.rowData.values.effectiveFormat,sheets.data.rowData.values.dataValidation'
    })
    data = resp['sheets'][0]['data'][0]
    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('dataValidation')
    return DataValidationRule.from_props(props) if props else None


def get_default_format(spreadsheet):
    """Return Default CellFormat for spreadsheet, or None if no default formatting was specified."""
    fmt = _fetch_with_updated_properties(spreadsheet, 'defaultFormat')
    return CellFormat.from_props(fmt) if fmt else None


def get_effective_format(worksheet, label):
    """Returns a CellFormat object or None representing the effective formatting directives,
    if any, for the cell; that is a combination of default formatting, user-entered formatting,
    and conditional formatting.

    :param worksheet: Worksheet object containing the cell whose format is desired.
    :param label: String with cell label in common format, e.g. 'B1'.
                  Letter case is ignored.

    Example:

    >>> get_effective_format(worksheet, 'A1')
    <CellFormat textFormat=(bold=True)>
    >>> get_effective_format(worksheet, 'A2')
    None
    """
    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))

    resp = worksheet.spreadsheet.fetch_sheet_metadata({
        'includeGridData': True,
        'ranges': [label],
        'fields': 'sheets.data.rowData.values.effectiveFormat'
    })
    data = resp['sheets'][0]['data'][0]
    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('effectiveFormat')
    return CellFormat.from_props(props) if props else None


def get_user_entered_format(worksheet, label):
    """Returns a CellFormat object or None representing the user-entered formatting directives,
    if any, for the cell.

    :param worksheet: Worksheet object containing the cell whose format is desired.
    :param label: String with cell label in common format, e.g. 'B1'.
                  Letter case is ignored.

    Example:

    >>> get_user_entered_format(worksheet, 'A1')
    <CellFormat textFormat=(bold=True)>
    >>> get_user_entered_format(worksheet, 'A2')
    None
    """
    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))

    resp = worksheet.spreadsheet.fetch_sheet_metadata({
        'includeGridData': True,
        'ranges': [label],
        'fields': 'sheets.data.rowData.values.userEnteredFormat'
    })
    data = resp['sheets'][0]['data'][0]
    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('userEnteredFormat')
    return CellFormat.from_props(props) if props else None


def get_text_format_runs(worksheet, label):
    """Returns a list of TextFormatRun objects for the cell. List will be empty
    if no TextFormatRuns exist for the cell.

    :param worksheet: Worksheet object containing the cell whose format is desired.
    :param label: String with cell label in common format, e.g. 'B1'.
                  Letter case is ignored.

    Example:

    >>> get_text_format_runs(worksheet, 'A1')
    [<TextFormatRun startIndex=0 textFormat=(bold=True)>, <TextFormatRun startIndex=10 textFormat=(italic=True)>]
    >>> get_text_format_runs(worksheet, 'A2')
    []
    """
    label = '%s!%s' % (worksheet.title, rowcol_to_a1(*a1_to_rowcol(label)))

    resp = worksheet.spreadsheet.fetch_sheet_metadata({
        'includeGridData': True,
        'ranges': [label],
        'fields': 'sheets.data.rowData.values.textFormatRuns'
    })
    data = resp['sheets'][0]['data'][0]
    props = data.get('rowData', [{}])[0].get('values', [{}])[0].get('textFormatRuns', [])
    return [TextFormatRun.from_props(item) for item in props]


def get_frozen_row_count(worksheet):
    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})
    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])
    grid_props = sheet_data['properties']['gridProperties']
    return grid_props.get('frozenRowCount')


def get_frozen_column_count(worksheet):
    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})
    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])
    grid_props = sheet_data['properties']['gridProperties']
    return grid_props.get('frozenColumnCount')

def get_right_to_left(worksheet):
    """Returns True or False (never None) if worksheet is rightToLeft."""
    md = worksheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})
    sheet_data = finditem(lambda i: i['properties']['title'] == worksheet.title, md['sheets'])
    pr = sheet_data['properties']
    return bool(pr.get('rightToLeft'))

# monkey-patch Spreadsheet class

def fetch_sheet_metadata(self, params=None):
    if params is None:
        params = {'includeGridData': 'false'}
    url = SPREADSHEET_URL % self.id
    r = self.client.request('get', url, params=params)
    return r.json()

Spreadsheet.fetch_sheet_metadata = fetch_sheet_metadata
del fetch_sheet_metadata



================================================
FILE: gspread_formatting/models.py
================================================
# -*- coding: utf-8 -*-

from .util import _props_to_component, _extract_props, _extract_fieldrefs, \
    _parse_string_enum, _underlower, _range_to_gridrange_object

import abc
import re
                  
class FormattingComponent(abc.ABC):
    _FIELDS = ()
    _DEFAULTS = {}

    @classmethod
    def from_props(cls, props):
        return _props_to_component(_CLASSES, _underlower(cls.__name__), props)

    def __repr__(self):
        return '<' + self.__class__.__name__ + ' ' + str(self) + '>'

    def __str__(self):
        p = []
        for a in self._FIELDS:
            v = getattr(self, a)
            if v is not None:
                if isinstance(v, FormattingComponent):
                    p.append( (a, "(" + str(v) + ")") )
                else:
                    p.append( (a, str(v)) )
        return ";".join(["%s=%s" % (k, v) for k, v in p])

    def to_props(self):
        p = {}
        for a in self._FIELDS:
            v = getattr(self, a, None)
            if v is None:
                v = self._DEFAULTS.get(a)
            if v is not None:
                p[a] = _extract_props(v)
        return p

    def affected_fields(self, prefix):
        fields = []
        for a in self._FIELDS:
            v = getattr(self, a, None)
            if v is None:
                v = self._DEFAULTS.get(a)
            if v is not None:
                fields.extend( _extract_fieldrefs(a, v, prefix) )
        return fields

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return False
        for a in self._FIELDS:
            self_v = getattr(self, a, None)
            if self_v == None:
                self_v = self._DEFAULTS.get(a)
            other_v = getattr(other, a, None)
            if other_v == None:
                other_v = other._DEFAULTS.get(a)
            if self_v != other_v:
                return False
        return True

    def __ne__(self, other):
        return not self.__eq__(other)

    def add(self, other):
        new_props = {}
        for a in self._FIELDS:
            self_v = getattr(self, a, None)
            other_v = getattr(other, a, None)
            if isinstance(self_v, CellFormatComponent):
                this_v = self_v.add(other_v)
            elif other_v is not None:
                this_v = other_v
            else:
                this_v = self_v
            if this_v is not None:
                new_props[a] = _extract_props(this_v)
        return self.__class__.from_props(new_props)

    __add__ = add

    def intersection(self, other):
        new_props = {}
        for a in self._FIELDS:
            self_v = getattr(self, a, None)
            other_v = getattr(other, a, None)
            this_v = None
            if isinstance(self_v, CellFormatComponent):
                this_v = self_v.intersection(other_v)
            elif self_v == other_v:
                this_v = self_v
            if this_v is not None:
                new_props[a] = _extract_props(this_v)
        return self.__class__.from_props(new_props) if new_props else None

    __and__ = intersection

    def difference(self, other):
        new_props = {}
        for a in self._FIELDS:
            self_v = getattr(self, a, None)
            other_v = getattr(other, a, None)
            this_v = None
            if isinstance(self_v, CellFormatComponent):
                this_v = self_v.difference(other_v)
            elif other_v != self_v:
                this_v = self_v
            if this_v is not None:
                new_props[a] = _extract_props(this_v)
        return self.__class__.from_props(new_props) if new_props else None

    __sub__ = difference

class GridRange(FormattingComponent):
    _FIELDS = ('sheetId', 'startRowIndex', 'endRowIndex', 'startColumnIndex', 'endColumnIndex')

    @classmethod
    def from_a1_range(cls, range, worksheet):
        return GridRange.from_props(_range_to_gridrange_object(range, worksheet.id))

    def __init__(self, sheetId=None, startRowIndex=None, endRowIndex=None, startColumnIndex=None, endColumnIndex=None):
        self.sheetId = (0 if sheetId is None else sheetId)
        self.startRowIndex = startRowIndex
        self.endRowIndex = endRowIndex
        self.startColumnIndex = startColumnIndex
        self.endColumnIndex = endColumnIndex

class CellFormatComponent(FormattingComponent, abc.ABC):
    pass

class CellFormat(CellFormatComponent):
    _FIELDS = {
        'numberFormat': None,
        'backgroundColor': 'color',
        'borders': None,
        'padding': None,
        'horizontalAlignment': None,
        'verticalAlignment': None,
        'wrapStrategy': None,
        'textDirection': None,
        'textFormat': None,
        'hyperlinkDisplayType': None,
        'textRotation': None,
        'backgroundColorStyle': 'colorStyle'
    }

    def __init__(self,
        numberFormat=None,
        backgroundColor=None,
        borders=None,
        padding=None,
        horizontalAlignment=None,
        verticalAlignment=None,
        wrapStrategy=None,
        textDirection=None,
        textFormat=None,
        hyperlinkDisplayType=None,
        textRotation=None,
        backgroundColorStyle=None
        ):
        self.numberFormat = numberFormat
        self.backgroundColor = backgroundColor
        self.borders = borders
        self.padding = padding
        self.horizontalAlignment = _parse_string_enum('horizontalAlignment', horizontalAlignment, set(['LEFT', 'CENTER', 'RIGHT']))
        self.verticalAlignment = _parse_string_enum('verticalAlignment', verticalAlignment, set(['TOP', 'MIDDLE', 'BOTTOM']))
        self.wrapStrategy = _parse_string_enum('wrapStrategy', wrapStrategy, set(['OVERFLOW_CELL', 'LEGACY_WRAP', 'CLIP', 'WRAP']))
        self.textDirection = _parse_string_enum('textDirection', textDirection, set(['LEFT_TO_RIGHT', 'RIGHT_TO_LEFT']))
        self.textFormat = textFormat
        self.hyperlinkDisplayType = _parse_string_enum('hyperlinkDisplayType', hyperlinkDisplayType, set(['LINKED', 'PLAIN_TEXT']))
        self.textRotation = textRotation
        self.backgroundColorStyle = backgroundColorStyle

class NumberFormat(CellFormatComponent):
    _FIELDS = ('type', 'pattern')

    TYPES = set(['TEXT', 'NUMBER', 'PERCENT', 'CURRENCY', 'DATE', 'TIME', 'DATE_TIME', 'SCIENTIFIC'])

    def __init__(self, type=None, pattern=None):
        self.type = _parse_string_enum('type', type, NumberFormat.TYPES, True)
        self.pattern = pattern

class ColorStyle(CellFormatComponent):
    _FIELDS = {
        'themeColor': None,
        'rgbColor': 'color'
    }

    def __init__(self, themeColor=None, rgbColor=None):
        self.themeColor = themeColor
        self.rgbColor = rgbColor

class Color(CellFormatComponent):
    _HEX_PATTERN = re.compile(r'^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$', re.IGNORECASE)
    _FIELDS = ('red', 'green', 'blue', 'alpha')
    _DEFAULTS = {
        'red': 0.0,
        'green': 0.0,
        'blue': 0.0,
        'alpha': 1.0
    }

    def __init__(self, red=None, green=None, blue=None, alpha=None):
        self.red = red
        self.green = green
        self.blue = blue
        self.alpha = alpha

    @classmethod
    def fromHex(cls,hexcolor):
        match = cls._HEX_PATTERN.search(hexcolor)
        if not match:
            raise ValueError('Color string given: %s: Hex string must be of the form: "#RRGGBB" or "#RRGGBBAA"' % hexcolor)
        # Convert Hex range 0-255 to 0-1.0 for red, green, blue, alpha
        return cls(*[int(a, 16)/255.0 if a else None for a in match.groups()])

    def toHex(self):
        RR = format(int((self.red if self.red else 0) * 255), '02x')
        GG = format(int((self.green if self.green else 0) * 255), '02x')
        BB = format(int((self.blue if self.blue else 0) * 255), '02x')
        AA = format(int((self.alpha if self.alpha else 0) * 255), '02x')
        return '#{0}{1}{2}{3}'.format(RR, GG, BB, (AA if self.alpha != None else ''))

class Border(CellFormatComponent):
    # Note: 'width' field is deprecated and we wish never to serialize it.
    _FIELDS = ('style', 'color', 'colorStyle')

    STYLES = set(['DOTTED', 'DASHED', 'SOLID', 'SOLID_MEDIUM', 'SOLID_THICK', 'NONE', 'DOUBLE'])

    def __init__(self, style=None, color=None, width=None, colorStyle=None):
        self.style = _parse_string_enum('style', style, Border.STYLES, True)
        self.width = width
        self.color = color
        self.colorStyle = colorStyle

class Borders(CellFormatComponent):
    _FIELDS = {
        'top': 'border',
        'bottom': 'border',
        'left': 'border',
        'right': 'border'
    }

    def __init__(self, top=None, bottom=None, left=None, right=None):
        self.top = top
        self.bottom = bottom
        self.left = left
        self.right = right

class Padding(CellFormatComponent):
    _FIELDS = ('top', 'right', 'bottom', 'left')

    def __init__(self, top=None, right=None, bottom=None, left=None):
        self.top = top
        self.right = right
        self.bottom = bottom
        self.left = left

class Link(CellFormatComponent):
    _FIELDS = ('uri',)

    def __init__(self, uri=None):
        self.uri = uri

class TextFormat(CellFormatComponent):
    _FIELDS = {
        'foregroundColor': 'color',
        'fontFamily': None,
        'fontSize': None,
        'bold': None,
        'italic': None,
        'strikethrough': None,
        'underline': None,
        'foregroundColorStyle': 'colorStyle',
        'link': None
    }

    def __init__(self,
        foregroundColor=None,
        fontFamily=None,
        fontSize=None,
        bold=None,
        italic=None,
        strikethrough=None,
        underline=None,
        foregroundColorStyle=None,
        link=None
        ):
        self.foregroundColor = foregroundColor
        self.fontFamily = fontFamily
        self.fontSize = fontSize
        self.bold = bold
        self.italic = italic
        self.strikethrough = strikethrough
        self.underline = underline
        self.foregroundColorStyle = foregroundColorStyle
        self.link = link

class TextFormatRun(FormattingComponent):
    _FIELDS = {'format': 'textFormat', 'startIndex': None}

    def __init__(self, format=None, startIndex=0):
        self.startIndex = startIndex
        self.format = format if format is not None else TextFormat()

class TextRotation(CellFormatComponent):
    _FIELDS = ('angle', 'vertical')

    def __init__(self, angle=None, vertical=None):
        if len([expr for expr in (angle is not None, vertical is not None) if expr]) != 1:
            raise ValueError("Either angle or vertical must be specified, not both or neither")
        self.angle = angle
        self.vertical = vertical

# provide camelCase aliases for all component classes.

_CLASSES = {}
for _c in [ obj for name, obj in locals().items() if isinstance(obj, type) and issubclass(obj, FormattingComponent)]:
    _k = _underlower(_c.__name__)
    _CLASSES[_k] = _c
    locals()[_k] = _c


================================================
FILE: gspread_formatting/util.py
================================================
# -*- coding: utf-8 -*-
from functools import reduce
from operator import or_
import re 

def _convert_to_properties(fobj):
    if isinstance(fobj, list):
        return [i.to_props() for i in fobj]
    elif fobj != None:
        return fobj.to_props()
    else:
        return None

def _affected_fields_for(fobj, field_name):
    if isinstance(fobj, list):
        return list(reduce(or_, [set(i.affected_fields(field_name)) for i in fobj]))
    elif fobj != None:
        return fobj.affected_fields(field_name)
    else:
        return [field_name]

def _build_repeat_cell_request(worksheet, range, formatting_object, celldata_field='userEnteredFormat'):
    return {
        'repeatCell': {
            'range': _range_to_gridrange_object(range, worksheet.id),
            'cell': { celldata_field: _convert_to_properties(formatting_object) },
            'fields': ",".join(_affected_fields_for(formatting_object, celldata_field))
        }
    }

def _fetch_with_updated_properties(spreadsheet, key, params=None):
    try:
        return spreadsheet._properties[key]
    except KeyError:
        metadata = spreadsheet.fetch_sheet_metadata(params)
        spreadsheet._properties.update(metadata['properties'])
        return spreadsheet._properties[key]

_MAGIC_NUMBER = 64
_CELL_ADDR_RE = re.compile(r'([A-Za-z]+)?([1-9]\d*)?')

def _a1_to_rowcol(label):
    if not label:
        raise ValueError(label)
    m = _CELL_ADDR_RE.match(label)
    if m:
        column_label = m.group(1).upper() if m.group(1) else None
        row = int(m.group(2)) if m.group(2) else None

        if column_label is not None:
            col = 0
            for i, c in enumerate(reversed(column_label)):
                col += (ord(c) - _MAGIC_NUMBER) * (26 ** i)
        else:
            col = None
        return (row, col)
    raise ValueError(label)


def _range_to_dimensionrange_object(range, worksheet_id):
    gridrange = _range_to_gridrange_object(range, worksheet_id)
    is_row_range = ('startRowIndex' in gridrange or 'endRowIndex' in gridrange)
    is_column_range = ('startColumnIndex' in gridrange or 'endColumnIndex' in gridrange)
    if is_row_range and is_column_range:
        raise ValueError("Range for dimension must specify only column(s) or only row(s), not both: %s" % range)
    obj = { 'sheetId': worksheet_id }
    if is_row_range:
        obj['dimension'] = 'ROWS'
        if 'endRowIndex' in gridrange:
            obj['endIndex'] = gridrange['endRowIndex']
        if 'startRowIndex' in gridrange:
            obj['startIndex'] = gridrange['startRowIndex']
    if is_column_range:
        obj['dimension'] = 'COLUMNS'
        if 'endColumnIndex' in gridrange:
            obj['endIndex'] = gridrange['endColumnIndex']
        if 'startColumnIndex' in gridrange:
            obj['startIndex'] = gridrange['startColumnIndex']
    return obj

def _range_to_gridrange_object(range, worksheet_id):
    parts = range.split(':')
    start = parts[0]
    end = parts[1] if len(parts) > 1 else ''
    row_offset, column_offset = _a1_to_rowcol(start)
    last_row, last_column = _a1_to_rowcol(end) if end else (row_offset, column_offset)
    # check for illegal ranges
    if (row_offset is not None and last_row is not None and row_offset > last_row):
        raise ValueError(range)
    if (column_offset is not None and last_column is not None and column_offset > last_column):
        raise ValueError(range)
    obj = {
        'sheetId': worksheet_id
    }
    if row_offset is not None:
        obj['startRowIndex'] = row_offset-1
    if last_row is not None:
        obj['endRowIndex'] = last_row
    if column_offset is not None:
        obj['startColumnIndex'] = column_offset-1
    if last_column is not None:
        obj['endColumnIndex'] = last_column
    return obj

def _props_to_component(class_registry, class_alias, value, none_if_empty=False):
    if class_alias not in class_registry:
        raise ValueError("No format component named '%s'" % class_alias)
    cls = class_registry[class_alias]
    kwargs = {}
    for k, v in value.items():
        if isinstance(v, dict):
            if isinstance(cls._FIELDS, dict) and cls._FIELDS.get(k) is not None:
                item_alias = cls._FIELDS[k]
            else:
                item_alias = k
            v = _props_to_component(class_registry, item_alias, v, True)
        if v is not None:
            kwargs[k] = v
    # if our kwargs are empty and there are default values defined
    # for properties in the class, it means to apply all the default values
    # as kwargs.
    if not kwargs and cls._DEFAULTS:
        kwargs = { k: v for k, v in cls._DEFAULTS.items() }
    rv = cls(**kwargs) if (kwargs or not none_if_empty) else None
    return rv

def _ul_repl(m):
    return '_' + m.group(1).lower()

def _underlower(name):
    return name[0].lower() + name[1:]

def _parse_string_enum(name, value, set_of_values, required=False):
    if value is None and required:
        raise ValueError("%s value is required" % name)
    if value is not None and value.upper() not in set_of_values:
        raise ValueError("%s value must be one of: %s" % (name, set_of_values))
    return value.upper() if value is not None else None

def _enforce_type(name, cls, value, required=False):
    if value is None and required:
        raise ValueError("%s value is required" % name)
    if value is not None and not isinstance(value, cls):
        raise ValueError("%s value must be instance of: %s" % (name, cls))
    return value

def _extract_props(value):
    if hasattr(value, 'to_props'):
        return value.to_props()
    return value

def _extract_fieldrefs(name, value, prefix):
    if hasattr(value, 'affected_fields'):
        return value.affected_fields(".".join([prefix, name]))
    elif value is not None:
        return [".".join([prefix, name])]
    else:
        return []



================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" 

[project]
name = "gspread-formatting" 
dynamic = ["version"]
description = "Complete Google Sheets formatting support for gspread worksheets"
readme = "README.rst"
requires-python = ">=3.0"
license = { file = "LICENSE" }
keywords = ["spreadsheets", "google-spreadsheets", "formatting", "cell-format"]
authors = [{ name = "Robin Thomas", email = "rthomas900@gmail.com" }]
maintainers = [{ name = "Robin Thomas", email = "rthomas900@gmail.com" }]

classifiers = [
  "Development Status :: 5 - Production/Stable",
  "Intended Audience :: Developers",
  "Intended Audience :: Science/Research",
  "Topic :: Office/Business :: Financial :: Spreadsheet",
  "Topic :: Software Development :: Libraries :: Python Modules",
  "License :: OSI Approved :: MIT License",
  "Programming Language :: Python :: 3"
]

dependencies = ["gspread>=3.0.0"]

[project.optional-dependencies]
dev = [
"gitchangelog",
"Sphinx",
"Sphinx-PyPI-upload3",
"twine",
"pytest",
"oauth2client",
"pandas",
"gspread-dataframe",
]

test = [
"pytest",
"oauth2client",
"pandas",
"gspread-dataframe",
"tox"
]

[project.urls]
"Homepage" = "https://github.com/robin900/gspread-formatting"
"Bug Reports" = "https://github.com/robin900/gspread-formatting/issues"
"Source" = "https://github.com/robin900/gspread-formatting/"

[tool.setuptools.dynamic]
version = {file = "VERSION"}

[tool.coverage.report]
fail_under = 95
show_missing = true
exclude_lines = [
    'pragma: no cover',
    '\.\.\.',
    'if TYPE_CHECKING:',
    "if __name__ == '__main__':",
]



================================================
FILE: test.py
================================================
# -*- coding: utf-8 -*-

import os
import re
import random
import unittest
import itertools
import uuid
from datetime import datetime, date
import pandas as pd
from gspread_dataframe import set_with_dataframe

try:
    from StringIO import StringIO
except ImportError:
    from io import StringIO

try:
    import ConfigParser
except ImportError:
    import configparser as ConfigParser

from oauth2client.service_account import ServiceAccountCredentials

import gspread
from gspread import utils
from gspread_formatting import *
from gspread_formatting.dataframe import *
from gspread_formatting.util import _range_to_gridrange_object, _range_to_dimensionrange_object

try:
    unicode
except NameError:
    basestring = unicode = str

# making a Worksheet object without fetching it from API changed in 6.0.0.

if gspread.__version__ < '6.0.0':
    def make_worksheet_object(spreadsheet, props):
        return gspread.Worksheet(spreadsheet, props)
else:
    def make_worksheet_object(spreadsheet, props):
        return gspread.Worksheet(spreadsheet, props, spreadsheet.id, spreadsheet.client)


CONFIG_FILENAME = os.path.join(os.path.dirname(__file__), 'tests.config')
CREDS_FILENAME = os.path.join(os.path.dirname(__file__), 'creds.json')
SCOPE = [
    'https://spreadsheets.google.com/feeds',
    'https://www.googleapis.com/auth/drive.file'
]


I18N_STR = u'Iñtërnâtiônàlizætiøn'  # .encode('utf8')


def read_config():
    config = ConfigParser.ConfigParser()
    envconfig = os.environ.get('GSPREAD_FORMATTING_CONFIG')
    if envconfig:
        fp = StringIO(envconfig)
    else:
        fp = open(CONFIG_FILENAME)
    if hasattr(config, 'read_file'):
       read_func = config.read_file
    else:
       read_func = config.readfp
    try:
        read_func(fp)
    finally:
        fp.close()
    return config

def read_credentials():
    credjson = os.environ.get('GSPREAD_FORMATTING_CREDENTIALS')
    if credjson:
        return ServiceAccountCredentials.from_json_keyfile_dict(json.loads(credjson), SCOPE)
    else:
        return ServiceAccountCredentials.from_json_keyfile_name(CREDS_FILENAME, SCOPE)


def gen_value(prefix=None):
    if prefix:
        return u'%s %s' % (prefix, gen_value())
    else:
        return unicode(uuid.uuid4())

TEST_WORKSHEET_NAME = f'wksht_test{gen_value()}'


class RangeConversionTest(unittest.TestCase):
    RANGES = {
        'A': {'startColumnIndex': 0, 'endColumnIndex': 1},
        'A:C': {'startColumnIndex': 0, 'endColumnIndex': 3},
        'A5:B': {'startRowIndex': 4, 'startColumnIndex': 0, 'endColumnIndex': 2},
        '3': {'startRowIndex': 2, 'endRowIndex': 3},
        '3:100': {'startRowIndex': 2, 'endRowIndex': 100}
    }

    ILLEGAL_RANGES = (
        'B:A',
        'A100:A1',
        'C1:A20',
        'AA1:A1',
        ''
    )

    DIMENSION_RANGES = {
        'A': {'dimension': 'COLUMNS', 'startIndex': 0, 'endIndex': 1},
        'A:C': {'dimension': 'COLUMNS', 'startIndex': 0, 'endIndex': 3},
        '3': {'dimension': 'ROWS', 'startIndex': 2, 'endIndex': 3},
        '3:100': {'dimension': 'ROWS', 'startIndex': 2, 'endIndex': 100}
    }

    ILLEGAL_DIMENSION_RANGES = ( 'A5:B', '1:C3', 'A1:D5' )

    def test_ranges(self):
        worksheet_id = 0
        for range, gridrange_obj in self.RANGES.items():
            gridrange_obj['sheetId'] = worksheet_id
            self.assertEqual(gridrange_obj, _range_to_gridrange_object(range, worksheet_id))
        pass

    def test_illegal_ranges(self):
        for range in self.ILLEGAL_RANGES:
            exc = None
            try:
                _range_to_gridrange_object(range, 0)
            except Exception as e:
                exc = e
            self.assertTrue(isinstance(exc, ValueError))

    def test_dimension_ranges(self):
        worksheet_id = 0
        for range, range_obj in self.DIMENSION_RANGES.items():
            range_obj['sheetId'] = worksheet_id
            self.assertEqual(range_obj, _range_to_dimensionrange_object(range, worksheet_id))
        pass

    def test_illegal_dimension_ranges(self):
        for range in self.ILLEGAL_DIMENSION_RANGES:
            exc = None
            try:
                _range_to_dimensionrange_object(range, 0)
            except Exception as e:
                exc = e
            self.assertTrue(isinstance(exc, ValueError))

class GspreadTest(unittest.TestCase):
    maxDiff = None
    config = None
    gc = None

    @classmethod
    def setUpClass(cls):
        try:
            cls.config = read_config()
            credentials = read_credentials()
            cls.gc = gspread.authorize(credentials)
        except IOError as e:
            msg = "Can't find %s for reading test configuration. "
            raise Exception(msg % e.filename)

    def setUp(self):
        if self.__class__.gc is None:
            self.__class__.setUpClass()
        self.assertTrue(isinstance(self.gc, gspread.client.Client))

class WorksheetTest(GspreadTest):
    """Test for gspread.Worksheet."""
    spreadsheet = None

    @classmethod
    def setUpClass(cls):
        super(WorksheetTest, cls).setUpClass()
        ss_id = cls.config.get('Spreadsheet', 'id')
        cls.spreadsheet = cls.gc.open_by_key(ss_id)
        cls.spreadsheet.batch_update(
            {
                "requests": [
                    {
                        "updateSpreadsheetProperties": {
                            "properties": {"locale": "en_US"},
                            "fields": "locale",
                        }
                    }
                ]
            }
        )
        try:
            test_sheet = cls.spreadsheet.worksheet(TEST_WORKSHEET_NAME)
            if test_sheet:
                # somehow left over from interrupted test, remove.
                cls.spreadsheet.del_worksheet(test_sheet)
        except gspread.exceptions.WorksheetNotFound:
            pass # expected

    def setUp(self):
        super(WorksheetTest, self).setUp()
        if self.__class__.spreadsheet is None:
            self.__class__.setUpClass()
        try:
            test_sheet = self.spreadsheet.worksheet(TEST_WORKSHEET_NAME)
            if test_sheet:
                # somehow left over from interrupted test, remove.
                self.spreadsheet.del_worksheet(test_sheet)
        except gspread.exceptions.WorksheetNotFound:
            pass # expected
        self.sheet = self.spreadsheet.add_worksheet(TEST_WORKSHEET_NAME, 20, 20)

    def tearDown(self):
        try:
            test_sheet = self.spreadsheet.worksheet(TEST_WORKSHEET_NAME)
            if test_sheet:
                self.spreadsheet.del_worksheet(test_sheet)
        except gspread.exceptions.WorksheetNotFound:
            # it's ok if the worksheet is absent
            pass
        self.sheet = None

    def test_some_format_constructors(self):
        f = numberFormat('TEXT', '###0')
        f = border('DOTTED', color(0.2, 0.2, 0.2))

    def test_bottom_attribute(self):
        f = padding(bottom=1.1)
        f = borders(bottom=border('SOLID'))

    def test_format_range(self):
        rows = [["", "", "", ""],
                ["", "", "", ""],
                ["A1", "B1", "", "D1"],
                [1, "b2", 1.45, ""],
                ["", "", "", ""],
                ["A4", 0.4, "", 4]]

        def_fmt = get_default_format(self.spreadsheet)
        cell_list = self.sheet.range('A1:D6')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list)

        fmt = cellFormat(textFormat=textFormat(bold=True), backgroundColorStyle=ColorStyle(rgbColor=Color(1,0,0)))
        format_cell_ranges(self.sheet, [('A:A', fmt), ('B1:B6', fmt), ('C1:D6', fmt), ('2', fmt)])
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.textFormat.bold, True)
        # userEnteredFormat will not have backgroundColorStyle...
        eff_fmt = get_effective_format(self.sheet, 'A1')
        self.assertEqual(eff_fmt.textFormat.bold, True)
        # effectiveFormat will have backgroundColorStyle...
        self.assertEqual(eff_fmt.backgroundColorStyle.rgbColor.red, 1)
        self.assertEqual(eff_fmt.textFormat.bold, True)
        fmt2 = cellFormat(textFormat=textFormat(italic=True))
        format_cell_range(self.sheet, 'A:D', fmt2)
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.textFormat.italic, True)
        eff_fmt = get_effective_format(self.sheet, 'A1')
        self.assertEqual(eff_fmt.textFormat.italic, True)

    def test_bottom_formatting(self):
        rows = [["", "", "", ""],
                ["", "", "", ""],
                ["A1", "B1", "", "D1"],
                [1, "b2", 1.45, ""],
                ["", "", "", ""],
                ["A4", 0.4, "", 4]]

        def_fmt = get_default_format(self.spreadsheet)
        cell_list = self.sheet.range('A1:D6')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list)
        fmt = cellFormat(textFormat=textFormat(bold=True))
        format_cell_ranges(self.sheet, [('A1:B6', fmt), ('C1:D6', fmt)])

        orig_fmt = get_user_entered_format(self.sheet, 'A1')
        new_fmt = cellFormat(borders=borders(bottom=border('SOLID')), padding=padding(bottom=3))
        format_cell_range(self.sheet, 'A1:A1', new_fmt)
        # Sheets API bug: user entered format will now contain default color and colorStyle.rgbColor
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(new_fmt.borders.bottom.style, ue_fmt.borders.bottom.style)
        self.assertEqual(new_fmt.padding.bottom, ue_fmt.padding.bottom)
        eff_fmt = get_effective_format(self.sheet, 'A1')
        self.assertEqual(new_fmt.borders.bottom.style, eff_fmt.borders.bottom.style)
        self.assertEqual(new_fmt.padding.bottom, eff_fmt.padding.bottom)

    def test_frozen_rows_cols_bad_args(self):
        with self.assertRaises(ValueError):
            set_frozen(self.sheet)

    def test_frozen_rows_cols(self):
        set_frozen(self.sheet, rows=1, cols=1)
        fresh = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})
        item = utils.finditem(lambda x: x['properties']['title'] == self.sheet.title, fresh['sheets'])
        pr = item['properties']['gridProperties']
        self.assertEqual(pr.get('frozenRowCount'), 1)
        self.assertEqual(pr.get('frozenColumnCount'), 1)
        self.assertEqual(get_frozen_row_count(self.sheet), 1)
        self.assertEqual(get_frozen_column_count(self.sheet), 1)

    def test_right_to_left(self):
        set_right_to_left(self.sheet, True)
        self.assertEqual(get_right_to_left(self.sheet), True)
        set_right_to_left(self.sheet, False)
        # Important! Sheets API will omit rightToLeft from sheet properies when it's False
        # but our function guarantees boolean return
        self.assertEqual(get_right_to_left(self.sheet), False)
        fresh = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': True})
        item = utils.finditem(lambda x: x['properties']['title'] == self.sheet.title, fresh['sheets'])
        # but underneath, property is absent
        pr = item['properties']
        self.assertTrue('rightToLeft' not in pr)

    def test_format_props_roundtrip(self):
        fmt = cellFormat(backgroundColor=Color(1,0,1),textFormat=textFormat(italic=False))
        fmt_roundtrip = CellFormat.from_props(fmt.to_props())
        self.assertEqual(fmt, fmt_roundtrip)

    def test_formats_equality_and_arithmetic(self):
        def_fmt = cellFormat(backgroundColor=Color(1,0,1),textFormat=textFormat(italic=False))
        fmt = cellFormat(textFormat=textFormat(bold=True))
        effective_format = def_fmt + fmt
        self.assertEqual(effective_format.textFormat.bold, True)
        effective_format2 = def_fmt + fmt
        self.assertEqual(effective_format, effective_format2)
        self.assertEqual(effective_format - fmt, def_fmt)
        self.assertEqual(effective_format.difference(fmt), def_fmt)
        self.assertEqual(effective_format.intersection(effective_format), effective_format)
        self.assertEqual(effective_format & effective_format, effective_format)
        self.assertEqual(effective_format - effective_format, None)

    def test_date_formatting_roundtrip(self):
        rows = [
            ["9/1/2018", "1/2/2017", "4/4/2014", "4/4/2019"],
            ["10/2/2019", "2/4/2000", "5/5/1994", "7/7/1979"]
        ]
        def_fmt = get_default_format(self.spreadsheet)
        cell_list = self.sheet.range('A1:D2')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')
        fmt = cellFormat(
                numberFormat=numberFormat('DATE', ' DD MM YYYY'),
                backgroundColor=color(0.8, 0.9, 1),
                horizontalAlignment='RIGHT',
                textFormat=textFormat(bold=False))
        format_cell_range(self.sheet, 'A1:D2', fmt)
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.numberFormat.type, 'DATE')
        self.assertEqual(ue_fmt.numberFormat.pattern, ' DD MM YYYY')
        eff_fmt = get_effective_format(self.sheet, 'A1')
        self.assertEqual(eff_fmt.numberFormat.type, 'DATE')
        self.assertEqual(eff_fmt.numberFormat.pattern, ' DD MM YYYY')
        dt = self.sheet.acell('A1').value
        self.assertEqual(dt, ' 01 09 2018')

    def test_blank_color_as_black(self):
        rows = [
            ["A", "B", "C", "D"],
            ["1", "2", "3", "4"],
            ["A", "B", "C", "D"],
            ["TRUE", "FALSE", "FALSE", "TRUE"],
        ]
        cell_list = self.sheet.range('A1:D4')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')
        fmt = CellFormat(backgroundColor=Color(0,0,0,1))
        format_cell_range(self.sheet, '1:1', fmt)
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.backgroundColor, Color(0,0,0,1))
        self.assertEqual(ue_fmt.backgroundColor, Color())
        fmt = CellFormat(backgroundColor=Color(red=1))
        format_cell_range(self.sheet, '1:1', fmt)
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.backgroundColor, Color(1,0,0,1))
        self.assertEqual(ue_fmt.backgroundColor, Color(red=1))
        fmt = CellFormat(backgroundColor=Color())
        format_cell_range(self.sheet, '1:1', fmt)
        ue_fmt = get_user_entered_format(self.sheet, 'A1')
        eff_fmt = get_effective_format(self.sheet, 'A1')
        self.assertEqual(ue_fmt.backgroundColor, Color(0,0,0,1))
        self.assertEqual(ue_fmt.backgroundColor, Color())


    def test_empty_cell_formatting(self):
        self.assertEqual(get_user_entered_format(self.sheet, 'A1'), None)
        self.assertEqual(get_effective_format(self.sheet, 'A1'), None)
        self.assertEqual(get_data_validation_rule(self.sheet, 'A1'), None)
        

    def test_data_validation_rule(self):
        rows = [
            ["A", "B", "C", "D"],
            ["1", "2", "3", "4"],
            ["A", "B", "C", "D"],
            ["TRUE", "FALSE", "FALSE", "TRUE"],
        ]
        cell_list = self.sheet.range('A1:D4')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')
        validation_rule = DataValidationRule(
            BooleanCondition('ONE_OF_LIST', ['1', '2', '3', '4']), 
            showCustomUi=True
        )
        set_data_validation_for_cell_range(self.sheet, 'A2:D2', validation_rule)
        # No data validation for A1
        eff_rule = get_data_validation_rule(self.sheet, 'A1')
        self.assertEqual(eff_rule, None)
        # data validation for A2 should be equal to validation_rule
        eff_rule = get_data_validation_rule(self.sheet, 'A2')
        self.assertEqual(eff_rule.condition.type, 'ONE_OF_LIST')
        self.assertEqual([ x.userEnteredValue for x in eff_rule.condition.values ], ['1', '2', '3', '4'])
        self.assertEqual(eff_rule.showCustomUi, True)
        self.assertEqual(eff_rule.strict, None)
        self.assertEqual(eff_rule, validation_rule)

        boolean_validation_rule = DataValidationRule(
            BooleanCondition('BOOLEAN', [])
        )
        set_data_validation_for_cell_range(self.sheet, 'A4:D4', boolean_validation_rule)
        eff_rule = get_data_validation_rule(self.sheet, 'A4')
        self.assertEqual([ x.userEnteredValue for x in eff_rule.condition.values ], [])
        self.assertEqual(eff_rule.showCustomUi, None)
        self.assertEqual(eff_rule.strict, None)
        self.assertEqual(eff_rule, boolean_validation_rule)

        set_data_validation_for_cell_range(self.sheet, 'A4:D4', None)
        eff_rule = get_data_validation_rule(self.sheet, 'A4')
        self.assertEqual(eff_rule, None)

    def test_boolean_condition(self):
        with self.assertRaises(ValueError):
            BooleanCondition('TEXT_EQ', 'foo')
        with self.assertRaises(ValueError):
            BooleanCondition('ONE_OF_LIST', 'foo')

    def test_conditional_format_rules(self):
        rows = [
            ["A", "B", "C", "D"],
            ["1", "2", "3", "4"]
        ]
        cell_list = self.sheet.range('A1:D2')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')

        current_rules = get_conditional_format_rules(self.sheet)
        self.assertEqual(list(current_rules), [])

        with self.assertRaises(ValueError):
            current_rules.append([])

        new_rule = ConditionalFormatRule(
            ranges=[GridRange.from_a1_range('A1:D1', self.sheet)],
            booleanRule=BooleanRule(
                condition=BooleanCondition('TEXT_CONTAINS', ['A']), 
                format=CellFormat(textFormat=TextFormat(bold=True))
            )
        )
        new_rule_2 = ConditionalFormatRule(
            ranges=[GridRange.from_a1_range('A2:D2', self.sheet)],
            gradientRule=GradientRule(
                maxpoint=InterpolationPoint(colorStyle=ColorStyle(themeColor='BACKGROUND'), type='MAX'),
                minpoint=InterpolationPoint(colorStyle=ColorStyle(themeColor='TEXT'), type='NUMBER', value='1')
            )
        )
        new_rule_3 = ConditionalFormatRule(
            ranges=[GridRange.from_a1_range('A2:D2', self.sheet)],
            booleanRule=BooleanRule(
                condition=BooleanCondition('DATE_AFTER', [RelativeDate('PAST_WEEK')]),
                format=CellFormat(textFormat=TextFormat(italic=True))
            )
        )
        current_rules.append(new_rule)
        current_rules.append(new_rule_2)
        current_rules.append(new_rule_3)
        self.assertNotEqual(current_rules.save(), None)
        # re-saving _always_ sends a request to API, even if no local changes made
        self.assertNotEqual(current_rules.save(), None)
        current_rules = get_conditional_format_rules(self.sheet)
        self.assertEqual(
            current_rules.rules[0].booleanRule.format.textFormat.bold, 
            new_rule.booleanRule.format.textFormat.bold
        )
        self.assertEqual(
            current_rules.rules[1].gradientRule.maxpoint.colorStyle.themeColor, 
            new_rule_2.gradientRule.maxpoint.colorStyle.themeColor, 
        )
        self.assertEqual(
            current_rules.rules[2].booleanRule.format.textFormat.italic,
            new_rule_3.booleanRule.format.textFormat.italic
        )
        current_rules[0] = new_rule_2
        del current_rules[1]
        current_rules.append(new_rule)
        self.assertNotEqual(current_rules.save(), None)
        current_rules = get_conditional_format_rules(self.sheet)
        self.assertEqual(
            current_rules.rules[0].gradientRule.maxpoint.colorStyle.themeColor, 
            new_rule_2.gradientRule.maxpoint.colorStyle.themeColor, 
        )
        self.assertEqual(
            current_rules.rules[1].booleanRule.format.textFormat.italic,
            new_rule_3.booleanRule.format.textFormat.italic
        )
        self.assertEqual(
            current_rules.rules[2].booleanRule.format.textFormat.bold, 
            new_rule.booleanRule.format.textFormat.bold
        )

        bold_fmt = get_effective_format(self.sheet, 'A1')
        normal_fmt = get_effective_format(self.sheet, 'C1')
        self.assertEqual(bold_fmt.textFormat.bold, True)
        self.assertEqual(bool(normal_fmt.textFormat.bold), False)
        self.assertEqual(bool(normal_fmt.textFormat.italic), False)

        current_rules.clear()
        current_rules.append(new_rule_3)
        current_rules.save()
        current_rules = get_conditional_format_rules(self.sheet)
        self.assertEqual(len(current_rules), 1)
        self.assertEqual(
            current_rules[0].booleanRule.format.textFormat.italic, 
            new_rule_3.booleanRule.format.textFormat.italic, 
        )

        current_rules.clear()
        current_rules.save()
        current_rules = get_conditional_format_rules(self.sheet)
        self.assertEqual(list(current_rules), [])
        
    def test_conditionals_issue_31(self):
        rules = [
            ConditionalFormatRule(
                ranges=[GridRange(self.sheet.id, 1, 1, 1, 2)],
                booleanRule=BooleanRule(
                    BooleanCondition('NUMBER_EQ', ['1']),
                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex("#000000")))
                )
            ),
            ConditionalFormatRule(
                ranges=[GridRange(self.sheet.id, 2, 3, 2, 3)],
                booleanRule=BooleanRule(
                    BooleanCondition('NUMBER_EQ', ['1']),
                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex("#00FFFF")))
                )
           ),
            ConditionalFormatRule(
                ranges=[GridRange(self.sheet.id, 1, 2, 1, 3)],
                booleanRule=BooleanRule(
                    BooleanCondition('NUMBER_EQ', ['1']),
                    CellFormat(textFormat=TextFormat(foregroundColor=Color.fromHex("#FFFF00")))
                )
            )
        ]
        rows = [
            ["A", "B", "C", "D"],
            ["1", "2", "3", "4"]
        ]
        cell_list = self.sheet.range('A1:D2')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        current_rules = get_conditional_format_rules(self.sheet)
        current_rules.extend(rules)
        self.assertNotEqual(current_rules.save(), None)
        current_rules_fetched = get_conditional_format_rules(self.sheet)
        self.assertEqual(
            current_rules_fetched.rules[0].booleanRule.format.textFormat.foregroundColor,
            current_rules.rules[0].booleanRule.format.textFormat.foregroundColor
        )
        self.assertEqual(
            current_rules_fetched.rules[1].booleanRule.format.textFormat.foregroundColor,
            current_rules.rules[1].booleanRule.format.textFormat.foregroundColor
        )
        self.assertEqual(
            current_rules_fetched.rules[2].booleanRule.format.textFormat.foregroundColor,
            current_rules.rules[2].booleanRule.format.textFormat.foregroundColor
        )


    def test_dataframe_formatter(self):
        rows = [  
            {
                'i': i,
                'j': i * 2,
                'A': 'Label ' + str(i), 
                'B': i * 100 + 2.34, 
                'C': date(2019, 3, i % 31 + 1), 
                'D': datetime(2019, 3, i % 31 + 1, i % 24, i % 60, i % 60),
                'E': i * 1000 + 7.8001, 
            } 
            for i in range(200) 
        ]
        df = pd.DataFrame.from_records(rows, index=['i', 'j'])
        set_with_dataframe(self.sheet, df, include_index=True)
        format_with_dataframe(
            self.sheet, 
            df, 
            formatter=BasicFormatter.with_defaults(
                freeze_headers=True, 
                column_formats={
                    'C': cellFormat(
                            numberFormat=numberFormat(type='DATE', pattern='yyyy mmmmmm dd'), 
                            horizontalAlignment='CENTER'
                        ),
                    'E': cellFormat(
                            numberFormat=numberFormat(type='NUMBER', pattern='[Color23][>40000]"HIGH";[Color43][<=10000]"LOW";0000'), 
                            horizontalAlignment='CENTER'
                        )
                }
            ), 
            include_index=True,
        )
        for cell_range, expected_uef in [
            ('A2:A201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), 
            ('B2:B201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), 
            ('C2:C201', cellFormat(horizontalAlignment='CENTER')), 
            ('D2:D201', cellFormat(numberFormat=numberFormat(type='NUMBER'), horizontalAlignment='RIGHT')), 
            ('E2:E201', 
                cellFormat(
                    numberFormat=numberFormat(type='DATE', pattern='yyyy mmmmmm dd'), 
                    horizontalAlignment='CENTER'
                )
            ), 
            ('F2:F201', cellFormat(numberFormat=numberFormat(type='DATE'), horizontalAlignment='CENTER')), 
            ('G2:G201', 
                cellFormat(
                    numberFormat=numberFormat(
                        type='NUMBER', 
                        pattern='[Color23][>40000]"HIGH";[Color43][<=10000]"LOW";0000'
                    ), 
                    horizontalAlignment='CENTER'
                )
            ), 
            ('A1:B201', 
                cellFormat(
                    backgroundColor=DEFAULT_HEADER_BACKGROUND_COLOR,
                    textFormat=textFormat(bold=True)
                )
            ), 
            ('A1:G1', 
                cellFormat(
                    backgroundColor=DEFAULT_HEADER_BACKGROUND_COLOR,
                    textFormat=textFormat(bold=True)
                )
            )
            ]:
            start_cell, end_cell = cell_range.split(':')
            for cell in (start_cell, end_cell):
                actual_uef = get_user_entered_format(self.sheet, cell)
                # actual_uef must be a superset of expected_uef
                self.assertTrue(
                    actual_uef & expected_uef == expected_uef, 
                    "%s range expected format %s, got %s" % (cell_range, expected_uef, actual_uef)
                )
        self.assertEqual(1, get_frozen_row_count(self.sheet))
        self.assertEqual(2, get_frozen_column_count(self.sheet))

    def test_dataframe_formatter_no_column_header(self):
        rows = [  
            {
                'i': i,
                'j': i * 2,
                'A': 'Label ' + str(i), 
                'B': i * 100 + 2.34, 
                'C': date(2019, 3, i % 31 + 1), 
                'D': datetime(2019, 3, i % 31 + 1, i % 24, i % 60, i % 60),
                'E': i * 1000 + 7.8001, 
            } 
            for i in range(200) 
        ]
        df = pd.DataFrame.from_records(rows, index=['i', 'j'])
        set_with_dataframe(self.sheet, df, include_index=True, include_column_header=False)
        format_with_dataframe(
            self.sheet, 
            df, 
            formatter=DEFAULT_FORMATTER,
            include_index=True,
            include_column_header=False
        )

    def test_row_height_and_column_width(self):
        set_row_height(self.sheet, '1:5', 42)
        set_column_width(self.sheet, 'A', 187)
        metadata = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': 'true'})
        sheet_md = [ s for s in metadata['sheets'] if s['properties']['sheetId'] == self.sheet.id ][0]
        row_md = sheet_md['data'][0]['rowMetadata']
        col_md = sheet_md['data'][0]['columnMetadata']
        for row in row_md[0:4]:
            self.assertEqual(42, row['pixelSize'])
        for col in col_md[0:1]:
            self.assertEqual(187, col['pixelSize'])

    def test_row_height_and_column_width_batch(self):
        with batch_updater(self.sheet.spreadsheet) as batch:
            batch.set_row_height(self.sheet, '1:5', 42)
            batch.set_column_width(self.sheet, 'A', 187)
        metadata = self.sheet.spreadsheet.fetch_sheet_metadata({'includeGridData': 'true'})
        sheet_md = [ s for s in metadata['sheets'] if s['properties']['sheetId'] == self.sheet.id ][0]
        row_md = sheet_md['data'][0]['rowMetadata']
        col_md = sheet_md['data'][0]['columnMetadata']
        for row in row_md[0:4]:
            self.assertEqual(42, row['pixelSize'])
        for col in col_md[0:1]:
            self.assertEqual(187, col['pixelSize'])

    def test_text_format_runs(self):
        rows = [
            ["Label A", "B", "C", "D"],
            ["1", "2", "3", "4"]
        ]
        cell_list = self.sheet.range('A1:D2')
        for cell, value in zip(cell_list, itertools.chain(*rows)):
            cell.value = value
        self.sheet.update_cells(cell_list, value_input_option='USER_ENTERED')

        runs = [TextFormatRun(startIndex=0, format=TextFormat(bold=True)), TextFormatRun(startIndex=6, format=TextFormat(italic=True))]
        with batch_updater(self.sheet.spreadsheet) as batch:
            batch.set_text_format_runs(self.sheet, 'A1', runs)
        fetched_runs = get_text_format_runs(self.sheet, 'A1')
        self.assertEqual(runs, fetched_runs)
        fetched_runs = get_text_format_runs(self.sheet, 'A2')
        self.assertEqual([], fetched_runs)

        # no args should succeed
        TextFormatRun()

    def test_batch_updater_different_spreadsheet(self):
        batch = batch_updater(self.sheet.spreadsheet)
        other_spread = gspread.Spreadsheet.__new__(gspread.Spreadsheet)
        other_spread.client = self.sheet.spreadsheet.client
        other_spread._properties = {'id': 'blech', 'title': 'Other sheet'}
        other_sheet = make_worksheet_object(other_spread, {'sheetId': 4, 'title': 'Bleh'})
        batch.set_row_height(self.sheet, '1:5', 42)
        with self.assertRaises(ValueError):
            batch.set_row_height(other_sheet, '1:5', 42)

    def test_batch_updater_context(self):
        batch = batch_updater(self.sheet.spreadsheet)
        batch.set_row_height(self.sheet, '1:5', 42)
        batch.set_column_width(self.sheet, 'A', 187)
        self.assertEqual(2, len(batch.requests))
        try:
            with batch:
                batch.set_row_height(self.sheet, '1:5', 40)
        except Exception as e:
            self.assertIsInstance(e, IOError)
        self.assertEqual(2, len(batch.requests))
        batch.execute()
        self.assertEqual(0, len(batch.requests))


class ColorTest(unittest.TestCase):

    SAMPLE_HEXSTRINGS_NOALPHA = ['#230ac7','#9ec08b','#037778','#134d70','#f1f974','#0997b6','#42da14','#be5ee8']
    SAMPLE_HEXSTRINGS_ALPHA = ['#b7d90600','#0a29f321','#33db6a48','#4134a467','#7d172388','#58fe5fa1','#2ea14ecc','#c18de9f8']
    SAMPLE_HEXSTRING_CAPS = ['#DDEEFF','#EEFFAABB','#1A2B3C4E','#A1F2B3']
    # [NO_POUND_SIGN, NO_POUND_SIGN_ALPHA, INVALID_HEX_CHAR, INVALID_HEX_CHAR_ALPHA, SPECIAL_INVALID_CHAR, TOO_FEW_CHARS, TOO_MANY_CHARS]
    SAMPLE_HEXSTRINGS_BAD = ['230ac7','9ec08b9b','#Adbeye','#1122ccgg','#11$100FF', '#11678','#867530910']

    def test_color_roundtrip(self):
        for hexstring in self.SAMPLE_HEXSTRINGS_NOALPHA:
            self.assertEqual(hexstring, Color.fromHex(hexstring).toHex())
        for hexstring in self.SAMPLE_HEXSTRINGS_ALPHA:
            self.assertEqual(hexstring, Color.fromHex(hexstring).toHex())
        for hexstring in self.SAMPLE_HEXSTRING_CAPS:
            # Check equality with lowercase version of string
            self.assertEqual(hexstring.lower(), Color.fromHex(hexstring).toHex())

    def test_color_malformed(self):
        for hexstring in self.SAMPLE_HEXSTRINGS_BAD:
            with self.assertRaises(ValueError):
                Color.fromHex(hexstring)


class FormattingComponentTest(unittest.TestCase):

    def test_repr_and_equality(self):
        comp = TextFormat(bold=True, italic=True)
        comp2 = TextFormat(bold=True)
        self.assertEqual('<TextFormat bold=True;italic=True>', repr(comp))
        self.assertNotEqual(comp, comp2)
        self.assertNotEqual(comp, None)
        self.assertEqual(comp, comp)

    def test_number_format_types(self):
        for type_ in NumberFormat.TYPES:
            f = NumberFormat(type_)
            self.assertEqual(f, f)
            self.assertEqual(type_, f.type)
        with self.assertRaises(ValueError):
            NumberFormat('BAD_TYPE')

    def test_border_styles(self):
        for style in Border.STYLES:
            f = Border(style)
            self.assertEqual(f, f)
            self.assertEqual(style, f.style)
        with self.assertRaises(ValueError):
            Border('BAD_STYLE')

    def test_text_format_link(self):
        TextFormat(link=None)
        TextFormat(link=Link("https://foo.com/"))
        tf = TextFormat(link=Link(uri="https://foo.com/"))
        self.assertEqual("https://foo.com/", tf.link.uri)
        tf2 = TextFormat.from_props(tf.to_props())
        self.assertEqual(tf, tf2)

    def test_text_rotation_exclusion(self):
        TextRotation(angle=1)
        TextRotation(vertical=True)
        with self.assertRaises(ValueError):
            TextRotation(angle=1, vertical=True)
        with self.assertRaises(ValueError):
            TextRotation()

    def test_condition_with_relative_date_value(self):
        c = BooleanCondition('DATE_AFTER', [RelativeDate('PAST_WEEK')])
        self.assertEqual('DATE_AFTER', c.type)
        self.assertEqual('PAST_WEEK', c.values[0].relativeDate.value)



class GridRangeTest(unittest.TestCase):

    def test_absent_sheet_id(self):
        gr = GridRange.from_props({'startRowIndex': 1})
        self.assertEqual(0, gr.sheetId)
        self.assertEqual(1, gr.startRowIndex)


================================================
FILE: tests.config.example
================================================
[Spreadsheet]
id: 1P3rdCDxfO760TJdE-cbi0k_yy9vmC-joapjuGw9vNjc


================================================
FILE: tox.ini
================================================
[tox]
env_list =
    3.8
    3.13
minversion = 4.24.2

[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
    pytest>=6
    coverage
    oauth2client
    pandas
    gspread-dataframe
commands = 
  coverage erase
  coverage run -m pytest {tty:--color=yes} test.py {posargs}
  coverage report --omit=test.py

[gh-actions]
python = 
  3.8: py38
  3.13: py313
Download .txt
gitextract_5v4gfxc8/

├── .gitchangelog.rc
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   └── bug_report.md
│   └── workflows/
│       └── python-package.yml
├── .gitignore
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── VERSION
├── diffs_to_discovery.py
├── docs/
│   ├── Makefile
│   ├── conf.py
│   ├── index.rst
│   └── make.bat
├── gspread_formatting/
│   ├── __init__.py
│   ├── batch.py
│   ├── batch_update_requests.py
│   ├── conditionals.py
│   ├── dataframe.py
│   ├── functions.py
│   ├── models.py
│   └── util.py
├── pyproject.toml
├── test.py
├── tests.config.example
└── tox.ini
Download .txt
SYMBOL INDEX (188 symbols across 9 files)

FILE: diffs_to_discovery.py
  function resolve_schema_property (line 17) | def resolve_schema_property(sch_prop):
  function resolve_class_field (line 23) | def resolve_class_field(fields, field_name):
  function compare_property (line 38) | def compare_property(name, sch_prop, cls_prop):
  function compare_object (line 52) | def compare_object(schema, cls):

FILE: gspread_formatting/batch.py
  function batch_updater (line 10) | def batch_updater(spreadsheet):
  class SpreadsheetBatchUpdater (line 13) | class SpreadsheetBatchUpdater(object):
    method __init__ (line 14) | def __init__(self, spreadsheet):
    method __enter__ (line 18) | def __enter__(self):
    method __exit__ (line 23) | def __exit__(self, exc_type, exc_val, exc_tb):
    method execute (line 27) | def execute(self):
  function _wrap_for_batch_updater (line 32) | def _wrap_for_batch_updater(func):

FILE: gspread_formatting/batch_update_requests.py
  function set_row_heights (line 22) | def set_row_heights(worksheet, ranges):
  function set_row_height (line 44) | def set_row_height(worksheet, label, height):
  function set_column_widths (line 56) | def set_column_widths(worksheet, ranges):
  function set_column_width (line 79) | def set_column_width(worksheet, label, width):
  function set_text_format_runs (line 92) | def set_text_format_runs(worksheet, label, runs):
  function format_cell_ranges (line 102) | def format_cell_ranges(worksheet, ranges):
  function format_cell_range (line 119) | def format_cell_range(worksheet, name, cell_format):
  function set_data_validation_for_cell_ranges (line 132) | def set_data_validation_for_cell_ranges(worksheet, ranges):
  function set_data_validation_for_cell_range (line 150) | def set_data_validation_for_cell_range(worksheet, range, rule):
  function set_right_to_left (line 163) | def set_right_to_left(worksheet, right_to_left):
  function set_frozen (line 176) | def set_frozen(worksheet, rows=None, cols=None):

FILE: gspread_formatting/conditionals.py
  function get_conditional_format_rules (line 12) | def get_conditional_format_rules(worksheet):
  function _make_delete_rule_request (line 21) | def _make_delete_rule_request(worksheet, rule, ruleIndex):
  function _make_add_rule_request (line 29) | def _make_add_rule_request(worksheet, rule, ruleIndex):
  class ConditionalFormatRules (line 37) | class ConditionalFormatRules(MutableSequence):
    method __init__ (line 38) | def __init__(self, worksheet, *rules):
    method __getitem__ (line 45) | def __getitem__(self, idx):
    method __setitem__ (line 48) | def __setitem__(self, idx, value):
    method __delitem__ (line 51) | def __delitem__(self, idx):
    method __len__ (line 54) | def __len__(self):
    method clear (line 58) | def clear(self):
    method insert (line 61) | def insert(self, idx, value):
    method save (line 64) | def save(self):
  class ConditionalFormattingComponent (line 89) | class ConditionalFormattingComponent(FormattingComponent):
  class BooleanRule (line 92) | class BooleanRule(ConditionalFormattingComponent):
    method __init__ (line 98) | def __init__(self, condition=None, format=None):
  class BooleanCondition (line 102) | class BooleanCondition(ConditionalFormattingComponent):
    method __init__ (line 158) | def __init__(self, type=None, values=()):
    method to_props (line 183) | def to_props(self):
  class RelativeDate (line 189) | class RelativeDate(FormattingComponent):
    method __init__ (line 192) | def __init__(self, value=None):
    method to_props (line 195) | def to_props(self):
  class ConditionValue (line 198) | class ConditionValue(ConditionalFormattingComponent):
    method __init__ (line 201) | def __init__(self, relativeDate=None, userEnteredValue=None):
  class InterpolationPoint (line 205) | class InterpolationPoint(ConditionalFormattingComponent):
    method __init__ (line 210) | def __init__(self, color=None, colorStyle=None, type=None, value=None):
  class GradientRule (line 219) | class GradientRule(ConditionalFormattingComponent):
    method __init__ (line 226) | def __init__(self, minpoint=None, maxpoint=None, midpoint=None):
  class ConditionalFormatRule (line 231) | class ConditionalFormatRule(ConditionalFormattingComponent):
    method __init__ (line 234) | def __init__(self, ranges=None, booleanRule=None, gradientRule=None):
    method to_props (line 251) | def to_props(self):
  class DataValidationRule (line 261) | class DataValidationRule(FormattingComponent):
    method __init__ (line 269) | def __init__(self, condition=None, inputMessage=None, strict=None, sho...

FILE: gspread_formatting/dataframe.py
  function _determine_index_or_columns_size (line 25) | def _determine_index_or_columns_size(obj):
  function _format_with_dataframe (line 30) | def _format_with_dataframe(worksheet,
  function format_with_dataframe (line 169) | def format_with_dataframe(worksheet, *args, **kwargs):
  class DataFrameFormatter (line 174) | class DataFrameFormatter(object):
    method resolve_number_format (line 180) | def resolve_number_format(cls, value, type=None):
    method format_with_dataframe (line 195) | def format_with_dataframe(self, worksheet, dataframe, row=1, col=1, in...
    method format_for_header (line 210) | def format_for_header(self, series, dataframe):
    method format_for_column (line 223) | def format_for_column(self, column, col_number, dataframe):
    method format_for_data_row (line 235) | def format_for_data_row(self, values, row_number, dataframe):
    method format_for_cell (line 251) | def format_for_cell(self, value, row_number, col_number, dataframe):
    method should_freeze_header (line 266) | def should_freeze_header(self, series, dataframe):
  class BasicFormatter (line 278) | class BasicFormatter(DataFrameFormatter):
    method with_defaults (line 287) | def with_defaults(cls,
    method __init__ (line 311) | def __init__(self,
    method format_for_header (line 327) | def format_for_header(self, series, dataframe):
    method format_for_column (line 333) | def format_for_column(self, column, col_number, dataframe):
    method format_for_cell (line 348) | def format_for_cell(self, value, row_number, col_number, dataframe):
    method format_for_data_row (line 351) | def format_for_data_row(self, values, row_number, dataframe):
    method should_freeze_header (line 354) | def should_freeze_header(self, series, dataframe):

FILE: gspread_formatting/functions.py
  function _wrap_as_standalone_function (line 24) | def _wrap_as_standalone_function(func):
  function get_data_validation_rule (line 34) | def get_data_validation_rule(worksheet, label):
  function get_default_format (line 61) | def get_default_format(spreadsheet):
  function get_effective_format (line 67) | def get_effective_format(worksheet, label):
  function get_user_entered_format (line 95) | def get_user_entered_format(worksheet, label):
  function get_text_format_runs (line 122) | def get_text_format_runs(worksheet, label):
  function get_frozen_row_count (line 149) | def get_frozen_row_count(worksheet):
  function get_frozen_column_count (line 156) | def get_frozen_column_count(worksheet):
  function get_right_to_left (line 162) | def get_right_to_left(worksheet):
  function fetch_sheet_metadata (line 171) | def fetch_sheet_metadata(self, params=None):

FILE: gspread_formatting/models.py
  class FormattingComponent (line 9) | class FormattingComponent(abc.ABC):
    method from_props (line 14) | def from_props(cls, props):
    method __repr__ (line 17) | def __repr__(self):
    method __str__ (line 20) | def __str__(self):
    method to_props (line 31) | def to_props(self):
    method affected_fields (line 41) | def affected_fields(self, prefix):
    method __eq__ (line 51) | def __eq__(self, other):
    method __ne__ (line 65) | def __ne__(self, other):
    method add (line 68) | def add(self, other):
    method intersection (line 85) | def intersection(self, other):
    method difference (line 101) | def difference(self, other):
  class GridRange (line 117) | class GridRange(FormattingComponent):
    method from_a1_range (line 121) | def from_a1_range(cls, range, worksheet):
    method __init__ (line 124) | def __init__(self, sheetId=None, startRowIndex=None, endRowIndex=None,...
  class CellFormatComponent (line 131) | class CellFormatComponent(FormattingComponent, abc.ABC):
  class CellFormat (line 134) | class CellFormat(CellFormatComponent):
    method __init__ (line 150) | def __init__(self,
  class NumberFormat (line 177) | class NumberFormat(CellFormatComponent):
    method __init__ (line 182) | def __init__(self, type=None, pattern=None):
  class ColorStyle (line 186) | class ColorStyle(CellFormatComponent):
    method __init__ (line 192) | def __init__(self, themeColor=None, rgbColor=None):
  class Color (line 196) | class Color(CellFormatComponent):
    method __init__ (line 206) | def __init__(self, red=None, green=None, blue=None, alpha=None):
    method fromHex (line 213) | def fromHex(cls,hexcolor):
    method toHex (line 220) | def toHex(self):
  class Border (line 227) | class Border(CellFormatComponent):
    method __init__ (line 233) | def __init__(self, style=None, color=None, width=None, colorStyle=None):
  class Borders (line 239) | class Borders(CellFormatComponent):
    method __init__ (line 247) | def __init__(self, top=None, bottom=None, left=None, right=None):
  class Padding (line 253) | class Padding(CellFormatComponent):
    method __init__ (line 256) | def __init__(self, top=None, right=None, bottom=None, left=None):
  class Link (line 262) | class Link(CellFormatComponent):
    method __init__ (line 265) | def __init__(self, uri=None):
  class TextFormat (line 268) | class TextFormat(CellFormatComponent):
    method __init__ (line 281) | def __init__(self,
  class TextFormatRun (line 302) | class TextFormatRun(FormattingComponent):
    method __init__ (line 305) | def __init__(self, format=None, startIndex=0):
  class TextRotation (line 309) | class TextRotation(CellFormatComponent):
    method __init__ (line 312) | def __init__(self, angle=None, vertical=None):

FILE: gspread_formatting/util.py
  function _convert_to_properties (line 6) | def _convert_to_properties(fobj):
  function _affected_fields_for (line 14) | def _affected_fields_for(fobj, field_name):
  function _build_repeat_cell_request (line 22) | def _build_repeat_cell_request(worksheet, range, formatting_object, cell...
  function _fetch_with_updated_properties (line 31) | def _fetch_with_updated_properties(spreadsheet, key, params=None):
  function _a1_to_rowcol (line 42) | def _a1_to_rowcol(label):
  function _range_to_dimensionrange_object (line 60) | def _range_to_dimensionrange_object(range, worksheet_id):
  function _range_to_gridrange_object (line 81) | def _range_to_gridrange_object(range, worksheet_id):
  function _props_to_component (line 105) | def _props_to_component(class_registry, class_alias, value, none_if_empt...
  function _ul_repl (line 127) | def _ul_repl(m):
  function _underlower (line 130) | def _underlower(name):
  function _parse_string_enum (line 133) | def _parse_string_enum(name, value, set_of_values, required=False):
  function _enforce_type (line 140) | def _enforce_type(name, cls, value, required=False):
  function _extract_props (line 147) | def _extract_props(value):
  function _extract_fieldrefs (line 152) | def _extract_fieldrefs(name, value, prefix):

FILE: test.py
  function make_worksheet_object (line 39) | def make_worksheet_object(spreadsheet, props):
  function make_worksheet_object (line 42) | def make_worksheet_object(spreadsheet, props):
  function read_config (line 57) | def read_config():
  function read_credentials (line 74) | def read_credentials():
  function gen_value (line 82) | def gen_value(prefix=None):
  class RangeConversionTest (line 91) | class RangeConversionTest(unittest.TestCase):
    method test_ranges (line 117) | def test_ranges(self):
    method test_illegal_ranges (line 124) | def test_illegal_ranges(self):
    method test_dimension_ranges (line 133) | def test_dimension_ranges(self):
    method test_illegal_dimension_ranges (line 140) | def test_illegal_dimension_ranges(self):
  class GspreadTest (line 149) | class GspreadTest(unittest.TestCase):
    method setUpClass (line 155) | def setUpClass(cls):
    method setUp (line 164) | def setUp(self):
  class WorksheetTest (line 169) | class WorksheetTest(GspreadTest):
    method setUpClass (line 174) | def setUpClass(cls):
    method setUp (line 198) | def setUp(self):
    method tearDown (line 211) | def tearDown(self):
    method test_some_format_constructors (line 221) | def test_some_format_constructors(self):
    method test_bottom_attribute (line 225) | def test_bottom_attribute(self):
    method test_format_range (line 229) | def test_format_range(self):
    method test_bottom_formatting (line 260) | def test_bottom_formatting(self):
    method test_frozen_rows_cols_bad_args (line 287) | def test_frozen_rows_cols_bad_args(self):
    method test_frozen_rows_cols (line 291) | def test_frozen_rows_cols(self):
    method test_right_to_left (line 301) | def test_right_to_left(self):
    method test_format_props_roundtrip (line 314) | def test_format_props_roundtrip(self):
    method test_formats_equality_and_arithmetic (line 319) | def test_formats_equality_and_arithmetic(self):
    method test_date_formatting_roundtrip (line 332) | def test_date_formatting_roundtrip(self):
    method test_blank_color_as_black (line 357) | def test_blank_color_as_black(self):
    method test_empty_cell_formatting (line 386) | def test_empty_cell_formatting(self):
    method test_data_validation_rule (line 392) | def test_data_validation_rule(self):
    method test_boolean_condition (line 433) | def test_boolean_condition(self):
    method test_conditional_format_rules (line 439) | def test_conditional_format_rules(self):
    method test_conditionals_issue_31 (line 534) | def test_conditionals_issue_31(self):
    method test_dataframe_formatter (line 583) | def test_dataframe_formatter(self):
    method test_dataframe_formatter_no_column_header (line 661) | def test_dataframe_formatter_no_column_header(self):
    method test_row_height_and_column_width (line 684) | def test_row_height_and_column_width(self):
    method test_row_height_and_column_width_batch (line 696) | def test_row_height_and_column_width_batch(self):
    method test_text_format_runs (line 709) | def test_text_format_runs(self):
    method test_batch_updater_different_spreadsheet (line 730) | def test_batch_updater_different_spreadsheet(self):
    method test_batch_updater_context (line 740) | def test_batch_updater_context(self):
  class ColorTest (line 755) | class ColorTest(unittest.TestCase):
    method test_color_roundtrip (line 763) | def test_color_roundtrip(self):
    method test_color_malformed (line 772) | def test_color_malformed(self):
  class FormattingComponentTest (line 778) | class FormattingComponentTest(unittest.TestCase):
    method test_repr_and_equality (line 780) | def test_repr_and_equality(self):
    method test_number_format_types (line 788) | def test_number_format_types(self):
    method test_border_styles (line 796) | def test_border_styles(self):
    method test_text_format_link (line 804) | def test_text_format_link(self):
    method test_text_rotation_exclusion (line 812) | def test_text_rotation_exclusion(self):
    method test_condition_with_relative_date_value (line 820) | def test_condition_with_relative_date_value(self):
  class GridRangeTest (line 827) | class GridRangeTest(unittest.TestCase):
    method test_absent_sheet_id (line 829) | def test_absent_sheet_id(self):
Condensed preview — 26 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (151K chars).
[
  {
    "path": ".gitchangelog.rc",
    "chars": 9992,
    "preview": "# -*- coding: utf-8; mode: python -*-\n##\n## Format\n##\n##   ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...]\n##\n## Description\n#"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 943,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
  },
  {
    "path": ".github/workflows/python-package.yml",
    "chars": 1106,
    "preview": "# This workflow will install Python dependencies, run tests and lint with a variety of Python versions\n# For more inform"
  },
  {
    "path": ".gitignore",
    "chars": 1255,
    "preview": "/.travis.secrets.tar.gz\n/creds.json\n/tests.config\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.c"
  },
  {
    "path": "CHANGELOG.rst",
    "chars": 14686,
    "preview": "Changelog\n=========\n\n\nv1.2.1 (2025-03-07)\n-------------------\n- Bump to v1.2.1. [Robin Thomas]\n- Test coverage for fix o"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2018 Robin Thomas\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "MANIFEST.in",
    "chars": 206,
    "preview": "include VERSION\ninclude *.example\ninclude *.py\ninclude *.rc\ninclude *.rst\ninclude *.txt\nrecursive-include docs *.bat\nrec"
  },
  {
    "path": "README.rst",
    "chars": 12701,
    "preview": "gspread-formatting\n------------------\n\n.. image:: https://badge.fury.io/py/gspread-formatting.svg\n    :target: https://b"
  },
  {
    "path": "VERSION",
    "chars": 8,
    "preview": "2.0.0b1\n"
  },
  {
    "path": "diffs_to_discovery.py",
    "chars": 2671,
    "preview": "import requests\nimport gspread_formatting.models\nfrom gspread_formatting.util import _underlower\n\nimport inspect\nimport "
  },
  {
    "path": "docs/Makefile",
    "chars": 616,
    "preview": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHI"
  },
  {
    "path": "docs/conf.py",
    "chars": 4956,
    "preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n#\n# gspread-formatting documentation build configuration file, created by"
  },
  {
    "path": "docs/index.rst",
    "chars": 851,
    "preview": ".. gspread-formatting documentation master file, created by\n   sphinx-quickstart on Fri Mar 10 22:46:18 2017.\n   You can"
  },
  {
    "path": "docs/make.bat",
    "chars": 822,
    "preview": "@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=sp"
  },
  {
    "path": "gspread_formatting/__init__.py",
    "chars": 121,
    "preview": "# -*- coding: utf-8 -*-\n\nfrom .functions import *\nfrom .models import *\nfrom .conditionals import *\nfrom .batch import *"
  },
  {
    "path": "gspread_formatting/batch.py",
    "chars": 1641,
    "preview": "# -*- coding: utf-8 -*-\n\nimport gspread_formatting.functions\nimport gspread_formatting.dataframe\n\nfrom functools import "
  },
  {
    "path": "gspread_formatting/batch_update_requests.py",
    "chars": 6518,
    "preview": "# -*- coding: utf-8 -*-\n\"\"\"\nThis module provides functions that generate request objects compatible with the\n\"batchUpdat"
  },
  {
    "path": "gspread_formatting/conditionals.py",
    "chars": 10405,
    "preview": "# -*- coding: utf-8 -*-\n\nfrom .util import _parse_string_enum, _underlower, _enforce_type\nfrom .models import Formatting"
  },
  {
    "path": "gspread_formatting/dataframe.py",
    "chars": 14297,
    "preview": "# -*- coding: utf-8 -*-\n\ntry:\n    from itertools import zip_longest\nexcept ImportError:\n    from itertools import izip_l"
  },
  {
    "path": "gspread_formatting/functions.py",
    "chars": 7081,
    "preview": "# -*- coding: utf-8 -*-\n\nfrom .util import _fetch_with_updated_properties, _range_to_dimensionrange_object\nfrom .models "
  },
  {
    "path": "gspread_formatting/models.py",
    "chars": 11027,
    "preview": "# -*- coding: utf-8 -*-\n\nfrom .util import _props_to_component, _extract_props, _extract_fieldrefs, \\\n    _parse_string_"
  },
  {
    "path": "gspread_formatting/util.py",
    "chars": 5878,
    "preview": "# -*- coding: utf-8 -*-\nfrom functools import reduce\nfrom operator import or_\nimport re \n\ndef _convert_to_properties(fob"
  },
  {
    "path": "pyproject.toml",
    "chars": 1606,
    "preview": "[build-system]\nrequires = [\"setuptools\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\" \n\n[project]\nname = \"gspread-fo"
  },
  {
    "path": "test.py",
    "chars": 34405,
    "preview": "# -*- coding: utf-8 -*-\n\nimport os\nimport re\nimport random\nimport unittest\nimport itertools\nimport uuid\nfrom datetime im"
  },
  {
    "path": "tests.config.example",
    "chars": 63,
    "preview": "[Spreadsheet]\nid: 1P3rdCDxfO760TJdE-cbi0k_yy9vmC-joapjuGw9vNjc\n"
  },
  {
    "path": "tox.ini",
    "chars": 401,
    "preview": "[tox]\nenv_list =\n    3.8\n    3.13\nminversion = 4.24.2\n\n[testenv]\ndescription = run the tests with pytest\npackage = wheel"
  }
]

About this extraction

This page contains the full source code of the robin900/gspread-formatting GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 26 files (141.9 KB), approximately 35.8k tokens, and a symbol index with 188 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!