├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs └── source │ ├── conf.py │ ├── grist_api.rst │ └── index.rst ├── grist_api ├── __init__.py └── grist_api.py ├── setup.cfg ├── setup.py ├── test ├── fixtures │ ├── TestGristDocAPI.grist │ └── vcr │ │ ├── test_add_delete_records │ │ ├── test_chunking │ │ ├── test_columns │ │ ├── test_errors │ │ ├── test_fetch_table │ │ ├── test_list_tables │ │ ├── test_sync_table │ │ ├── test_sync_table_with_filters │ │ ├── test_sync_table_with_methods │ │ ├── test_update_records │ │ └── test_update_records_varied └── test_grist_api.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | init-hook='import sys; sys.path.append("./env27/lib/python2.7/site-packages")' 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=print-statement, 58 | parameter-unpacking, 59 | unpacking-in-except, 60 | old-raise-syntax, 61 | backtick, 62 | long-suffix, 63 | old-ne-operator, 64 | old-octal-literal, 65 | import-star-module-level, 66 | non-ascii-bytes-literal, 67 | invalid-unicode-literal, 68 | raw-checker-failed, 69 | bad-inline-option, 70 | locally-disabled, 71 | locally-enabled, 72 | file-ignored, 73 | suppressed-message, 74 | useless-suppression, 75 | deprecated-pragma, 76 | apply-builtin, 77 | basestring-builtin, 78 | buffer-builtin, 79 | cmp-builtin, 80 | coerce-builtin, 81 | execfile-builtin, 82 | file-builtin, 83 | long-builtin, 84 | raw_input-builtin, 85 | reduce-builtin, 86 | standarderror-builtin, 87 | unicode-builtin, 88 | xrange-builtin, 89 | coerce-method, 90 | delslice-method, 91 | getslice-method, 92 | setslice-method, 93 | no-absolute-import, 94 | old-division, 95 | dict-iter-method, 96 | dict-view-method, 97 | next-method-called, 98 | metaclass-assignment, 99 | indexing-exception, 100 | raising-string, 101 | reload-builtin, 102 | oct-method, 103 | hex-method, 104 | nonzero-method, 105 | cmp-method, 106 | input-builtin, 107 | round-builtin, 108 | intern-builtin, 109 | unichr-builtin, 110 | map-builtin-not-iterating, 111 | zip-builtin-not-iterating, 112 | range-builtin-not-iterating, 113 | filter-builtin-not-iterating, 114 | using-cmp-argument, 115 | eq-without-hash, 116 | div-method, 117 | idiv-method, 118 | rdiv-method, 119 | exception-message-attribute, 120 | invalid-str-codec, 121 | sys-max-int, 122 | bad-python3-import, 123 | deprecated-string-function, 124 | deprecated-str-translate-call, 125 | deprecated-itertools-function, 126 | deprecated-types-field, 127 | next-method-defined, 128 | dict-items-not-iterating, 129 | dict-keys-not-iterating, 130 | dict-values-not-iterating, 131 | deprecated-operator-function, 132 | deprecated-urllib-function, 133 | xreadlines-attribute, 134 | deprecated-sys-function, 135 | exception-escape, 136 | comprehension-escape, 137 | bad-continuation 138 | 139 | # Enable the message, report, category or checker with the given id(s). You can 140 | # either give multiple identifier separated by comma (,) or put this option 141 | # multiple time (only on the command line, not in the configuration file where 142 | # it should appear only once). See also the "--disable" option for examples. 143 | enable=c-extension-no-member 144 | 145 | 146 | [REPORTS] 147 | 148 | # Python expression which should return a note less than 10 (10 is the highest 149 | # note). You have access to the variables errors warning, statement which 150 | # respectively contain the number of errors / warnings messages and the total 151 | # number of statements analyzed. This is used by the global evaluation report 152 | # (RP0004). 153 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 154 | 155 | # Template used to display messages. This is a python new-style format string 156 | # used to format the message information. See doc for all details 157 | #msg-template= 158 | 159 | # Set the output format. Available formats are text, parseable, colorized, json 160 | # and msvs (visual studio).You can also give a reporter class, eg 161 | # mypackage.mymodule.MyReporterClass. 162 | output-format=text 163 | 164 | # Tells whether to display a full report or only the messages 165 | reports=no 166 | 167 | # Activate the evaluation score. 168 | score=yes 169 | 170 | 171 | [REFACTORING] 172 | 173 | # Maximum number of nested blocks for function / method body 174 | max-nested-blocks=5 175 | 176 | # Complete name of functions that never returns. When checking for 177 | # inconsistent-return-statements if a never returning function is called then 178 | # it will be considered as an explicit return statement and no message will be 179 | # printed. 180 | never-returning-functions=optparse.Values,sys.exit 181 | 182 | 183 | [LOGGING] 184 | 185 | # Logging modules to check that the string format arguments are in logging 186 | # function parameter format 187 | logging-modules=logging 188 | 189 | 190 | [SPELLING] 191 | 192 | # Limits count of emitted suggestions for spelling mistakes 193 | max-spelling-suggestions=4 194 | 195 | # Spelling dictionary name. Available dictionaries: none. To make it working 196 | # install python-enchant package. 197 | spelling-dict= 198 | 199 | # List of comma separated words that should not be checked. 200 | spelling-ignore-words= 201 | 202 | # A path to a file that contains private dictionary; one word per line. 203 | spelling-private-dict-file= 204 | 205 | # Tells whether to store unknown words to indicated private dictionary in 206 | # --spelling-private-dict-file option instead of raising a message. 207 | spelling-store-unknown-words=no 208 | 209 | 210 | [MISCELLANEOUS] 211 | 212 | # List of note tags to take in consideration, separated by a comma. 213 | notes=FIXME, 214 | XXX, 215 | #TODO 216 | 217 | 218 | [SIMILARITIES] 219 | 220 | # Ignore comments when computing similarities. 221 | ignore-comments=yes 222 | 223 | # Ignore docstrings when computing similarities. 224 | ignore-docstrings=yes 225 | 226 | # Ignore imports when computing similarities. 227 | ignore-imports=no 228 | 229 | # Minimum lines number of a similarity. 230 | min-similarity-lines=4 231 | 232 | 233 | [TYPECHECK] 234 | 235 | # List of decorators that produce context managers, such as 236 | # contextlib.contextmanager. Add to this list to register other decorators that 237 | # produce valid context managers. 238 | contextmanager-decorators=contextlib.contextmanager 239 | 240 | # List of members which are set dynamically and missed by pylint inference 241 | # system, and so shouldn't trigger E1101 when accessed. Python regular 242 | # expressions are accepted. 243 | generated-members= 244 | 245 | # Tells whether missing members accessed in mixin class should be ignored. A 246 | # mixin class is detected if its name ends with "mixin" (case insensitive). 247 | ignore-mixin-members=yes 248 | 249 | # This flag controls whether pylint should warn about no-member and similar 250 | # checks whenever an opaque object is returned when inferring. The inference 251 | # can return multiple potential results while evaluating a Python object, but 252 | # some branches might not be evaluated, which results in partial inference. In 253 | # that case, it might be useful to still emit no-member and other checks for 254 | # the rest of the inferred objects. 255 | ignore-on-opaque-inference=yes 256 | 257 | # List of class names for which member attributes should not be checked (useful 258 | # for classes with dynamically set attributes). This supports the use of 259 | # qualified names. 260 | ignored-classes=optparse.Values,thread._local,_thread._local 261 | 262 | # List of module names for which member attributes should not be checked 263 | # (useful for modules/projects where namespaces are manipulated during runtime 264 | # and thus existing member attributes cannot be deduced by static analysis. It 265 | # supports qualified module names, as well as Unix pattern matching. 266 | ignored-modules= 267 | 268 | # Show a hint with possible names when a member name was not found. The aspect 269 | # of finding the hint is based on edit distance. 270 | missing-member-hint=yes 271 | 272 | # The minimum edit distance a name should have in order to be considered a 273 | # similar match for a missing member name. 274 | missing-member-hint-distance=1 275 | 276 | # The total number of similar names that should be taken in consideration when 277 | # showing a hint for a missing member. 278 | missing-member-max-choices=1 279 | 280 | 281 | [VARIABLES] 282 | 283 | # List of additional names supposed to be defined in builtins. Remember that 284 | # you should avoid to define new builtins when possible. 285 | additional-builtins= 286 | 287 | # Tells whether unused global variables should be treated as a violation. 288 | allow-global-unused-variables=yes 289 | 290 | # List of strings which can identify a callback function by name. A callback 291 | # name must start or end with one of those strings. 292 | callbacks=cb_, 293 | _cb 294 | 295 | # A regular expression matching the name of dummy variables (i.e. expectedly 296 | # not used). 297 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 298 | 299 | # Argument names that match this expression will be ignored. Default to name 300 | # with leading underscore 301 | ignored-argument-names=_.*|^ignored_|^unused_ 302 | 303 | # Tells whether we should check for unused import in __init__ files. 304 | init-import=no 305 | 306 | # List of qualified module names which can have objects that can redefine 307 | # builtins. 308 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,io,builtins 309 | 310 | 311 | [FORMAT] 312 | 313 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 314 | expected-line-ending-format= 315 | 316 | # Regexp for a line that is allowed to be longer than the limit. 317 | ignore-long-lines=^\s*(# )??$ 318 | 319 | # Number of spaces of indent required inside a hanging or continued line. 320 | indent-after-paren=2 321 | 322 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 323 | # tab). 324 | indent-string=' ' 325 | 326 | # Maximum number of characters on a single line. 327 | max-line-length=100 328 | 329 | # Maximum number of lines in a module 330 | max-module-lines=1000 331 | 332 | # List of optional constructs for which whitespace checking is disabled. `dict- 333 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 334 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 335 | # `empty-line` allows space-only lines. 336 | no-space-check=trailing-comma, 337 | dict-separator 338 | 339 | # Allow the body of a class to be on the same line as the declaration if body 340 | # contains single statement. 341 | single-line-class-stmt=no 342 | 343 | # Allow the body of an if to be on the same line as the test if there is no 344 | # else. 345 | single-line-if-stmt=no 346 | 347 | 348 | [BASIC] 349 | 350 | # Naming style matching correct argument names 351 | argument-naming-style=snake_case 352 | 353 | # Regular expression matching correct argument names. Overrides argument- 354 | # naming-style 355 | #argument-rgx= 356 | 357 | # Naming style matching correct attribute names 358 | attr-naming-style=snake_case 359 | 360 | # Regular expression matching correct attribute names. Overrides attr-naming- 361 | # style 362 | #attr-rgx= 363 | 364 | # Bad variable names which should always be refused, separated by a comma 365 | bad-names=foo, 366 | bar, 367 | baz, 368 | toto, 369 | tutu, 370 | tata 371 | 372 | # Naming style matching correct class attribute names 373 | class-attribute-naming-style=any 374 | 375 | # Regular expression matching correct class attribute names. Overrides class- 376 | # attribute-naming-style 377 | #class-attribute-rgx= 378 | 379 | # Naming style matching correct class names 380 | class-naming-style=PascalCase 381 | 382 | # Regular expression matching correct class names. Overrides class-naming-style 383 | #class-rgx= 384 | 385 | # Naming style matching correct constant names 386 | const-naming-style=any #UPPER_CASE 387 | 388 | # Regular expression matching correct constant names. Overrides const-naming- 389 | # style 390 | #const-rgx= 391 | 392 | # Minimum line length for functions/classes that require docstrings, shorter 393 | # ones are exempt. 394 | docstring-min-length=20 395 | 396 | # Naming style matching correct function names 397 | function-naming-style=snake_case 398 | 399 | # Regular expression matching correct function names. Overrides function- 400 | # naming-style 401 | #function-rgx= 402 | 403 | # Good variable names which should always be accepted, separated by a comma 404 | good-names=i, 405 | j, 406 | k, 407 | it, 408 | ex, 409 | log, 410 | Run, 411 | _ 412 | 413 | # Include a hint for the correct naming format with invalid-name 414 | include-naming-hint=no 415 | 416 | # Naming style matching correct inline iteration names 417 | inlinevar-naming-style=any 418 | 419 | # Regular expression matching correct inline iteration names. Overrides 420 | # inlinevar-naming-style 421 | #inlinevar-rgx= 422 | 423 | # Naming style matching correct method names 424 | method-naming-style=snake_case 425 | 426 | # Regular expression matching correct method names. Overrides method-naming- 427 | # style 428 | #method-rgx= 429 | 430 | # Naming style matching correct module names 431 | module-naming-style=snake_case 432 | 433 | # Regular expression matching correct module names. Overrides module-naming- 434 | # style 435 | #module-rgx= 436 | 437 | # Colon-delimited sets of names that determine each other's naming style when 438 | # the name regexes allow several styles. 439 | name-group= 440 | 441 | # Regular expression which should only match function or class names that do 442 | # not require a docstring. 443 | no-docstring-rgx=^_ 444 | 445 | # List of decorators that produce properties, such as abc.abstractproperty. Add 446 | # to this list to register other decorators that produce valid properties. 447 | property-classes=abc.abstractproperty 448 | 449 | # Naming style matching correct variable names 450 | variable-naming-style=snake_case 451 | 452 | # Regular expression matching correct variable names. Overrides variable- 453 | # naming-style 454 | #variable-rgx= 455 | 456 | 457 | [DESIGN] 458 | 459 | # Maximum number of arguments for function / method 460 | max-args=5 461 | 462 | # Maximum number of attributes for a class (see R0902). 463 | max-attributes=7 464 | 465 | # Maximum number of boolean expressions in a if statement 466 | max-bool-expr=5 467 | 468 | # Maximum number of branch for function / method body 469 | max-branches=12 470 | 471 | # Maximum number of locals for function / method body 472 | max-locals=15 473 | 474 | # Maximum number of parents for a class (see R0901). 475 | max-parents=7 476 | 477 | # Maximum number of public methods for a class (see R0904). 478 | max-public-methods=20 479 | 480 | # Maximum number of return / yield for function / method body 481 | max-returns=6 482 | 483 | # Maximum number of statements in function / method body 484 | max-statements=50 485 | 486 | # Minimum number of public methods for a class (see R0903). 487 | min-public-methods=2 488 | 489 | 490 | [CLASSES] 491 | 492 | # List of method names used to declare (i.e. assign) instance attributes. 493 | defining-attr-methods=__init__, 494 | __new__, 495 | setUp 496 | 497 | # List of member names, which should be excluded from the protected access 498 | # warning. 499 | exclude-protected=_asdict, 500 | _fields, 501 | _replace, 502 | _source, 503 | _make 504 | 505 | # List of valid names for the first argument in a class method. 506 | valid-classmethod-first-arg=cls 507 | 508 | # List of valid names for the first argument in a metaclass class method. 509 | valid-metaclass-classmethod-first-arg=mcs 510 | 511 | 512 | [IMPORTS] 513 | 514 | # Allow wildcard imports from modules that define __all__. 515 | allow-wildcard-with-all=no 516 | 517 | # Analyse import fallback blocks. This can be used to support both Python 2 and 518 | # 3 compatible code, which means that the block might have code that exists 519 | # only in one or another interpreter, leading to false positives when analysed. 520 | analyse-fallback-blocks=no 521 | 522 | # Deprecated modules which should not be used, separated by a comma 523 | deprecated-modules=regsub, 524 | TERMIOS, 525 | Bastion, 526 | rexec 527 | 528 | # Create a graph of external dependencies in the given file (report RP0402 must 529 | # not be disabled) 530 | ext-import-graph= 531 | 532 | # Create a graph of every (i.e. internal and external) dependencies in the 533 | # given file (report RP0402 must not be disabled) 534 | import-graph= 535 | 536 | # Create a graph of internal dependencies in the given file (report RP0402 must 537 | # not be disabled) 538 | int-import-graph= 539 | 540 | # Force import order to recognize a module as part of the standard 541 | # compatibility libraries. 542 | known-standard-library= 543 | 544 | # Force import order to recognize a module as part of a third party library. 545 | known-third-party=enchant 546 | 547 | 548 | [EXCEPTIONS] 549 | 550 | # Exceptions that will emit a warning when being caught. Defaults to 551 | # "Exception" 552 | overgeneral-exceptions=Exception 553 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | matrix: 5 | include: 6 | - python: 2.7 7 | - python: 3.5 8 | - python: 3.6 9 | - python: 3.7 10 | 11 | # command to install dependencies 12 | install: pip install tox-travis 13 | # command to run tests 14 | script: tox 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-include tests *.py 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dist: 2 | @echo Build python distribution 3 | python setup.py sdist bdist_wheel 4 | 5 | publish: 6 | @echo "Publish to PyPI at https://pypi.python.org/pypi/grist_api/" 7 | @echo "Version in setup.py is `python setup.py --version`" 8 | @echo "Git tag is `git describe --tags`" 9 | @echo "Run this manually: twine upload dist/grist_api-`python setup.py --version`*" 10 | 11 | docs: 12 | @echo "Build documentation in docs/build/html using virtualenv in ./env (override with \$$ENV)" 13 | $${ENV:-env}/bin/sphinx-build -b html docs/source/ docs/build/html 14 | 15 | clean: 16 | python setup.py clean 17 | 18 | .PHONY: dist publish docs clean 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | grist_api 2 | ========= 3 | 4 | .. image:: https://img.shields.io/pypi/v/grist_api.svg 5 | :target: https://pypi.python.org/pypi/grist_api/ 6 | .. image:: https://img.shields.io/pypi/pyversions/grist_api.svg 7 | :target: https://pypi.python.org/pypi/grist_api/ 8 | .. image:: https://travis-ci.org/gristlabs/py_grist_api.svg?branch=master 9 | :target: https://travis-ci.org/gristlabs/py_grist_api 10 | .. image:: https://readthedocs.org/projects/py_grist_api/badge/?version=latest 11 | :target: https://py-grist-api.readthedocs.io/en/latest/index.html 12 | 13 | .. Start of user-guide 14 | 15 | The ``grist_api`` module is a Python client library for interacting with Grist. 16 | 17 | Installation 18 | ------------ 19 | grist_api is available on PyPI: https://pypi.python.org/pypi/grist_api/:: 20 | 21 | pip install grist_api 22 | 23 | The code is on GitHub: https://github.com/gristlabs/py_grist_api. 24 | 25 | The API Reference is here: https://py-grist-api.readthedocs.io/en/latest/grist_api.html. 26 | 27 | Usage 28 | ----- 29 | 30 | See ``tests/test_grist_api.py`` for usage examples. A simple script to add 31 | some rows to a table and then fetch all cells in the table could look like: 32 | 33 | .. code-block:: python 34 | 35 | from grist_api import GristDocAPI 36 | import os 37 | 38 | SERVER = "https://subdomain.getgrist.com" # your org goes here 39 | DOC_ID = "9dc7e414-2761-4ef2-bc28-310e634754fb" # document id goes here 40 | 41 | # Get api key from your Profile Settings, and run with GRIST_API_KEY= 42 | api = GristDocAPI(DOC_ID, server=SERVER) 43 | 44 | # add some rows to a table 45 | rows = api.add_records('Table1', [ 46 | {'food': 'eggs'}, 47 | {'food': 'beets'} 48 | ]) 49 | 50 | # fetch all the rows 51 | data = api.fetch_table('Table1') 52 | print(data) 53 | 54 | 55 | Tests 56 | ----- 57 | Tests are in the ``tests/`` subdirectory. To run all tests, run:: 58 | 59 | nosetests 60 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../grist_api')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'grist_api' 21 | copyright = '2021, Grist Labs' 22 | author = 'Grist Labs' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.1' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.intersphinx', 36 | 'sphinx.ext.todo', 37 | 'sphinx.ext.coverage', 38 | 'sphinx.ext.mathjax', 39 | 'sphinx.ext.ifconfig', 40 | 'sphinx.ext.viewcode', 41 | 'sphinx.ext.githubpages', 42 | 'sphinx.ext.napoleon', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = [] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = 'sphinx_rtd_theme' 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = ['_static'] 65 | -------------------------------------------------------------------------------- /docs/source/grist_api.rst: -------------------------------------------------------------------------------- 1 | grist\_api module 2 | ================== 3 | 4 | .. automodule:: grist_api 5 | :members: 6 | :show-inheritance: 7 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. py_grist_api documentation master file, created by 2 | sphinx-quickstart on Sat Aug 7 12:43:32 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../../README.rst 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | :caption: Module Documentation: 11 | 12 | grist_api 13 | -------------------------------------------------------------------------------- /grist_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .grist_api import GristDocAPI, init_logging, date_to_ts, ts_to_date 2 | -------------------------------------------------------------------------------- /grist_api/grist_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client-side library to interact with Grist. 3 | 4 | Handling data types. Currently, datetime.date and datetime.datetime objects sent to Grist (with 5 | add_records() or update_records()) get converted to numerical timestamps as expected by Grist. 6 | 7 | Dates received from Grist remain as numerical timestamps, and may be converted using ts_to_date() 8 | function exported by this module. 9 | """ 10 | 11 | # pylint: disable=wrong-import-position,wrong-import-order,import-error 12 | from future import standard_library 13 | from future.builtins import range, str 14 | from future.utils import viewitems 15 | standard_library.install_aliases() 16 | 17 | import datetime 18 | import decimal 19 | import itertools 20 | import json 21 | import logging 22 | import os 23 | import requests 24 | import sys 25 | import time 26 | from collections import namedtuple 27 | from numbers import Number 28 | from urllib.parse import quote_plus 29 | 30 | # Set environment variable GRIST_LOGLEVEL=DEBUG for more verbosity, WARNING for less. 31 | log = logging.getLogger('grist_api') 32 | 33 | def init_logging(): 34 | if not log.handlers: 35 | handler = logging.StreamHandler(sys.stderr) 36 | handler.setFormatter(logging.Formatter(fmt='%(asctime)s %(levelname)s %(name)s %(message)s')) 37 | log.setLevel(os.environ.get("GRIST_LOGLEVEL", "INFO")) 38 | log.addHandler(handler) 39 | log.propagate = False 40 | 41 | def get_api_key(): 42 | key = os.environ.get("GRIST_API_KEY") 43 | if key: 44 | return key 45 | key_path = os.path.expanduser("~/.grist-api-key") 46 | if os.path.exists(key_path): 47 | with open(key_path, "r") as key_file: 48 | return key_file.read().strip() 49 | raise KeyError("Grist API key not found in GRIST_API_KEY env, nor in %s" % key_path) 50 | 51 | class GristDocAPI(object): 52 | """ 53 | Class for interacting with a Grist document. 54 | """ 55 | def __init__(self, doc_id, api_key=None, server='https://api.getgrist.com', dryrun=False, verify_ssl=True): 56 | """ 57 | Initialize GristDocAPI with the API Key (available from user settings), DocId (the part of the 58 | URL after /doc/), and optionally a server URL. If dryrun is true, will not make any changes to 59 | the doc. The API key, if omitted, is taken from GRIST_API_KEY env var, or ~/.grist-api-key file. 60 | 61 | Parameters: 62 | - doc_id (str): The document ID from Grist (the part of the URL after /doc/). 63 | - api_key (str, optional): API key for authentication. If not provided, it will be read from 64 | the environment variable GRIST_API_KEY or from the file ~/.grist-api-key. 65 | - server (str, optional): Base URL of the Grist server. Defaults to 'https://api.getgrist.com'. 66 | - dryrun (bool, optional): If True, no actual changes will be made to the document. Defaults to False. 67 | - verify_ssl (bool, optional): If True (default), SSL certificates will be verified. 68 | Set to False to disable SSL verification (use with caution). This should only be used for local 69 | development or in a trusted environment, as disabling SSL verification on public servers poses a risk 70 | of Man-In-The-Middle (MITM) attacks. 71 | """ 72 | self._dryrun = dryrun 73 | self._server = server 74 | self._api_key = api_key or get_api_key() 75 | self._doc_id = doc_id 76 | self._verify_ssl = verify_ssl 77 | 78 | def _raw_call(self, url, json_data=None, method=None, prefix=None): 79 | """ 80 | Low-level interface to make a REST call. 81 | """ 82 | if prefix is None: 83 | prefix = '/api/docs/%s/' % self._doc_id 84 | data = json.dumps(json_data, sort_keys=True).encode('utf8') if json_data is not None else None 85 | method = method or ('POST' if data else 'GET') 86 | 87 | while True: 88 | full_url = self._server + prefix + url 89 | if self._dryrun and method != 'GET': 90 | log.info("DRYRUN NOT sending %s request to %s", method, full_url) 91 | return None 92 | log.debug("sending %s request to %s", method, full_url) 93 | resp = requests.request(method, full_url, data=data, headers={ 94 | 'Authorization': 'Bearer %s' % self._api_key, 95 | 'Content-Type': 'application/json', 96 | 'Accept': 'application/json', 97 | }, verify=self._verify_ssl) 98 | if not resp.ok: 99 | # If the error has {"error": ...} content, use the message in the Python exception. 100 | err_msg = None 101 | try: 102 | error_obj = resp.json() 103 | if error_obj and isinstance(error_obj.get("error"), str): 104 | err_msg = error_obj.get("error") 105 | # TODO: This is a temporary workaround: SQLITE_BUSY shows up in messages for a 106 | # temporary problem for which it's safe to retry. 107 | if 'SQLITE_BUSY' in err_msg: 108 | log.warn("Retrying after error: %s", err_msg) 109 | time.sleep(2) 110 | continue 111 | except Exception: # pylint: disable=broad-except 112 | pass 113 | 114 | if err_msg: 115 | raise requests.HTTPError(err_msg, response=resp) 116 | else: 117 | raise resp.raise_for_status() 118 | return resp 119 | 120 | def call(self, url, json_data=None, method=None, prefix=None): 121 | """ 122 | Low-level interface that returns the _raw_call result as json 123 | """ 124 | result = self._raw_call(url, json_data=json_data, method=method, prefix=prefix) 125 | return result.json() if result else None 126 | 127 | def attachement_metadata(self, id_attachment): 128 | """ 129 | Get the metadata of an attachement in json (fileName…) 130 | see the api documentation for the full list of metadata: 131 | https://support.getgrist.com/api/#tag/attachments/operation/getAttachmentMetadata 132 | """ 133 | return self.call(f'attachments/%s' % id_attachment, method='GET') 134 | 135 | def attachement(self, id_attachment): 136 | """ 137 | Download the contents of an attachment in the return_value.content 138 | 139 | Response: 140 | 200: Attachment contents, with suitable Content-Type. 141 | """ 142 | return self._raw_call('attachments/%s/download' % id_attachment, method='GET') 143 | 144 | def tables(self): 145 | """ 146 | List all tables in the document 147 | """ 148 | return self.call('tables') 149 | 150 | def columns(self, table_name): 151 | """ 152 | Fetch columns for a table 153 | """ 154 | return self.call('tables/%s/columns' % table_name) 155 | 156 | def fetch_table(self, table_name, filters=None): 157 | """ 158 | Fetch all data in the table by the given name, returning a list of namedtuples with field 159 | names corresponding to the columns in that table. 160 | 161 | If filters is given, it should be a dictionary mapping column names to values, to fetch only 162 | records that match. 163 | """ 164 | query = '' 165 | if filters: 166 | query = '?filter=' + quote_plus(json.dumps( 167 | {k: [to_grist(v)] for k, v in viewitems(filters)}, sort_keys=True)) 168 | 169 | columns = self.call('tables/%s/data%s' % (table_name, query)) 170 | # convert columns to rows 171 | Record = namedtuple(table_name, columns.keys()) # pylint: disable=invalid-name 172 | count = len(columns['id']) 173 | values = columns.values() 174 | log.info("fetch_table %s returned %s rows", table_name, count) 175 | return [Record._make(v[i] for v in values) for i in range(count)] 176 | 177 | def add_records(self, table_name, record_dicts, chunk_size=None): 178 | """ 179 | Adds new records to the given table. The data is a list of dictionaries, with keys 180 | corresponding to the columns in the table. Returns a list of added rowIds. 181 | 182 | If chunk_size is given, we'll make multiple requests, each limited to chunk_size rows. 183 | """ 184 | if not record_dicts: 185 | return [] 186 | 187 | call_data = [] 188 | for records in chunks(record_dicts, max_size=chunk_size): 189 | columns = set().union(*records) 190 | col_values = {col: [to_grist(rec.get(col)) for rec in records] for col in columns} 191 | call_data.append(col_values) 192 | 193 | results = [] 194 | for data in call_data: 195 | log.info("add_records %s %s", table_name, desc_col_values(data)) 196 | results.extend(self.call('tables/%s/data' % table_name, json_data=data) or []) 197 | return results 198 | 199 | def delete_records(self, table_name, record_ids, chunk_size=None): 200 | """ 201 | Deletes records from the given table. The data is a list of record IDs. 202 | """ 203 | # There is an endpoint missing to delete records, but we can use the "apply" endpoint 204 | # meanwhile. 205 | for rec_ids in chunks(record_ids, max_size=chunk_size): 206 | log.info("delete_records %s %s records", table_name, len(rec_ids)) 207 | data = [['BulkRemoveRecord', table_name, rec_ids]] 208 | self.call('apply', json_data=data) 209 | 210 | def update_records(self, table_name, record_dicts, group_if_needed=False, chunk_size=None): 211 | """ 212 | Update existing records in the given table. The data is a list of dictionaries, with keys 213 | corresponding to the columns in the table. Each record must contain the key "id" with the 214 | rowId of the row to update. 215 | 216 | If records aren't all for the same set of columns, then a single-call update is impossible. 217 | With group_if_needed is set, we'll make multiple calls. Otherwise, will raise an exception. 218 | 219 | If chunk_size is given, we'll make multiple requests, each limited to chunk_size rows. 220 | """ 221 | groups = {} 222 | for rec in record_dicts: 223 | groups.setdefault(tuple(sorted(rec.keys())), []).append(rec) 224 | if len(groups) > 1 and not group_if_needed: 225 | raise ValueError("update_records needs group_if_needed for varied sets of columns") 226 | 227 | call_data = [] 228 | for columns, group_records in sorted(groups.items()): 229 | for records in chunks(group_records, max_size=chunk_size): 230 | col_values = {col: [to_grist(rec[col]) for rec in records] for col in columns} 231 | if 'id' not in col_values or None in col_values["id"]: 232 | raise ValueError("update_records requires 'id' key in each record") 233 | call_data.append(col_values) 234 | 235 | for data in call_data: 236 | log.info("update_records %s %s", table_name, desc_col_values(data)) 237 | self.call('tables/%s/data' % table_name, json_data=data, method="PATCH") 238 | 239 | def sync_table(self, table_id, new_data, key_cols, other_cols, grist_fetch=None, 240 | chunk_size=None, filters=None): 241 | # pylint: disable=too-many-locals,too-many-arguments 242 | """ 243 | Updates Grist table with new data, updating existing rows or adding new ones, matching rows on 244 | the given key columns. (This method does not remove rows from Grist.) 245 | 246 | New data is a list of objects with column IDs as attributes (e.g. namedtuple or sqlalchemy 247 | result rows). 248 | 249 | Parameters key_cols and other_cols list primary-key columns, and other columns to be synced. 250 | Each column in these lists must have the form (grist_col_id, new_data_col_id[, opt_type]). 251 | See make_type() for available types. In place of grist_col_id or new_data_col_id, you may use 252 | a function that takes a record and returns a value. 253 | 254 | Initial Grist data is fetched using fetch_table(table_id), unless grist_fetch is given, in 255 | which case it should contain the result of such a call. 256 | 257 | If chunk_size is given, individual requests will be limited to chunk_size rows each. 258 | 259 | If filters is given, it should be a dictionary mapping grist_col_ids from key columns to 260 | values. Only records matching these filters will be synced. 261 | """ 262 | key_cols = [make_colspec(*cs) for cs in key_cols] 263 | other_cols = [make_colspec(*cs) for cs in other_cols] 264 | 265 | def grist_attr(rec, colspec): 266 | if callable(colspec.gcol): 267 | return colspec.gcol(rec) 268 | return make_type(getattr(rec, colspec.gcol), colspec.gtype) 269 | 270 | def ext_attr(rec, colspec): 271 | if callable(colspec.ncol): 272 | return colspec.ncol(rec) 273 | return make_type(getattr(rec, colspec.ncol), colspec.gtype) 274 | 275 | # Maps unique keys to Grist rows 276 | grist_rows = {} 277 | for rec in (grist_fetch or self.fetch_table(table_id, filters=filters)): 278 | grist_rows[tuple(grist_attr(rec, cs) for cs in key_cols)] = rec 279 | 280 | all_cols = key_cols + other_cols 281 | 282 | update_list = [] 283 | add_list = [] 284 | data_count = 0 285 | filtered_out = 0 286 | for nrecord in new_data: 287 | key = tuple(ext_attr(nrecord, cs) for cs in key_cols) 288 | if filters and any((cs.ncol in filters and ext_attr(nrecord, cs) != filters[cs.ncol]) 289 | for cs in key_cols): 290 | filtered_out += 1 291 | continue 292 | 293 | data_count += 1 294 | 295 | grecord = grist_rows.get(key) 296 | if grecord: 297 | changes = [(cs, grist_attr(grecord, cs), ext_attr(nrecord, cs)) 298 | for cs in other_cols 299 | if grist_attr(grecord, cs) != ext_attr(nrecord, cs) 300 | ] 301 | update = {cs.gcol: nval for (cs, gval, nval) in changes} 302 | if update: 303 | log.debug("syncing: #%r %r needs updates %r", grecord.id, key, 304 | [(cs.gcol, gval, nval) for (cs, gval, nval) in changes]) 305 | update["id"] = grecord.id 306 | update_list.append(update) 307 | else: 308 | log.debug("syncing: %r not in grist", key) 309 | update = {cs.gcol: ext_attr(nrecord, cs) for cs in all_cols} 310 | add_list.append(update) 311 | 312 | log.info("syncing %s (%d) with %d records (%d filtered out): %d updates, %d new", 313 | table_id, len(grist_rows), data_count, filtered_out, len(update_list), len(add_list)) 314 | self.update_records(table_id, update_list, group_if_needed=True, chunk_size=chunk_size) 315 | self.add_records(table_id, add_list, chunk_size=chunk_size) 316 | 317 | class ColSpec(namedtuple('ColSpec', ('gcol', 'ncol', 'gtype'))): 318 | """ 319 | Column specifier for syncing data. Each column is represented by the tuple 320 | `(grist_col_id, new_data_col_id[, grist_type])`. 321 | """ 322 | pass 323 | 324 | def make_colspec(gcol, ncol, gtype=None): 325 | return ColSpec(gcol, ncol, gtype) 326 | 327 | 328 | 329 | EPOCH = datetime.datetime(1970, 1, 1) 330 | DATE_EPOCH = EPOCH.date() 331 | 332 | def ts_to_dt(timestamp): 333 | """ 334 | Converts a numerical timestamp in seconds to a naive datetime.datetime object representing UTC. 335 | """ 336 | return EPOCH + datetime.timedelta(seconds=timestamp) 337 | 338 | def dt_to_ts(dtime): 339 | """ 340 | Converts a datetime.datetime object to a numerical timestamp in seconds. 341 | Defaults to UTC if dtime is unaware (has no associated timezone). 342 | """ 343 | offset = dtime.utcoffset() 344 | if offset is None: 345 | offset = datetime.timedelta(0) 346 | return (dtime.replace(tzinfo=None) - offset - EPOCH).total_seconds() 347 | 348 | def date_to_ts(date): 349 | """ 350 | Converts a datetime.date object to a numerical timestamp of the UTC midnight in seconds. 351 | """ 352 | return (date - DATE_EPOCH).total_seconds() 353 | 354 | def ts_to_date(timestamp): 355 | """ 356 | Converts a numerical timestamp in seconds to a datetime.date object. 357 | """ 358 | return DATE_EPOCH + datetime.timedelta(seconds=timestamp) 359 | 360 | def to_grist(value): 361 | if isinstance(value, datetime.datetime): 362 | return value.isoformat() 363 | if isinstance(value, datetime.date): 364 | return date_to_ts(value) 365 | if isinstance(value, decimal.Decimal): 366 | return float(value) 367 | return value 368 | 369 | def make_type(value, grist_type): 370 | """ 371 | Convert a value, whether from Grist or external, to a sensible type, determined by grist_type, 372 | which should correspond to the type of the column in Grist. Currently supported types are: 373 | 374 | - Numeric: empty values default to 0.0 375 | - Text: empty values default to "" 376 | - Date: in Grist values are numerical timestamps; in Python, datetime.date. 377 | - DateTime: in Grist values are numerical timestamps; in Python, datetime.datetime. 378 | """ 379 | if grist_type in ('Text', None): 380 | return '' if value is None else value 381 | if grist_type == 'Date': 382 | return (value.date() if isinstance(value, datetime.datetime) 383 | else ts_to_date(value) if isinstance(value, Number) 384 | else value) 385 | if grist_type == 'DateTime': 386 | return ts_to_date(value) if isinstance(value, Number) else value 387 | return value 388 | 389 | def desc_col_values(data): 390 | """ 391 | Returns a human-readable summary of the given TableData object (dict mapping column name to list 392 | of values). 393 | """ 394 | rows = 0 395 | for _, values in viewitems(data): 396 | rows = len(values) 397 | break 398 | return "%s rows, cols (%s)" % (rows, ', '.join(sorted(data.keys()))) 399 | 400 | def chunks(items, max_size=None): 401 | """ 402 | Generator to return subsets of items as chunks of size at most max_size. 403 | """ 404 | if max_size is None: 405 | yield list(items) 406 | return 407 | it = iter(items) 408 | while True: 409 | chunk = list(itertools.islice(it, max_size)) 410 | if not chunk: 411 | return 412 | yield chunk 413 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | 7 | [metadata] 8 | license_file = LICENSE 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/en/latest/distributing.html 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | import io 9 | from os import path 10 | from setuptools import setup 11 | 12 | here = path.dirname(__file__) 13 | 14 | # Get the long description from the README file 15 | with io.open(path.join(here, 'README.rst'), encoding='utf-8') as f: 16 | long_description = f.read() 17 | 18 | setup( 19 | name='grist_api', 20 | version='0.1.1', 21 | description='Python client for interacting with Grist', 22 | long_description=long_description, 23 | url='https://github.com/gristlabs/py_grist_api', 24 | 25 | # Author details 26 | author='Dmitry Sagalovskiy, Grist Labs', 27 | author_email='dmitry@getgrist.com', 28 | 29 | # Choose your license 30 | license='Apache 2.0', 31 | 32 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | classifiers=[ 34 | # How mature is this project? Common values are 35 | # 3 - Alpha 36 | # 4 - Beta 37 | # 5 - Production/Stable 38 | 'Development Status :: 3 - Alpha', 39 | 40 | # Indicate who your project is intended for 41 | 'Intended Audience :: Developers', 42 | 'Topic :: Software Development :: Libraries :: Python Modules ', 43 | 'Environment :: Console', 44 | 'Operating System :: OS Independent', 45 | 46 | # Specify the Python versions you support here. In particular, ensure 47 | # that you indicate whether you support Python 2, Python 3 or both. 48 | 'Programming Language :: Python :: 2', 49 | 'Programming Language :: Python :: 2.7', 50 | 'Programming Language :: Python :: 3', 51 | 'Programming Language :: Python :: 3.5', 52 | 'Programming Language :: Python :: 3.6', 53 | 'Programming Language :: Python :: 3.7', 54 | 'Programming Language :: Python :: Implementation :: CPython', 55 | ], 56 | 57 | # What does your project relate to? 58 | keywords='grist api database', 59 | 60 | # You can just specify the packages manually here if your project is 61 | # simple. Or you can use find_packages(). 62 | packages=['grist_api'], 63 | 64 | # List run-time dependencies here. These will be installed by pip when 65 | # your project is installed. For an analysis of "install_requires" vs pip's 66 | # requirements files see: 67 | # https://packaging.python.org/en/latest/requirements.html 68 | install_requires=['future', 'requests'], 69 | 70 | # List additional groups of dependencies here (e.g. development 71 | # dependencies). You can install these using the following syntax, 72 | # for example: 73 | # $ pip install -e .[dev,test] 74 | extras_require={ 75 | 'test': ['nose', 'coverage', 'vcrpy'], 76 | }, 77 | test_suite="nose.collector", 78 | ) 79 | -------------------------------------------------------------------------------- /test/fixtures/TestGristDocAPI.grist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gristlabs/py_grist_api/7ff12056927104764226dbd200d54d53d61429dd/test/fixtures/TestGristDocAPI.grist -------------------------------------------------------------------------------- /test/fixtures/vcr/test_add_delete_records: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"ColorRef": [3, null], "Date": [1547683200.0, null], "Num": [2, 2], "Text_Field": 4 | ["Eggs", "Beets"]}' 5 | headers: 6 | Accept: 7 | - application/json 8 | Accept-Encoding: 9 | - gzip, deflate 10 | Connection: 11 | - keep-alive 12 | Content-Length: 13 | - '101' 14 | Content-Type: 15 | - application/json 16 | User-Agent: 17 | - python-requests/2.22.0 18 | method: POST 19 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 20 | response: 21 | body: 22 | string: '[5,6]' 23 | headers: 24 | Access-Control-Allow-Credentials: 25 | - 'true' 26 | Access-Control-Allow-Headers: 27 | - Authorization, Content-Type 28 | Access-Control-Allow-Methods: 29 | - GET, PATCH, POST, DELETE, OPTIONS 30 | Connection: 31 | - keep-alive 32 | Content-Length: 33 | - '5' 34 | Content-Type: 35 | - application/json; charset=utf-8 36 | Date: 37 | - Thu, 27 Jun 2019 22:17:18 GMT 38 | ETag: 39 | - W/"5-3jQgRQkkJsayENfC5SHCYuZ0SAc" 40 | X-Powered-By: 41 | - Express 42 | status: 43 | code: 200 44 | message: OK 45 | - request: 46 | body: null 47 | headers: 48 | Accept: 49 | - application/json 50 | Accept-Encoding: 51 | - gzip, deflate 52 | Connection: 53 | - keep-alive 54 | Content-Type: 55 | - application/json 56 | User-Agent: 57 | - python-requests/2.22.0 58 | method: GET 59 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22Num%22%3A+%5B2%5D%7D 60 | response: 61 | body: 62 | string: '{"id":[5,6],"manualSort":[5,6],"ColorRef":[3,0],"gristHelper_Display2":["Green",""],"Num":[2,2],"Text_Field":["Eggs","Beets"],"Date":[1547683200,null],"ColorRef_Value":["GREEN",null]}' 63 | headers: 64 | Access-Control-Allow-Credentials: 65 | - 'true' 66 | Access-Control-Allow-Headers: 67 | - Authorization, Content-Type 68 | Access-Control-Allow-Methods: 69 | - GET, PATCH, POST, DELETE, OPTIONS 70 | Connection: 71 | - keep-alive 72 | Content-Length: 73 | - '183' 74 | Content-Type: 75 | - application/json; charset=utf-8 76 | Date: 77 | - Thu, 27 Jun 2019 22:17:18 GMT 78 | ETag: 79 | - W/"b7-BC640K419qGLbvvPwSqUy2f4Aj8" 80 | X-Powered-By: 81 | - Express 82 | status: 83 | code: 200 84 | message: OK 85 | - request: 86 | body: '[["BulkRemoveRecord", "Table1", [5, 6]]]' 87 | headers: 88 | Accept: 89 | - application/json 90 | Accept-Encoding: 91 | - gzip, deflate 92 | Connection: 93 | - keep-alive 94 | Content-Length: 95 | - '40' 96 | Content-Type: 97 | - application/json 98 | User-Agent: 99 | - python-requests/2.22.0 100 | method: POST 101 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 102 | response: 103 | body: 104 | string: '{"actionNum":248,"retValues":[null]}' 105 | headers: 106 | Access-Control-Allow-Credentials: 107 | - 'true' 108 | Access-Control-Allow-Headers: 109 | - Authorization, Content-Type 110 | Access-Control-Allow-Methods: 111 | - GET, PATCH, POST, DELETE, OPTIONS 112 | Connection: 113 | - keep-alive 114 | Content-Length: 115 | - '36' 116 | Content-Type: 117 | - application/json; charset=utf-8 118 | Date: 119 | - Thu, 27 Jun 2019 22:17:18 GMT 120 | ETag: 121 | - W/"24-zNC7MVDUxJ5XtURlkcP6VSAF4Js" 122 | X-Powered-By: 123 | - Express 124 | status: 125 | code: 200 126 | message: OK 127 | - request: 128 | body: null 129 | headers: 130 | Accept: 131 | - application/json 132 | Accept-Encoding: 133 | - gzip, deflate 134 | Connection: 135 | - keep-alive 136 | Content-Type: 137 | - application/json 138 | User-Agent: 139 | - python-requests/2.22.0 140 | method: GET 141 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22Num%22%3A+%5B2%5D%7D 142 | response: 143 | body: 144 | string: '{"id":[],"manualSort":[],"ColorRef":[],"gristHelper_Display2":[],"Num":[],"Text_Field":[],"Date":[],"ColorRef_Value":[]}' 145 | headers: 146 | Access-Control-Allow-Credentials: 147 | - 'true' 148 | Access-Control-Allow-Headers: 149 | - Authorization, Content-Type 150 | Access-Control-Allow-Methods: 151 | - GET, PATCH, POST, DELETE, OPTIONS 152 | Connection: 153 | - keep-alive 154 | Content-Length: 155 | - '120' 156 | Content-Type: 157 | - application/json; charset=utf-8 158 | Date: 159 | - Thu, 27 Jun 2019 22:17:18 GMT 160 | ETag: 161 | - W/"78-p/QE8hK4ZY3tR437eq99uPyT8U0" 162 | X-Powered-By: 163 | - Express 164 | status: 165 | code: 200 166 | message: OK 167 | version: 1 168 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_chunking: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"Num": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "Text_Field": ["Chunk", 4 | "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", 5 | "Chunk", "Chunk"]}' 6 | headers: 7 | Accept: 8 | - application/json 9 | Accept-Encoding: 10 | - gzip, deflate 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '171' 15 | Content-Type: 16 | - application/json 17 | User-Agent: 18 | - python-requests/2.22.0 19 | method: POST 20 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 21 | response: 22 | body: 23 | string: '[5,6,7,8,9,10,11,12,13,14,15,16]' 24 | headers: 25 | Access-Control-Allow-Credentials: 26 | - 'true' 27 | Access-Control-Allow-Headers: 28 | - Authorization, Content-Type 29 | Access-Control-Allow-Methods: 30 | - GET, PATCH, POST, DELETE, OPTIONS 31 | Connection: 32 | - keep-alive 33 | Content-Length: 34 | - '32' 35 | Content-Type: 36 | - application/json; charset=utf-8 37 | Date: 38 | - Thu, 27 Jun 2019 22:17:18 GMT 39 | ETag: 40 | - W/"20-2RBtdM4Xoe128aJkTSAPZwSBUU8" 41 | X-Powered-By: 42 | - Express 43 | status: 44 | code: 200 45 | message: OK 46 | - request: 47 | body: '{"Num": [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], "Text_Field": 48 | ["Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", 49 | "Chunk", "Chunk", "Chunk"]}' 50 | headers: 51 | Accept: 52 | - application/json 53 | Accept-Encoding: 54 | - gzip, deflate 55 | Connection: 56 | - keep-alive 57 | Content-Length: 58 | - '181' 59 | Content-Type: 60 | - application/json 61 | User-Agent: 62 | - python-requests/2.22.0 63 | method: POST 64 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 65 | response: 66 | body: 67 | string: '[17,18,19,20,21,22,23,24,25,26,27,28]' 68 | headers: 69 | Access-Control-Allow-Credentials: 70 | - 'true' 71 | Access-Control-Allow-Headers: 72 | - Authorization, Content-Type 73 | Access-Control-Allow-Methods: 74 | - GET, PATCH, POST, DELETE, OPTIONS 75 | Connection: 76 | - keep-alive 77 | Content-Length: 78 | - '37' 79 | Content-Type: 80 | - application/json; charset=utf-8 81 | Date: 82 | - Thu, 27 Jun 2019 22:17:18 GMT 83 | ETag: 84 | - W/"25-XmiV9KikkplQhAoanlHBpdkbg0I" 85 | X-Powered-By: 86 | - Express 87 | status: 88 | code: 200 89 | message: OK 90 | - request: 91 | body: '{"Num": [24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35], "Text_Field": 92 | ["Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", 93 | "Chunk", "Chunk", "Chunk"]}' 94 | headers: 95 | Accept: 96 | - application/json 97 | Accept-Encoding: 98 | - gzip, deflate 99 | Connection: 100 | - keep-alive 101 | Content-Length: 102 | - '181' 103 | Content-Type: 104 | - application/json 105 | User-Agent: 106 | - python-requests/2.22.0 107 | method: POST 108 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 109 | response: 110 | body: 111 | string: '[29,30,31,32,33,34,35,36,37,38,39,40]' 112 | headers: 113 | Access-Control-Allow-Credentials: 114 | - 'true' 115 | Access-Control-Allow-Headers: 116 | - Authorization, Content-Type 117 | Access-Control-Allow-Methods: 118 | - GET, PATCH, POST, DELETE, OPTIONS 119 | Connection: 120 | - keep-alive 121 | Content-Length: 122 | - '37' 123 | Content-Type: 124 | - application/json; charset=utf-8 125 | Date: 126 | - Thu, 27 Jun 2019 22:17:18 GMT 127 | ETag: 128 | - W/"25-mfbkUiqpc15psKrg0ODJ2LXgze8" 129 | X-Powered-By: 130 | - Express 131 | status: 132 | code: 200 133 | message: OK 134 | - request: 135 | body: '{"Num": [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47], "Text_Field": 136 | ["Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk", 137 | "Chunk", "Chunk", "Chunk"]}' 138 | headers: 139 | Accept: 140 | - application/json 141 | Accept-Encoding: 142 | - gzip, deflate 143 | Connection: 144 | - keep-alive 145 | Content-Length: 146 | - '181' 147 | Content-Type: 148 | - application/json 149 | User-Agent: 150 | - python-requests/2.22.0 151 | method: POST 152 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 153 | response: 154 | body: 155 | string: '[41,42,43,44,45,46,47,48,49,50,51,52]' 156 | headers: 157 | Access-Control-Allow-Credentials: 158 | - 'true' 159 | Access-Control-Allow-Headers: 160 | - Authorization, Content-Type 161 | Access-Control-Allow-Methods: 162 | - GET, PATCH, POST, DELETE, OPTIONS 163 | Connection: 164 | - keep-alive 165 | Content-Length: 166 | - '37' 167 | Content-Type: 168 | - application/json; charset=utf-8 169 | Date: 170 | - Thu, 27 Jun 2019 22:17:18 GMT 171 | ETag: 172 | - W/"25-AAK5swNgzauoNmhX9VQP5HUne1E" 173 | X-Powered-By: 174 | - Express 175 | status: 176 | code: 200 177 | message: OK 178 | - request: 179 | body: '{"Num": [48, 49], "Text_Field": ["Chunk", "Chunk"]}' 180 | headers: 181 | Accept: 182 | - application/json 183 | Accept-Encoding: 184 | - gzip, deflate 185 | Connection: 186 | - keep-alive 187 | Content-Length: 188 | - '51' 189 | Content-Type: 190 | - application/json 191 | User-Agent: 192 | - python-requests/2.22.0 193 | method: POST 194 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 195 | response: 196 | body: 197 | string: '[53,54]' 198 | headers: 199 | Access-Control-Allow-Credentials: 200 | - 'true' 201 | Access-Control-Allow-Headers: 202 | - Authorization, Content-Type 203 | Access-Control-Allow-Methods: 204 | - GET, PATCH, POST, DELETE, OPTIONS 205 | Connection: 206 | - keep-alive 207 | Content-Length: 208 | - '7' 209 | Content-Type: 210 | - application/json; charset=utf-8 211 | Date: 212 | - Thu, 27 Jun 2019 22:17:18 GMT 213 | ETag: 214 | - W/"7-6+7V+9tJA1LUdJm1bDo9dm/xrEs" 215 | X-Powered-By: 216 | - Express 217 | status: 218 | code: 200 219 | message: OK 220 | - request: 221 | body: null 222 | headers: 223 | Accept: 224 | - application/json 225 | Accept-Encoding: 226 | - gzip, deflate 227 | Connection: 228 | - keep-alive 229 | Content-Type: 230 | - application/json 231 | User-Agent: 232 | - python-requests/2.22.0 233 | method: GET 234 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 235 | response: 236 | body: 237 | string: '{"id":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"manualSort":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"ColorRef":[1,2,3,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"gristHelper_Display2":["Red","Orange","Green","Red","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""],"Num":[5,8,12,1.5,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"Text_Field":["Apple","Orange","Melon","Strawberry","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk","Chunk"],"Date":[1561507200,1556668800,1554163200,1551571200,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}' 238 | headers: 239 | Access-Control-Allow-Credentials: 240 | - 'true' 241 | Access-Control-Allow-Headers: 242 | - Authorization, Content-Type 243 | Access-Control-Allow-Methods: 244 | - GET, PATCH, POST, DELETE, OPTIONS 245 | Connection: 246 | - keep-alive 247 | Content-Length: 248 | - '1867' 249 | Content-Type: 250 | - application/json; charset=utf-8 251 | Date: 252 | - Thu, 27 Jun 2019 22:17:18 GMT 253 | ETag: 254 | - W/"74b-xShRIJ5mIr2E7QZnCYkXjiXUyDY" 255 | X-Powered-By: 256 | - Express 257 | status: 258 | code: 200 259 | message: OK 260 | - request: 261 | body: '{"ColorRef": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], "Text_Field": ["Peanut 262 | Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", 263 | "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut 264 | Butter", "Peanut Butter", "Peanut Butter"], "id": [5, 6, 7, 8, 9, 10, 11, 12, 265 | 13, 14, 15, 16]}' 266 | headers: 267 | Accept: 268 | - application/json 269 | Accept-Encoding: 270 | - gzip, deflate 271 | Connection: 272 | - keep-alive 273 | Content-Length: 274 | - '321' 275 | Content-Type: 276 | - application/json 277 | User-Agent: 278 | - python-requests/2.22.0 279 | method: PATCH 280 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 281 | response: 282 | body: 283 | string: 'null' 284 | headers: 285 | Access-Control-Allow-Credentials: 286 | - 'true' 287 | Access-Control-Allow-Headers: 288 | - Authorization, Content-Type 289 | Access-Control-Allow-Methods: 290 | - GET, PATCH, POST, DELETE, OPTIONS 291 | Connection: 292 | - keep-alive 293 | Content-Length: 294 | - '4' 295 | Content-Type: 296 | - application/json; charset=utf-8 297 | Date: 298 | - Thu, 27 Jun 2019 22:17:18 GMT 299 | ETag: 300 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 301 | X-Powered-By: 302 | - Express 303 | status: 304 | code: 200 305 | message: OK 306 | - request: 307 | body: '{"ColorRef": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], "Text_Field": ["Peanut 308 | Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", 309 | "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut 310 | Butter", "Peanut Butter", "Peanut Butter"], "id": [17, 18, 19, 20, 21, 22, 23, 311 | 24, 25, 26, 27, 28]}' 312 | headers: 313 | Accept: 314 | - application/json 315 | Accept-Encoding: 316 | - gzip, deflate 317 | Connection: 318 | - keep-alive 319 | Content-Length: 320 | - '326' 321 | Content-Type: 322 | - application/json 323 | User-Agent: 324 | - python-requests/2.22.0 325 | method: PATCH 326 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 327 | response: 328 | body: 329 | string: 'null' 330 | headers: 331 | Access-Control-Allow-Credentials: 332 | - 'true' 333 | Access-Control-Allow-Headers: 334 | - Authorization, Content-Type 335 | Access-Control-Allow-Methods: 336 | - GET, PATCH, POST, DELETE, OPTIONS 337 | Connection: 338 | - keep-alive 339 | Content-Length: 340 | - '4' 341 | Content-Type: 342 | - application/json; charset=utf-8 343 | Date: 344 | - Thu, 27 Jun 2019 22:17:18 GMT 345 | ETag: 346 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 347 | X-Powered-By: 348 | - Express 349 | status: 350 | code: 200 351 | message: OK 352 | - request: 353 | body: '{"ColorRef": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], "Text_Field": ["Peanut 354 | Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", 355 | "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut 356 | Butter", "Peanut Butter", "Peanut Butter"], "id": [29, 30, 31, 32, 33, 34, 35, 357 | 36, 37, 38, 39, 40]}' 358 | headers: 359 | Accept: 360 | - application/json 361 | Accept-Encoding: 362 | - gzip, deflate 363 | Connection: 364 | - keep-alive 365 | Content-Length: 366 | - '326' 367 | Content-Type: 368 | - application/json 369 | User-Agent: 370 | - python-requests/2.22.0 371 | method: PATCH 372 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 373 | response: 374 | body: 375 | string: 'null' 376 | headers: 377 | Access-Control-Allow-Credentials: 378 | - 'true' 379 | Access-Control-Allow-Headers: 380 | - Authorization, Content-Type 381 | Access-Control-Allow-Methods: 382 | - GET, PATCH, POST, DELETE, OPTIONS 383 | Connection: 384 | - keep-alive 385 | Content-Length: 386 | - '4' 387 | Content-Type: 388 | - application/json; charset=utf-8 389 | Date: 390 | - Thu, 27 Jun 2019 22:17:18 GMT 391 | ETag: 392 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 393 | X-Powered-By: 394 | - Express 395 | status: 396 | code: 200 397 | message: OK 398 | - request: 399 | body: '{"ColorRef": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], "Text_Field": ["Peanut 400 | Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", 401 | "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut Butter", "Peanut 402 | Butter", "Peanut Butter", "Peanut Butter"], "id": [41, 42, 43, 44, 45, 46, 47, 403 | 48, 49, 50, 51, 52]}' 404 | headers: 405 | Accept: 406 | - application/json 407 | Accept-Encoding: 408 | - gzip, deflate 409 | Connection: 410 | - keep-alive 411 | Content-Length: 412 | - '326' 413 | Content-Type: 414 | - application/json 415 | User-Agent: 416 | - python-requests/2.22.0 417 | method: PATCH 418 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 419 | response: 420 | body: 421 | string: 'null' 422 | headers: 423 | Access-Control-Allow-Credentials: 424 | - 'true' 425 | Access-Control-Allow-Headers: 426 | - Authorization, Content-Type 427 | Access-Control-Allow-Methods: 428 | - GET, PATCH, POST, DELETE, OPTIONS 429 | Connection: 430 | - keep-alive 431 | Content-Length: 432 | - '4' 433 | Content-Type: 434 | - application/json; charset=utf-8 435 | Date: 436 | - Thu, 27 Jun 2019 22:17:18 GMT 437 | ETag: 438 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 439 | X-Powered-By: 440 | - Express 441 | status: 442 | code: 200 443 | message: OK 444 | - request: 445 | body: '{"ColorRef": [2, 2], "Text_Field": ["Peanut Butter", "Peanut Butter"], 446 | "id": [53, 54]}' 447 | headers: 448 | Accept: 449 | - application/json 450 | Accept-Encoding: 451 | - gzip, deflate 452 | Connection: 453 | - keep-alive 454 | Content-Length: 455 | - '86' 456 | Content-Type: 457 | - application/json 458 | User-Agent: 459 | - python-requests/2.22.0 460 | method: PATCH 461 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 462 | response: 463 | body: 464 | string: 'null' 465 | headers: 466 | Access-Control-Allow-Credentials: 467 | - 'true' 468 | Access-Control-Allow-Headers: 469 | - Authorization, Content-Type 470 | Access-Control-Allow-Methods: 471 | - GET, PATCH, POST, DELETE, OPTIONS 472 | Connection: 473 | - keep-alive 474 | Content-Length: 475 | - '4' 476 | Content-Type: 477 | - application/json; charset=utf-8 478 | Date: 479 | - Thu, 27 Jun 2019 22:17:18 GMT 480 | ETag: 481 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 482 | X-Powered-By: 483 | - Express 484 | status: 485 | code: 200 486 | message: OK 487 | - request: 488 | body: null 489 | headers: 490 | Accept: 491 | - application/json 492 | Accept-Encoding: 493 | - gzip, deflate 494 | Connection: 495 | - keep-alive 496 | Content-Type: 497 | - application/json 498 | User-Agent: 499 | - python-requests/2.22.0 500 | method: GET 501 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 502 | response: 503 | body: 504 | string: '{"id":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"manualSort":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"ColorRef":[1,2,3,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"gristHelper_Display2":["Red","Orange","Green","Red","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange","Orange"],"Num":[5,8,12,1.5,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"Text_Field":["Apple","Orange","Melon","Strawberry","Peanut 505 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 506 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 507 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 508 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 509 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 510 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 511 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 512 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 513 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut 514 | Butter","Peanut Butter","Peanut Butter","Peanut Butter","Peanut Butter"],"Date":[1561507200,1556668800,1554163200,1551571200,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"ColorRef_Value":["RED","ORANGE","GREEN","RED","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE","ORANGE"]}' 515 | headers: 516 | Access-Control-Allow-Credentials: 517 | - 'true' 518 | Access-Control-Allow-Headers: 519 | - Authorization, Content-Type 520 | Access-Control-Allow-Methods: 521 | - GET, PATCH, POST, DELETE, OPTIONS 522 | Connection: 523 | - keep-alive 524 | Content-Length: 525 | - '2767' 526 | Content-Type: 527 | - application/json; charset=utf-8 528 | Date: 529 | - Thu, 27 Jun 2019 22:17:18 GMT 530 | ETag: 531 | - W/"acf-SLplyQ/dK3CLwKZ2Cdh6v+WcTQ4" 532 | X-Powered-By: 533 | - Express 534 | status: 535 | code: 200 536 | message: OK 537 | - request: 538 | body: '[["BulkRemoveRecord", "Table1", [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 539 | 16]]]' 540 | headers: 541 | Accept: 542 | - application/json 543 | Accept-Encoding: 544 | - gzip, deflate 545 | Connection: 546 | - keep-alive 547 | Content-Length: 548 | - '77' 549 | Content-Type: 550 | - application/json 551 | User-Agent: 552 | - python-requests/2.22.0 553 | method: POST 554 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 555 | response: 556 | body: 557 | string: '{"actionNum":259,"retValues":[null]}' 558 | headers: 559 | Access-Control-Allow-Credentials: 560 | - 'true' 561 | Access-Control-Allow-Headers: 562 | - Authorization, Content-Type 563 | Access-Control-Allow-Methods: 564 | - GET, PATCH, POST, DELETE, OPTIONS 565 | Connection: 566 | - keep-alive 567 | Content-Length: 568 | - '36' 569 | Content-Type: 570 | - application/json; charset=utf-8 571 | Date: 572 | - Thu, 27 Jun 2019 22:17:18 GMT 573 | ETag: 574 | - W/"24-+OOYsbAQss8e4dr6lxenAiPyB04" 575 | X-Powered-By: 576 | - Express 577 | status: 578 | code: 200 579 | message: OK 580 | - request: 581 | body: '[["BulkRemoveRecord", "Table1", [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 582 | 27, 28]]]' 583 | headers: 584 | Accept: 585 | - application/json 586 | Accept-Encoding: 587 | - gzip, deflate 588 | Connection: 589 | - keep-alive 590 | Content-Length: 591 | - '82' 592 | Content-Type: 593 | - application/json 594 | User-Agent: 595 | - python-requests/2.22.0 596 | method: POST 597 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 598 | response: 599 | body: 600 | string: '{"actionNum":260,"retValues":[null]}' 601 | headers: 602 | Access-Control-Allow-Credentials: 603 | - 'true' 604 | Access-Control-Allow-Headers: 605 | - Authorization, Content-Type 606 | Access-Control-Allow-Methods: 607 | - GET, PATCH, POST, DELETE, OPTIONS 608 | Connection: 609 | - keep-alive 610 | Content-Length: 611 | - '36' 612 | Content-Type: 613 | - application/json; charset=utf-8 614 | Date: 615 | - Thu, 27 Jun 2019 22:17:18 GMT 616 | ETag: 617 | - W/"24-gDRqN++hZn//ocFQtoFq5yisIa8" 618 | X-Powered-By: 619 | - Express 620 | status: 621 | code: 200 622 | message: OK 623 | - request: 624 | body: '[["BulkRemoveRecord", "Table1", [29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 625 | 39, 40]]]' 626 | headers: 627 | Accept: 628 | - application/json 629 | Accept-Encoding: 630 | - gzip, deflate 631 | Connection: 632 | - keep-alive 633 | Content-Length: 634 | - '82' 635 | Content-Type: 636 | - application/json 637 | User-Agent: 638 | - python-requests/2.22.0 639 | method: POST 640 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 641 | response: 642 | body: 643 | string: '{"actionNum":261,"retValues":[null]}' 644 | headers: 645 | Access-Control-Allow-Credentials: 646 | - 'true' 647 | Access-Control-Allow-Headers: 648 | - Authorization, Content-Type 649 | Access-Control-Allow-Methods: 650 | - GET, PATCH, POST, DELETE, OPTIONS 651 | Connection: 652 | - keep-alive 653 | Content-Length: 654 | - '36' 655 | Content-Type: 656 | - application/json; charset=utf-8 657 | Date: 658 | - Thu, 27 Jun 2019 22:17:18 GMT 659 | ETag: 660 | - W/"24-Hc1+cgZcvKyu0ktP/Sst9cSJDoA" 661 | X-Powered-By: 662 | - Express 663 | status: 664 | code: 200 665 | message: OK 666 | - request: 667 | body: '[["BulkRemoveRecord", "Table1", [41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 668 | 51, 52]]]' 669 | headers: 670 | Accept: 671 | - application/json 672 | Accept-Encoding: 673 | - gzip, deflate 674 | Connection: 675 | - keep-alive 676 | Content-Length: 677 | - '82' 678 | Content-Type: 679 | - application/json 680 | User-Agent: 681 | - python-requests/2.22.0 682 | method: POST 683 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 684 | response: 685 | body: 686 | string: '{"actionNum":262,"retValues":[null]}' 687 | headers: 688 | Access-Control-Allow-Credentials: 689 | - 'true' 690 | Access-Control-Allow-Headers: 691 | - Authorization, Content-Type 692 | Access-Control-Allow-Methods: 693 | - GET, PATCH, POST, DELETE, OPTIONS 694 | Connection: 695 | - keep-alive 696 | Content-Length: 697 | - '36' 698 | Content-Type: 699 | - application/json; charset=utf-8 700 | Date: 701 | - Thu, 27 Jun 2019 22:17:18 GMT 702 | ETag: 703 | - W/"24-aZbyNsHQaOZG16L7VW3wSksvG0U" 704 | X-Powered-By: 705 | - Express 706 | status: 707 | code: 200 708 | message: OK 709 | - request: 710 | body: '[["BulkRemoveRecord", "Table1", [53, 54]]]' 711 | headers: 712 | Accept: 713 | - application/json 714 | Accept-Encoding: 715 | - gzip, deflate 716 | Connection: 717 | - keep-alive 718 | Content-Length: 719 | - '42' 720 | Content-Type: 721 | - application/json 722 | User-Agent: 723 | - python-requests/2.22.0 724 | method: POST 725 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 726 | response: 727 | body: 728 | string: '{"actionNum":263,"retValues":[null]}' 729 | headers: 730 | Access-Control-Allow-Credentials: 731 | - 'true' 732 | Access-Control-Allow-Headers: 733 | - Authorization, Content-Type 734 | Access-Control-Allow-Methods: 735 | - GET, PATCH, POST, DELETE, OPTIONS 736 | Connection: 737 | - keep-alive 738 | Content-Length: 739 | - '36' 740 | Content-Type: 741 | - application/json; charset=utf-8 742 | Date: 743 | - Thu, 27 Jun 2019 22:17:18 GMT 744 | ETag: 745 | - W/"24-wI4RZKuWuUOzNe66e7KAmMiU3ro" 746 | X-Powered-By: 747 | - Express 748 | status: 749 | code: 200 750 | message: OK 751 | - request: 752 | body: null 753 | headers: 754 | Accept: 755 | - application/json 756 | Accept-Encoding: 757 | - gzip, deflate 758 | Connection: 759 | - keep-alive 760 | Content-Type: 761 | - application/json 762 | User-Agent: 763 | - python-requests/2.22.0 764 | method: GET 765 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 766 | response: 767 | body: 768 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 769 | headers: 770 | Access-Control-Allow-Credentials: 771 | - 'true' 772 | Access-Control-Allow-Headers: 773 | - Authorization, Content-Type 774 | Access-Control-Allow-Methods: 775 | - GET, PATCH, POST, DELETE, OPTIONS 776 | Connection: 777 | - keep-alive 778 | Content-Length: 779 | - '287' 780 | Content-Type: 781 | - application/json; charset=utf-8 782 | Date: 783 | - Thu, 27 Jun 2019 22:17:18 GMT 784 | ETag: 785 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 786 | X-Powered-By: 787 | - Express 788 | status: 789 | code: 200 790 | message: OK 791 | version: 1 792 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_columns: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: http://localhost:8080/api/docs/mqpaKNo1B3Ag/tables/Table1/columns 17 | response: 18 | body: 19 | string: '{"columns":[{"id":"col1","fields":{"colRef":2,"parentId":1,"parentPos":2,"type":"Text","widgetOptions":"","isFormula":false,"formula":"","label":"col1","description":"","untieColIdFromLabel":false,"summarySourceCol":0,"displayCol":0,"visibleCol":0,"rules":null,"recalcWhen":1,"recalcDeps":null}},{"id":"col2","fields":{"colRef":3,"parentId":1,"parentPos":3,"type":"Numeric","widgetOptions":"","isFormula":false,"formula":"","label":"col2","description":"","untieColIdFromLabel":false,"summarySourceCol":0,"displayCol":0,"visibleCol":0,"rules":null,"recalcWhen":0,"recalcDeps":null}},{"id":"col3","fields":{"colRef":4,"parentId":1,"parentPos":4,"type":"Int","widgetOptions":"","isFormula":false,"formula":"","label":"col3","description":"","untieColIdFromLabel":false,"summarySourceCol":0,"displayCol":0,"visibleCol":0,"rules":null,"recalcWhen":0,"recalcDeps":null}},{"id":"col4","fields":{"colRef":5,"parentId":1,"parentPos":5,"type":"Bool","widgetOptions":"","isFormula":false,"formula":"","label":"col4","description":"","untieColIdFromLabel":false,"summarySourceCol":0,"displayCol":0,"visibleCol":0,"rules":null,"recalcWhen":0,"recalcDeps":null}}]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type, X-Requested-With 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, PUT, POST, DELETE, OPTIONS 27 | Cache-Control: 28 | - no-cache 29 | Connection: 30 | - keep-alive 31 | Content-Language: 32 | - en 33 | Content-Length: 34 | - '1151' 35 | Content-Type: 36 | - application/json; charset=utf-8 37 | Date: 38 | - Tue, 06 Jun 2023 07:16:41 GMT 39 | ETag: 40 | - W/"47f-gfsOrOVhFsmmnhQKMpqxw99aq8I" 41 | Server: 42 | - nginx/1.18.0 43 | Set-Cookie: 44 | - grist_core=s%3Ag-6dB3Fsko5ViRFMypswi3Aj.7FM6ForjG03mVrkpe60ShKCQpmFH5aoHMmZwgyBR1sM; 45 | Path=/; HttpOnly; SameSite=Lax 46 | Strict-Transport-Security: 47 | - max-age=15552000; 48 | X-Powered-By: 49 | - Express 50 | status: 51 | code: 200 52 | message: OK 53 | version: 1 54 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_errors: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Unicorn/data 17 | response: 18 | body: 19 | string: '{"error":"Table not found \"Unicorn\""}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '39' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:18 GMT 35 | ETag: 36 | - W/"27-FMJZhUs83UfjomHAsYXqleAOlW8" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 404 41 | message: Not Found 42 | - request: 43 | body: null 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Type: 52 | - application/json 53 | User-Agent: 54 | - python-requests/2.22.0 55 | method: GET 56 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22ColorBoom%22%3A+%5B2%5D%2C+%22ColorRef%22%3A+%5B1%5D%7D 57 | response: 58 | body: 59 | string: '{"error":"Error doing API call: [Sandbox] ''ColorBoom''"}' 60 | headers: 61 | Access-Control-Allow-Credentials: 62 | - 'true' 63 | Access-Control-Allow-Headers: 64 | - Authorization, Content-Type 65 | Access-Control-Allow-Methods: 66 | - GET, PATCH, POST, DELETE, OPTIONS 67 | Connection: 68 | - keep-alive 69 | Content-Length: 70 | - '55' 71 | Content-Type: 72 | - application/json; charset=utf-8 73 | Date: 74 | - Thu, 27 Jun 2019 22:17:18 GMT 75 | ETag: 76 | - W/"37-o2R7QYeeDJbOZYFxmjjxOTF3HSU" 77 | X-Powered-By: 78 | - Express 79 | status: 80 | code: 400 81 | message: Bad Request 82 | - request: 83 | body: '{"NumX": [2], "Text_Field": ["Beets"]}' 84 | headers: 85 | Accept: 86 | - application/json 87 | Accept-Encoding: 88 | - gzip, deflate 89 | Connection: 90 | - keep-alive 91 | Content-Length: 92 | - '38' 93 | Content-Type: 94 | - application/json 95 | User-Agent: 96 | - python-requests/2.22.0 97 | method: POST 98 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 99 | response: 100 | body: 101 | string: '{"error":"Invalid column \"NumX\""}' 102 | headers: 103 | Access-Control-Allow-Credentials: 104 | - 'true' 105 | Access-Control-Allow-Headers: 106 | - Authorization, Content-Type 107 | Access-Control-Allow-Methods: 108 | - GET, PATCH, POST, DELETE, OPTIONS 109 | Connection: 110 | - keep-alive 111 | Content-Length: 112 | - '35' 113 | Content-Type: 114 | - application/json; charset=utf-8 115 | Date: 116 | - Thu, 27 Jun 2019 22:17:18 GMT 117 | ETag: 118 | - W/"23-I49wdG0VGicmjjNQfZ7/z6Jk9Ks" 119 | X-Powered-By: 120 | - Express 121 | status: 122 | code: 400 123 | message: Bad Request 124 | version: 1 125 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_fetch_table: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 17 | response: 18 | body: 19 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '287' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:18 GMT 35 | ETag: 36 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 200 41 | message: OK 42 | - request: 43 | body: null 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Type: 52 | - application/json 53 | User-Agent: 54 | - python-requests/2.22.0 55 | method: GET 56 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22ColorRef%22%3A+%5B1%5D%7D 57 | response: 58 | body: 59 | string: '{"id":[1,4],"manualSort":[1,4],"ColorRef":[1,1],"gristHelper_Display2":["Red","Red"],"Num":[5,1.5],"Text_Field":["Apple","Strawberry"],"Date":[1561507200,1551571200],"ColorRef_Value":["RED","RED"]}' 60 | headers: 61 | Access-Control-Allow-Credentials: 62 | - 'true' 63 | Access-Control-Allow-Headers: 64 | - Authorization, Content-Type 65 | Access-Control-Allow-Methods: 66 | - GET, PATCH, POST, DELETE, OPTIONS 67 | Connection: 68 | - keep-alive 69 | Content-Length: 70 | - '197' 71 | Content-Type: 72 | - application/json; charset=utf-8 73 | Date: 74 | - Thu, 27 Jun 2019 22:17:18 GMT 75 | ETag: 76 | - W/"c5-1yi+CVlki9fTbSsHX8VJaaV6VCU" 77 | X-Powered-By: 78 | - Express 79 | status: 80 | code: 200 81 | message: OK 82 | version: 1 83 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_list_tables: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: http://localhost:8080/api/docs/mqpaKNo1B3Ag/tables 17 | response: 18 | body: 19 | string: '{"tables":[{"id":"Table1","fields":{"primaryViewId":1,"summarySourceTable":0,"onDemand":false,"rawViewSectionRef":2,"tableRef":1}}]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type, X-Requested-With 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, PUT, POST, DELETE, OPTIONS 27 | Cache-Control: 28 | - no-cache 29 | Connection: 30 | - keep-alive 31 | Content-Language: 32 | - en 33 | Content-Length: 34 | - '132' 35 | Content-Type: 36 | - application/json; charset=utf-8 37 | Date: 38 | - Tue, 06 Jun 2023 07:16:41 GMT 39 | ETag: 40 | - W/"84-e37GeRL+Yq1h+OANyMhvBg0Mrgg" 41 | Server: 42 | - nginx/1.18.0 43 | Set-Cookie: 44 | - grist_core=s%3Ag-6dB3Fsko5ViRFMypswi3Aj.7FM6ForjG03mVrkpe60ShKCQpmFH5aoHMmZwgyBR1sM; 45 | Path=/; HttpOnly; SameSite=Lax 46 | Strict-Transport-Security: 47 | - max-age=15552000; 48 | X-Powered-By: 49 | - Express 50 | status: 51 | code: 200 52 | message: OK 53 | version: 1 54 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_sync_table: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 17 | response: 18 | body: 19 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '287' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:18 GMT 35 | ETag: 36 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 200 41 | message: OK 42 | - request: 43 | body: '{"Date": [1588291200.0, null], "Num": [17, 28], "id": [1, 3]}' 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Length: 52 | - '61' 53 | Content-Type: 54 | - application/json 55 | User-Agent: 56 | - python-requests/2.22.0 57 | method: PATCH 58 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 59 | response: 60 | body: 61 | string: 'null' 62 | headers: 63 | Access-Control-Allow-Credentials: 64 | - 'true' 65 | Access-Control-Allow-Headers: 66 | - Authorization, Content-Type 67 | Access-Control-Allow-Methods: 68 | - GET, PATCH, POST, DELETE, OPTIONS 69 | Connection: 70 | - keep-alive 71 | Content-Length: 72 | - '4' 73 | Content-Type: 74 | - application/json; charset=utf-8 75 | Date: 76 | - Thu, 27 Jun 2019 22:17:18 GMT 77 | ETag: 78 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 79 | X-Powered-By: 80 | - Express 81 | status: 82 | code: 200 83 | message: OK 84 | - request: 85 | body: '{"Date": [1588377600.0], "Num": [33], "Text_Field": ["Banana"]}' 86 | headers: 87 | Accept: 88 | - application/json 89 | Accept-Encoding: 90 | - gzip, deflate 91 | Connection: 92 | - keep-alive 93 | Content-Length: 94 | - '63' 95 | Content-Type: 96 | - application/json 97 | User-Agent: 98 | - python-requests/2.22.0 99 | method: POST 100 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 101 | response: 102 | body: 103 | string: '[5]' 104 | headers: 105 | Access-Control-Allow-Credentials: 106 | - 'true' 107 | Access-Control-Allow-Headers: 108 | - Authorization, Content-Type 109 | Access-Control-Allow-Methods: 110 | - GET, PATCH, POST, DELETE, OPTIONS 111 | Connection: 112 | - keep-alive 113 | Content-Length: 114 | - '3' 115 | Content-Type: 116 | - application/json; charset=utf-8 117 | Date: 118 | - Thu, 27 Jun 2019 22:17:18 GMT 119 | ETag: 120 | - W/"3-EK4kl5xQKPqHNlG8ozgVLcBIQkU" 121 | X-Powered-By: 122 | - Express 123 | status: 124 | code: 200 125 | message: OK 126 | - request: 127 | body: null 128 | headers: 129 | Accept: 130 | - application/json 131 | Accept-Encoding: 132 | - gzip, deflate 133 | Connection: 134 | - keep-alive 135 | Content-Type: 136 | - application/json 137 | User-Agent: 138 | - python-requests/2.22.0 139 | method: GET 140 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 141 | response: 142 | body: 143 | string: '{"id":[1,2,3,4,5],"manualSort":[1,2,3,4,5],"ColorRef":[1,2,3,1,0],"gristHelper_Display2":["Red","Orange","Green","Red",""],"Num":[17,8,28,1.5,33],"Text_Field":["Apple","Orange","Melon","Strawberry","Banana"],"Date":[1588291200,1556668800,null,1551571200,1588377600],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null]}' 144 | headers: 145 | Access-Control-Allow-Credentials: 146 | - 'true' 147 | Access-Control-Allow-Headers: 148 | - Authorization, Content-Type 149 | Access-Control-Allow-Methods: 150 | - GET, PATCH, POST, DELETE, OPTIONS 151 | Connection: 152 | - keep-alive 153 | Content-Length: 154 | - '319' 155 | Content-Type: 156 | - application/json; charset=utf-8 157 | Date: 158 | - Thu, 27 Jun 2019 22:17:18 GMT 159 | ETag: 160 | - W/"13f-BF3pEScfys88/9c94VLWwshSEnM" 161 | X-Powered-By: 162 | - Express 163 | status: 164 | code: 200 165 | message: OK 166 | - request: 167 | body: null 168 | headers: 169 | Accept: 170 | - application/json 171 | Accept-Encoding: 172 | - gzip, deflate 173 | Connection: 174 | - keep-alive 175 | Content-Type: 176 | - application/json 177 | User-Agent: 178 | - python-requests/2.22.0 179 | method: GET 180 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 181 | response: 182 | body: 183 | string: '{"id":[1,2,3,4,5],"manualSort":[1,2,3,4,5],"ColorRef":[1,2,3,1,0],"gristHelper_Display2":["Red","Orange","Green","Red",""],"Num":[17,8,28,1.5,33],"Text_Field":["Apple","Orange","Melon","Strawberry","Banana"],"Date":[1588291200,1556668800,null,1551571200,1588377600],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null]}' 184 | headers: 185 | Access-Control-Allow-Credentials: 186 | - 'true' 187 | Access-Control-Allow-Headers: 188 | - Authorization, Content-Type 189 | Access-Control-Allow-Methods: 190 | - GET, PATCH, POST, DELETE, OPTIONS 191 | Connection: 192 | - keep-alive 193 | Content-Length: 194 | - '319' 195 | Content-Type: 196 | - application/json; charset=utf-8 197 | Date: 198 | - Thu, 27 Jun 2019 22:17:18 GMT 199 | ETag: 200 | - W/"13f-BF3pEScfys88/9c94VLWwshSEnM" 201 | X-Powered-By: 202 | - Express 203 | status: 204 | code: 200 205 | message: OK 206 | - request: 207 | body: '{"Date": [1561507200.0, 1554163200.0], "Num": [5, 12], "id": [1, 3]}' 208 | headers: 209 | Accept: 210 | - application/json 211 | Accept-Encoding: 212 | - gzip, deflate 213 | Connection: 214 | - keep-alive 215 | Content-Length: 216 | - '68' 217 | Content-Type: 218 | - application/json 219 | User-Agent: 220 | - python-requests/2.22.0 221 | method: PATCH 222 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 223 | response: 224 | body: 225 | string: 'null' 226 | headers: 227 | Access-Control-Allow-Credentials: 228 | - 'true' 229 | Access-Control-Allow-Headers: 230 | - Authorization, Content-Type 231 | Access-Control-Allow-Methods: 232 | - GET, PATCH, POST, DELETE, OPTIONS 233 | Connection: 234 | - keep-alive 235 | Content-Length: 236 | - '4' 237 | Content-Type: 238 | - application/json; charset=utf-8 239 | Date: 240 | - Thu, 27 Jun 2019 22:17:18 GMT 241 | ETag: 242 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 243 | X-Powered-By: 244 | - Express 245 | status: 246 | code: 200 247 | message: OK 248 | - request: 249 | body: '[["BulkRemoveRecord", "Table1", [5]]]' 250 | headers: 251 | Accept: 252 | - application/json 253 | Accept-Encoding: 254 | - gzip, deflate 255 | Connection: 256 | - keep-alive 257 | Content-Length: 258 | - '37' 259 | Content-Type: 260 | - application/json 261 | User-Agent: 262 | - python-requests/2.22.0 263 | method: POST 264 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 265 | response: 266 | body: 267 | string: '{"actionNum":267,"retValues":[null]}' 268 | headers: 269 | Access-Control-Allow-Credentials: 270 | - 'true' 271 | Access-Control-Allow-Headers: 272 | - Authorization, Content-Type 273 | Access-Control-Allow-Methods: 274 | - GET, PATCH, POST, DELETE, OPTIONS 275 | Connection: 276 | - keep-alive 277 | Content-Length: 278 | - '36' 279 | Content-Type: 280 | - application/json; charset=utf-8 281 | Date: 282 | - Thu, 27 Jun 2019 22:17:18 GMT 283 | ETag: 284 | - W/"24-Mw/nJcDYoXp1Gw4jyAOuQe+yzec" 285 | X-Powered-By: 286 | - Express 287 | status: 288 | code: 200 289 | message: OK 290 | - request: 291 | body: null 292 | headers: 293 | Accept: 294 | - application/json 295 | Accept-Encoding: 296 | - gzip, deflate 297 | Connection: 298 | - keep-alive 299 | Content-Type: 300 | - application/json 301 | User-Agent: 302 | - python-requests/2.22.0 303 | method: GET 304 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 305 | response: 306 | body: 307 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 308 | headers: 309 | Access-Control-Allow-Credentials: 310 | - 'true' 311 | Access-Control-Allow-Headers: 312 | - Authorization, Content-Type 313 | Access-Control-Allow-Methods: 314 | - GET, PATCH, POST, DELETE, OPTIONS 315 | Connection: 316 | - keep-alive 317 | Content-Length: 318 | - '287' 319 | Content-Type: 320 | - application/json; charset=utf-8 321 | Date: 322 | - Thu, 27 Jun 2019 22:17:18 GMT 323 | ETag: 324 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 325 | X-Powered-By: 326 | - Express 327 | status: 328 | code: 200 329 | message: OK 330 | version: 1 331 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_sync_table_with_filters: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22ColorRef%22%3A+%5B1%5D%7D 17 | response: 18 | body: 19 | string: '{"id":[1,4],"manualSort":[1,4],"ColorRef":[1,1],"gristHelper_Display2":["Red","Red"],"Num":[5,1.5],"Text_Field":["Apple","Strawberry"],"Date":[1561507200,1551571200],"ColorRef_Value":["RED","RED"]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '197' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:18 GMT 35 | ETag: 36 | - W/"c5-1yi+CVlki9fTbSsHX8VJaaV6VCU" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 200 41 | message: OK 42 | - request: 43 | body: '{"Date": [1591056000.0], "Num": [200], "id": [4]}' 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Length: 52 | - '49' 53 | Content-Type: 54 | - application/json 55 | User-Agent: 56 | - python-requests/2.22.0 57 | method: PATCH 58 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 59 | response: 60 | body: 61 | string: 'null' 62 | headers: 63 | Access-Control-Allow-Credentials: 64 | - 'true' 65 | Access-Control-Allow-Headers: 66 | - Authorization, Content-Type 67 | Access-Control-Allow-Methods: 68 | - GET, PATCH, POST, DELETE, OPTIONS 69 | Connection: 70 | - keep-alive 71 | Content-Length: 72 | - '4' 73 | Content-Type: 74 | - application/json; charset=utf-8 75 | Date: 76 | - Thu, 27 Jun 2019 22:17:18 GMT 77 | ETag: 78 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 79 | X-Powered-By: 80 | - Express 81 | status: 82 | code: 200 83 | message: OK 84 | - request: 85 | body: '{"Date": [1590969600.0], "Num": [100], "Text_Field": ["Melon"]}' 86 | headers: 87 | Accept: 88 | - application/json 89 | Accept-Encoding: 90 | - gzip, deflate 91 | Connection: 92 | - keep-alive 93 | Content-Length: 94 | - '63' 95 | Content-Type: 96 | - application/json 97 | User-Agent: 98 | - python-requests/2.22.0 99 | method: POST 100 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 101 | response: 102 | body: 103 | string: '[5]' 104 | headers: 105 | Access-Control-Allow-Credentials: 106 | - 'true' 107 | Access-Control-Allow-Headers: 108 | - Authorization, Content-Type 109 | Access-Control-Allow-Methods: 110 | - GET, PATCH, POST, DELETE, OPTIONS 111 | Connection: 112 | - keep-alive 113 | Content-Length: 114 | - '3' 115 | Content-Type: 116 | - application/json; charset=utf-8 117 | Date: 118 | - Thu, 27 Jun 2019 22:17:18 GMT 119 | ETag: 120 | - W/"3-EK4kl5xQKPqHNlG8ozgVLcBIQkU" 121 | X-Powered-By: 122 | - Express 123 | status: 124 | code: 200 125 | message: OK 126 | - request: 127 | body: null 128 | headers: 129 | Accept: 130 | - application/json 131 | Accept-Encoding: 132 | - gzip, deflate 133 | Connection: 134 | - keep-alive 135 | Content-Type: 136 | - application/json 137 | User-Agent: 138 | - python-requests/2.22.0 139 | method: GET 140 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 141 | response: 142 | body: 143 | string: '{"id":[1,2,3,4,5],"manualSort":[1,2,3,4,5],"ColorRef":[1,2,3,1,0],"gristHelper_Display2":["Red","Orange","Green","Red",""],"Num":[5,8,12,200,100],"Text_Field":["Apple","Orange","Melon","Strawberry","Melon"],"Date":[1561507200,1556668800,1554163200,1591056000,1590969600],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null]}' 144 | headers: 145 | Access-Control-Allow-Credentials: 146 | - 'true' 147 | Access-Control-Allow-Headers: 148 | - Authorization, Content-Type 149 | Access-Control-Allow-Methods: 150 | - GET, PATCH, POST, DELETE, OPTIONS 151 | Connection: 152 | - keep-alive 153 | Content-Length: 154 | - '324' 155 | Content-Type: 156 | - application/json; charset=utf-8 157 | Date: 158 | - Thu, 27 Jun 2019 22:17:18 GMT 159 | ETag: 160 | - W/"144-+eBhBzvrzkVnLXFM9fv5eMWqorM" 161 | X-Powered-By: 162 | - Express 163 | status: 164 | code: 200 165 | message: OK 166 | - request: 167 | body: null 168 | headers: 169 | Accept: 170 | - application/json 171 | Accept-Encoding: 172 | - gzip, deflate 173 | Connection: 174 | - keep-alive 175 | Content-Type: 176 | - application/json 177 | User-Agent: 178 | - python-requests/2.22.0 179 | method: GET 180 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data?filter=%7B%22ColorRef%22%3A+%5B1%5D%7D 181 | response: 182 | body: 183 | string: '{"id":[1,4],"manualSort":[1,4],"ColorRef":[1,1],"gristHelper_Display2":["Red","Red"],"Num":[5,200],"Text_Field":["Apple","Strawberry"],"Date":[1561507200,1591056000],"ColorRef_Value":["RED","RED"]}' 184 | headers: 185 | Access-Control-Allow-Credentials: 186 | - 'true' 187 | Access-Control-Allow-Headers: 188 | - Authorization, Content-Type 189 | Access-Control-Allow-Methods: 190 | - GET, PATCH, POST, DELETE, OPTIONS 191 | Connection: 192 | - keep-alive 193 | Content-Length: 194 | - '197' 195 | Content-Type: 196 | - application/json; charset=utf-8 197 | Date: 198 | - Thu, 27 Jun 2019 22:17:18 GMT 199 | ETag: 200 | - W/"c5-3iov12WHMII6Wxm87DX2KBZgxM4" 201 | X-Powered-By: 202 | - Express 203 | status: 204 | code: 200 205 | message: OK 206 | - request: 207 | body: '{"Date": [1551571200.0], "Num": [1.5], "id": [4]}' 208 | headers: 209 | Accept: 210 | - application/json 211 | Accept-Encoding: 212 | - gzip, deflate 213 | Connection: 214 | - keep-alive 215 | Content-Length: 216 | - '49' 217 | Content-Type: 218 | - application/json 219 | User-Agent: 220 | - python-requests/2.22.0 221 | method: PATCH 222 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 223 | response: 224 | body: 225 | string: 'null' 226 | headers: 227 | Access-Control-Allow-Credentials: 228 | - 'true' 229 | Access-Control-Allow-Headers: 230 | - Authorization, Content-Type 231 | Access-Control-Allow-Methods: 232 | - GET, PATCH, POST, DELETE, OPTIONS 233 | Connection: 234 | - keep-alive 235 | Content-Length: 236 | - '4' 237 | Content-Type: 238 | - application/json; charset=utf-8 239 | Date: 240 | - Thu, 27 Jun 2019 22:17:18 GMT 241 | ETag: 242 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 243 | X-Powered-By: 244 | - Express 245 | status: 246 | code: 200 247 | message: OK 248 | - request: 249 | body: '[["BulkRemoveRecord", "Table1", [5]]]' 250 | headers: 251 | Accept: 252 | - application/json 253 | Accept-Encoding: 254 | - gzip, deflate 255 | Connection: 256 | - keep-alive 257 | Content-Length: 258 | - '37' 259 | Content-Type: 260 | - application/json 261 | User-Agent: 262 | - python-requests/2.22.0 263 | method: POST 264 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 265 | response: 266 | body: 267 | string: '{"actionNum":271,"retValues":[null]}' 268 | headers: 269 | Access-Control-Allow-Credentials: 270 | - 'true' 271 | Access-Control-Allow-Headers: 272 | - Authorization, Content-Type 273 | Access-Control-Allow-Methods: 274 | - GET, PATCH, POST, DELETE, OPTIONS 275 | Connection: 276 | - keep-alive 277 | Content-Length: 278 | - '36' 279 | Content-Type: 280 | - application/json; charset=utf-8 281 | Date: 282 | - Thu, 27 Jun 2019 22:17:18 GMT 283 | ETag: 284 | - W/"24-BE7V9uzWQDC1lMSZPFW0Y8VLpGQ" 285 | X-Powered-By: 286 | - Express 287 | status: 288 | code: 200 289 | message: OK 290 | - request: 291 | body: null 292 | headers: 293 | Accept: 294 | - application/json 295 | Accept-Encoding: 296 | - gzip, deflate 297 | Connection: 298 | - keep-alive 299 | Content-Type: 300 | - application/json 301 | User-Agent: 302 | - python-requests/2.22.0 303 | method: GET 304 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 305 | response: 306 | body: 307 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 308 | headers: 309 | Access-Control-Allow-Credentials: 310 | - 'true' 311 | Access-Control-Allow-Headers: 312 | - Authorization, Content-Type 313 | Access-Control-Allow-Methods: 314 | - GET, PATCH, POST, DELETE, OPTIONS 315 | Connection: 316 | - keep-alive 317 | Content-Length: 318 | - '287' 319 | Content-Type: 320 | - application/json; charset=utf-8 321 | Date: 322 | - Thu, 27 Jun 2019 22:17:18 GMT 323 | ETag: 324 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 325 | X-Powered-By: 326 | - Express 327 | status: 328 | code: 200 329 | message: OK 330 | version: 1 331 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_sync_table_with_methods: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 17 | response: 18 | body: 19 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '287' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:19 GMT 35 | ETag: 36 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 200 41 | message: OK 42 | - request: 43 | body: '{"Date": [1588291200.0, null], "Num": [17, 28], "id": [1, 3]}' 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Length: 52 | - '61' 53 | Content-Type: 54 | - application/json 55 | User-Agent: 56 | - python-requests/2.22.0 57 | method: PATCH 58 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 59 | response: 60 | body: 61 | string: 'null' 62 | headers: 63 | Access-Control-Allow-Credentials: 64 | - 'true' 65 | Access-Control-Allow-Headers: 66 | - Authorization, Content-Type 67 | Access-Control-Allow-Methods: 68 | - GET, PATCH, POST, DELETE, OPTIONS 69 | Connection: 70 | - keep-alive 71 | Content-Length: 72 | - '4' 73 | Content-Type: 74 | - application/json; charset=utf-8 75 | Date: 76 | - Thu, 27 Jun 2019 22:17:19 GMT 77 | ETag: 78 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 79 | X-Powered-By: 80 | - Express 81 | status: 82 | code: 200 83 | message: OK 84 | - request: 85 | body: '{"Date": [1588377600.0], "Num": [33], "Text_Field": ["Banana"]}' 86 | headers: 87 | Accept: 88 | - application/json 89 | Accept-Encoding: 90 | - gzip, deflate 91 | Connection: 92 | - keep-alive 93 | Content-Length: 94 | - '63' 95 | Content-Type: 96 | - application/json 97 | User-Agent: 98 | - python-requests/2.22.0 99 | method: POST 100 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 101 | response: 102 | body: 103 | string: '[5]' 104 | headers: 105 | Access-Control-Allow-Credentials: 106 | - 'true' 107 | Access-Control-Allow-Headers: 108 | - Authorization, Content-Type 109 | Access-Control-Allow-Methods: 110 | - GET, PATCH, POST, DELETE, OPTIONS 111 | Connection: 112 | - keep-alive 113 | Content-Length: 114 | - '3' 115 | Content-Type: 116 | - application/json; charset=utf-8 117 | Date: 118 | - Thu, 27 Jun 2019 22:17:19 GMT 119 | ETag: 120 | - W/"3-EK4kl5xQKPqHNlG8ozgVLcBIQkU" 121 | X-Powered-By: 122 | - Express 123 | status: 124 | code: 200 125 | message: OK 126 | - request: 127 | body: null 128 | headers: 129 | Accept: 130 | - application/json 131 | Accept-Encoding: 132 | - gzip, deflate 133 | Connection: 134 | - keep-alive 135 | Content-Type: 136 | - application/json 137 | User-Agent: 138 | - python-requests/2.22.0 139 | method: GET 140 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 141 | response: 142 | body: 143 | string: '{"id":[1,2,3,4,5],"manualSort":[1,2,3,4,5],"ColorRef":[1,2,3,1,0],"gristHelper_Display2":["Red","Orange","Green","Red",""],"Num":[17,8,28,1.5,33],"Text_Field":["Apple","Orange","Melon","Strawberry","Banana"],"Date":[1588291200,1556668800,null,1551571200,1588377600],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null]}' 144 | headers: 145 | Access-Control-Allow-Credentials: 146 | - 'true' 147 | Access-Control-Allow-Headers: 148 | - Authorization, Content-Type 149 | Access-Control-Allow-Methods: 150 | - GET, PATCH, POST, DELETE, OPTIONS 151 | Connection: 152 | - keep-alive 153 | Content-Length: 154 | - '319' 155 | Content-Type: 156 | - application/json; charset=utf-8 157 | Date: 158 | - Thu, 27 Jun 2019 22:17:19 GMT 159 | ETag: 160 | - W/"13f-BF3pEScfys88/9c94VLWwshSEnM" 161 | X-Powered-By: 162 | - Express 163 | status: 164 | code: 200 165 | message: OK 166 | - request: 167 | body: null 168 | headers: 169 | Accept: 170 | - application/json 171 | Accept-Encoding: 172 | - gzip, deflate 173 | Connection: 174 | - keep-alive 175 | Content-Type: 176 | - application/json 177 | User-Agent: 178 | - python-requests/2.22.0 179 | method: GET 180 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 181 | response: 182 | body: 183 | string: '{"id":[1,2,3,4,5],"manualSort":[1,2,3,4,5],"ColorRef":[1,2,3,1,0],"gristHelper_Display2":["Red","Orange","Green","Red",""],"Num":[17,8,28,1.5,33],"Text_Field":["Apple","Orange","Melon","Strawberry","Banana"],"Date":[1588291200,1556668800,null,1551571200,1588377600],"ColorRef_Value":["RED","ORANGE","GREEN","RED",null]}' 184 | headers: 185 | Access-Control-Allow-Credentials: 186 | - 'true' 187 | Access-Control-Allow-Headers: 188 | - Authorization, Content-Type 189 | Access-Control-Allow-Methods: 190 | - GET, PATCH, POST, DELETE, OPTIONS 191 | Connection: 192 | - keep-alive 193 | Content-Length: 194 | - '319' 195 | Content-Type: 196 | - application/json; charset=utf-8 197 | Date: 198 | - Thu, 27 Jun 2019 22:17:19 GMT 199 | ETag: 200 | - W/"13f-BF3pEScfys88/9c94VLWwshSEnM" 201 | X-Powered-By: 202 | - Express 203 | status: 204 | code: 200 205 | message: OK 206 | - request: 207 | body: '{"Date": [1561507200.0, 1554163200.0], "Num": [5, 12], "id": [1, 3]}' 208 | headers: 209 | Accept: 210 | - application/json 211 | Accept-Encoding: 212 | - gzip, deflate 213 | Connection: 214 | - keep-alive 215 | Content-Length: 216 | - '68' 217 | Content-Type: 218 | - application/json 219 | User-Agent: 220 | - python-requests/2.22.0 221 | method: PATCH 222 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 223 | response: 224 | body: 225 | string: 'null' 226 | headers: 227 | Access-Control-Allow-Credentials: 228 | - 'true' 229 | Access-Control-Allow-Headers: 230 | - Authorization, Content-Type 231 | Access-Control-Allow-Methods: 232 | - GET, PATCH, POST, DELETE, OPTIONS 233 | Connection: 234 | - keep-alive 235 | Content-Length: 236 | - '4' 237 | Content-Type: 238 | - application/json; charset=utf-8 239 | Date: 240 | - Thu, 27 Jun 2019 22:17:19 GMT 241 | ETag: 242 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 243 | X-Powered-By: 244 | - Express 245 | status: 246 | code: 200 247 | message: OK 248 | - request: 249 | body: '[["BulkRemoveRecord", "Table1", [5]]]' 250 | headers: 251 | Accept: 252 | - application/json 253 | Accept-Encoding: 254 | - gzip, deflate 255 | Connection: 256 | - keep-alive 257 | Content-Length: 258 | - '37' 259 | Content-Type: 260 | - application/json 261 | User-Agent: 262 | - python-requests/2.22.0 263 | method: POST 264 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/apply 265 | response: 266 | body: 267 | string: '{"actionNum":275,"retValues":[null]}' 268 | headers: 269 | Access-Control-Allow-Credentials: 270 | - 'true' 271 | Access-Control-Allow-Headers: 272 | - Authorization, Content-Type 273 | Access-Control-Allow-Methods: 274 | - GET, PATCH, POST, DELETE, OPTIONS 275 | Connection: 276 | - keep-alive 277 | Content-Length: 278 | - '36' 279 | Content-Type: 280 | - application/json; charset=utf-8 281 | Date: 282 | - Thu, 27 Jun 2019 22:17:19 GMT 283 | ETag: 284 | - W/"24-/vW3izesQYbVLtsRGd4pO5L0ey4" 285 | X-Powered-By: 286 | - Express 287 | status: 288 | code: 200 289 | message: OK 290 | - request: 291 | body: null 292 | headers: 293 | Accept: 294 | - application/json 295 | Accept-Encoding: 296 | - gzip, deflate 297 | Connection: 298 | - keep-alive 299 | Content-Type: 300 | - application/json 301 | User-Agent: 302 | - python-requests/2.22.0 303 | method: GET 304 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 305 | response: 306 | body: 307 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 308 | headers: 309 | Access-Control-Allow-Credentials: 310 | - 'true' 311 | Access-Control-Allow-Headers: 312 | - Authorization, Content-Type 313 | Access-Control-Allow-Methods: 314 | - GET, PATCH, POST, DELETE, OPTIONS 315 | Connection: 316 | - keep-alive 317 | Content-Length: 318 | - '287' 319 | Content-Type: 320 | - application/json; charset=utf-8 321 | Date: 322 | - Thu, 27 Jun 2019 22:17:19 GMT 323 | ETag: 324 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 325 | X-Powered-By: 326 | - Express 327 | status: 328 | code: 200 329 | message: OK 330 | version: 1 331 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_update_records: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"ColorRef": [2, 2], "Num": [-5, -1.5], "Text_Field": ["snapple", null], 4 | "id": [1, 4]}' 5 | headers: 6 | Accept: 7 | - application/json 8 | Accept-Encoding: 9 | - gzip, deflate 10 | Connection: 11 | - keep-alive 12 | Content-Length: 13 | - '86' 14 | Content-Type: 15 | - application/json 16 | User-Agent: 17 | - python-requests/2.22.0 18 | method: PATCH 19 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 20 | response: 21 | body: 22 | string: 'null' 23 | headers: 24 | Access-Control-Allow-Credentials: 25 | - 'true' 26 | Access-Control-Allow-Headers: 27 | - Authorization, Content-Type 28 | Access-Control-Allow-Methods: 29 | - GET, PATCH, POST, DELETE, OPTIONS 30 | Connection: 31 | - keep-alive 32 | Content-Length: 33 | - '4' 34 | Content-Type: 35 | - application/json; charset=utf-8 36 | Date: 37 | - Thu, 27 Jun 2019 22:17:19 GMT 38 | ETag: 39 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 40 | X-Powered-By: 41 | - Express 42 | status: 43 | code: 200 44 | message: OK 45 | - request: 46 | body: null 47 | headers: 48 | Accept: 49 | - application/json 50 | Accept-Encoding: 51 | - gzip, deflate 52 | Connection: 53 | - keep-alive 54 | Content-Type: 55 | - application/json 56 | User-Agent: 57 | - python-requests/2.22.0 58 | method: GET 59 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 60 | response: 61 | body: 62 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[2,2,3,2],"gristHelper_Display2":["Orange","Orange","Green","Orange"],"Num":[-5,8,12,-1.5],"Text_Field":["snapple","Orange","Melon",null],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["ORANGE","ORANGE","GREEN","ORANGE"]}' 63 | headers: 64 | Access-Control-Allow-Credentials: 65 | - 'true' 66 | Access-Control-Allow-Headers: 67 | - Authorization, Content-Type 68 | Access-Control-Allow-Methods: 69 | - GET, PATCH, POST, DELETE, OPTIONS 70 | Connection: 71 | - keep-alive 72 | Content-Length: 73 | - '295' 74 | Content-Type: 75 | - application/json; charset=utf-8 76 | Date: 77 | - Thu, 27 Jun 2019 22:17:19 GMT 78 | ETag: 79 | - W/"127-e8X08KGFDG7Z2hvYdzzVLjCfERY" 80 | X-Powered-By: 81 | - Express 82 | status: 83 | code: 200 84 | message: OK 85 | - request: 86 | body: '{"ColorRef": [1, 1], "Num": [5, 1.5], "Text_Field": ["Apple", "Strawberry"], 87 | "id": [1, 4]}' 88 | headers: 89 | Accept: 90 | - application/json 91 | Accept-Encoding: 92 | - gzip, deflate 93 | Connection: 94 | - keep-alive 95 | Content-Length: 96 | - '90' 97 | Content-Type: 98 | - application/json 99 | User-Agent: 100 | - python-requests/2.22.0 101 | method: PATCH 102 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 103 | response: 104 | body: 105 | string: 'null' 106 | headers: 107 | Access-Control-Allow-Credentials: 108 | - 'true' 109 | Access-Control-Allow-Headers: 110 | - Authorization, Content-Type 111 | Access-Control-Allow-Methods: 112 | - GET, PATCH, POST, DELETE, OPTIONS 113 | Connection: 114 | - keep-alive 115 | Content-Length: 116 | - '4' 117 | Content-Type: 118 | - application/json; charset=utf-8 119 | Date: 120 | - Thu, 27 Jun 2019 22:17:19 GMT 121 | ETag: 122 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 123 | X-Powered-By: 124 | - Express 125 | status: 126 | code: 200 127 | message: OK 128 | - request: 129 | body: null 130 | headers: 131 | Accept: 132 | - application/json 133 | Accept-Encoding: 134 | - gzip, deflate 135 | Connection: 136 | - keep-alive 137 | Content-Type: 138 | - application/json 139 | User-Agent: 140 | - python-requests/2.22.0 141 | method: GET 142 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 143 | response: 144 | body: 145 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 146 | headers: 147 | Access-Control-Allow-Credentials: 148 | - 'true' 149 | Access-Control-Allow-Headers: 150 | - Authorization, Content-Type 151 | Access-Control-Allow-Methods: 152 | - GET, PATCH, POST, DELETE, OPTIONS 153 | Connection: 154 | - keep-alive 155 | Content-Length: 156 | - '287' 157 | Content-Type: 158 | - application/json; charset=utf-8 159 | Date: 160 | - Thu, 27 Jun 2019 22:17:19 GMT 161 | ETag: 162 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 163 | X-Powered-By: 164 | - Express 165 | status: 166 | code: 200 167 | message: OK 168 | version: 1 169 | -------------------------------------------------------------------------------- /test/fixtures/vcr/test_update_records_varied: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.22.0 15 | method: GET 16 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 17 | response: 18 | body: 19 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Authorization, Content-Type 25 | Access-Control-Allow-Methods: 26 | - GET, PATCH, POST, DELETE, OPTIONS 27 | Connection: 28 | - keep-alive 29 | Content-Length: 30 | - '287' 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Thu, 27 Jun 2019 22:17:19 GMT 35 | ETag: 36 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 37 | X-Powered-By: 38 | - Express 39 | status: 40 | code: 200 41 | message: OK 42 | - request: 43 | body: '{"ColorRef": [2], "Num": [-1.5], "id": [4]}' 44 | headers: 45 | Accept: 46 | - application/json 47 | Accept-Encoding: 48 | - gzip, deflate 49 | Connection: 50 | - keep-alive 51 | Content-Length: 52 | - '43' 53 | Content-Type: 54 | - application/json 55 | User-Agent: 56 | - python-requests/2.22.0 57 | method: PATCH 58 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 59 | response: 60 | body: 61 | string: 'null' 62 | headers: 63 | Access-Control-Allow-Credentials: 64 | - 'true' 65 | Access-Control-Allow-Headers: 66 | - Authorization, Content-Type 67 | Access-Control-Allow-Methods: 68 | - GET, PATCH, POST, DELETE, OPTIONS 69 | Connection: 70 | - keep-alive 71 | Content-Length: 72 | - '4' 73 | Content-Type: 74 | - application/json; charset=utf-8 75 | Date: 76 | - Thu, 27 Jun 2019 22:17:19 GMT 77 | ETag: 78 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 79 | X-Powered-By: 80 | - Express 81 | status: 82 | code: 200 83 | message: OK 84 | - request: 85 | body: '{"Num": [-5], "Text_Field": ["snapple"], "id": [1]}' 86 | headers: 87 | Accept: 88 | - application/json 89 | Accept-Encoding: 90 | - gzip, deflate 91 | Connection: 92 | - keep-alive 93 | Content-Length: 94 | - '51' 95 | Content-Type: 96 | - application/json 97 | User-Agent: 98 | - python-requests/2.22.0 99 | method: PATCH 100 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 101 | response: 102 | body: 103 | string: 'null' 104 | headers: 105 | Access-Control-Allow-Credentials: 106 | - 'true' 107 | Access-Control-Allow-Headers: 108 | - Authorization, Content-Type 109 | Access-Control-Allow-Methods: 110 | - GET, PATCH, POST, DELETE, OPTIONS 111 | Connection: 112 | - keep-alive 113 | Content-Length: 114 | - '4' 115 | Content-Type: 116 | - application/json; charset=utf-8 117 | Date: 118 | - Thu, 27 Jun 2019 22:17:19 GMT 119 | ETag: 120 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 121 | X-Powered-By: 122 | - Express 123 | status: 124 | code: 200 125 | message: OK 126 | - request: 127 | body: null 128 | headers: 129 | Accept: 130 | - application/json 131 | Accept-Encoding: 132 | - gzip, deflate 133 | Connection: 134 | - keep-alive 135 | Content-Type: 136 | - application/json 137 | User-Agent: 138 | - python-requests/2.22.0 139 | method: GET 140 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 141 | response: 142 | body: 143 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,2],"gristHelper_Display2":["Red","Orange","Green","Orange"],"Num":[-5,8,12,-1.5],"Text_Field":["snapple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","ORANGE"]}' 144 | headers: 145 | Access-Control-Allow-Credentials: 146 | - 'true' 147 | Access-Control-Allow-Headers: 148 | - Authorization, Content-Type 149 | Access-Control-Allow-Methods: 150 | - GET, PATCH, POST, DELETE, OPTIONS 151 | Connection: 152 | - keep-alive 153 | Content-Length: 154 | - '297' 155 | Content-Type: 156 | - application/json; charset=utf-8 157 | Date: 158 | - Thu, 27 Jun 2019 22:17:19 GMT 159 | ETag: 160 | - W/"129-QC3BH5rop+7ojnOcoluV+3KVl28" 161 | X-Powered-By: 162 | - Express 163 | status: 164 | code: 200 165 | message: OK 166 | - request: 167 | body: '{"ColorRef": [1], "Num": [1.5], "id": [4]}' 168 | headers: 169 | Accept: 170 | - application/json 171 | Accept-Encoding: 172 | - gzip, deflate 173 | Connection: 174 | - keep-alive 175 | Content-Length: 176 | - '42' 177 | Content-Type: 178 | - application/json 179 | User-Agent: 180 | - python-requests/2.22.0 181 | method: PATCH 182 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 183 | response: 184 | body: 185 | string: 'null' 186 | headers: 187 | Access-Control-Allow-Credentials: 188 | - 'true' 189 | Access-Control-Allow-Headers: 190 | - Authorization, Content-Type 191 | Access-Control-Allow-Methods: 192 | - GET, PATCH, POST, DELETE, OPTIONS 193 | Connection: 194 | - keep-alive 195 | Content-Length: 196 | - '4' 197 | Content-Type: 198 | - application/json; charset=utf-8 199 | Date: 200 | - Thu, 27 Jun 2019 22:17:19 GMT 201 | ETag: 202 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 203 | X-Powered-By: 204 | - Express 205 | status: 206 | code: 200 207 | message: OK 208 | - request: 209 | body: '{"Num": [5], "Text_Field": ["Apple"], "id": [1]}' 210 | headers: 211 | Accept: 212 | - application/json 213 | Accept-Encoding: 214 | - gzip, deflate 215 | Connection: 216 | - keep-alive 217 | Content-Length: 218 | - '48' 219 | Content-Type: 220 | - application/json 221 | User-Agent: 222 | - python-requests/2.22.0 223 | method: PATCH 224 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 225 | response: 226 | body: 227 | string: 'null' 228 | headers: 229 | Access-Control-Allow-Credentials: 230 | - 'true' 231 | Access-Control-Allow-Headers: 232 | - Authorization, Content-Type 233 | Access-Control-Allow-Methods: 234 | - GET, PATCH, POST, DELETE, OPTIONS 235 | Connection: 236 | - keep-alive 237 | Content-Length: 238 | - '4' 239 | Content-Type: 240 | - application/json; charset=utf-8 241 | Date: 242 | - Thu, 27 Jun 2019 22:17:19 GMT 243 | ETag: 244 | - W/"4-K+iMpCQsduglOsYkdIUQZQMtaDM" 245 | X-Powered-By: 246 | - Express 247 | status: 248 | code: 200 249 | message: OK 250 | - request: 251 | body: null 252 | headers: 253 | Accept: 254 | - application/json 255 | Accept-Encoding: 256 | - gzip, deflate 257 | Connection: 258 | - keep-alive 259 | Content-Type: 260 | - application/json 261 | User-Agent: 262 | - python-requests/2.22.0 263 | method: GET 264 | uri: http://localhost:8080/o/docs-8/api/docs/28a446f2-903e-4bd4-8001-1dbd3a68e5a5/tables/Table1/data 265 | response: 266 | body: 267 | string: '{"id":[1,2,3,4],"manualSort":[1,2,3,4],"ColorRef":[1,2,3,1],"gristHelper_Display2":["Red","Orange","Green","Red"],"Num":[5,8,12,1.5],"Text_Field":["Apple","Orange","Melon","Strawberry"],"Date":[1561507200,1556668800,1554163200,1551571200],"ColorRef_Value":["RED","ORANGE","GREEN","RED"]}' 268 | headers: 269 | Access-Control-Allow-Credentials: 270 | - 'true' 271 | Access-Control-Allow-Headers: 272 | - Authorization, Content-Type 273 | Access-Control-Allow-Methods: 274 | - GET, PATCH, POST, DELETE, OPTIONS 275 | Connection: 276 | - keep-alive 277 | Content-Length: 278 | - '287' 279 | Content-Type: 280 | - application/json; charset=utf-8 281 | Date: 282 | - Thu, 27 Jun 2019 22:17:19 GMT 283 | ETag: 284 | - W/"11f-Yjmht0raG/ZIu/99QjYP0kXYrG4" 285 | X-Powered-By: 286 | - Express 287 | status: 288 | code: 200 289 | message: OK 290 | version: 1 291 | -------------------------------------------------------------------------------- /test/test_grist_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=no-self-use,missing-docstring,bad-whitespace 3 | 4 | # The test is intended to test the behavior of the library, i.e. translating python calls to HTTP 5 | # requests and interpreting the results. But we can only know it's correct by sending these 6 | # requests to an actual Grist instance and checking their effects. 7 | # 8 | # These tests rely on the "vcr" library to record and replay requests. When writing the tests, run 9 | # them with VCR_RECORD=1 environment variables to run against an actual Grist instance. If the 10 | # tests pass, the HTTP requests and responses get recorded in test/fixtures/vcr/. When tests run 11 | # without this environment variables, the requests get matched, and responses get replayed. When 12 | # replaying, we are not checking Grist functionality, only that correct requests get produced, and 13 | # that responses get parsed. 14 | # 15 | # To record interactions with VRC_RECORD=1, you need to use a functional instance of Grist. Upload 16 | # document test/fixtures/TestGristDocAPI.grist to Grist, and set SERVER and DOC_ID constants below 17 | # to point to it. Find your API key, and set GRIST_API_KEY to it. 18 | 19 | # Run nosetests with --nologcapture to see logging, and with -s to see print output. 20 | 21 | from __future__ import unicode_literals, print_function 22 | from collections import namedtuple 23 | from datetime import date 24 | import logging 25 | import os 26 | import unittest 27 | import requests 28 | from vcr import VCR 29 | from grist_api import GristDocAPI, date_to_ts 30 | 31 | SERVER = "http://localhost:8080/o/docs-8" 32 | DOC_ID = "28a446f2-903e-4bd4-8001-1dbd3a68e5a5" 33 | LIVE = bool(os.environ.get("VCR_RECORD", None)) 34 | 35 | logging.basicConfig(format='%(asctime)s %(levelname)s %(name)s %(message)s') 36 | logging.getLogger("vcr").setLevel(logging.INFO) 37 | logging.getLogger("grist_api").setLevel(logging.INFO) 38 | 39 | vcr = VCR( 40 | cassette_library_dir='test/fixtures/vcr', 41 | filter_headers=['authorization'], 42 | # To update recorded requests, remove file, and run with VCR_RECORD=1 env var. 43 | record_mode="all" if LIVE else "none") 44 | 45 | def datets(*args): 46 | return int(date_to_ts(date(*args))) 47 | 48 | initial_data = { 49 | "Table1": [ 50 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 51 | (1, 'Apple', 5, datets(2019, 6, 26), 1, "RED"), 52 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 53 | (3, 'Melon', 12, datets(2019, 4, 2), 3, "GREEN"), 54 | (4, 'Strawberry', 1.5, datets(2019, 3, 3), 1, "RED"), 55 | ] 56 | } 57 | 58 | class TestGristDocAPI(unittest.TestCase): 59 | def setUp(self): 60 | self._grist_api = GristDocAPI(DOC_ID, server=SERVER, api_key=None if LIVE else "unused") 61 | 62 | def assert_data(self, records, expected_with_headers): 63 | headers = expected_with_headers[0] 64 | expected = expected_with_headers[1:] 65 | actual = [tuple(getattr(rec, h) for h in headers) for rec in records] 66 | self.assertEqual(actual, expected) 67 | 68 | @vcr.use_cassette() 69 | def test_list_tables(self): 70 | tables_result = self._grist_api.tables() 71 | 72 | assert 'tables' in tables_result 73 | tables = tables_result['tables'] 74 | assert isinstance(tables, list) 75 | 76 | @vcr.use_cassette() 77 | def test_columns(self): 78 | columns_result = self._grist_api.columns("Table1") 79 | 80 | assert 'columns' in columns_result 81 | columns = columns_result['columns'] 82 | assert isinstance(columns, list) 83 | 84 | @vcr.use_cassette() 85 | def test_fetch_table(self): 86 | # Test the basic fetch_table 87 | data = self._grist_api.fetch_table('Table1') 88 | self.assert_data(data, initial_data["Table1"]) 89 | 90 | # Test fetch_table with filters 91 | data = self._grist_api.fetch_table('Table1', {"ColorRef": 1}) 92 | self.assert_data(data, [ 93 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 94 | (1, 'Apple', 5, datets(2019, 6, 26), 1, "RED"), 95 | (4, 'Strawberry', 1.5, datets(2019, 3, 3), 1, "RED"), 96 | ]) 97 | 98 | @vcr.use_cassette() 99 | def test_add_delete_records(self): 100 | data = self._grist_api.add_records('Table1', [ 101 | {"Text_Field": "Eggs", "Num": 2, "ColorRef": 3, "Date": date(2019, 1, 17)}, 102 | {"Text_Field": "Beets", "Num": 2} 103 | ]) 104 | self.assertEqual(data, [5, 6]) 105 | 106 | data = self._grist_api.fetch_table('Table1', {"Num": 2}) 107 | self.assert_data(data, [ 108 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 109 | (5, 'Eggs', 2, datets(2019, 1, 17), 3, "GREEN"), 110 | (6, 'Beets', 2, None, 0, None), 111 | ]) 112 | 113 | self._grist_api.delete_records('Table1', [5, 6]) 114 | 115 | data = self._grist_api.fetch_table('Table1', {"Num": 2}) 116 | self.assert_data(data, [ 117 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 118 | ]) 119 | 120 | @vcr.use_cassette() 121 | def test_update_records(self): 122 | self._grist_api.update_records('Table1', [ 123 | {"id": 1, "Num": -5, "Text_Field": "snapple", "ColorRef": 2}, 124 | {"id": 4, "Num": -1.5, "Text_Field": None, "ColorRef": 2}, 125 | ]) 126 | 127 | # Note that the formula field gets updated too. 128 | data = self._grist_api.fetch_table('Table1') 129 | self.assert_data(data, [ 130 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 131 | (1, 'snapple', -5, datets(2019, 6, 26), 2, "ORANGE"), 132 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 133 | (3, 'Melon', 12, datets(2019, 4, 2), 3, "GREEN"), 134 | (4, None, -1.5, datets(2019, 3, 3), 2, "ORANGE"), 135 | ]) 136 | 137 | # Revert the changes. 138 | self._grist_api.update_records('Table1', [ 139 | {"id": 1, "Num": 5, "Text_Field": "Apple", "ColorRef": 1}, 140 | {"id": 4, "Num": 1.5, "Text_Field": "Strawberry", "ColorRef": 1}, 141 | ]) 142 | data = self._grist_api.fetch_table('Table1') 143 | self.assert_data(data, initial_data["Table1"]) 144 | 145 | @vcr.use_cassette() 146 | def test_update_records_varied(self): 147 | # Mismatched column sets cause an error. 148 | with self.assertRaisesRegexp(ValueError, "needs group_if_needed"): 149 | self._grist_api.update_records('Table1', [ 150 | {"id": 1, "Num": -5, "Text_Field": "snapple"}, 151 | {"id": 4, "Num": -1.5, "ColorRef": 2}, 152 | ]) 153 | 154 | # Check that no changes were made. 155 | data = self._grist_api.fetch_table('Table1') 156 | self.assert_data(data, initial_data["Table1"]) 157 | 158 | # Try again with group_if_needed flag 159 | self._grist_api.update_records('Table1', [ 160 | {"id": 1, "Num": -5, "Text_Field": "snapple"}, 161 | {"id": 4, "Num": -1.5, "ColorRef": 2}, 162 | ], group_if_needed=True) 163 | 164 | data = self._grist_api.fetch_table('Table1') 165 | self.assert_data(data, [ 166 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 167 | (1, 'snapple', -5, datets(2019, 6, 26), 1, "RED"), 168 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 169 | (3, 'Melon', 12, datets(2019, 4, 2), 3, "GREEN"), 170 | (4, 'Strawberry', -1.5, datets(2019, 3, 3), 2, "ORANGE"), 171 | ]) 172 | 173 | # Revert the changes. 174 | self._grist_api.update_records('Table1', [ 175 | {"id": 1, "Num": 5, "Text_Field": "Apple"}, 176 | {"id": 4, "Num": 1.5, "ColorRef": 1}, 177 | ], group_if_needed=True) 178 | 179 | data = self._grist_api.fetch_table('Table1') 180 | self.assert_data(data, initial_data["Table1"]) 181 | 182 | @vcr.use_cassette() 183 | def test_sync_table(self): 184 | # The sync_table method requires data as objects with attributes, so use namedtuple. 185 | Rec = namedtuple('Rec', ['name', 'num', 'date']) # pylint: disable=invalid-name 186 | self._grist_api.sync_table('Table1', [ 187 | Rec('Apple', 17, date(2020, 5, 1)), 188 | Rec('Banana', 33, date(2020, 5, 2)), 189 | Rec('Melon', 28, None) 190 | ], [ 191 | ('Text_Field', 'name', 'Text'), 192 | ], [ 193 | ('Num', 'num', 'Numeric'), 194 | ('Date', 'date', 'Date'), 195 | ]) 196 | 197 | data = self._grist_api.fetch_table('Table1') 198 | self.assert_data(data, [ 199 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 200 | (1, 'Apple', 17, datets(2020, 5, 1), 1, "RED"), 201 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 202 | (3, 'Melon', 28, None, 3, "GREEN"), 203 | (4, 'Strawberry', 1.5, datets(2019, 3, 3), 1, "RED"), 204 | (5, 'Banana', 33, datets(2020, 5, 2), 0, None), 205 | ]) 206 | 207 | # Revert data, and delete the newly-added record. 208 | self._grist_api.sync_table('Table1', [ 209 | Rec('Apple', 5, date(2019, 6, 26)), 210 | Rec('Melon', 12, date(2019, 4, 2)), 211 | ], [ 212 | ('Text_Field', 'name', 'Text'), 213 | ], [ 214 | ('Num', 'num', 'Numeric'), 215 | ('Date', 'date', 'Date'), 216 | ]) 217 | self._grist_api.delete_records('Table1', [5]) 218 | 219 | # Check we are back to where we started. 220 | data = self._grist_api.fetch_table('Table1') 221 | self.assert_data(data, initial_data["Table1"]) 222 | 223 | @vcr.use_cassette() 224 | def test_sync_table_with_methods(self): 225 | # Try sync_table with a method in place of col name, and with omitting types. 226 | # Types (third member of tuples) may be omitted if values have correct type. 227 | # TODO should add test cases where value is e.g. numeric for date, and specifying type of 228 | # "Date" is required for correct syncing. 229 | self._grist_api.sync_table('Table1', [ 230 | ('Apple', 17, date(2020, 5, 1)), 231 | ('Banana', 33, date(2020, 5, 2)), 232 | ('Melon', 28, None) 233 | ], [ 234 | ('Text_Field', lambda r: r[0]), 235 | ], [ 236 | ('Num', lambda r: r[1]), 237 | ('Date', lambda r: r[2]), 238 | ]) 239 | 240 | # check the results 241 | data = self._grist_api.fetch_table('Table1') 242 | self.assert_data(data, [ 243 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 244 | (1, 'Apple', 17, datets(2020, 5, 1), 1, "RED"), 245 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 246 | (3, 'Melon', 28, None, 3, "GREEN"), 247 | (4, 'Strawberry', 1.5, datets(2019, 3, 3), 1, "RED"), 248 | (5, 'Banana', 33, datets(2020, 5, 2), 0, None), 249 | ]) 250 | 251 | # Revert data, and delete the newly-added record. 252 | self._grist_api.sync_table('Table1', [ 253 | ('Apple', 5, date(2019, 6, 26)), 254 | ('Melon', 12, date(2019, 4, 2)), 255 | ], [ 256 | ('Text_Field', lambda r: r[0]), 257 | ], [ 258 | ('Num', lambda r: r[1]), 259 | ('Date', lambda r: r[2]), 260 | ]) 261 | self._grist_api.delete_records('Table1', [5]) 262 | 263 | # Check we are back to where we started. 264 | data = self._grist_api.fetch_table('Table1') 265 | self.assert_data(data, initial_data["Table1"]) 266 | 267 | @vcr.use_cassette() 268 | def test_sync_table_with_filters(self): 269 | Rec = namedtuple('Rec', ['name', 'num', 'date']) # pylint: disable=invalid-name 270 | self._grist_api.sync_table('Table1', [ 271 | Rec('Melon', 100, date(2020, 6, 1)), 272 | Rec('Strawberry', 200, date(2020, 6, 2)), 273 | ], [ 274 | ('Text_Field', 'name', 'Text'), 275 | ], [ 276 | ('Num', 'num', 'Numeric'), 277 | ('Date', 'date', 'Date'), 278 | ], 279 | filters={"ColorRef": 1}) 280 | 281 | # Note that Melon got added because it didn't exist in the filtered view. 282 | data = self._grist_api.fetch_table('Table1') 283 | self.assert_data(data, [ 284 | ('id', 'Text_Field', 'Num', 'Date', 'ColorRef', 'ColorRef_Value'), 285 | (1, 'Apple', 5, datets(2019, 6, 26), 1, "RED"), 286 | (2, 'Orange', 8, datets(2019, 5, 1), 2, "ORANGE"), 287 | (3, 'Melon', 12, datets(2019, 4, 2), 3, "GREEN"), 288 | (4, 'Strawberry', 200, datets(2020, 6, 2), 1, "RED"), 289 | (5, 'Melon', 100, datets(2020, 6, 1), 0, None), 290 | ]) 291 | 292 | # Revert data, and delete the newly-added record. 293 | self._grist_api.sync_table('Table1', [ 294 | Rec('Strawberry', 1.5, date(2019, 3, 3)), 295 | ], [ 296 | ('Text_Field', 'name', 'Text'), 297 | ], [ 298 | ('Num', 'num', 'Numeric'), 299 | ('Date', 'date', 'Date'), 300 | ], 301 | filters={"ColorRef": 1}) 302 | self._grist_api.delete_records('Table1', [5]) 303 | 304 | # Check we are back to where we started. 305 | data = self._grist_api.fetch_table('Table1') 306 | self.assert_data(data, initial_data["Table1"]) 307 | 308 | @vcr.use_cassette() 309 | def test_chunking(self): 310 | my_range = range(50) 311 | 312 | # Using chunk_size should produce 5 requests (4 of 12 records, and 1 of 2). We can only tell 313 | # that by examining the recorded fixture in "test/fixtures/vcr/test_chunking" after running 314 | # with VCR_RECORD=1. 315 | data = self._grist_api.add_records('Table1', [ 316 | {"Text_Field": "Chunk", "Num": n} for n in my_range 317 | ], chunk_size=12) 318 | self.assertEqual(data, [5 + n for n in my_range]) 319 | 320 | # Verify data is correct. 321 | data = self._grist_api.fetch_table('Table1') 322 | self.assert_data(data, initial_data['Table1'] + [ 323 | (5 + n, 'Chunk', n, None, 0, None) 324 | for n in my_range 325 | ]) 326 | 327 | # Update data using chunking. 328 | self._grist_api.update_records('Table1', [ 329 | {"id": 5 + n, "Text_Field": "Peanut Butter", "ColorRef": 2} 330 | for n in my_range 331 | ], chunk_size=12) 332 | 333 | data = self._grist_api.fetch_table('Table1') 334 | self.assert_data(data, initial_data['Table1'] + [ 335 | (5 + n, 'Peanut Butter', n, None, 2, 'ORANGE') 336 | for n in my_range 337 | ]) 338 | 339 | # Delete data using chunking. 340 | self._grist_api.delete_records('Table1', [5 + n for n in my_range], 341 | chunk_size=12) 342 | data = self._grist_api.fetch_table('Table1') 343 | self.assert_data(data, initial_data["Table1"]) 344 | 345 | @vcr.use_cassette() 346 | def test_errors(self): 347 | with self.assertRaisesRegexp(requests.HTTPError, "Table not found.*Unicorn"): 348 | self._grist_api.fetch_table('Unicorn') 349 | with self.assertRaisesRegexp(requests.HTTPError, "ColorBoom"): 350 | self._grist_api.fetch_table('Table1', {"ColorRef": 1, "ColorBoom": 2}) 351 | with self.assertRaisesRegexp(requests.HTTPError, "Invalid column.*NumX"): 352 | self._grist_api.add_records('Table1', [{"Text_Field": "Beets", "NumX": 2}]) 353 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{27,35,36,37} 8 | 9 | [testenv] 10 | commands = nosetests 11 | deps = 12 | nose 13 | coverage 14 | vcrpy 15 | --------------------------------------------------------------------------------