├── .appveyor.yml ├── .coveragerc ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .isort.cfg ├── .mypy.ini ├── .pre-commit-config.yaml ├── .pydocstyle.ini ├── .pylint.ini ├── .python-version ├── .readthedocs.yml ├── .scrutinizer.yml ├── .travis.yml ├── .verchew.ini ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CREDITS.md ├── LICENSE.md ├── Makefile ├── README.md ├── bin ├── checksum ├── open ├── update └── verchew ├── docs ├── about │ ├── changelog.md │ ├── contributing.md │ ├── credits.md │ └── license.md ├── advanced │ ├── create_etch_packet.md │ └── dynamic_queries.md ├── api_usage.md ├── cli_usage.md ├── index.md └── requirements.txt ├── examples ├── __init__.py ├── cli │ ├── __init__.py │ └── fill_pdf.csv ├── create_etch_existing_cast.py ├── create_etch_markdown.py ├── create_etch_markup.py ├── create_etch_upload_file.py ├── create_etch_upload_file_multipart.py ├── create_workflow_submission.py ├── fill_pdf.py ├── forge_submit.py ├── generate_pdf.py ├── make_graphql_request.py ├── my_local_file.pdf └── pdf │ └── blank_8_5x11.pdf ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── python_anvil ├── __init__.py ├── api.py ├── api_resources │ ├── __init__.py │ ├── base.py │ ├── mutations │ │ ├── __init__.py │ │ ├── base.py │ │ ├── create_etch_packet.py │ │ ├── forge_submit.py │ │ ├── generate_etch_signing_url.py │ │ └── helpers.py │ ├── payload.py │ └── requests.py ├── cli.py ├── constants.py ├── exceptions.py ├── http.py ├── models.py ├── py.typed ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── payloads.py │ ├── test_api.py │ ├── test_cli.py │ ├── test_http.py │ ├── test_models.py │ └── test_utils.py └── utils.py ├── scent.py ├── schema └── anvil_schema.graphql ├── tests ├── __init__.py ├── conftest.py └── test_cli.py └── tox.ini /.appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | global: 3 | RANDOM_SEED: 0 4 | matrix: 5 | - PYTHON_MAJOR: 3 6 | PYTHON_MINOR: 6 7 | 8 | cache: 9 | - .venv -> poetry.lock 10 | 11 | install: 12 | # Add Make and Python to the PATH 13 | - copy C:\MinGW\bin\mingw32-make.exe C:\MinGW\bin\make.exe 14 | - set PATH=%PATH%;C:\MinGW\bin 15 | - set PATH=C:\Python%PYTHON_MAJOR%%PYTHON_MINOR%;%PATH% 16 | - set PATH=C:\Python%PYTHON_MAJOR%%PYTHON_MINOR%\Scripts;%PATH% 17 | # Install system dependencies 18 | - curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 19 | - set PATH=%USERPROFILE%\.poetry\bin;%PATH% 20 | - make doctor 21 | # Install project dependencies 22 | - make install 23 | 24 | build: off 25 | 26 | test_script: 27 | - make check 28 | - make test 29 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | branch = true 4 | 5 | data_file = .cache/coverage 6 | 7 | omit = 8 | .venv/* 9 | */tests/* 10 | */__main__.py 11 | 12 | [report] 13 | 14 | exclude_lines = 15 | pragma: no cover 16 | raise NotImplementedError 17 | except DistributionNotFound 18 | TYPE_CHECKING 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Run linters and tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Install poetry 17 | run: pipx install poetry 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | cache: 'poetry' 24 | 25 | - name: Install poetry dependencies 26 | run: poetry install 27 | 28 | - name: Check dependencies 29 | run: make doctor 30 | 31 | - name: Install dependencies 32 | run: make install 33 | 34 | - name: Check code 35 | run: make check 36 | 37 | - name: Test code 38 | run: make test 39 | 40 | # - name: Upload coverage 41 | # uses: codecov/codecov-action@v1 42 | # with: 43 | # fail_ci_if_error: true 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary Python files 2 | *.pyc 3 | *.egg-info 4 | __pycache__ 5 | .ipynb_checkpoints 6 | setup.py 7 | pip-wheel-metadata/ 8 | 9 | # Temporary OS files 10 | Icon* 11 | 12 | # Temporary virtual environment files 13 | /.cache/ 14 | /.venv/ 15 | /env/ 16 | 17 | # Temporary server files 18 | .env 19 | *.pid 20 | 21 | # Generated documentation 22 | /docs/gen/ 23 | /docs/apidocs/ 24 | /site/ 25 | /*.html 26 | /docs/*.png 27 | 28 | # Google Drive 29 | *.gdoc 30 | *.gsheet 31 | *.gslides 32 | *.gdraw 33 | 34 | # Testing and coverage results 35 | /.coverage 36 | /.coverage.* 37 | /htmlcov/ 38 | 39 | # Build and release directories 40 | /build/ 41 | /dist/ 42 | *.spec 43 | 44 | # Sublime Text 45 | *.sublime-workspace 46 | 47 | # Eclipse 48 | .settings 49 | 50 | .tox 51 | .idea 52 | .vscode 53 | 54 | filled.pdf 55 | generated.pdf 56 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | 3 | multi_line_output = 3 4 | 5 | known_standard_library = dataclasses,typing_extensions 6 | known_third_party = click 7 | known_first_party = python_anvil 8 | 9 | combine_as_imports = true 10 | force_grid_wrap = false 11 | include_trailing_comma = true 12 | 13 | lines_after_imports = 2 14 | line_length = 88 15 | 16 | profile = black 17 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | ignore_missing_imports = true 4 | no_implicit_optional = true 5 | check_untyped_defs = true 6 | 7 | cache_dir = .cache/mypy/ 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 22.12.0 10 | hooks: 11 | - id: black 12 | -------------------------------------------------------------------------------- /.pydocstyle.ini: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | 3 | # D211: No blank lines allowed before class docstring 4 | add_select = D211 5 | 6 | # D100: Missing docstring in public module 7 | # D101: Missing docstring in public class 8 | # D102: Missing docstring in public method 9 | # D103: Missing docstring in public function 10 | # D104: Missing docstring in public package 11 | # D105: Missing docstring in magic method 12 | # D107: Missing docstring in __init__ 13 | # D202: No blank lines allowed after function docstring 14 | add_ignore = D100,D101,D102,D103,D104,D105,D107,D202 15 | -------------------------------------------------------------------------------- /.pylint.ini: -------------------------------------------------------------------------------- 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= 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 | # Allow loading of arbitrary C extensions. Extensions are imported into the 34 | # active Python interpreter and may run arbitrary code. 35 | unsafe-load-any-extension=no 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then reenable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable= 54 | fixme, 55 | global-statement, 56 | invalid-name, 57 | missing-docstring, 58 | redefined-outer-name, 59 | too-few-public-methods, 60 | too-many-locals, 61 | too-many-arguments, 62 | unnecessary-pass, 63 | broad-except, 64 | duplicate-code, 65 | too-many-branches, 66 | too-many-return-statements, 67 | too-many-public-methods, 68 | too-many-ancestors, 69 | too-many-instance-attributes, 70 | too-many-statements, 71 | attribute-defined-outside-init, 72 | unsupported-assignment-operation, 73 | unsupported-delete-operation, 74 | too-many-nested-blocks, 75 | protected-access, 76 | wrong-import-order, 77 | use-dict-literal, 78 | 79 | # Enable the message, report, category or checker with the given id(s). You can 80 | # either give multiple identifier separated by comma (,) or put this option 81 | # multiple time (only on the command line, not in the configuration file where 82 | # it should appear only once). See also the "--disable" option for examples. 83 | enable= 84 | 85 | 86 | [REPORTS] 87 | 88 | # Python expression which should return a note less than 10 (10 is the highest 89 | # note). You have access to the variables errors warning, statement which 90 | # respectively contain the number of errors / warnings messages and the total 91 | # number of statements analyzed. This is used by the global evaluation report 92 | # (RP0004). 93 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 94 | 95 | # Template used to display messages. This is a python new-style format string 96 | # used to format the message information. See doc for all details 97 | #msg-template= 98 | 99 | # Set the output format. Available formats are text, parseable, colorized, json 100 | # and msvs (visual studio).You can also give a reporter class, eg 101 | # mypackage.mymodule.MyReporterClass. 102 | output-format=text 103 | 104 | # Tells whether to display a full report or only the messages 105 | reports=no 106 | 107 | # Activate the evaluation score. 108 | score=no 109 | 110 | 111 | [REFACTORING] 112 | 113 | # Maximum number of nested blocks for function / method body 114 | max-nested-blocks=5 115 | 116 | 117 | [BASIC] 118 | 119 | # Regular expression matching correct argument names 120 | argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 121 | 122 | # Regular expression matching correct attribute names 123 | attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 124 | 125 | # Bad variable names which should always be refused, separated by a comma 126 | bad-names=foo,bar,baz,toto,tutu,tata 127 | 128 | # Regular expression matching correct class attribute names 129 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 130 | 131 | # Regular expression matching correct class names 132 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 133 | 134 | # Regular expression matching correct constant names 135 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 136 | 137 | # Minimum line length for functions/classes that require docstrings, shorter 138 | # ones are exempt. 139 | docstring-min-length=-1 140 | 141 | # Regular expression matching correct function names 142 | function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 143 | 144 | # Good variable names which should always be accepted, separated by a comma 145 | good-names=i,j,k,ex,Run,_ 146 | 147 | # Include a hint for the correct naming format with invalid-name 148 | include-naming-hint=no 149 | 150 | # Regular expression matching correct inline iteration names 151 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 152 | 153 | # Regular expression matching correct method names 154 | method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 155 | 156 | # Regular expression matching correct module names 157 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 158 | 159 | # Colon-delimited sets of names that determine each other's naming style when 160 | # the name regexes allow several styles. 161 | name-group= 162 | 163 | # Regular expression which should only match function or class names that do 164 | # not require a docstring. 165 | no-docstring-rgx=^_ 166 | 167 | # List of decorators that produce properties, such as abc.abstractproperty. Add 168 | # to this list to register other decorators that produce valid properties. 169 | property-classes=abc.abstractproperty 170 | 171 | # Regular expression matching correct variable names 172 | variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 173 | 174 | 175 | [FORMAT] 176 | 177 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 178 | expected-line-ending-format= 179 | 180 | # Regexp for a line that is allowed to be longer than the limit. 181 | ignore-long-lines=^.*((https?:)|(pragma:)|(TODO:)).*$ 182 | 183 | # Number of spaces of indent required inside a hanging or continued line. 184 | indent-after-paren=4 185 | 186 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 187 | # tab). 188 | indent-string=' ' 189 | 190 | # Maximum number of characters on a single line. 191 | max-line-length=88 192 | 193 | # Maximum number of lines in a module 194 | max-module-lines=1000 195 | 196 | # Allow the body of a class to be on the same line as the declaration if body 197 | # contains single statement. 198 | single-line-class-stmt=no 199 | 200 | # Allow the body of an if to be on the same line as the test if there is no 201 | # else. 202 | single-line-if-stmt=no 203 | 204 | 205 | [LOGGING] 206 | 207 | # Logging modules to check that the string format arguments are in logging 208 | # function parameter format 209 | logging-modules=logging 210 | 211 | 212 | [MISCELLANEOUS] 213 | 214 | # List of note tags to take in consideration, separated by a comma. 215 | notes=FIXME,XXX,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 | [SPELLING] 234 | 235 | # Spelling dictionary name. Available dictionaries: none. To make it working 236 | # install python-enchant package. 237 | spelling-dict= 238 | 239 | # List of comma separated words that should not be checked. 240 | spelling-ignore-words= 241 | 242 | # A path to a file that contains private dictionary; one word per line. 243 | spelling-private-dict-file= 244 | 245 | # Tells whether to store unknown words to indicated private dictionary in 246 | # --spelling-private-dict-file option instead of raising a message. 247 | spelling-store-unknown-words=no 248 | 249 | 250 | [TYPECHECK] 251 | 252 | # List of decorators that produce context managers, such as 253 | # contextlib.contextmanager. Add to this list to register other decorators that 254 | # produce valid context managers. 255 | contextmanager-decorators=contextlib.contextmanager 256 | 257 | # List of members which are set dynamically and missed by pylint inference 258 | # system, and so shouldn't trigger E1101 when accessed. Python regular 259 | # expressions are accepted. 260 | generated-members= 261 | 262 | # Tells whether missing members accessed in mixin class should be ignored. A 263 | # mixin class is detected if its name ends with "mixin" (case insensitive). 264 | ignore-mixin-members=yes 265 | 266 | # This flag controls whether pylint should warn about no-member and similar 267 | # checks whenever an opaque object is returned when inferring. The inference 268 | # can return multiple potential results while evaluating a Python object, but 269 | # some branches might not be evaluated, which results in partial inference. In 270 | # that case, it might be useful to still emit no-member and other checks for 271 | # the rest of the inferred objects. 272 | ignore-on-opaque-inference=yes 273 | 274 | # List of class names for which member attributes should not be checked (useful 275 | # for classes with dynamically set attributes). This supports the use of 276 | # qualified names. 277 | ignored-classes=optparse.Values,thread._local,_thread._local 278 | 279 | # List of module names for which member attributes should not be checked 280 | # (useful for modules/projects where namespaces are manipulated during runtime 281 | # and thus existing member attributes cannot be deduced by static analysis. It 282 | # supports qualified module names, as well as Unix pattern matching. 283 | ignored-modules= 284 | 285 | # Show a hint with possible names when a member name was not found. The aspect 286 | # of finding the hint is based on edit distance. 287 | missing-member-hint=yes 288 | 289 | # The minimum edit distance a name should have in order to be considered a 290 | # similar match for a missing member name. 291 | missing-member-hint-distance=1 292 | 293 | # The total number of similar names that should be taken in consideration when 294 | # showing a hint for a missing member. 295 | missing-member-max-choices=1 296 | 297 | 298 | [VARIABLES] 299 | 300 | # List of additional names supposed to be defined in builtins. Remember that 301 | # you should avoid to define new builtins when possible. 302 | additional-builtins= 303 | 304 | # Tells whether unused global variables should be treated as a violation. 305 | allow-global-unused-variables=yes 306 | 307 | # List of strings which can identify a callback function by name. A callback 308 | # name must start or end with one of those strings. 309 | callbacks=cb_,_cb 310 | 311 | # A regular expression matching the name of dummy variables (i.e. expectedly 312 | # not used). 313 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 314 | 315 | # Argument names that match this expression will be ignored. Default to name 316 | # with leading underscore 317 | ignored-argument-names=_.*|^ignored_|^unused_ 318 | 319 | # Tells whether we should check for unused import in __init__ files. 320 | init-import=no 321 | 322 | # List of qualified module names which can have objects that can redefine 323 | # builtins. 324 | redefining-builtins-modules=six.moves,future.builtins 325 | 326 | 327 | [CLASSES] 328 | 329 | # List of method names used to declare (i.e. assign) instance attributes. 330 | defining-attr-methods=__init__,__new__,setUp 331 | 332 | # List of member names, which should be excluded from the protected access 333 | # warning. 334 | exclude-protected=_asdict,_fields,_replace,_source,_make 335 | 336 | # List of valid names for the first argument in a class method. 337 | valid-classmethod-first-arg=cls 338 | 339 | # List of valid names for the first argument in a metaclass class method. 340 | valid-metaclass-classmethod-first-arg=mcs 341 | 342 | 343 | [DESIGN] 344 | 345 | # Maximum number of arguments for function / method 346 | max-args=5 347 | 348 | # Maximum number of attributes for a class (see R0902). 349 | max-attributes=7 350 | 351 | # Maximum number of boolean expressions in a if statement 352 | max-bool-expr=5 353 | 354 | # Maximum number of branch for function / method body 355 | max-branches=12 356 | 357 | # Maximum number of locals for function / method body 358 | max-locals=15 359 | 360 | # Maximum number of parents for a class (see R0901). 361 | max-parents=7 362 | 363 | # Maximum number of public methods for a class (see R0904). 364 | max-public-methods=20 365 | 366 | # Maximum number of return / yield for function / method body 367 | max-returns=6 368 | 369 | # Maximum number of statements in function / method body 370 | max-statements=50 371 | 372 | # Minimum number of public methods for a class (see R0903). 373 | min-public-methods=2 374 | 375 | 376 | [IMPORTS] 377 | 378 | # Allow wildcard imports from modules that define __all__. 379 | allow-wildcard-with-all=no 380 | 381 | # Analyse import fallback blocks. This can be used to support both Python 2 and 382 | # 3 compatible code, which means that the block might have code that exists 383 | # only in one or another interpreter, leading to false positives when analysed. 384 | analyse-fallback-blocks=no 385 | 386 | # Deprecated modules which should not be used, separated by a comma 387 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 388 | 389 | # Create a graph of external dependencies in the given file (report RP0402 must 390 | # not be disabled) 391 | ext-import-graph= 392 | 393 | # Create a graph of every (i.e. internal and external) dependencies in the 394 | # given file (report RP0402 must not be disabled) 395 | import-graph= 396 | 397 | # Create a graph of internal dependencies in the given file (report RP0402 must 398 | # not be disabled) 399 | int-import-graph= 400 | 401 | # Force import order to recognize a module as part of the standard 402 | # compatibility libraries. 403 | known-standard-library= 404 | 405 | # Force import order to recognize a module as part of a third party library. 406 | known-third-party=enchant 407 | 408 | 409 | [EXCEPTIONS] 410 | 411 | # Exceptions that will emit a warning when being caught. Defaults to 412 | # "Exception" 413 | overgeneral-exceptions=builtins.Exception 414 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8.13 2 | 3.9.12 3 | 3.10.4 4 | 3.11.11 5 | 3.12.8 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.9" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | mkdocs: 16 | configuration: mkdocs.yml 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | tests: 3 | override: 4 | - pylint-run --rcfile=.pylint.ini 5 | - py-scrutinizer-run 6 | checks: 7 | python: 8 | code_rating: true 9 | duplicate_code: true 10 | filter: 11 | excluded_paths: 12 | - "*/tests/*" 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | 3 | language: python 4 | python: 5 | - 3.8 6 | 7 | cache: 8 | pip: true 9 | directories: 10 | - ${VIRTUAL_ENV} 11 | 12 | env: 13 | global: 14 | - RANDOM_SEED=0 15 | 16 | before_install: 17 | - curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 18 | - source $HOME/.poetry/env 19 | - make doctor 20 | 21 | install: 22 | - make install 23 | 24 | script: 25 | - make check 26 | - make test 27 | 28 | after_success: 29 | - pip install coveralls scrutinizer-ocular 30 | - coveralls 31 | - ocular 32 | 33 | notifications: 34 | email: 35 | on_success: never 36 | on_failure: never 37 | -------------------------------------------------------------------------------- /.verchew.ini: -------------------------------------------------------------------------------- 1 | [Make] 2 | 3 | cli = make 4 | version = GNU Make 5 | 6 | [Python] 7 | 8 | cli = python 9 | version = 3.8 10 | 11 | [Poetry] 12 | 13 | cli = poetry 14 | version = 1 15 | 16 | [Graphviz] 17 | 18 | cli = dot 19 | cli_version_arg = -V 20 | version = 2 21 | optional = true 22 | message = This is only needed to generate UML diagrams for documentation. 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 5.0.3 (2025-02-24) 2 | 3 | - Package import now uses `importlib.metadata` to get the version and throws `PackageNotFoundError` if the package is not 4 | installed. 5 | 6 | # 5.0.2 (2025-01-14) 7 | 8 | - `gql` requirement is now `3.6.0b2` 9 | 10 | # 5.0.1 (2025-01-06) 11 | 12 | - Python requirement is now `>= 3.8.0,<3.13`. 13 | - Updated `pylint` to `^3.0` in support of the above. 14 | 15 | # 5.0.0 (2024-12-27) 16 | 17 | - **[BREAKING CHANGE]** Python requirement is now `>= 3.8.0,<3.12`. 18 | - Unpegged `urllib3`. 19 | - Utilizing pydantic v2 syntax and best practices. 20 | - Improves file handling with `FileCompatibleBaseModel` (Thanks @cyrusradfar!) 21 | 22 | # 4.0.0 (2024-07-23) 23 | 24 | - Updated `pydantic` package dependency to `v2`, but still using `v1` internally. 25 | - **[BREAKING CHANGE]** Python requirement is now `>= 3.8.0`, up from `>= 3.7.2`. 26 | 27 | # 3.0.1 (2023-06-28) 28 | 29 | - Fixed issue with `requests_toolbelt` (`gql` dependency) using an incompatible version of `urllib3`. 30 | This caused an error of `ImportError: cannot import name 'appengine'` to be thrown. 31 | 32 | # 3.0.0 (2023-02-17) 33 | 34 | - **[BREAKING CHANGE]** [`graphql-python/gql`](https://github.com/graphql-python/gql) is now the main GraphQL client 35 | implementation. All functions should still work the same as before. If there are any issues please let us know 36 | in `python-anvil` GitHub issues. 37 | - Updated examples to reflect new GraphQL implementation and added `examples/make_graphql_request.py` example. 38 | 39 | # 2.0.0 (2023-01-26) 40 | 41 | - **[BREAKING CHANGE]** Minimum required Python version updated to `>=3.7.2` 42 | 43 | # 1.9.0 (2023-01-26) 44 | 45 | - Clearer version number support 46 | - Add additional variables for `CreateEtchPacket` mutation 47 | - Add missing `webhookURL` variable in `ForgeSubmit` mutation 48 | - Add `forgeSubmit` example 49 | 50 | # 1.8.0 (2023-01-10) 51 | 52 | - Added support for multipart uploads on `CreateEtchPacket` requests. 53 | - New example for multipart uploads in `examples/create_etch_upload_file_multipart.py` 54 | - Added environment variable usage in all `examples/` files for easier usage. 55 | - Updated a few minor development packages. 56 | 57 | # 1.7.0 (2022-09-09) 58 | 59 | - Added support for `version_number` in PDF Fill requests. 60 | 61 | # 1.6.0 (2022-09-07) 62 | 63 | - Added support for HTML/CSS and Markdown in `CreateEtchPacket`. [See examples here](https://www.useanvil.com/docs/api/e-signatures#generating-a-pdf-from-html-and-css). 64 | 65 | # 1.5.0 (2022-08-05) 66 | 67 | - Added support for `ForgeSubmit` mutation. 68 | 69 | # 1.4.1 (2022-05-11) 70 | 71 | - Updated `mkdocs` dependency to fix issue with Read the Docs. 72 | 73 | # 1.4.0 (2022-05-10) 74 | 75 | - Updated a number of packages to fix linter and pre-commit issues 76 | - Added support for `CreateEtchPacket.merge_pdfs`. 77 | 78 | # 1.3.1 (2022-03-18) 79 | 80 | - Updated `click` package dependency to `^8.0` 81 | - Update other minor dependencies. [See full list here](https://github.com/anvilco/python-anvil/pull/31). 82 | 83 | # 1.3.0 (2022-03-04) 84 | 85 | - Fixed optional field `CreateEtchPacket.signature_email_subject` being required. This is now truly optional. 86 | - Added support for `CreateEtchPacket.signature_email_body`. 87 | - Added support for `CreateEtchPacket.replyToName` and `CreateEtchPacket.replyToEmail` which customizes the "Reply-To" 88 | header in Etch packet emails. 89 | 90 | # 1.2.1 (2022-01-03) 91 | 92 | - Fixed issue with Etch packet `is_test` and `is_draft` options not properly applying to the final GraphQL mutation when 93 | using `CreateEtchPacket.create_payload`. 94 | 95 | # 1.2.0 (2021-12-15) 96 | 97 | - Added `py.typed` for better mypy support. 98 | - Updated a number of dev dependencies. 99 | 100 | # 1.1.0 (2021-11-15) 101 | 102 | - Added support for `webhook_url` on Etch packets. Please see the `CreateEtchPacketPayload` class 103 | and [Anvil API docs](https://www.useanvil.com/docs/api/e-signatures#webhook-notifications) for more info. 104 | - Better support for extra (unsupported) fields in all models. Previously fields not defined in models would be 105 | stripped, or would raise a runtime error. Additional fields will no longer be stripped and will be used in JSON 106 | payloads as you may expect. Note that, though this is now supported, the Anvil API will return an error for any 107 | unsupported fields. 108 | - Updated documentation. 109 | 110 | # 1.0.0 (2021-10-14) 111 | 112 | - **[BREAKING CHANGE]** `dataclasses-json` library removed and replaced 113 | with [pydantic](https://github.com/samuelcolvin/pydantic/). 114 | This should not affect any users who only use the CLI and API methods, but if you are using any models directly 115 | from `api_resources/payload.py`, you will likely need to update all usages. Please 116 | see [pydantic's docs](https://pydantic-docs.helpmanual.io/usage/models/) for more details. 117 | - **[BREAKING CHANGE]** Increased minimum required Python version to 3.6.2. 118 | - Updated `EtchSigner` model to be more in sync with new official documentation. 119 | See `create_etch_existing_cast.py` file for examples and `api_resources/payload.py` for `EtchSigner` changes. 120 | - Updated CLI command `anvil cast --list` to only return casts that are templates. 121 | Use `anvil cast --all` if you'd like the previous behavior. 122 | - Updated a number of dependencies, the vast majority being dev-dependencies. 123 | 124 | # 0.3.0 (2021-08-03) 125 | 126 | - Fixed API ratelimit not being set correctly 127 | - Added support for setting API key environment which sets different API rate limits 128 | - Added support for `--include-headers` in all API methods which includes HTTP response headers in function returns 129 | - Added support for `--retry` in all API methods which enables/disables automatic retries 130 | - Added support for `--debug` flag in CLI which outputs headers from HTTP responses 131 | 132 | # 0.2.0 (2021-05-05) 133 | 134 | - Added support for HTML to PDF on `generate_pdf` 135 | 136 | # 0.1.1 (2021-02-16) 137 | 138 | - Fixed for REST API calls failing 139 | 140 | # 0.1.0 (2021-01-30) 141 | 142 | #### Initial public release 143 | 144 | - Added GraphQL queries 145 | - Raw queries 146 | - casts 147 | - etchPackets 148 | - currentUser 149 | - availableQueries 150 | - welds 151 | - weldData 152 | - Added GraphQL mutations 153 | - TODO: sendEtchPacket 154 | - createEtchPacket 155 | - generateEtchSignURL 156 | - Added other requests 157 | - Fill PDF 158 | - Generate PDF 159 | - Download Documents 160 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Requirements 4 | 5 | * Make: 6 | * macOS: `$ xcode-select --install` 7 | * Linux: [https://www.gnu.org/software/make](https://www.gnu.org/software/make) 8 | * Windows: [https://mingw.org/download/installer](https://mingw.org/download/installer) 9 | * Python: `$ pyenv install` 10 | * Poetry: [https://poetry.eustace.io/docs/#installation](https://poetry.eustace.io/docs/#installation) 11 | * Graphviz: 12 | * macOS: `$ brew install graphviz` 13 | * Linux: [https://graphviz.org/download](https://graphviz.org/download/) 14 | * Windows: [https://graphviz.org/download](https://graphviz.org/download/) 15 | 16 | To confirm these system dependencies are configured correctly: 17 | 18 | ```text 19 | $ make doctor 20 | ``` 21 | 22 | ## Installation 23 | 24 | Install project dependencies into a virtual environment: 25 | 26 | ```text 27 | $ make install 28 | ``` 29 | 30 | # Development Tasks 31 | 32 | ## Manual 33 | 34 | Run the tests: 35 | 36 | ```text 37 | $ make test 38 | ``` 39 | 40 | Run static analysis: 41 | 42 | ```text 43 | $ make check 44 | ``` 45 | 46 | Build the documentation: 47 | 48 | ```text 49 | $ make docs 50 | ``` 51 | 52 | ## Automatic 53 | 54 | Keep all of the above tasks running on change: 55 | 56 | ```text 57 | $ make watch 58 | ``` 59 | 60 | > In order to have OS X notifications, `brew install terminal-notifier`. 61 | 62 | # Continuous Integration 63 | 64 | The CI server will report overall build status: 65 | 66 | ```text 67 | $ make ci 68 | ``` 69 | 70 | # Demo Tasks 71 | 72 | Run the program: 73 | 74 | ```text 75 | $ make run 76 | ```` 77 | 78 | Launch an IPython session: 79 | 80 | ```text 81 | $ make ipython 82 | ``` 83 | 84 | # Release Tasks 85 | 86 | Release to PyPI: 87 | 88 | ```text 89 | $ make upload 90 | ``` 91 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | This project was generated with [cookiecutter](https://github.com/audreyr/cookiecutter) using [jacebrowning/template-python](https://github.com/jacebrowning/template-python). 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | **The MIT License (MIT)** 2 | 3 | Copyright © 2021, www.useanvil.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Project settings 2 | PROJECT := python-anvil 3 | PACKAGE := python_anvil 4 | REPOSITORY := anvilco/python-anvil 5 | 6 | # Project paths 7 | PACKAGES := $(PACKAGE) tests 8 | CONFIG := $(wildcard *.py) 9 | MODULES := $(wildcard $(PACKAGE)/*.py) 10 | 11 | # MAIN TASKS ################################################################## 12 | 13 | .PHONY: all 14 | all: install 15 | 16 | .PHONY: ci 17 | ci: format check test mkdocs ## Run all tasks that determine CI status 18 | 19 | .PHONY: watch 20 | watch: install .clean-test ## Continuously run all CI tasks when files chanage 21 | poetry run sniffer 22 | 23 | .PHONY: run ## Start the program 24 | run: install 25 | poetry run python $(PACKAGE)/__main__.py 26 | 27 | # SYSTEM DEPENDENCIES ######################################################### 28 | 29 | .PHONY: doctor 30 | doctor: ## Confirm system dependencies are available 31 | bin/verchew 32 | 33 | # PROJECT DEPENDENCIES ######################################################## 34 | 35 | VIRTUAL_ENV ?= .venv 36 | DEPENDENCIES := $(VIRTUAL_ENV)/.poetry-$(shell bin/checksum pyproject.toml poetry.lock) 37 | 38 | .PHONY: install 39 | install: $(DEPENDENCIES) .cache 40 | 41 | $(DEPENDENCIES): poetry.lock 42 | @ rm -rf $(VIRTUAL_ENV)/.poetry-* 43 | @ poetry config virtualenvs.in-project true 44 | poetry install 45 | #@ touch $@ 46 | 47 | ifndef CI 48 | poetry.lock: pyproject.toml 49 | poetry lock --no-update 50 | #@ touch $@ 51 | endif 52 | 53 | .cache: 54 | @ mkdir -p .cache 55 | 56 | # CHECKS ###################################################################### 57 | 58 | .PHONY: format 59 | format: install 60 | poetry run isort $(PACKAGE) examples tests 61 | poetry run black $(PACKAGE) examples tests 62 | @ echo 63 | 64 | .PHONY: check 65 | check: install format ## Run formaters, linters, and static analysis 66 | ifdef CI 67 | git diff --exit-code 68 | endif 69 | poetry run mypy $(PACKAGE) examples tests --config-file=.mypy.ini 70 | poetry run pylint $(PACKAGE) examples tests --rcfile=.pylint.ini 71 | poetry run pydocstyle $(PACKAGE) examples tests 72 | 73 | # TESTS ####################################################################### 74 | 75 | RANDOM_SEED ?= $(shell date +%s) 76 | FAILURES := .cache/v/cache/lastfailed 77 | 78 | PYTEST_OPTIONS := --random --random-seed=$(RANDOM_SEED) 79 | ifndef DISABLE_COVERAGE 80 | PYTEST_OPTIONS += --cov=$(PACKAGE) 81 | endif 82 | PYTEST_RERUN_OPTIONS := --last-failed --exitfirst 83 | 84 | .PHONY: test 85 | test: test-all ## Run unit and integration tests 86 | 87 | .PHONY: test-unit 88 | test-unit: install 89 | @ ( mv $(FAILURES) $(FAILURES).bak || true ) > /dev/null 2>&1 90 | poetry run pytest $(PACKAGE) $(PYTEST_OPTIONS) 91 | @ ( mv $(FAILURES).bak $(FAILURES) || true ) > /dev/null 2>&1 92 | ifndef DISABLE_COVERAGE 93 | poetry run coveragespace update unit 94 | endif 95 | 96 | .PHONY: test-int 97 | test-int: install 98 | @ if test -e $(FAILURES); then poetry run pytest tests $(PYTEST_RERUN_OPTIONS); fi 99 | @ rm -rf $(FAILURES) 100 | poetry run pytest tests $(PYTEST_OPTIONS) 101 | ifndef DISABLE_COVERAGE 102 | poetry run coveragespace update integration 103 | endif 104 | 105 | .PHONY: test-all 106 | test-all: install 107 | @ if test -e $(FAILURES); then poetry run pytest $(PACKAGE) tests $(PYTEST_RERUN_OPTIONS); fi 108 | @ rm -rf $(FAILURES) 109 | poetry run pytest $(PACKAGE) tests $(PYTEST_OPTIONS) 110 | ifndef DISABLE_COVERAGE 111 | poetry run coveragespace update overall 112 | endif 113 | 114 | .PHONY: tox 115 | # Export PACKAGES so tox doesn't have to be reconfigured if these change 116 | tox: export TESTS = $(PACKAGE) tests 117 | tox: install 118 | poetry run tox -p 2 119 | 120 | .PHONY: read-coverage 121 | read-coverage: 122 | bin/open htmlcov/index.html 123 | 124 | # DOCUMENTATION ############################################################### 125 | 126 | MKDOCS_INDEX := site/index.html 127 | 128 | .PHONY: docs 129 | docs: mkdocs uml ## Generate documentation and UML 130 | 131 | .PHONY: mkdocs 132 | mkdocs: install $(MKDOCS_INDEX) 133 | $(MKDOCS_INDEX): docs/requirements.txt mkdocs.yml docs/*.md 134 | @ mkdir -p docs/about 135 | @ cd docs && ln -sf ../README.md index.md 136 | @ cd docs/about && ln -sf ../../CHANGELOG.md changelog.md 137 | @ cd docs/about && ln -sf ../../CONTRIBUTING.md contributing.md 138 | @ cd docs/about && ln -sf ../../LICENSE.md license.md 139 | poetry run mkdocs build --clean --strict 140 | 141 | docs/requirements.txt: poetry.lock 142 | @ poetry export --dev --without-hashes | grep mkdocs > $@ 143 | @ poetry export --dev --without-hashes | grep pygments >> $@ 144 | 145 | .PHONY: uml 146 | uml: install docs/*.png 147 | docs/*.png: $(MODULES) 148 | poetry run pyreverse $(PACKAGE) -p $(PACKAGE) -a 1 -f ALL -o png --ignore tests 149 | - mv -f classes_$(PACKAGE).png docs/classes.png 150 | - mv -f packages_$(PACKAGE).png docs/packages.png 151 | 152 | .PHONY: mkdocs-serve 153 | mkdocs-serve: mkdocs 154 | eval "sleep 3; bin/open http://127.0.0.1:8000" & 155 | poetry run mkdocs serve 156 | 157 | # BUILD ####################################################################### 158 | 159 | DIST_FILES := dist/*.tar.gz dist/*.whl 160 | EXE_FILES := dist/$(PACKAGE).* 161 | 162 | .PHONY: dist 163 | dist: install $(DIST_FILES) 164 | $(DIST_FILES): $(MODULES) pyproject.toml 165 | rm -f $(DIST_FILES) 166 | poetry build 167 | 168 | .PHONY: exe 169 | exe: install $(EXE_FILES) 170 | $(EXE_FILES): $(MODULES) $(PACKAGE).spec 171 | # For framework/shared support: https://github.com/yyuu/pyenv/wiki 172 | poetry run pyinstaller $(PACKAGE).spec --noconfirm --clean 173 | 174 | $(PACKAGE).spec: 175 | poetry run pyi-makespec $(PACKAGE)/__main__.py --onefile --windowed --name=$(PACKAGE) 176 | 177 | # RELEASE ##################################################################### 178 | 179 | .PHONY: upload 180 | upload: dist ## Upload the current version to PyPI 181 | git diff --name-only --exit-code 182 | poetry publish 183 | bin/open https://pypi.org/project/$(PACKAGE) 184 | 185 | # CLEANUP ##################################################################### 186 | 187 | .PHONY: clean 188 | clean: .clean-build .clean-docs .clean-test .clean-install ## Delete all generated and temporary files 189 | 190 | .PHONY: clean-all 191 | clean-all: clean 192 | rm -rf $(VIRTUAL_ENV) 193 | 194 | .PHONY: .clean-install 195 | .clean-install: 196 | find $(PACKAGE) tests -name '__pycache__' -delete 197 | rm -rf *.egg-info 198 | 199 | .PHONY: .clean-test 200 | .clean-test: 201 | rm -rf .cache .pytest .coverage htmlcov 202 | 203 | .PHONY: .clean-docs 204 | .clean-docs: 205 | rm -rf docs/*.png site 206 | 207 | .PHONY: .clean-build 208 | .clean-build: 209 | rm -rf *.spec dist build 210 | 211 | # HELP ######################################################################## 212 | 213 | .PHONY: help 214 | help: all 215 | @ grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 216 | 217 | .DEFAULT_GOAL := help 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Horizontal Lockupblack](https://user-images.githubusercontent.com/293079/169453889-ae211c6c-7634-4ccd-8ca9-8970c2621b6f.png#gh-light-mode-only) 2 | ![Horizontal Lockup copywhite](https://user-images.githubusercontent.com/293079/169453892-895f637b-4633-4a14-b997-960c9e17579b.png#gh-dark-mode-only) 3 | 4 | # Anvil API Library 5 | 6 | [![PyPI Version](https://img.shields.io/pypi/v/python-anvil.svg)](https://pypi.org/project/python-anvil) 7 | [![PyPI License](https://img.shields.io/pypi/l/python-anvil.svg)](https://pypi.org/project/python-anvil) 8 | 9 | This is a library that provides an interface to access the [Anvil API](https://www.useanvil.com/developers) from applications 10 | written in the Python programming language. 11 | 12 | [Anvil](https://www.useanvil.com/developers/) provides easy APIs for all things paperwork. 13 | 14 | 1. [PDF filling API](https://www.useanvil.com/products/pdf-filling-api/) - fill out a PDF template with a web request and structured JSON data. 15 | 2. [PDF generation API](https://www.useanvil.com/products/pdf-generation-api/) - send markdown or HTML and Anvil will render it to a PDF. 16 | 3. [Etch e-sign with API](https://www.useanvil.com/products/etch/) - customizable, embeddable, e-signature platform with an API to control the signing process end-to-end. 17 | 4. [Anvil Workflows (w/ API)](https://www.useanvil.com/products/workflows/) - Webforms + PDF + e-sign with a powerful no-code builder. Easily collect structured data, generate PDFs, and request signatures. 18 | 19 | Learn more on our [Anvil developer page](https://www.useanvil.com/developers/). See the [API guide](https://www.useanvil.com/docs) and the [GraphQL reference](https://www.useanvil.com/docs/api/graphql/reference/) for full documentation. 20 | 21 | ### Documentation 22 | 23 | General API documentation: [Anvil API docs](https://www.useanvil.com/docs) 24 | 25 | # Setup 26 | 27 | ## Requirements 28 | 29 | * Python >=3.8 30 | 31 | ## Installation 32 | 33 | Install it directly into an activated virtual environment: 34 | 35 | ```shell 36 | $ pip install python-anvil 37 | ``` 38 | 39 | or add it to your [Poetry](https://python-poetry.org/) project: 40 | 41 | ```shell 42 | $ poetry add python-anvil 43 | ``` 44 | -------------------------------------------------------------------------------- /bin/checksum: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import hashlib 5 | import sys 6 | 7 | 8 | def run(paths): 9 | sha = hashlib.sha1() 10 | 11 | for path in paths: 12 | try: 13 | with open(path, 'rb') as f: 14 | for chunk in iter(lambda: f.read(4096), b''): 15 | sha.update(chunk) 16 | except IOError: 17 | sha.update(path.encode()) 18 | 19 | print(sha.hexdigest()) 20 | 21 | 22 | if __name__ == '__main__': 23 | run(sys.argv[1:]) 24 | -------------------------------------------------------------------------------- /bin/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | 8 | COMMANDS = { 9 | 'linux': "open", 10 | 'win32': "cmd /c start", 11 | 'cygwin': "cygstart", 12 | 'darwin': "open", 13 | } 14 | 15 | 16 | def run(path): 17 | command = COMMANDS.get(sys.platform, "open") 18 | os.system(command + ' ' + path) 19 | 20 | 21 | if __name__ == '__main__': 22 | run(sys.argv[-1]) 23 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import importlib 6 | import tempfile 7 | import shutil 8 | import subprocess 9 | import sys 10 | 11 | CWD = os.getcwd() 12 | TMP = tempfile.gettempdir() 13 | CONFIG = { 14 | "full_name": "Allan Almazan", 15 | "email": "allan@useanvil.com", 16 | "github_username": "aalmazan", 17 | "github_repo": "python-anvil", 18 | "default_branch": "master", 19 | "project_name": "python_anvil", 20 | "package_name": "python_anvil", 21 | "project_short_description": "Anvil API", 22 | "python_major_version": 3, 23 | "python_minor_version": 6, 24 | } 25 | 26 | 27 | def install(package='cookiecutter'): 28 | try: 29 | importlib.import_module(package) 30 | except ImportError: 31 | print("Installing cookiecutter") 32 | subprocess.check_call([sys.executable, '-m', 'pip', 'install', package]) 33 | 34 | 35 | def run(): 36 | print("Generating project") 37 | 38 | from cookiecutter.main import cookiecutter 39 | 40 | os.chdir(TMP) 41 | cookiecutter( 42 | 'https://github.com/jacebrowning/template-python.git', 43 | no_input=True, 44 | overwrite_if_exists=True, 45 | extra_context=CONFIG, 46 | ) 47 | 48 | 49 | def copy(): 50 | for filename in [ 51 | '.appveyor.yml', 52 | '.coveragerc', 53 | '.gitattributes', 54 | '.gitignore', 55 | '.isort.cfg', 56 | '.mypy.ini', 57 | '.pydocstyle.ini', 58 | '.pylint.ini', 59 | '.scrutinizer.yml', 60 | '.travis.yml', 61 | '.verchew.ini', 62 | 'CONTRIBUTING.md', 63 | 'Makefile', 64 | os.path.join('bin', 'checksum'), 65 | os.path.join('bin', 'open'), 66 | os.path.join('bin', 'update'), 67 | os.path.join('bin', 'verchew'), 68 | 'pytest.ini', 69 | 'scent.py', 70 | ]: 71 | src = os.path.join(TMP, CONFIG['project_name'], filename) 72 | dst = os.path.join(CWD, filename) 73 | print("Updating " + filename) 74 | shutil.copy(src, dst) 75 | 76 | 77 | if __name__ == '__main__': 78 | install() 79 | run() 80 | copy() 81 | -------------------------------------------------------------------------------- /bin/verchew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License (MIT) 5 | # Copyright © 2016, Jace Browning 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | # 25 | # Source: https://github.com/jacebrowning/verchew 26 | # Documentation: https://verchew.readthedocs.io 27 | # Package: https://pypi.org/project/verchew 28 | 29 | 30 | from __future__ import unicode_literals 31 | 32 | import argparse 33 | import logging 34 | import os 35 | import re 36 | import sys 37 | from collections import OrderedDict 38 | from subprocess import PIPE, STDOUT, Popen 39 | 40 | 41 | PY2 = sys.version_info[0] == 2 42 | 43 | if PY2: 44 | import ConfigParser as configparser 45 | from urllib import urlretrieve 46 | else: 47 | import configparser 48 | from urllib.request import urlretrieve 49 | 50 | __version__ = '3.1.1' 51 | 52 | SCRIPT_URL = ( 53 | "https://raw.githubusercontent.com/jacebrowning/verchew/main/verchew/script.py" 54 | ) 55 | 56 | CONFIG_FILENAMES = ['verchew.ini', '.verchew.ini', '.verchewrc', '.verchew'] 57 | 58 | SAMPLE_CONFIG = """ 59 | [Python] 60 | 61 | cli = python 62 | version = Python 3.5 || Python 3.6 63 | 64 | [Legacy Python] 65 | 66 | cli = python2 67 | version = Python 2.7 68 | 69 | [virtualenv] 70 | 71 | cli = virtualenv 72 | version = 15 73 | message = Only required with Python 2. 74 | 75 | [Make] 76 | 77 | cli = make 78 | version = GNU Make 79 | optional = true 80 | 81 | """.strip() 82 | 83 | STYLE = { 84 | "~": "✔", 85 | "?": "▴", 86 | "x": "✘", 87 | "#": "䷉", 88 | } 89 | 90 | COLOR = { 91 | "~": "\033[92m", # green 92 | "?": "\033[93m", # yellow 93 | "x": "\033[91m", # red 94 | "#": "\033[96m", # cyan 95 | None: "\033[0m", # reset 96 | } 97 | 98 | QUIET = False 99 | 100 | log = logging.getLogger(__name__) 101 | 102 | 103 | def main(): 104 | global QUIET 105 | 106 | args = parse_args() 107 | configure_logging(args.verbose) 108 | if args.quiet: 109 | QUIET = True 110 | 111 | log.debug("PWD: %s", os.getenv('PWD')) 112 | log.debug("PATH: %s", os.getenv('PATH')) 113 | 114 | if args.vendor: 115 | vendor_script(args.vendor) 116 | sys.exit(0) 117 | 118 | path = find_config(args.root, generate=args.init) 119 | config = parse_config(path) 120 | 121 | if not check_dependencies(config) and args.exit_code: 122 | sys.exit(1) 123 | 124 | 125 | def parse_args(): 126 | parser = argparse.ArgumentParser( 127 | description="System dependency version checker.", 128 | ) 129 | 130 | version = "%(prog)s v" + __version__ 131 | parser.add_argument( 132 | '--version', 133 | action='version', 134 | version=version, 135 | ) 136 | parser.add_argument( 137 | '-r', '--root', metavar='PATH', help="specify a custom project root directory" 138 | ) 139 | parser.add_argument( 140 | '--exit-code', 141 | action='store_true', 142 | help="return a non-zero exit code on failure", 143 | ) 144 | 145 | group_logging = parser.add_mutually_exclusive_group() 146 | group_logging.add_argument( 147 | '-v', '--verbose', action='count', default=0, help="enable verbose logging" 148 | ) 149 | group_logging.add_argument( 150 | '-q', '--quiet', action='store_true', help="suppress all output on success" 151 | ) 152 | 153 | group_commands = parser.add_argument_group('commands') 154 | group_commands.add_argument( 155 | '--init', action='store_true', help="generate a sample configuration file" 156 | ) 157 | 158 | group_commands.add_argument( 159 | '--vendor', metavar='PATH', help="download the program for offline use" 160 | ) 161 | 162 | args = parser.parse_args() 163 | 164 | return args 165 | 166 | 167 | def configure_logging(count=0): 168 | if count == 0: 169 | level = logging.WARNING 170 | elif count == 1: 171 | level = logging.INFO 172 | else: 173 | level = logging.DEBUG 174 | 175 | logging.basicConfig(level=level, format="%(levelname)s: %(message)s") 176 | 177 | 178 | def vendor_script(path): 179 | root = os.path.abspath(os.path.join(path, os.pardir)) 180 | if not os.path.isdir(root): 181 | log.info("Creating directory %s", root) 182 | os.makedirs(root) 183 | 184 | log.info("Downloading %s to %s", SCRIPT_URL, path) 185 | urlretrieve(SCRIPT_URL, path) 186 | 187 | log.debug("Making %s executable", path) 188 | mode = os.stat(path).st_mode 189 | os.chmod(path, mode | 0o111) 190 | 191 | 192 | def find_config(root=None, filenames=None, generate=False): 193 | root = root or os.getcwd() 194 | filenames = filenames or CONFIG_FILENAMES 195 | 196 | path = None 197 | log.info("Looking for config file in: %s", root) 198 | log.debug("Filename options: %s", ", ".join(filenames)) 199 | for filename in os.listdir(root): 200 | if filename in filenames: 201 | path = os.path.join(root, filename) 202 | log.info("Found config file: %s", path) 203 | return path 204 | 205 | if generate: 206 | path = generate_config(root, filenames) 207 | return path 208 | 209 | msg = "No config file found in: {0}".format(root) 210 | raise RuntimeError(msg) 211 | 212 | 213 | def generate_config(root=None, filenames=None): 214 | root = root or os.getcwd() 215 | filenames = filenames or CONFIG_FILENAMES 216 | 217 | path = os.path.join(root, filenames[0]) 218 | 219 | log.info("Generating sample config: %s", path) 220 | with open(path, 'w') as config: 221 | config.write(SAMPLE_CONFIG + '\n') 222 | 223 | return path 224 | 225 | 226 | def parse_config(path): 227 | data = OrderedDict() # type: ignore 228 | 229 | log.info("Parsing config file: %s", path) 230 | config = configparser.ConfigParser() 231 | config.read(path) 232 | 233 | for section in config.sections(): 234 | data[section] = OrderedDict() 235 | for name, value in config.items(section): 236 | data[section][name] = value 237 | 238 | for name in data: 239 | version = data[name].get('version') or "" 240 | data[name]['version'] = version 241 | data[name]['patterns'] = [v.strip() for v in version.split('||')] 242 | 243 | return data 244 | 245 | 246 | def check_dependencies(config): 247 | success = [] 248 | 249 | for name, settings in config.items(): 250 | show("Checking for {0}...".format(name), head=True) 251 | output = get_version(settings['cli'], settings.get('cli_version_arg')) 252 | 253 | for pattern in settings['patterns']: 254 | if match_version(pattern, output): 255 | show(_("~") + " MATCHED: {0}".format(pattern or "")) 256 | success.append(_("~")) 257 | break 258 | else: 259 | if settings.get('optional'): 260 | show(_("?") + " EXPECTED (OPTIONAL): {0}".format(settings['version'])) 261 | success.append(_("?")) 262 | else: 263 | if QUIET: 264 | if "not found" in output: 265 | actual = "Not found" 266 | else: 267 | actual = output.split('\n')[0].strip('.') 268 | expected = settings['version'] or "" 269 | print("{0}: {1}, EXPECTED: {2}".format(name, actual, expected)) 270 | show( 271 | _("x") 272 | + " EXPECTED: {0}".format(settings['version'] or "") 273 | ) 274 | success.append(_("x")) 275 | if settings.get('message'): 276 | show(_("#") + " MESSAGE: {0}".format(settings['message'])) 277 | 278 | show("Results: " + " ".join(success), head=True) 279 | 280 | return _("x") not in success 281 | 282 | 283 | def get_version(program, argument=None): 284 | if argument is None: 285 | args = [program, '--version'] 286 | elif argument: 287 | args = [program, argument] 288 | else: 289 | args = [program] 290 | 291 | show("$ {0}".format(" ".join(args))) 292 | output = call(args) 293 | lines = output.splitlines() 294 | show(lines[0] if lines else "") 295 | 296 | return output 297 | 298 | 299 | def match_version(pattern, output): 300 | if "not found" in output.split('\n')[0]: 301 | return False 302 | 303 | regex = pattern.replace('.', r'\.') + r'(\b|/)' 304 | 305 | log.debug("Matching %s: %s", regex, output) 306 | match = re.match(regex, output) 307 | if match is None: 308 | match = re.match(r'.*[^\d.]' + regex, output) 309 | 310 | return bool(match) 311 | 312 | 313 | def call(args): 314 | try: 315 | process = Popen(args, stdout=PIPE, stderr=STDOUT) 316 | except OSError: 317 | log.debug("Command not found: %s", args[0]) 318 | output = "sh: command not found: {0}".format(args[0]) 319 | else: 320 | raw = process.communicate()[0] 321 | output = raw.decode('utf-8').strip() 322 | log.debug("Command output: %r", output) 323 | 324 | return output 325 | 326 | 327 | def show(text, start='', end='\n', head=False): 328 | """Python 2 and 3 compatible version of print.""" 329 | if QUIET: 330 | return 331 | 332 | if head: 333 | start = '\n' 334 | end = '\n\n' 335 | 336 | if log.getEffectiveLevel() < logging.WARNING: 337 | log.info(text) 338 | else: 339 | formatted = start + text + end 340 | if PY2: 341 | formatted = formatted.encode('utf-8') 342 | sys.stdout.write(formatted) 343 | sys.stdout.flush() 344 | 345 | 346 | def _(word, is_tty=None, supports_utf8=None, supports_ansi=None): 347 | """Format and colorize a word based on available encoding.""" 348 | formatted = word 349 | 350 | if is_tty is None: 351 | is_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() 352 | if supports_utf8 is None: 353 | supports_utf8 = str(sys.stdout.encoding).lower() == 'utf-8' 354 | if supports_ansi is None: 355 | supports_ansi = sys.platform != 'win32' or 'ANSICON' in os.environ 356 | 357 | style_support = supports_utf8 358 | color_support = is_tty and supports_ansi 359 | 360 | if style_support: 361 | formatted = STYLE.get(word, word) 362 | 363 | if color_support and COLOR.get(word): 364 | formatted = COLOR[word] + formatted + COLOR[None] 365 | 366 | return formatted 367 | 368 | 369 | if __name__ == '__main__': # pragma: no cover 370 | main() 371 | -------------------------------------------------------------------------------- /docs/about/changelog.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/about/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/about/credits.md: -------------------------------------------------------------------------------- 1 | ../../CREDITS.md -------------------------------------------------------------------------------- /docs/about/license.md: -------------------------------------------------------------------------------- 1 | ../../LICENSE.md -------------------------------------------------------------------------------- /docs/advanced/create_etch_packet.md: -------------------------------------------------------------------------------- 1 | ## Create Etch Packet 2 | 3 | The Anvil Etch E-sign API allows you to collect e-signatures from within your 4 | app. Send a signature packet including multiple PDFs, images, and other uploads 5 | to one or more signers. Templatize your common PDFs then fill them with your 6 | user's information before sending out the signature packet. 7 | 8 | This is one of the more complex methods, but it should be a simpler process 9 | with the builder in `python_anvil.api_resources.mutations.CreateEtchPacket`. 10 | 11 | ### Example usage 12 | 13 | Depending on your needs, `python_api.api.create_etch_packet` accepts either a 14 | payload in a `CreateEtchPacket`/`CreateEtchPacketPayload` dataclass type, or a 15 | simple `dict`. 16 | 17 | It's recommended to use the `CreateEtchPacket` class as it will build the 18 | payload for you. 19 | 20 | 21 | ```python 22 | from python_anvil.api import Anvil 23 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 24 | from python_anvil.api_resources.payload import ( 25 | EtchSigner, 26 | SignerField, 27 | DocumentUpload, 28 | EtchCastRef, 29 | SignatureField, 30 | FillPDFPayload, 31 | ) 32 | 33 | API_KEY = 'your_api_key_here' 34 | 35 | anvil = Anvil(api_key=API_KEY) 36 | 37 | # Create an instance of the builder 38 | packet = CreateEtchPacket( 39 | name="Packet Name", 40 | signature_email_subject="Please sign these forms", 41 | ) 42 | 43 | # Gather your signer data 44 | signer1 = EtchSigner( 45 | name="Jackie", 46 | email="jackie@example.com", 47 | # Fields where the signer needs to sign 48 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 49 | # PDF Templates section on the Anvil app. 50 | # This basically says: "In the 'introPages' file (defined as 51 | # `pdf_template` above), assign the signature field with cast id of 52 | # 'def456' to this signer." You can add multiple signer fields here. 53 | fields=[SignerField( 54 | file_id="fileAlias", 55 | field_id="signOne", 56 | )], 57 | # By default, `signer_type` will be "email" which will automatically 58 | # send emails when this etch packet is created. 59 | # It can also be set to "embedded" which will _not_ send emails, and 60 | # you will need to handle sending the signer URLs manually in some way. 61 | signer_type="email", 62 | # 63 | # You can also change how signatures will be collected. 64 | # "draw" will allow the signer to draw their signature 65 | # "text" will insert a text version of the signer's name into the 66 | # signature field. 67 | signature_mode="draw", 68 | # 69 | # Whether or not to the signer is required to click each signature 70 | # field manually. If `False`, the PDF will be signed once the signer 71 | # accepts the PDF without making the user go through the PDF. 72 | accept_each_field=False, 73 | # 74 | # URL of where the signer will be redirected after signing. 75 | # The URL will also have certain URL params added on, so the page 76 | # can be customized based on the signing action. 77 | redirect_url="https://app.useanvil.com", 78 | ) 79 | 80 | # Add your signer. This could also be done when the `Anvil` class is 81 | # instantiated with `Anvil(..., signers=[signer1])`. 82 | packet.add_signer(signer1) 83 | 84 | # Create the files you want the signer to sign 85 | file1 = DocumentUpload( 86 | id="myNewFile", 87 | title="Please sign this important form", 88 | # A base64 encoded pdf should be here. 89 | # Currently, this library does not do this for you, so make sure that 90 | # the file data is ready at this point. 91 | file="BASE64 ENCODED DATA HERE", 92 | fields=[SignatureField( 93 | id="firstSignature", 94 | type="signature", 95 | page_num=0, 96 | # The position and size of the field 97 | rect=dict(x=100, y=100, width=100, height=100) 98 | )] 99 | ) 100 | 101 | # You can reference an existing PDF Template from your Anvil account 102 | # instead of uploading a new file. 103 | # You can find this information by going to the "PDF Templates" section of 104 | # your Anvil account, choosing a template, and selecting "API Info" at the 105 | # top-right of the page. 106 | # Additionally, you can get this information by using the provided CLI by: 107 | # `anvil cast --list` to list all your available templates, then: 108 | # `anvil cast [THE_EID_OF_THE_CAST]` to get a listing of data in that 109 | # template. 110 | file2 = EtchCastRef( 111 | # The `id` here is what should be used by signer objects above. 112 | # This can be any string, but should be unique if adding multiple files. 113 | id="fileAlias", 114 | # The eid of the cast you want to use from "API Info" or through the CLI 115 | cast_eid="CAST_EID_GOES_HERE" 116 | ) 117 | 118 | # Add files to your payload 119 | packet.add_file(file1) 120 | packet.add_file(file2) 121 | 122 | # Optionally, you can pre-fill fields in the PDFs you've used above. 123 | # This reuses the payload shape used when using the `fill_pdf` method. 124 | packet.add_file_payloads("fileAlias", FillPDFPayload(data={ 125 | "aTextFieldId": "This is pre-filled." 126 | })) 127 | 128 | anvil.create_etch_packet(payload=packet) 129 | ``` 130 | -------------------------------------------------------------------------------- /docs/advanced/dynamic_queries.md: -------------------------------------------------------------------------------- 1 | ## Dynamic query building with `gql` 2 | 3 | This library makes use of [`graphql-python/gql`](https://github.com/graphql-python/gql) as its GraphQL client 4 | implementation. This allows us to have a simpler interface when interacting with Anvil's GraphQL API. This also gives us 5 | the ability to use gql's dynamic query 6 | builder. [More info on their documentation page](https://gql.readthedocs.io/en/latest/advanced/dsl_module.html). 7 | 8 | We have a few helper functions to help generate your first dynamic query. These are shown in the example below. 9 | Keep in mind that your IDE will likely not be able to autocomplete any field lookups, so it may help to also 10 | have [Anvil's GraphQL reference page](https://www.useanvil.com/docs/api/graphql/reference/) open as you create your 11 | queries. 12 | 13 | ### Example usage 14 | 15 | ```python 16 | from gql.dsl import DSLQuery, dsl_gql 17 | 18 | from python_anvil.api import Anvil 19 | from python_anvil.http import get_gql_ds 20 | 21 | # These steps are similar to `gql's` docs on dynamic queries with Anvil helper functions. 22 | # https://gql.readthedocs.io/en/latest/advanced/dsl_module.html 23 | 24 | anvil = Anvil(api_key=MY_API_KEY) 25 | 26 | # Use `ds` to create your queries 27 | ds = get_gql_ds(anvil.gql_client) 28 | 29 | # Create your query in one step 30 | query = ds.Query.currentUser.select( 31 | ds.User.name, 32 | ds.User.email, 33 | ) 34 | 35 | # Or, build your query with a chain or multiple steps until you're ready to use it. 36 | query = ds.Query.currentUser.select(ds.User.name) 37 | query.select(ds.User.email) 38 | query.select(ds.User.firstName) 39 | query.select(ds.User.lastName) 40 | 41 | # Once your root query fields are defined, you can put them in an operation using DSLQuery, DSLMutation or DSLSubscription: 42 | final_query = dsl_gql(DSLQuery(query)) 43 | 44 | res = anvil.query(final_query) 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/api_usage.md: -------------------------------------------------------------------------------- 1 | # API Usage 2 | 3 | All methods assume that a valid API key is already available. Please take a look 4 | at [Anvil API Basics](https://www.useanvil.com/docs/api/basics) for more details on how to get your key. 5 | 6 | ### `Anvil` constructor 7 | 8 | * `api_key` - Your Anvil API key, either development or production 9 | * `environment` (default: `'dev'`) - The type of key being used. This affects how the library sets rate limits on API 10 | calls if a rate limit error occurs. Allowed values: `["dev", "prod"]` 11 | 12 | Example: 13 | 14 | ```python 15 | from python_anvil.api import Anvil 16 | 17 | anvil = Anvil(api_key="MY_KEY", environment="prod") 18 | ``` 19 | 20 | ### Anvil.fill_pdf 21 | 22 | Anvil allows you to fill templatized PDFs using the payload provided. 23 | 24 | **template_data: str (required)** 25 | 26 | The template id that will be filled. The template must already exist in your organization account. 27 | 28 | **payload: Optional[Union[dict, AnyStr, FillPDFPayload]]** 29 | 30 | Data to embed into the PDF. Supported `payload` types are: 31 | 32 | * `dict` - root-level keys should be in snake-case (i.e. some_var_name). 33 | * `str`/JSON - raw JSON string/JSON payload to send to the endpoint. There will be minimal processing of payload. Make 34 | sure all required data is set. 35 | * `FillPDFPayload` - dataclass (see: [Data Types](#data-types)) 36 | 37 | **version_number: Optional[int]** 38 | 39 | Version of the PDF template to use. By default, the request will use the latest published version. 40 | 41 | You can also use the constants `Anvil.VERSION_LATEST_PUBLISHED` and `Anvil.VERSION_LATEST` 42 | instead of providing a specific version number. 43 | 44 | Example: 45 | 46 | ```python 47 | from python_anvil.api import Anvil 48 | 49 | anvil = Anvil(api_key="MY KEY") 50 | data = { 51 | "title": "Some Title", 52 | "font_size": 10, 53 | "data": {"textField": "Some data"} 54 | } 55 | response = anvil.fill_pdf("some_template", data) 56 | 57 | # A version number can also be passed in. This will retrieve a specific 58 | # version of the PDF to be filled if you don't want the current version 59 | # to be used. 60 | # You can also use the constant `Anvil.VERSION_LATEST` to fill a PDF that has not 61 | # been published yet. Use this if you'd like to fill out a draft version of 62 | # your template/PDF. 63 | response = anvil.fill_pdf("some_template", data, version_number=Anvil.VERSION_LATEST) 64 | ``` 65 | 66 | ### Anvil.generate_pdf 67 | 68 | Anvil allows you to dynamically generate new PDFs using JSON data you provide via the /api/v1/generate-pdf REST 69 | endpoint. Useful for agreements, invoices, disclosures, or any other text-heavy documents. 70 | 71 | By default, `generate_pdf` will format data assuming it's in [Markdown](https://daringfireball.net/projects/markdown/). 72 | 73 | HTML is another supported input type. This can be used by providing 74 | `"type": "html"` in the payload and making the `data` field a dict containing 75 | keys `"html"` and an optional `"css"`. Example below: 76 | 77 | ```python 78 | from python_anvil.api import Anvil 79 | 80 | anvil = Anvil(api_key="MY KEY") 81 | data = { 82 | "type": "html", 83 | "title": "Some Title", 84 | "data": { 85 | "html": "

HTML Heading

", 86 | "css": "h2 { color: red }", 87 | } 88 | } 89 | response = anvil.generate_pdf(data) 90 | ``` 91 | 92 | See the official [Anvil Docs on HTML to PDF](https://www.useanvil.com/docs/api/generate-pdf#html--css-to-pdf) 93 | for more details. 94 | 95 | **payload: Union[dict, AnyStr, GeneratePDFPayload]** 96 | 97 | Data to embed into the PDF. Supported `payload` types are: 98 | 99 | * `dict` - root-level keys should be in snake-case (i.e. some_var_name). 100 | * `str`/JSON - raw JSON string/JSON payload to send to the endpoint. There will be minimal processing of payload. Make 101 | sure all required data is set. 102 | * `GeneratePDFPayload` - dataclass (see: [Data Types](#data-types)) 103 | 104 | ### Anvil.get_casts 105 | 106 | Queries the GraphQL API and returns a list of available casts. 107 | 108 | By default, this will retrieve the `'eid', 'title', 'fieldInfo'` fields for the 109 | casts, but this can be changed with the `fields` argument. 110 | 111 | * `fields` - (Optional) list of fields to return for each Cast 112 | 113 | ### Anvil.get_cast 114 | 115 | Queries the GraphQL API for data about a single cast. 116 | 117 | By default, this will retrieve the `'eid', 'title', 'fieldInfo'` fields for the 118 | casts, but this can be changed with the `fields` argument. 119 | 120 | * `eid` - The eid of the Cast 121 | * `fields` - (Optional) list of fields you want from the Cast instance. 122 | * `version_number` - (Optional) Version number of the cast to fill out. If this is not provided, the latest published 123 | version will be used. 124 | 125 | ### Anvil.get_welds 126 | 127 | Queries the GraphQL API and returns a list of available welds. 128 | 129 | Fetching the welds is the best way to fetch the data submitted to a given workflow 130 | (weld). An instances of a workflow is called a weldData. 131 | 132 | ### Anvil.get_current_user 133 | 134 | Returns the currently logged in user. You can generally get a lot of what you 135 | may need from this query. 136 | 137 | ### Anvil.download_documents 138 | 139 | Retrieves zip file data from the API with a given docoument eid. 140 | 141 | When all parties have signed an Etch Packet, you can fetch the completed 142 | documents in zip form with this API call. 143 | 144 | * `document_group_eid` - The eid of the document group you wish to download. 145 | 146 | ### Anvil.generate_signing_url 147 | 148 | Generates a signing URL for a given signature process. 149 | 150 | By default, we will solicit all signatures via email. However, if you'd like 151 | to embed the signature process into one of your own flows we support this as 152 | well. 153 | 154 | * `signer_eid` - eid of the signer. This can be found in the response of the 155 | `createEtchPacket` mutation. 156 | * `client_user_id` - the signer's user id in your system 157 | 158 | ### Anvil.create_etch_packet 159 | 160 | Creates an Anvil Etch E-sign packet. 161 | 162 | This is one of the more complex processes due to the different types of data 163 | needed in the final payload. Please take a look at the [advanced section](advanced/create_etch_packet.md) 164 | for more details the creation process. 165 | 166 | * `payload` - Payload to use for the packet. Accepted types are `dict`, 167 | `CreateEtchPacket` and `CreateEtchPacketPayload`. 168 | * `json` - Raw JSON payload of the etch packet 169 | 170 | ### Anvil.forge_submit 171 | 172 | Creates an Anvil submission 173 | object. [See documentation](https://www.useanvil.com/docs/api/graphql/reference/#operation-forgesubmit-Mutations) for 174 | more details. 175 | 176 | * `payload` - Payload to use for the submission. Accepted types are `dict`, 177 | `ForgeSubmit` and `ForgeSubmitPayload`. 178 | * `json` - Raw JSON payload of the `forgeSubmit` mutation. 179 | 180 | ### Data Types 181 | 182 | This package uses `pydantic` heavily to serialize and validate data. 183 | These dataclasses exist in `python_anvil/api_resources/payload.py`. 184 | 185 | Please see [pydantic's docs](https://pydantic-docs.helpmanual.io/) for more details on how to use these 186 | dataclass instances. 187 | 188 | 189 | ### Supported kwargs 190 | 191 | All API functions also accept arbitrary kwargs which will affect how some underlying functions behave. 192 | 193 | * `retry` (default: `True`) - When this is passed as an argument, it will enable/disable request retries due to rate 194 | limit errors. By default, this library _will_ retry requests for a maximum of 5 times. 195 | * `include_headers` (default: `False`) - When this is passed as an argument, the function's return will be a `dict` 196 | containing: `{"response": {...data}, "headers": {...data}}`. This is useful if you would like to have more control 197 | over the response data. Specifically, you can control API retries when used with `retry=False`. 198 | 199 | Example: 200 | 201 | ```python 202 | from python_anvil.api import Anvil 203 | 204 | anvil = Anvil(api_key=MY_API_KEY) 205 | 206 | # Including headers 207 | res = anvil.fill_pdf("some_template_id", payload, include_headers=True) 208 | response = res["response"] 209 | headers = res["headers"] 210 | 211 | # No headers 212 | res = anvil.fill_pdf("some_template_id", payload, include_headers=False) 213 | ``` 214 | 215 | ### Using fields that are not yet supported 216 | 217 | There may be times when the Anvil API has new features or options, but explicit support hasn't yet been added to this 218 | library. As of version 1.1 of `python-anvil`, extra fields are supported on all model objects. 219 | 220 | For example: 221 | 222 | ```python 223 | from python_anvil.api_resources.payload import EtchSigner, SignerField 224 | 225 | # Use `EtchSigner` 226 | signer = EtchSigner( 227 | name="Taylor Doe", 228 | email="tdoe@example.com", 229 | fields=[SignerField(file_id="file1", field_id="sig1")] 230 | ) 231 | 232 | # Previously, adding this next field would raise an error, or would be removed from the resulting JSON payload, but this 233 | # is now supported. 234 | # NOTE: the field name should be the _exact_ field name needed in JSON. This will usually be camel-case (myVariable) and 235 | # not the typical PEP 8 standard snake case (my_variable). 236 | signer.newFeature = True 237 | ``` 238 | -------------------------------------------------------------------------------- /docs/cli_usage.md: -------------------------------------------------------------------------------- 1 |

CLI Usage

2 | 3 | Also provided in this package is a CLI to quickly interact with the Anvil API. 4 | 5 | As with the API library, the CLI commands assume that you have a valid API key. Please take a look 6 | at [Anvil API Basics](https://www.useanvil.com/docs/api/basics) for more details on how to get your key. 7 | 8 | ### Quickstart 9 | 10 | In general, adding `--help` after a command will display more information on how to use the command. 11 | 12 | Running the command 13 | 14 | ```shell 15 | # The CLI commands will use the environment variable "ANVIL_API_KEY" for all 16 | # Anvil API requests. 17 | $ ANVIL_API_KEY=MY_GENERATED_KEY anvil 18 | Usage: anvil [OPTIONS] COMMAND [ARGS]... 19 | 20 | Options: 21 | --debug / --no-debug 22 | --help Show this message and exit. 23 | 24 | Commands: 25 | cast Fetch Cast data given a Cast eid. 26 | create-etch Create an etch packet with a JSON file. 27 | current-user Show details about your API user 28 | download-documents Download etch documents 29 | fill-pdf Fill PDF template with data 30 | generate-etch-url Generate an etch url for a signer 31 | generate-pdf Generate a PDF 32 | gql-query Run a raw graphql query 33 | weld Fetch weld info or list of welds 34 | 35 | $ ANVIL_API_KEY=MY_GENERATED_KEY anvil fill-pdf --help 36 | Usage: anvil fill-pdf [OPTIONS] TEMPLATE_ID 37 | 38 | Fill PDF template with data 39 | 40 | Options: 41 | -o, --out TEXT Filename of output PDF [required] 42 | -i, --input TEXT Filename of input CSV that provides data [required] 43 | --help Show this message and exit. 44 | ``` 45 | 46 | For example, you can fill a sample PDF template with the following command 47 | 48 | ```shell 49 | $ ANVIL_API_KEY=MY_GENERATED_KEY anvil fill-pdf -o test.pdf -i examples/cli/fill_pdf.csv 05xXsZko33JIO6aq5Pnr 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.5.3 ; python_full_version >= "3.8.0" and python_version < "3.13" 2 | pygments==2.17.2 ; python_full_version >= "3.8.0" and python_version < "3.13" 3 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvilco/python-anvil/d23c303bf4f393c0b389d253e3e168a9b1b6012e/examples/__init__.py -------------------------------------------------------------------------------- /examples/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvilco/python-anvil/d23c303bf4f393c0b389d253e3e168a9b1b6012e/examples/cli/__init__.py -------------------------------------------------------------------------------- /examples/cli/fill_pdf.csv: -------------------------------------------------------------------------------- 1 | shortText,name,date 2 | Test short,Bobby Jones,2028-01-03 3 | -------------------------------------------------------------------------------- /examples/create_etch_existing_cast.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | 3 | import os 4 | 5 | from python_anvil.api import Anvil 6 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 7 | from python_anvil.api_resources.payload import EtchCastRef, EtchSigner, SignerField 8 | 9 | 10 | API_KEY = os.environ.get("ANVIL_API_KEY") 11 | # or set your own key here 12 | # API_KEY = 'my-api-key' 13 | 14 | 15 | def main(): 16 | anvil = Anvil(api_key=API_KEY) 17 | 18 | # Create an instance of the builder 19 | packet = CreateEtchPacket( 20 | name="Etch packet with existing template", 21 | # 22 | # Optional changes to email subject and body content 23 | signature_email_subject="Please sign these forms", 24 | signature_email_body="This form requires information from your driver's " 25 | "license. Please have that available.", 26 | # 27 | # URL where Anvil will send POST requests when server events happen. 28 | # Take a look at https://www.useanvil.com/docs/api/e-signatures#webhook-notifications 29 | # for other details on how to configure webhooks on your account. 30 | # You can also use sites like webhook.site, requestbin.com or ngrok to 31 | # test webhooks. 32 | # webhook_url="https://my.webhook.example.com/etch-events/", 33 | # 34 | # Email overrides for the "reply-to" email header for signer emails. 35 | # If used, both `reply_to_email` and `reply_to_name` are required. 36 | # By default, this will point to your organization support email. 37 | # reply_to_email="my-org-email@example.com", 38 | # reply_to_name="My Name", 39 | # 40 | # Merge all PDFs into one. Use this if you have many PDF templates 41 | # and/or files, but want the final downloaded package to be only 42 | # 1 PDF file. 43 | # merge_pdfs=True, 44 | ) 45 | 46 | # You can reference an existing PDF Template from your Anvil account 47 | # instead of uploading a new file. 48 | # You can find this information by going to the "PDF Templates" section of 49 | # your Anvil account, choosing a template, and selecting "API Info" at the 50 | # top-right of the page. 51 | # Additionally, you can get this information by using the provided CLI by: 52 | # `anvil cast --list` to list all your available templates, then: 53 | # `anvil cast [THE_EID_OF_THE_CAST]` to get a listing of data in that 54 | # template. 55 | pdf_template = EtchCastRef( 56 | # The `id` here is what should be used by signer objects. 57 | # This can be any string, but should be unique if adding multiple files. 58 | id="introPages", 59 | # The eid of the cast you want to use from "API Info" or through the CLI. 60 | # This is a sample PDF anyone can use 61 | cast_eid="05xXsZko33JIO6aq5Pnr", 62 | ) 63 | 64 | # Gather your signer data 65 | signer1 = EtchSigner( 66 | name="Morgan", 67 | email="morgan@example.com", 68 | # Fields where the signer needs to sign. 69 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 70 | # PDF Templates section on the Anvil app. 71 | # This basically says: "In the 'introPages' file (defined as 72 | # `pdf_template` above), assign the signature field with cast id of 73 | # 'def456' to this signer." You can add multiple signer fields here. 74 | fields=[ 75 | SignerField( 76 | file_id="introPages", 77 | field_id="def456", 78 | ) 79 | ], 80 | # By default, `signer_type` will be "email" which will automatically 81 | # send emails when this etch packet is created. 82 | # It can also be set to "embedded" which will _not_ send emails, and 83 | # you will need to handle sending the signer URLs manually in some way. 84 | signer_type="email", 85 | # 86 | # You can also change how signatures will be collected. 87 | # "draw" will allow the signer to draw their signature 88 | # "text" will insert a text version of the signer's name into the 89 | # signature field. 90 | # signature_mode="draw", 91 | # 92 | # Whether or not to the signer is required to click each signature 93 | # field manually. If `False`, the PDF will be signed once the signer 94 | # accepts the PDF without making the user go through the PDF. 95 | # accept_each_field=False, 96 | # 97 | # URL of where the signer will be redirected after signing. 98 | # The URL will also have certain URL params added on, so the page 99 | # can be customized based on the signing action. 100 | # redirect_url="https://www.google.com", 101 | ) 102 | 103 | # Add your signer. 104 | packet.add_signer(signer1) 105 | 106 | # Add your file(s) 107 | packet.add_file(pdf_template) 108 | 109 | # If needed, you can also override or add additional payload fields this way. 110 | # This is useful if the Anvil API has new features, but `python-anvil` has not 111 | # yet been updated to support it. 112 | # payload = packet.create_payload() 113 | # payload.aNewFeature = True 114 | 115 | # Create your packet 116 | # If overriding/adding new fields, use the modified payload from 117 | # `packet.create_payload()` 118 | res = anvil.create_etch_packet(payload=packet) 119 | print(res) 120 | 121 | 122 | if __name__ == '__main__': 123 | main() 124 | -------------------------------------------------------------------------------- /examples/create_etch_markdown.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | 3 | import os 4 | 5 | from python_anvil.api import Anvil 6 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 7 | from python_anvil.api_resources.payload import ( 8 | DocumentMarkdown, 9 | EtchSigner, 10 | MarkdownContent, 11 | SignatureField, 12 | SignerField, 13 | ) 14 | 15 | 16 | API_KEY = os.environ.get("ANVIL_API_KEY") 17 | # or set your own key here 18 | # API_KEY = 'my-api-key' 19 | 20 | 21 | def main(): 22 | anvil = Anvil(api_key=API_KEY) 23 | 24 | # Create an instance of the builder 25 | packet = CreateEtchPacket( 26 | name="Etch packet with existing template", 27 | # 28 | # Optional changes to email subject and body content 29 | signature_email_subject="Please sign these forms", 30 | signature_email_body="This form requires information from your driver's " 31 | "license. Please have that available.", 32 | # 33 | # URL where Anvil will send POST requests when server events happen. 34 | # Take a look at https://www.useanvil.com/docs/api/e-signatures#webhook-notifications 35 | # for other details on how to configure webhooks on your account. 36 | # You can also use sites like webhook.site, requestbin.com or ngrok to 37 | # test webhooks. 38 | # webhook_url="https://my.webhook.example.com/etch-events/", 39 | # 40 | # Email overrides for the "reply-to" email header for signer emails. 41 | # If used, both `reply_to_email` and `reply_to_name` are required. 42 | # By default, this will point to your organization support email. 43 | # reply_to_email="my-org-email@example.com", 44 | # reply_to_name="My Name", 45 | # 46 | # Merge all PDFs into one. Use this if you have many PDF templates 47 | # and/or files, but want the final downloaded package to be only 48 | # 1 PDF file. 49 | # merge_pdfs=True, 50 | ) 51 | 52 | # Get your file(s) ready to sign. 53 | # For this example, a PDF will not be uploaded. We'll create and style the 54 | # document with HTML and CSS and add signing fields based on coordinates. 55 | 56 | # Define the document with Markdown 57 | file1 = DocumentMarkdown( 58 | id="markdownFile", 59 | filename="markdown.pdf", 60 | title="Sign this markdown file", 61 | fields=[ 62 | # This is markdown content 63 | MarkdownContent( 64 | table=dict( 65 | rows=[ 66 | ['Description', 'Quantity', 'Price'], 67 | ['3x Roof Shingles', '15', '$60.00'], 68 | ['5x Hardwood Plywood', '10', '$300.00'], 69 | ['80x Wood Screws', '80', '$45.00'], 70 | ], 71 | ) 72 | ), 73 | SignatureField( 74 | page_num=0, 75 | id="sign1", 76 | type="signature", 77 | # The position and size of the field. The coordinates provided here 78 | # (x=300, y=300) is the top-left of the rectangle. 79 | rect=dict(x=300, y=300, width=250, height=30), 80 | ), 81 | ], 82 | ) 83 | 84 | # Gather your signer data 85 | signer1 = EtchSigner( 86 | name="Jackie", 87 | email="jackie@example.com", 88 | # Fields where the signer needs to sign. 89 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 90 | # PDF Templates section on the Anvil app. 91 | # This basically says: "In the 'myNewFile' file (defined in 92 | # `file1` above), assign the signature field with cast id of 93 | # 'sign1' to this signer." You can add multiple signer fields here. 94 | fields=[ 95 | SignerField( 96 | # this is the `id` in the `DocumentUpload` object above 97 | file_id="markdownFile", 98 | # This is the signing field id in the `SignatureField` above 99 | field_id="sign1", 100 | ), 101 | ], 102 | signer_type="embedded", 103 | # 104 | # You can also change how signatures will be collected. 105 | # "draw" will allow the signer to draw their signature 106 | # "text" will insert a text version of the signer's name into the 107 | # signature field. 108 | # signature_mode="draw", 109 | # 110 | # Whether or not to the signer is required to click each signature 111 | # field manually. If `False`, the PDF will be signed once the signer 112 | # accepts the PDF without making the user go through the PDF. 113 | # accept_each_field=False, 114 | # 115 | # URL of where the signer will be redirected after signing. 116 | # The URL will also have certain URL params added on, so the page 117 | # can be customized based on the signing action. 118 | # redirect_url="https://www.google.com", 119 | ) 120 | 121 | # Add your signer. 122 | packet.add_signer(signer1) 123 | 124 | # Add files to your payload 125 | packet.add_file(file1) 126 | 127 | # If needed, you can also override or add additional payload fields this way. 128 | # This is useful if the Anvil API has new features, but `python-anvil` has not 129 | # yet been updated to support it. 130 | # payload = packet.create_payload() 131 | # payload.aNewFeature = True 132 | 133 | # Create your packet 134 | # If overriding/adding new fields, use the modified payload from 135 | # `packet.create_payload()` 136 | res = anvil.create_etch_packet(payload=packet, include_headers=True) 137 | print(res) 138 | 139 | 140 | if __name__ == '__main__': 141 | main() 142 | -------------------------------------------------------------------------------- /examples/create_etch_markup.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | 3 | import os 4 | 5 | from python_anvil.api import Anvil 6 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 7 | from python_anvil.api_resources.payload import ( 8 | DocumentMarkup, 9 | EtchSigner, 10 | SignatureField, 11 | SignerField, 12 | ) 13 | 14 | 15 | API_KEY = os.environ.get("ANVIL_API_KEY") 16 | # or set your own key here 17 | # API_KEY = 'my-api-key' 18 | 19 | 20 | def main(): 21 | anvil = Anvil(api_key=API_KEY) 22 | 23 | # Create an instance of the builder 24 | packet = CreateEtchPacket( 25 | name="Etch packet with existing template", 26 | # 27 | # Optional changes to email subject and body content 28 | signature_email_subject="Please sign these forms", 29 | signature_email_body="This form requires information from your driver's " 30 | "license. Please have that available.", 31 | # 32 | # URL where Anvil will send POST requests when server events happen. 33 | # Take a look at https://www.useanvil.com/docs/api/e-signatures#webhook-notifications 34 | # for other details on how to configure webhooks on your account. 35 | # You can also use sites like webhook.site, requestbin.com or ngrok to 36 | # test webhooks. 37 | # webhook_url="https://my.webhook.example.com/etch-events/", 38 | # 39 | # Email overrides for the "reply-to" email header for signer emails. 40 | # If used, both `reply_to_email` and `reply_to_name` are required. 41 | # By default, this will point to your organization support email. 42 | # reply_to_email="my-org-email@example.com", 43 | # reply_to_name="My Name", 44 | # 45 | # Merge all PDFs into one. Use this if you have many PDF templates 46 | # and/or files, but want the final downloaded package to be only 47 | # 1 PDF file. 48 | # merge_pdfs=True, 49 | ) 50 | 51 | # Get your file(s) ready to sign. 52 | # For this example, a PDF will not be uploaded. We'll create and style the 53 | # document with HTML and CSS and add signing fields based on coordinates. 54 | 55 | # Define the document with HTML/CSS 56 | file1 = DocumentMarkup( 57 | id="myNewFile", 58 | title="Please sign this important form", 59 | filename="markup.pdf", 60 | markup={ 61 | "html": """ 62 |
This document is created with HTML.
63 |
64 |
65 |
66 |
We can also define signing fields with text tags
67 |
{{ signature : First signature : textTag : textTag }}
68 | """, 69 | "css": """"body{ color: red; } div.first { color: blue; } """, 70 | }, 71 | fields=[ 72 | SignatureField( 73 | id="sign1", 74 | type="signature", 75 | page_num=0, 76 | # The position and size of the field. The coordinates provided here 77 | # (x=300, y=300) is the top-left of the rectangle. 78 | rect=dict(x=300, y=300, width=250, height=30), 79 | ) 80 | ], 81 | ) 82 | 83 | # Gather your signer data 84 | signer1 = EtchSigner( 85 | name="Jackie", 86 | email="jackie@example.com", 87 | # Fields where the signer needs to sign. 88 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 89 | # PDF Templates section on the Anvil app. 90 | # This basically says: "In the 'myNewFile' file (defined in 91 | # `file1` above), assign the signature field with cast id of 92 | # 'sign1' to this signer." You can add multiple signer fields here. 93 | fields=[ 94 | SignerField( 95 | # this is the `id` in the `DocumentUpload` object above 96 | file_id="myNewFile", 97 | # This is the signing field id in the `SignatureField` above 98 | field_id="sign1", 99 | ), 100 | SignerField( 101 | # this is the `id` in the `DocumentUpload` object above 102 | file_id="myNewFile", 103 | # This is the signing field id in the `SignatureField` above 104 | field_id="textTag", 105 | ), 106 | ], 107 | signer_type="embedded", 108 | # 109 | # You can also change how signatures will be collected. 110 | # "draw" will allow the signer to draw their signature 111 | # "text" will insert a text version of the signer's name into the 112 | # signature field. 113 | # signature_mode="draw", 114 | # 115 | # Whether or not to the signer is required to click each signature 116 | # field manually. If `False`, the PDF will be signed once the signer 117 | # accepts the PDF without making the user go through the PDF. 118 | # accept_each_field=False, 119 | # 120 | # URL of where the signer will be redirected after signing. 121 | # The URL will also have certain URL params added on, so the page 122 | # can be customized based on the signing action. 123 | # redirect_url="https://www.google.com", 124 | ) 125 | 126 | # Add your signer. 127 | packet.add_signer(signer1) 128 | 129 | # Add files to your payload 130 | packet.add_file(file1) 131 | 132 | # If needed, you can also override or add additional payload fields this way. 133 | # This is useful if the Anvil API has new features, but `python-anvil` has not 134 | # yet been updated to support it. 135 | # payload = packet.create_payload() 136 | # payload.aNewFeature = True 137 | 138 | # Create your packet 139 | # If overriding/adding new fields, use the modified payload from 140 | # `packet.create_payload()` 141 | res = anvil.create_etch_packet(payload=packet, include_headers=True) 142 | print(res) 143 | 144 | 145 | if __name__ == '__main__': 146 | main() 147 | -------------------------------------------------------------------------------- /examples/create_etch_upload_file.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | # 3 | # ANVIL_API_KEY=YOUR_KEY python examples/create_etch_upload_file.py 4 | 5 | import base64 6 | import os 7 | 8 | from python_anvil.api import Anvil 9 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 10 | from python_anvil.api_resources.payload import ( 11 | Base64Upload, 12 | DocumentUpload, 13 | EtchSigner, 14 | SignatureField, 15 | SignerField, 16 | ) 17 | 18 | 19 | API_KEY = os.environ.get("ANVIL_API_KEY") 20 | # or set your own key here 21 | # API_KEY = 'my-api-key' 22 | 23 | 24 | def main(): 25 | anvil = Anvil(api_key=API_KEY) 26 | 27 | # Create an instance of the builder 28 | packet = CreateEtchPacket( 29 | is_test=True, 30 | # 31 | name="Etch packet with existing template", 32 | # 33 | # Optional changes to email subject and body content 34 | signature_email_subject="Please sign these forms", 35 | signature_email_body="This form requires information from your driver's " 36 | "license. Please have that available.", 37 | # 38 | # URL where Anvil will send POST requests when server events happen. 39 | # Take a look at https://www.useanvil.com/docs/api/e-signatures#webhook-notifications 40 | # for other details on how to configure webhooks on your account. 41 | # You can also use sites like webhook.site, requestbin.com or ngrok to 42 | # test webhooks. 43 | # webhook_url="https://my.webhook.example.com/etch-events/", 44 | # 45 | # Email overrides for the "reply-to" email header for signer emails. 46 | # If used, both `reply_to_email` and `reply_to_name` are required. 47 | # By default, this will point to your organization support email. 48 | # reply_to_email="my-org-email@example.com", 49 | # reply_to_name="My Name", 50 | # 51 | # Merge all PDFs into one. Use this if you have many PDF templates 52 | # and/or files, but want the final downloaded package to be only 53 | # 1 PDF file. 54 | # merge_pdfs=True, 55 | ) 56 | 57 | # Get your file(s) ready to sign. 58 | # For this example, the PDF hasn't been uploaded to Anvil yet, so we need 59 | # to: open the file, upload the file as a base64 encoded payload along with 60 | # some data about where the user should sign. 61 | b64file = None 62 | with open("./examples/pdf/blank_8_5x11.pdf", "rb") as f: 63 | b64file = base64.b64encode(f.read()) 64 | 65 | if not b64file: 66 | raise ValueError('base64-encoded file not found') 67 | 68 | # Upload the file and define signer field locations. 69 | file1 = DocumentUpload( 70 | id="myNewFile", 71 | title="Please sign this important form", 72 | # A base64 encoded pdf should be here. 73 | # Currently, this library does not do this for you, so make sure that 74 | # the file data is ready at this point. 75 | file=Base64Upload( 76 | data=b64file.decode("utf-8"), 77 | # This is the filename your user will see after signing and 78 | # downloading their signature packet 79 | filename="a_custom_filename.pdf", 80 | ), 81 | fields=[ 82 | SignatureField( 83 | id="sign1", 84 | type="signature", 85 | page_num=0, 86 | # The position and size of the field. The coordinates provided here 87 | # (x=100, y=100) is the top-left of the rectangle. 88 | rect=dict(x=183, y=100, width=250, height=50), 89 | ) 90 | ], 91 | ) 92 | 93 | # Gather your signer data 94 | signer1 = EtchSigner( 95 | name="Jackie", 96 | email="jackie@example.com", 97 | # Fields where the signer needs to sign. 98 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 99 | # PDF Templates section on the Anvil app. 100 | # This basically says: "In the 'myNewFile' file (defined in 101 | # `file1` above), assign the signature field with cast id of 102 | # 'sign1' to this signer." You can add multiple signer fields here. 103 | fields=[ 104 | SignerField( 105 | # this is the `id` in the `DocumentUpload` object above 106 | file_id="myNewFile", 107 | # This is the signing field id in the `SignatureField` above 108 | field_id="sign1", 109 | ) 110 | ], 111 | signer_type="embedded", 112 | # 113 | # You can also change how signatures will be collected. 114 | # "draw" will allow the signer to draw their signature 115 | # "text" will insert a text version of the signer's name into the 116 | # signature field. 117 | # signature_mode="draw", 118 | # 119 | # Whether or not to the signer is required to click each signature 120 | # field manually. If `False`, the PDF will be signed once the signer 121 | # accepts the PDF without making the user go through the PDF. 122 | # accept_each_field=False, 123 | # 124 | # URL of where the signer will be redirected after signing. 125 | # The URL will also have certain URL params added on, so the page 126 | # can be customized based on the signing action. 127 | # redirect_url="https://www.google.com", 128 | ) 129 | 130 | # Add your signer. 131 | packet.add_signer(signer1) 132 | 133 | # Add files to your payload 134 | packet.add_file(file1) 135 | 136 | # If needed, you can also override or add additional payload fields this way. 137 | # This is useful if the Anvil API has new features, but `python-anvil` has not 138 | # yet been updated to support it. 139 | # payload = packet.create_payload() 140 | # payload.aNewFeature = True 141 | 142 | # Create your packet 143 | # If overriding/adding new fields, use the modified payload from 144 | # `packet.create_payload()` 145 | res = anvil.create_etch_packet(payload=packet) 146 | print(res) 147 | 148 | 149 | if __name__ == '__main__': 150 | main() 151 | -------------------------------------------------------------------------------- /examples/create_etch_upload_file_multipart.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | import os 3 | 4 | from python_anvil.api import Anvil 5 | from python_anvil.api_resources.mutations.create_etch_packet import CreateEtchPacket 6 | from python_anvil.api_resources.payload import ( 7 | DocumentUpload, 8 | EtchSigner, 9 | SignatureField, 10 | SignerField, 11 | ) 12 | 13 | 14 | API_KEY = os.environ.get("ANVIL_API_KEY") 15 | # or set your own key here 16 | # API_KEY = 'my-api-key' 17 | 18 | 19 | def main(): 20 | anvil = Anvil(api_key=API_KEY) 21 | 22 | # Create an instance of the builder 23 | packet = CreateEtchPacket( 24 | name="Etch packet with existing template multipart", 25 | # 26 | # Optional changes to email subject and body content 27 | signature_email_subject="Please sign these forms", 28 | signature_email_body="This form requires information from your driver's " 29 | "license. Please have that available.", 30 | # 31 | # URL where Anvil will send POST requests when server events happen. 32 | # Take a look at https://www.useanvil.com/docs/api/e-signatures#webhook-notifications 33 | # for other details on how to configure webhooks on your account. 34 | # You can also use sites like webhook.site, requestbin.com or ngrok to 35 | # test webhooks. 36 | # webhook_url="https://my.webhook.example.com/etch-events/", 37 | # 38 | # Email overrides for the "reply-to" email header for signer emails. 39 | # If used, both `reply_to_email` and `reply_to_name` are required. 40 | # By default, this will point to your organization support email. 41 | # reply_to_email="my-org-email@example.com", 42 | # reply_to_name="My Name", 43 | # 44 | # Merge all PDFs into one. Use this if you have many PDF templates 45 | # and/or files, but want the final downloaded package to be only 46 | # 1 PDF file. 47 | # merge_pdfs=True, 48 | ) 49 | 50 | # Get your file(s) ready to sign. 51 | # For this example, the PDF hasn't been uploaded to Anvil yet. 52 | # In the `create_etch_upload_file.py` example, we are base64 encoding our 53 | # file(s) before sending. In this case, we will be providing a file's path 54 | # or file descriptor (from an `open()` call) 55 | filename = "./pdf/blank_8_5x11.pdf" 56 | file_dir = os.path.dirname(os.path.realpath(__file__)) 57 | file_path = os.path.join(file_dir, filename) 58 | 59 | # You can check manually if your file exists, however, the validator being 60 | # used in the `GraphqlUpload` below will also check if the file exists. 61 | # 62 | # if not os.path.exists(file_path): 63 | # raise FileNotFoundError('File does not exist. Please check `file_path` ' 64 | # 'and ensure it points to an existing file.') 65 | 66 | # file data must be read in as _bytes_, not text. 67 | file = open(file_path, "rb") # pylint: disable=consider-using-with 68 | 69 | # You can also provide a custom `content_type` if you needed. 70 | # The Anvil library will guess the file's content_type by its file 71 | # extension automatically, but this can be used to force a different 72 | # content_type. 73 | # f1.content_type = "application/pdf" 74 | 75 | # Upload the file and define signer field locations. 76 | file1 = DocumentUpload( 77 | id="myNewFile", 78 | title="Please sign this important form", 79 | file=file, 80 | fields=[ 81 | SignatureField( 82 | id="sign1", 83 | type="signature", 84 | page_num=0, 85 | # The position and size of the field. The coordinates provided here 86 | # (x=100, y=100) is the top-left of the rectangle. 87 | rect=dict(x=183, y=100, width=250, height=50), 88 | ) 89 | ], 90 | ) 91 | 92 | # Gather your signer data 93 | signer1 = EtchSigner( 94 | name="Jackie", 95 | email="jackie@example.com", 96 | # Fields where the signer needs to sign. 97 | # Check your cast fields via the CLI (`anvil cast [cast_eid]`) or the 98 | # PDF Templates section on the Anvil app. 99 | # This basically says: "In the 'myNewFile' file (defined in 100 | # `file1` above), assign the signature field with cast id of 101 | # 'sign1' to this signer." You can add multiple signer fields here. 102 | fields=[ 103 | SignerField( 104 | # this is the `id` in the `DocumentUpload` object above 105 | file_id="myNewFile", 106 | # This is the signing field id in the `SignatureField` above 107 | field_id="sign1", 108 | ) 109 | ], 110 | signer_type="embedded", 111 | # 112 | # You can also change how signatures will be collected. 113 | # "draw" will allow the signer to draw their signature 114 | # "text" will insert a text version of the signer's name into the 115 | # signature field. 116 | # signature_mode="draw", 117 | # 118 | # Whether to the signer is required to click each signature 119 | # field manually. If `False`, the PDF will be signed once the signer 120 | # accepts the PDF without making the user go through the PDF. 121 | # accept_each_field=False, 122 | # 123 | # URL of where the signer will be redirected after signing. 124 | # The URL will also have certain URL params added on, so the page 125 | # can be customized based on the signing action. 126 | # redirect_url="https://www.google.com", 127 | ) 128 | 129 | # Add your signer. 130 | packet.add_signer(signer1) 131 | 132 | # Add files to your payload 133 | packet.add_file(file1) 134 | 135 | # If needed, you can also override or add additional payload fields this way. 136 | # This is useful if the Anvil API has new features, but `python-anvil` has not 137 | # yet been updated to support it. 138 | # payload = packet.create_payload() 139 | # payload.aNewFeature = True 140 | 141 | # Create your packet 142 | # If overriding/adding new fields, use the modified payload from 143 | # `packet.create_payload()` 144 | try: 145 | res = anvil.create_etch_packet(payload=packet) 146 | print(res) 147 | finally: 148 | file.close() 149 | 150 | 151 | if __name__ == '__main__': 152 | main() 153 | -------------------------------------------------------------------------------- /examples/create_workflow_submission.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Any, Dict 4 | 5 | from python_anvil.api import Anvil 6 | from python_anvil.api_resources.payload import ForgeSubmitPayload 7 | 8 | 9 | API_KEY = os.environ.get("ANVIL_API_KEY") 10 | # or set your own key here 11 | # API_KEY = 'my-api-key' 12 | 13 | 14 | def main(): 15 | # Use https://app.useanvil.com/org/YOUR_ORG_HERE/w/WORKFLOW_NAME/api 16 | # to get a detailed list and description of which fields, eids, etc. 17 | # are available to use. 18 | forge_eid = "" 19 | 20 | anvil = Anvil(api_key=API_KEY) 21 | 22 | # Create a payload with the payload model. 23 | payload = ForgeSubmitPayload( 24 | forge_eid=forge_eid, payload=dict(field1="Initial forgeSubmit") 25 | ) 26 | 27 | res: Dict[str, Any] = anvil.forge_submit(payload=payload) 28 | 29 | data = res.get("forgeSubmit", {}) 30 | 31 | print(data) 32 | 33 | # Get submission and weld_data eids from the initial response 34 | submission_eid = data["eid"] 35 | weld_data_eid = data["weldData"]["eid"] 36 | 37 | payload = ForgeSubmitPayload( 38 | forge_eid=forge_eid, 39 | # If submission and weld_data eids are provided, you will be _editing_ 40 | # an existing submission. 41 | submission_eid=submission_eid, 42 | weld_data_eid=weld_data_eid, 43 | # NOTE: If using a development key, this will `is_test` will always 44 | # be `True` even if it's set as `False` here. 45 | is_test=False, 46 | payload=dict( 47 | field1=f"Edited this field {datetime.now()}", 48 | ), 49 | ) 50 | 51 | res = anvil.forge_submit(payload=payload) 52 | 53 | data = res.get("forgeSubmit", {}) 54 | print(data) 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /examples/fill_pdf.py: -------------------------------------------------------------------------------- 1 | # Run this from the project root 2 | # 3 | # ANVIL_API_KEY=YOUR_KEY python examples/fill_pdf.py && open ./filled.pdf 4 | 5 | import os 6 | 7 | from python_anvil.api import Anvil 8 | 9 | 10 | API_KEY = os.environ.get("ANVIL_API_KEY") 11 | # or set your own key here 12 | # API_KEY = 'my-api-key' 13 | 14 | # The PDF template ID to fill. This PDF template ID is a sample template 15 | # available to anyone. 16 | # 17 | # See https://www.useanvil.com/help/tutorials/set-up-a-pdf-template for details 18 | # on setting up your own template 19 | PDF_TEMPLATE_EID = "05xXsZko33JIO6aq5Pnr" 20 | 21 | # PDF fill data can be an instance of `FillPDFPayload` or a plain dict. 22 | # `FillPDFPayload` is from `python_anvil.api_resources.payload import FillPDFPayload`. 23 | # If using a plain dict, fill data keys can be either Python snake_case with 24 | # underscores, or in camelCase. Note, though, that the keys in `data` must 25 | # match the keys on the form. This is usually in camelCase. 26 | # If you'd like to use camelCase on all data, you can call `Anvil.fill_pdf()` 27 | # with a full JSON payload instead. 28 | FILL_DATA = { 29 | "title": "My PDF Title", 30 | "font_size": 10, 31 | "text_color": "#333333", 32 | "data": { 33 | "shortText": "HELLOO", 34 | "date": "2022-07-08", 35 | "name": {"firstName": "Robin", "mi": "W", "lastName": "Smith"}, 36 | "email": "testy@example.com", 37 | "phone": {"num": "5554443333", "region": "US", "baseRegion": "US"}, 38 | "usAddress": { 39 | "street1": "123 Main St #234", 40 | "city": "San Francisco", 41 | "state": "CA", 42 | "zip": "94106", 43 | "country": "US", 44 | }, 45 | "ssn": "456454567", 46 | "ein": "897654321", 47 | "checkbox": True, 48 | "radioGroup": "cast68d7e540afba11ecaf289fa5a354293a", 49 | "decimalNumber": 12345.67, 50 | "dollar": 123.45, 51 | "integer": 12345, 52 | "percent": 50.3, 53 | "longText": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 54 | "textPerLine": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 55 | "textPerLetter": "taH9QGigei6G5BtTUA4", 56 | "image": "https://placehold.co/600x400", 57 | }, 58 | } 59 | 60 | 61 | def main(): 62 | anvil = Anvil(api_key=API_KEY) 63 | 64 | # Fill the provided cast eid (see PDF Templates in your Anvil account) 65 | # with the data above. This will return bytes for use in directly writing 66 | # to a file. 67 | res = anvil.fill_pdf(PDF_TEMPLATE_EID, FILL_DATA) 68 | 69 | # Version number support 70 | # ---------------------- 71 | # A version number can also be passed in. This will retrieve a specific 72 | # version of the PDF to be filled if you don't want the current version 73 | # to be used. 74 | # 75 | # You can also use the constant `Anvil.VERSION_LATEST` to fill a PDF with 76 | # your latest, unpublished changes. Use this if you'd like to fill out a 77 | # draft version of your template/PDF. 78 | # 79 | # res = anvil.fill_pdf('abc123', data, version_number=Anvil.VERSION_LATEST) 80 | 81 | # Write the bytes to disk 82 | with open('./filled.pdf', 'wb') as f: 83 | f.write(res) 84 | 85 | 86 | if __name__ == '__main__': 87 | main() 88 | -------------------------------------------------------------------------------- /examples/forge_submit.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from python_anvil.api import Anvil 4 | from python_anvil.api_resources.payload import ForgeSubmitPayload 5 | 6 | 7 | API_KEY = os.environ.get("ANVIL_API_KEY") 8 | # or set your own key here 9 | # API_KEY = 'my-api-key' 10 | 11 | 12 | def main(): 13 | anvil = Anvil(api_key=API_KEY) 14 | 15 | # Your ForgeSubmit payload. 16 | # In this example, we have a basic Webform containing a name and email field. 17 | # For both fields, we are using the field's eid which can be found in the 18 | # weld or forge GraphQL query. 19 | # More info here: https://www.useanvil.com/docs/api/graphql/reference/#definition-Forge 20 | payload = ForgeSubmitPayload( 21 | forge_eid="myForgeEidHere", 22 | payload=dict( 23 | forge16401fc09c3e11ed85f5a91873b464b4="FirstName LastName", 24 | forge1b57aeb09c3e11ed85f5a91873b464b4="myemail@example.com", 25 | ), 26 | ) 27 | 28 | # Submit the above payload 29 | res = anvil.forge_submit(payload) 30 | print(res) 31 | 32 | 33 | if __name__ == '__main__': 34 | main() 35 | -------------------------------------------------------------------------------- /examples/generate_pdf.py: -------------------------------------------------------------------------------- 1 | # Run this from the project root 2 | # 3 | # ANVIL_API_KEY=YOUR_KEY python examples/generate_pdf.py && open ./generated.pdf 4 | 5 | import os 6 | 7 | from python_anvil.api import Anvil 8 | from python_anvil.api_resources.payload import GeneratePDFPayload 9 | 10 | 11 | API_KEY = os.environ.get("ANVIL_API_KEY") 12 | # or set your own key here 13 | # API_KEY = 'my-api-key' 14 | 15 | 16 | def main(): 17 | anvil = Anvil(api_key=API_KEY) 18 | 19 | data = html_data() 20 | 21 | # You can specify data in literal dict form 22 | # data = html_data_literal() 23 | 24 | # Or you can generate from markdown 25 | # data = markdown_data() 26 | 27 | response = anvil.generate_pdf(data) 28 | 29 | # Write the bytes to disk 30 | with open('./generated.pdf', 'wb') as f: 31 | f.write(response) 32 | 33 | 34 | def html_data(): 35 | return GeneratePDFPayload( 36 | type="html", 37 | title="Some Title", 38 | data=dict( 39 | html="

HTML Heading

", 40 | css="h2 { color: red }", 41 | ), 42 | # Optional page configuration 43 | # page=dict( 44 | # width="8.5in", 45 | # height="11in", 46 | # ), 47 | ) 48 | 49 | 50 | def html_data_literal(): 51 | return { 52 | "type": "html", 53 | "title": "Some Title", 54 | "data": { 55 | "html": "

HTML Heading

", 56 | "css": "h2 { color: blue }", 57 | }, 58 | } 59 | 60 | 61 | def markdown_data(): 62 | return GeneratePDFPayload( 63 | type="markdown", 64 | title="Some Title", 65 | data=[dict(label="Test", content="Lorem __Ipsum__")], 66 | # Optional args 67 | # font_size=10, 68 | # font_family="Lobster", 69 | # text_color="#cc0000", 70 | ) 71 | 72 | 73 | if __name__ == '__main__': 74 | main() 75 | -------------------------------------------------------------------------------- /examples/make_graphql_request.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gql.dsl import DSLQuery, dsl_gql 3 | 4 | from python_anvil.api import Anvil 5 | from python_anvil.http import get_gql_ds 6 | 7 | 8 | API_KEY = os.environ.get("ANVIL_API_KEY") 9 | # or set your own key here 10 | # API_KEY = 'my-api-key' 11 | 12 | 13 | def call_current_user_query(anvil: Anvil) -> dict: 14 | """Get the user data attached to the current API key. 15 | 16 | :param anvil: 17 | :type anvil: Anvil 18 | :return: 19 | """ 20 | # See the reference docs for examples of all queries and mutations: 21 | # https://www.useanvil.com/docs/api/graphql/reference/ 22 | # pylint: disable=unused-variable 23 | user_query = """ 24 | query CurrentUser { 25 | currentUser { 26 | eid 27 | name 28 | organizations { 29 | eid 30 | slug 31 | name 32 | casts { 33 | eid 34 | name 35 | } 36 | welds { 37 | eid 38 | name 39 | } 40 | } 41 | } 42 | } 43 | """ 44 | 45 | # You can also use `gql`'s query builder. Below is the equivalent of the 46 | # string above, but can potentially be a better interface if you're 47 | # building a query in multiple steps. See the official `gql` docs for more 48 | # details: https://gql.readthedocs.io/en/stable/advanced/dsl_module.html 49 | 50 | # Use `ds` to create your queries 51 | ds = get_gql_ds(anvil.gql_client) 52 | ds_user_query_builder = ds.Query.currentUser.select( 53 | ds.User.eid, 54 | ds.User.name, 55 | ds.User.organizations.select( 56 | ds.Organization.eid, 57 | ds.Organization.slug, 58 | ds.Organization.name, 59 | ds.Organization.casts.select( 60 | ds.Cast.eid, 61 | ds.Cast.name, 62 | ), 63 | ds.Organization.welds.select( 64 | ds.Weld.eid, 65 | ds.Weld.name, 66 | ), 67 | ), 68 | ) 69 | 70 | ds_query = dsl_gql(DSLQuery(ds_user_query_builder)) 71 | 72 | res = anvil.query(query=ds_query, variables=None) 73 | return res["currentUser"] 74 | 75 | 76 | def call_weld_query(anvil: Anvil, weld_eid: str): 77 | """Call the weld query. 78 | 79 | The weld() query is an example of a query that takes variables. 80 | :param anvil: 81 | :type anvil: Anvil 82 | :param weld_eid: 83 | :type weld_eid: str 84 | :return: 85 | """ 86 | 87 | # pylint: disable=unused-variable 88 | weld_query = """ 89 | query WeldQuery ( 90 | $eid: String, 91 | ) { 92 | weld ( 93 | eid: $eid, 94 | ) { 95 | eid 96 | name 97 | forges { 98 | eid 99 | slug 100 | name 101 | } 102 | } 103 | } 104 | """ 105 | variables = {"eid": weld_eid} 106 | 107 | # You can also use `gql`'s query builder. Below is the equivalent of the 108 | # string above, but can potentially be a better interface if you're 109 | # building a query in multiple steps. See the official `gql` docs for more 110 | # details: https://gql.readthedocs.io/en/stable/advanced/dsl_module.html 111 | 112 | # Use `ds` to create your queries 113 | ds = get_gql_ds(anvil.gql_client) 114 | ds_weld_query_builder = ds.Query.weld.args(eid=weld_eid).select( 115 | ds.Weld.eid, 116 | ds.Weld.name, 117 | ds.Weld.forges.select( 118 | ds.Forge.eid, 119 | ds.Forge.slug, 120 | ds.Forge.name, 121 | ), 122 | ) 123 | 124 | ds_query = dsl_gql(DSLQuery(ds_weld_query_builder)) 125 | 126 | # You can call the query with the string literal and variables like usual 127 | # res = anvil.query(query=weld_query, variables=variables) 128 | 129 | # Or, use only the `dsl_gql` query. `variables` not needed as it was 130 | # already used in `.args()`. 131 | res = anvil.query(query=ds_query) 132 | return res["weld"] 133 | 134 | 135 | def call_queries(): 136 | anvil = Anvil(api_key=API_KEY) 137 | current_user = call_current_user_query(anvil) 138 | 139 | first_weld = current_user["organizations"][0]["welds"][0] 140 | weld_data = call_weld_query(anvil, weld_eid=first_weld["eid"]) 141 | 142 | print("currentUser: ", current_user) 143 | print("First weld details: ", weld_data) 144 | 145 | 146 | if __name__ == "__main__": 147 | call_queries() 148 | -------------------------------------------------------------------------------- /examples/my_local_file.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvilco/python-anvil/d23c303bf4f393c0b389d253e3e168a9b1b6012e/examples/my_local_file.pdf -------------------------------------------------------------------------------- /examples/pdf/blank_8_5x11.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvilco/python-anvil/d23c303bf4f393c0b389d253e3e168a9b1b6012e/examples/pdf/blank_8_5x11.pdf -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: python-anvil 2 | site_description: Anvil API 3 | site_author: Anvil Developers 4 | 5 | repo_url: https://github.com/anvilco/python-anvil 6 | edit_uri: https://github.com/anvilco/python-anvil/edit/master/docs 7 | 8 | theme: readthedocs 9 | 10 | markdown_extensions: 11 | - codehilite 12 | 13 | nav: 14 | - Home: index.md 15 | - API Usage: api_usage.md 16 | - CLI Usage: cli_usage.md 17 | - Advanced: 18 | - Create Etch Packet: advanced/create_etch_packet.md 19 | - Dynamic GraphQL queries: advanced/dynamic_queries.md 20 | - About: 21 | - Release Notes: about/changelog.md 22 | - Contributing: about/contributing.md 23 | - License: about/license.md 24 | - Credits: about/credits.md 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | 3 | name = "python_anvil" 4 | version = "5.1.0" 5 | description = "Anvil API" 6 | license = "MIT" 7 | authors = ["Anvil Foundry Inc. "] 8 | readme = "README.md" 9 | homepage = "https://www.useanvil.com/" 10 | documentation = "https://github.com/anvilco/python-anvil" 11 | repository = "https://github.com/anvilco/python-anvil" 12 | keywords = [ 13 | "anvil", 14 | "api", 15 | "pdf", 16 | "signing", 17 | ] 18 | classifiers = [ 19 | # Full list here: https://pypi.org/pypi?%3Aaction=list_classifiers 20 | "License :: OSI Approved :: MIT License", 21 | "Development Status :: 3 - Alpha", 22 | "Natural Language :: English", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Topic :: Software Development :: Libraries :: Python Modules", 32 | ] 33 | 34 | [tool.poetry.dependencies] 35 | 36 | python = ">=3.8.0,<3.13" 37 | 38 | click = "^8.0" 39 | requests = "^2.28.2" 40 | ratelimit = "^2.2.1" 41 | tabulate = "^0.9.0" 42 | pydantic = "^2.6.1" 43 | gql = { version = "3.6.0b2", extras = ["requests"] } 44 | 45 | [tool.poetry.group.dev.dependencies] 46 | 47 | # Formatters 48 | black = "^24.8.0" 49 | isort = "^5.11.4" 50 | 51 | # Linters 52 | pydocstyle = "^6.3.0" 53 | pylint = "^3.0" 54 | 55 | # FIXME: Upgrading mypy will require updates to aliased fields. e.g. 56 | # 57 | # class EtchSigner(BaseModel): 58 | # redirect_url: Optional[str] = Field(None, alias="redirectURL") 59 | # 60 | # Not sure what the solution is. 61 | mypy = "1.0.1" 62 | 63 | 64 | # Testing 65 | pytest = "^7.2.1" 66 | pytest-cov = "^4.0.0" 67 | pytest-describe = "^2.0" 68 | pytest-random = "^0.2" 69 | freezegun = "*" 70 | 71 | # Reports 72 | coveragespace = "*" 73 | 74 | # Documentation 75 | mkdocs = "^1.4.2" 76 | pygments = "^2.14.0" 77 | 78 | # Tooling 79 | pyinstaller = "^5.8.0" 80 | sniffer = "*" 81 | macfsevents = { version = "^0.8.4", platform = "darwin" } 82 | pync = { version = "*", platform = "darwin" } 83 | pyinotify = {version = "^0.9.6", optional = true} 84 | tox = "^3.21.2" 85 | pre-commit = "^2.21.0" 86 | types-dataclasses = "^0.6.5" 87 | types-requests = "^2.28.11.7" 88 | types-tabulate = "^0.9.0.0" 89 | types-setuptools = "^65.6.0.3" 90 | 91 | [tool.poetry.scripts] 92 | 93 | anvil = "python_anvil.cli:cli" 94 | 95 | [tool.black] 96 | 97 | target-version = ["py38", "py39", "py310", "py311", "py312"] 98 | skip-string-normalization = true 99 | 100 | [build-system] 101 | 102 | requires = ["poetry-core>=1.0.0"] 103 | build-backend = "poetry.core.masonry.api" 104 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | addopts = 4 | --strict-markers 5 | 6 | -r sxX 7 | --show-capture=log 8 | 9 | --cov-report=html 10 | --cov-report=term-missing:skip-covered 11 | --no-cov-on-fail 12 | 13 | cache_dir = .cache 14 | 15 | markers = 16 | -------------------------------------------------------------------------------- /python_anvil/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | from python_anvil import api, cli 4 | from python_anvil.models import FileCompatibleBaseModel 5 | 6 | 7 | try: 8 | __version__ = version('python_anvil') 9 | except PackageNotFoundError: 10 | __version__ = '(local)' 11 | 12 | __all__ = ['api', 'cli', 'FileCompatibleBaseModel'] 13 | -------------------------------------------------------------------------------- /python_anvil/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from gql import gql 3 | from graphql import DocumentNode 4 | from typing import Any, AnyStr, Callable, Dict, List, Optional, Tuple, Union 5 | 6 | from .api_resources.mutations import ( 7 | BaseQuery, 8 | CreateEtchPacket, 9 | ForgeSubmit, 10 | GenerateEtchSigningURL, 11 | ) 12 | from .api_resources.payload import ( 13 | CreateEtchPacketPayload, 14 | FillPDFPayload, 15 | ForgeSubmitPayload, 16 | GeneratePDFPayload, 17 | ) 18 | from .api_resources.requests import FullyQualifiedRequest, PlainRequest, RestRequest 19 | from .http import GQLClient, HTTPClient 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def _get_return(res: Dict, get_data: Callable[[Dict], Union[Dict, List]]): 26 | """Process response and get data from path if provided.""" 27 | _res = res 28 | if "response" in res and "headers" in res: 29 | _res = res["response"] 30 | return {"response": get_data(_res), "headers": res["headers"]} 31 | return get_data(_res) 32 | 33 | 34 | class Anvil: 35 | """Main Anvil API class. 36 | 37 | Handles all GraphQL and REST queries. 38 | 39 | Usage: 40 | >> anvil = Anvil(api_key="my_key") 41 | >> payload = {} 42 | >> pdf_data = anvil.fill_pdf("the_template_id", payload) 43 | """ 44 | 45 | # Version number to use for latest versions (usually drafts) 46 | VERSION_LATEST = -1 47 | # Version number to use for the latest published version. 48 | # This is the default when a version is not provided. 49 | VERSION_LATEST_PUBLISHED = -2 50 | 51 | def __init__( 52 | self, 53 | api_key: Optional[str] = None, 54 | environment="dev", 55 | endpoint_url=None, 56 | ): 57 | if not api_key: 58 | raise ValueError('`api_key` must be a valid string') 59 | 60 | self.client = HTTPClient(api_key=api_key, environment=environment) 61 | self.gql_client = GQLClient.get_client( 62 | api_key=api_key, 63 | environment=environment, 64 | endpoint_url=endpoint_url, 65 | ) 66 | 67 | def query( 68 | self, 69 | query: Union[str, DocumentNode], 70 | variables: Optional[Dict[str, Any]] = None, 71 | **kwargs, 72 | ): 73 | """Execute a GraphQL query. 74 | 75 | :param query: 76 | :type query: Union[str, DocumentNode] 77 | :param variables: 78 | :type variables: Optional[Dict[str, Any]] 79 | :param kwargs: 80 | :return: 81 | """ 82 | # Remove `debug` for now. 83 | kwargs.pop("debug", None) 84 | if isinstance(query, str): 85 | query = gql(query) 86 | 87 | return self.gql_client.execute(query, variable_values=variables, **kwargs) 88 | 89 | def mutate( 90 | self, query: Union[str, BaseQuery], variables: Dict[str, Any], **kwargs 91 | ) -> Dict[str, Any]: 92 | """ 93 | Execute a GraphQL mutation. 94 | 95 | NOTE: Any files attached provided in `variables` will be sent via the 96 | multipart spec: 97 | https://github.com/jaydenseric/graphql-multipart-request-spec 98 | 99 | :param query: 100 | :type query: Union[str, BaseQuery] 101 | :param variables: 102 | :type variables: Dict[str, Any] 103 | :param kwargs: 104 | :return: 105 | """ 106 | # Remove `debug` for now. 107 | kwargs.pop("debug", None) 108 | if isinstance(query, str): 109 | use_query = gql(query) 110 | else: 111 | mutation = query.get_mutation() 112 | use_query = gql(mutation) 113 | 114 | return self.gql_client.execute(use_query, variable_values=variables, **kwargs) 115 | 116 | def request_rest(self, options: Optional[dict] = None): 117 | api = RestRequest(self.client, options=options) 118 | return api 119 | 120 | def request_fully_qualified(self, options: Optional[dict] = None): 121 | api = FullyQualifiedRequest(self.client, options=options) 122 | return api 123 | 124 | def fill_pdf( 125 | self, template_id: str, payload: Union[dict, AnyStr, FillPDFPayload], **kwargs 126 | ): 127 | """Fill an existing template with provided payload data. 128 | 129 | Use the casts graphql query to get a list of available templates you 130 | can use for this request. 131 | 132 | :param template_id: eid of an existing template/cast 133 | :type template_id: str 134 | :param payload: payload in the form of a dict or JSON data 135 | :type payload: dict|str 136 | :param kwargs.version_number: specific template version number to use. If 137 | not provided, the latest _published_ version will be used. 138 | :type kwargs.version_number: int 139 | """ 140 | try: 141 | if isinstance(payload, dict): 142 | data = FillPDFPayload(**payload) 143 | elif isinstance(payload, str): 144 | data = FillPDFPayload.model_validate_json(payload) 145 | elif isinstance(payload, FillPDFPayload): 146 | data = payload 147 | else: 148 | raise ValueError("`payload` must be a valid JSON string or a dict") 149 | except KeyError as e: 150 | logger.exception(e) 151 | raise ValueError( 152 | "`payload` validation failed. Please make sure all required " 153 | "fields are set. " 154 | ) from e 155 | 156 | version_number = kwargs.pop("version_number", None) 157 | if version_number: 158 | kwargs["params"] = dict(versionNumber=version_number) 159 | 160 | api = RestRequest(client=self.client) 161 | return api.post( 162 | f"fill/{template_id}.pdf", 163 | data.model_dump(by_alias=True, exclude_none=True) if data else {}, 164 | **kwargs, 165 | ) 166 | 167 | def generate_pdf(self, payload: Union[AnyStr, Dict, GeneratePDFPayload], **kwargs): 168 | if not payload: 169 | raise ValueError("`payload` must be a valid JSON string or a dict") 170 | 171 | if isinstance(payload, dict): 172 | data = GeneratePDFPayload(**payload) 173 | elif isinstance(payload, str): 174 | data = GeneratePDFPayload.model_validate_json(payload) 175 | elif isinstance(payload, GeneratePDFPayload): 176 | data = payload 177 | else: 178 | raise ValueError("`payload` must be a valid JSON string or a dict") 179 | 180 | # Any data errors would come from here 181 | api = RestRequest(client=self.client) 182 | return api.post( 183 | "generate-pdf", 184 | data=data.model_dump(by_alias=True, exclude_none=True), 185 | **kwargs, 186 | ) 187 | 188 | def get_cast( 189 | self, 190 | eid: str, 191 | fields: Optional[List[str]] = None, 192 | version_number: Optional[int] = None, 193 | cast_args: Optional[List[Tuple[str, str]]] = None, 194 | **kwargs, 195 | ) -> Dict[str, Any]: 196 | 197 | if not fields: 198 | # Use default fields 199 | fields = ["eid", "title", "fieldInfo"] 200 | 201 | if not cast_args: 202 | cast_args = [] 203 | 204 | cast_args.append(("eid", f'"{eid}"')) 205 | 206 | # If `version_number` isn't provided, the API will default to the 207 | # latest published version. 208 | if version_number: 209 | cast_args.append(("versionNumber", str(version_number))) 210 | 211 | arg_str = "" 212 | if len(cast_args): 213 | joined_args = [(":".join(arg)) for arg in cast_args] 214 | arg_str = f"({','.join(joined_args)})" 215 | 216 | res = self.query( 217 | gql( 218 | f"""{{ 219 | cast {arg_str} {{ 220 | {" ".join(fields)} 221 | }} 222 | }}""" 223 | ), 224 | **kwargs, 225 | ) 226 | 227 | def get_data(r: dict) -> Dict[str, Any]: 228 | return r["cast"] 229 | 230 | return _get_return(res, get_data=get_data) 231 | 232 | def get_casts( 233 | self, fields: Optional[List[str]] = None, show_all: bool = False, **kwargs 234 | ) -> List[Dict[str, Any]]: 235 | """Retrieve all Cast objects for the current user across all organizations. 236 | 237 | :param fields: List of fields to retrieve for each cast object 238 | :type fields: Optional[List[str]] 239 | :param show_all: Boolean to show all Cast objects. 240 | Defaults to showing only templates. 241 | :type show_all: bool 242 | :param kwargs: 243 | :return: 244 | """ 245 | if not fields: 246 | # Use default fields 247 | fields = ["eid", "title", "fieldInfo"] 248 | 249 | cast_args = "" if show_all else "(isTemplate: true)" 250 | 251 | res = self.query( 252 | gql( 253 | f"""{{ 254 | currentUser {{ 255 | organizations {{ 256 | casts {cast_args} {{ 257 | {" ".join(fields)} 258 | }} 259 | }} 260 | }} 261 | }}""" 262 | ), 263 | **kwargs, 264 | ) 265 | 266 | def get_data(r: dict): 267 | orgs = r["currentUser"]["organizations"] 268 | return [item for org in orgs for item in org["casts"]] 269 | 270 | return _get_return(res, get_data=get_data) 271 | 272 | def get_current_user(self, **kwargs): 273 | """Retrieve current user data. 274 | 275 | :param kwargs: 276 | :return: 277 | """ 278 | res = self.query( 279 | gql( 280 | """{ 281 | currentUser { 282 | name 283 | email 284 | eid 285 | role 286 | organizations { 287 | eid 288 | name 289 | slug 290 | casts { 291 | eid 292 | name 293 | } 294 | } 295 | } 296 | }""" 297 | ), 298 | **kwargs, 299 | ) 300 | 301 | return _get_return(res, get_data=lambda r: r["currentUser"]) 302 | 303 | def get_welds(self, **kwargs) -> Union[List, Tuple[List, Dict]]: 304 | res = self.query( 305 | gql( 306 | """{ 307 | currentUser { 308 | organizations { 309 | welds { 310 | eid 311 | slug 312 | name 313 | forges { 314 | eid 315 | name 316 | } 317 | } 318 | } 319 | } 320 | }""" 321 | ), 322 | **kwargs, 323 | ) 324 | 325 | def get_data(r: dict): 326 | orgs = r["currentUser"]["organizations"] 327 | return [item for org in orgs for item in org["welds"]] 328 | 329 | return _get_return(res, get_data=get_data) 330 | 331 | def get_weld(self, eid: str, **kwargs): 332 | res = self.query( 333 | gql( 334 | """ 335 | query WeldQuery( 336 | #$organizationSlug: String!, 337 | #$slug: String! 338 | $eid: String! 339 | ) { 340 | weld( 341 | #organizationSlug: $organizationSlug, 342 | #slug: $slug 343 | eid: $eid 344 | ) { 345 | eid 346 | slug 347 | name 348 | forges { 349 | eid 350 | name 351 | slug 352 | } 353 | } 354 | }""" 355 | ), 356 | variables=dict(eid=eid), 357 | **kwargs, 358 | ) 359 | 360 | def get_data(r: dict): 361 | return r["weld"] 362 | 363 | return _get_return(res, get_data=get_data) 364 | 365 | def create_etch_packet( 366 | self, 367 | payload: Optional[ 368 | Union[ 369 | dict, 370 | CreateEtchPacketPayload, 371 | CreateEtchPacket, 372 | AnyStr, 373 | ] 374 | ] = None, 375 | json=None, 376 | **kwargs, 377 | ): 378 | """Create etch packet via a graphql mutation.""" 379 | # Create an etch packet payload instance excluding signers and files 380 | # (if any). We'll need to add those separately. below. 381 | if not any([payload, json]): 382 | raise TypeError('One of the arguments `payload` or `json` must exist') 383 | 384 | if json: 385 | payload = CreateEtchPacketPayload.model_validate_json(json) 386 | 387 | if isinstance(payload, dict): 388 | mutation = CreateEtchPacket.create_from_dict(payload) 389 | elif isinstance(payload, CreateEtchPacketPayload): 390 | mutation = CreateEtchPacket(payload=payload) 391 | elif isinstance(payload, CreateEtchPacket): 392 | mutation = payload 393 | else: 394 | raise ValueError( 395 | "`payload` must be a valid CreateEtchPacket instance or dict" 396 | ) 397 | 398 | payload = mutation.create_payload() 399 | variables = payload.model_dump(by_alias=True, exclude_none=True) 400 | 401 | return self.mutate(mutation, variables=variables, upload_files=True, **kwargs) 402 | 403 | def generate_etch_signing_url(self, signer_eid: str, client_user_id: str, **kwargs): 404 | """Generate a signing URL for a given user.""" 405 | mutation = GenerateEtchSigningURL( 406 | signer_eid=signer_eid, 407 | client_user_id=client_user_id, 408 | ) 409 | payload = mutation.create_payload() 410 | return self.mutate( 411 | mutation, variables=payload.model_dump(by_alias=True), **kwargs 412 | ) 413 | 414 | def download_documents(self, document_group_eid: str, **kwargs): 415 | """Retrieve all completed documents in zip form.""" 416 | api = PlainRequest(client=self.client) 417 | return api.get(f"document-group/{document_group_eid}.zip", **kwargs) 418 | 419 | def forge_submit( 420 | self, 421 | payload: Optional[Union[Dict[str, Any], ForgeSubmitPayload]] = None, 422 | json=None, 423 | **kwargs, 424 | ) -> Dict[str, Any]: 425 | """Create a Webform (forge) submission via a graphql mutation.""" 426 | if not any([json, payload]): 427 | raise TypeError('One of arguments `json` or `payload` are required') 428 | 429 | if json: 430 | payload = ForgeSubmitPayload.model_validate_json(json) 431 | 432 | if isinstance(payload, dict): 433 | mutation = ForgeSubmit.create_from_dict(payload) 434 | elif isinstance(payload, ForgeSubmitPayload): 435 | mutation = ForgeSubmit(payload=payload) 436 | else: 437 | raise ValueError( 438 | "`payload` must be a valid ForgeSubmitPayload instance or dict" 439 | ) 440 | 441 | return self.mutate( 442 | mutation, 443 | variables=mutation.create_payload().model_dump( 444 | by_alias=True, exclude_none=True 445 | ), 446 | **kwargs, 447 | ) 448 | -------------------------------------------------------------------------------- /python_anvil/api_resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvilco/python-anvil/d23c303bf4f393c0b389d253e3e168a9b1b6012e/python_anvil/api_resources/__init__.py -------------------------------------------------------------------------------- /python_anvil/api_resources/base.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=no-self-argument 2 | import re 3 | 4 | # Disabling pylint no-name-in-module because this is the documented way to 5 | # import `BaseModel` and it's not broken, so let's keep it. 6 | from pydantic import ( # pylint: disable=no-name-in-module 7 | BaseModel as _BaseModel, 8 | ConfigDict, 9 | ) 10 | 11 | 12 | under_pat = re.compile(r"_([a-z])") 13 | 14 | 15 | def underscore_to_camel(name): 16 | ret = under_pat.sub(lambda x: x.group(1).upper(), name) 17 | return ret 18 | 19 | 20 | class BaseModel(_BaseModel): 21 | """Config override for all models. 22 | 23 | This override is mainly so everything can go from snake to camel-case. 24 | """ 25 | 26 | # Allow extra fields even if it is not defined. This will allow models 27 | # to be more flexible if features are added in the Anvil API, but 28 | # explicit support hasn't been added yet to this library. 29 | model_config = ConfigDict( 30 | alias_generator=underscore_to_camel, populate_by_name=True, extra="allow" 31 | ) 32 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseQuery 2 | from .create_etch_packet import CreateEtchPacket 3 | from .forge_submit import ForgeSubmit 4 | from .generate_etch_signing_url import GenerateEtchSigningURL 5 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/base.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class BaseQuery: 5 | """Base class for any GraphQL queries/mutations.""" 6 | 7 | mutation: Optional[str] = None 8 | mutation_res_query: Optional[str] = None 9 | 10 | def get_mutation(self): 11 | if self.mutation and self.mutation_res_query: 12 | return self.mutation.format(query=self.mutation_res_query) 13 | return self.mutation 14 | 15 | def create_payload(self): 16 | if not self.mutation: 17 | raise ValueError( 18 | "`mutation` property must be set on the inheriting class level" 19 | ) 20 | raise NotImplementedError() 21 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/create_etch_packet.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=too-many-instance-attributes 2 | import logging 3 | from io import BufferedIOBase 4 | from logging import Logger 5 | from mimetypes import guess_type 6 | from typing import Any, Dict, List, Optional, Union 7 | 8 | from python_anvil.api_resources.mutations.base import BaseQuery 9 | from python_anvil.api_resources.payload import ( 10 | AttachableEtchFile, 11 | CreateEtchFilePayload, 12 | CreateEtchPacketPayload, 13 | DocumentUpload, 14 | EtchSigner, 15 | ) 16 | from python_anvil.utils import create_unique_id 17 | 18 | 19 | logger: Logger = logging.getLogger(__name__) 20 | 21 | DEFAULT_RESPONSE_QUERY = """{ 22 | eid 23 | name 24 | detailsURL 25 | documentGroup { 26 | eid 27 | status 28 | files 29 | signers { 30 | eid 31 | aliasId 32 | routingOrder 33 | name 34 | email 35 | status 36 | signActionType 37 | } 38 | } 39 | }""" 40 | 41 | # NOTE: Since the below will be used as a formatted string (this also applies 42 | # to f-strings) any literal curly braces need to be doubled, else they'll be 43 | # interpreted as string replacement tokens. 44 | CREATE_ETCH_PACKET = """ 45 | mutation CreateEtchPacket ( 46 | $name: String, 47 | $files: [EtchFile!], 48 | $isDraft: Boolean, 49 | $isTest: Boolean, 50 | $mergePDFs: Boolean, 51 | $signatureEmailSubject: String, 52 | $signatureEmailBody: String, 53 | $signatureProvider: String, 54 | $signaturePageOptions: JSON, 55 | $signers: [JSON!], 56 | $webhookURL: String, 57 | $replyToName: String, 58 | $replyToEmail: String, 59 | $data: JSON, 60 | $enableEmails: JSON, 61 | $createCastTemplatesFromUploads: Boolean, 62 | $duplicateCasts: Boolean=false, 63 | ) {{ 64 | createEtchPacket ( 65 | name: $name, 66 | files: $files, 67 | isDraft: $isDraft, 68 | isTest: $isTest, 69 | mergePDFs: $mergePDFs, 70 | signatureEmailSubject: $signatureEmailSubject, 71 | signatureEmailBody: $signatureEmailBody, 72 | signatureProvider: $signatureProvider, 73 | signaturePageOptions: $signaturePageOptions, 74 | signers: $signers, 75 | webhookURL: $webhookURL, 76 | replyToName: $replyToName, 77 | replyToEmail: $replyToEmail, 78 | data: $data, 79 | enableEmails: $enableEmails, 80 | createCastTemplatesFromUploads: $createCastTemplatesFromUploads, 81 | duplicateCasts: $duplicateCasts 82 | ) 83 | {query} 84 | }} 85 | """ 86 | 87 | 88 | class CreateEtchPacket(BaseQuery): 89 | mutation = CREATE_ETCH_PACKET 90 | mutation_res_query = DEFAULT_RESPONSE_QUERY 91 | 92 | def __init__( # pylint: disable=too-many-locals 93 | self, 94 | name: Optional[str] = None, 95 | signature_email_subject: Optional[str] = None, 96 | signature_email_body: Optional[str] = None, 97 | signers: Optional[List[EtchSigner]] = None, 98 | files: Optional[List[AttachableEtchFile]] = None, 99 | file_payloads: Optional[dict] = None, 100 | signature_page_options: Optional[Dict[Any, Any]] = None, 101 | is_draft: bool = False, 102 | is_test: bool = True, 103 | payload: Optional[CreateEtchPacketPayload] = None, 104 | webhook_url: Optional[str] = None, 105 | reply_to_name: Optional[str] = None, 106 | reply_to_email: Optional[str] = None, 107 | merge_pdfs: Optional[bool] = None, 108 | enable_emails: Optional[Union[bool, List[str]]] = None, 109 | create_cast_templates_from_uploads: Optional[bool] = None, 110 | duplicate_casts: Optional[bool] = None, 111 | ): 112 | # `name` is required when `payload` is not present. 113 | if not payload and not name: 114 | raise TypeError( 115 | "Missing 2 required positional arguments: 'name' and " 116 | "'signature_email_subject'" 117 | ) 118 | 119 | self.name = name 120 | self.signature_email_subject = signature_email_subject 121 | self.signature_email_body = signature_email_body 122 | self.signature_page_options = signature_page_options 123 | self.signers = signers or [] 124 | self.files = files or [] 125 | self.file_payloads = file_payloads or {} 126 | self.is_draft = is_draft 127 | self.is_test = is_test 128 | self.payload = payload 129 | self.webhook_url = webhook_url 130 | self.reply_to_name = reply_to_name 131 | self.reply_to_email = reply_to_email 132 | self.merge_pdfs = merge_pdfs 133 | self.enable_emails = enable_emails 134 | self.create_cast_templates_from_uploads = create_cast_templates_from_uploads 135 | self.duplicate_casts = duplicate_casts 136 | 137 | @classmethod 138 | def create_from_dict(cls, payload: Dict) -> 'CreateEtchPacket': 139 | """Create a new instance of `CreateEtchPacket` from a dict payload.""" 140 | try: 141 | mutation = cls( 142 | **{k: v for k, v in payload.items() if k not in ["signers", "files"]} 143 | ) 144 | except TypeError as e: 145 | raise ValueError( 146 | f"`payload` must be a valid CreateEtchPacket instance or dict. {e}" 147 | ) from e 148 | if "signers" in payload: 149 | for signer in payload["signers"]: 150 | mutation.add_signer(EtchSigner(**signer)) 151 | 152 | if "files" in payload: 153 | for file in payload["files"]: 154 | mutation.add_file(DocumentUpload(**file)) 155 | 156 | return mutation 157 | 158 | def add_signer(self, signer: Union[dict, EtchSigner]): 159 | """Add a signer to the mutation payload. 160 | 161 | :param signer: Signer object to add to the payload 162 | :type signer: dict|EtchSigner 163 | """ 164 | if isinstance(signer, dict): 165 | data = EtchSigner(**signer) 166 | elif isinstance(signer, EtchSigner): 167 | data = signer 168 | else: 169 | raise ValueError("Signer must be either a dict or EtchSigner type") 170 | 171 | if data.signer_type not in ["embedded", "email"]: 172 | raise ValueError( 173 | "Etch signer `signer_type` must be only 'embedded' or 'email" 174 | ) 175 | 176 | if not data.id: 177 | data.id = create_unique_id("signer") 178 | if not data.routing_order: 179 | if self.signers: 180 | # Basic thing to get the next number 181 | # But this might not be necessary since API goes by index 182 | # of signers in the list. 183 | all_signers = [(s.routing_order or 0) for s in self.signers] 184 | num = max(all_signers) + 1 185 | else: 186 | num = 1 187 | data.routing_order = num 188 | 189 | self.signers.append(data) 190 | 191 | def add_file(self, file: AttachableEtchFile): 192 | """Add file to a pending list of Upload objects. 193 | 194 | Files will not be uploaded when running this method. They will be 195 | uploaded when the mutation actually runs. 196 | """ 197 | if ( 198 | isinstance(file, DocumentUpload) 199 | and isinstance(file.file, BufferedIOBase) 200 | and getattr(file.file, "content_type", None) is None 201 | ): 202 | # Don't clobber existing `content_type`s provided. 203 | content_type, _ = guess_type(file.file.name) # type: ignore 204 | logger.debug( 205 | "File did not have a `content_type`, guessing as '%s'", content_type 206 | ) 207 | file.file.content_type = content_type # type: ignore 208 | 209 | self.files.append(file) 210 | 211 | def add_file_payloads(self, file_id: str, fill_payload): 212 | existing_files = [f.id for f in self.files if f] 213 | if file_id not in existing_files: 214 | raise ValueError( 215 | f"`{file_id}` was not added as a file. Please add " 216 | f"the file first before adding a fill payload." 217 | ) 218 | self.file_payloads[file_id] = fill_payload 219 | 220 | def get_file_payloads(self): 221 | existing_files = [f.id for f in self.files if f] 222 | for key, _ in self.file_payloads.items(): 223 | if key not in existing_files: 224 | raise ValueError( 225 | f"`{key}` was not added as a file. Please add " 226 | f"that file or remove its fill payload before " 227 | f"attempting to create an Etch payload." 228 | ) 229 | return self.file_payloads 230 | 231 | def create_payload(self) -> CreateEtchPacketPayload: 232 | """Create a payload based on data set on the class instance. 233 | 234 | Check `api_resources.payload.CreateEtchPacketPayload` for full payload 235 | requirements. Data requirements aren't explicitly enforced here, but 236 | at the payload class level. 237 | """ 238 | # If there's an existing payload instance attribute, just return that. 239 | if self.payload: 240 | return self.payload 241 | 242 | if not self.name: 243 | raise TypeError("`name` and `signature_email_subject` cannot be None") 244 | 245 | payload = CreateEtchPacketPayload( 246 | is_test=self.is_test, 247 | is_draft=self.is_draft, 248 | name=self.name, 249 | signers=self.signers, 250 | files=self.files, 251 | data=CreateEtchFilePayload(payloads=self.get_file_payloads()), 252 | signature_email_subject=self.signature_email_subject, 253 | signature_email_body=self.signature_email_body, 254 | signature_page_options=self.signature_page_options or {}, 255 | webhook_url=self.webhook_url, 256 | reply_to_email=self.reply_to_email, 257 | reply_to_name=self.reply_to_name, 258 | merge_pdfs=self.merge_pdfs, 259 | enable_emails=self.enable_emails, 260 | create_cast_templates_from_uploads=self.create_cast_templates_from_uploads, 261 | duplicate_casts=self.duplicate_casts, 262 | ) 263 | 264 | return payload 265 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/forge_submit.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from python_anvil.api_resources.mutations.base import BaseQuery 4 | from python_anvil.api_resources.mutations.helpers import get_payload_attrs 5 | from python_anvil.api_resources.payload import ForgeSubmitPayload 6 | 7 | 8 | DEFAULT_RESPONSE_QUERY = """ 9 | { 10 | eid 11 | status 12 | resolvedPayload 13 | currentStep 14 | completedAt 15 | createdAt 16 | updatedAt 17 | signer { 18 | name 19 | email 20 | status 21 | routingOrder 22 | } 23 | weldData { 24 | eid 25 | status 26 | isTest 27 | isComplete 28 | agents 29 | } 30 | } 31 | """ 32 | 33 | # NOTE: Since the below will be used as a formatted string (this also applies 34 | # to f-strings) any literal curly braces need to be doubled, else they'll be 35 | # interpreted as string replacement tokens. 36 | FORGE_SUBMIT = """ 37 | mutation ForgeSubmit( 38 | $forgeEid: String!, 39 | $weldDataEid: String, 40 | $submissionEid: String, 41 | $payload: JSON!, 42 | $currentStep: Int, 43 | $complete: Boolean, 44 | $isTest: Boolean, 45 | $timezone: String, 46 | $groupArrayId: String, 47 | $groupArrayIndex: Int, 48 | $webhookURL: String, 49 | ) {{ 50 | forgeSubmit ( 51 | forgeEid: $forgeEid, 52 | weldDataEid: $weldDataEid, 53 | submissionEid: $submissionEid, 54 | payload: $payload, 55 | currentStep: $currentStep, 56 | complete: $complete, 57 | isTest: $isTest, 58 | timezone: $timezone, 59 | groupArrayId: $groupArrayId, 60 | groupArrayIndex: $groupArrayIndex, 61 | webhookURL: $webhookURL 62 | ) {query} 63 | }} 64 | """ 65 | 66 | 67 | class ForgeSubmit(BaseQuery): 68 | mutation = FORGE_SUBMIT 69 | mutation_res_query = DEFAULT_RESPONSE_QUERY 70 | 71 | def __init__( 72 | self, 73 | payload: Union[Dict[str, Any], ForgeSubmitPayload], 74 | forge_eid: Optional[str] = None, 75 | weld_data_eid: Optional[str] = None, 76 | submission_eid: Optional[str] = None, 77 | is_test: Optional[bool] = None, 78 | **kwargs, 79 | ): 80 | """ 81 | Create a forgeSubmit query. 82 | 83 | :param forge_eid: 84 | :param payload: 85 | :param weld_data_eid: 86 | :param submission_eid: 87 | :param is_test: 88 | :param kwargs: kwargs may contain other fields defined in 89 | `ForgeSubmitPayload` if not explicitly in the `__init__` args. 90 | """ 91 | if not forge_eid and not isinstance(payload, ForgeSubmitPayload): 92 | raise ValueError( 93 | "`forge_eid` is required if `payload` is not a " 94 | "`ForgeSubmitPayload` instance" 95 | ) 96 | 97 | self.payload = payload 98 | self.forge_eid = forge_eid 99 | self.weld_data_eid = weld_data_eid 100 | self.submission_eid = submission_eid 101 | self.is_test = is_test 102 | 103 | # Get other attrs from the model and set on the instance 104 | model_attrs = get_payload_attrs(ForgeSubmitPayload) 105 | for attr in model_attrs: 106 | if attr in kwargs: 107 | setattr(self, attr, kwargs[attr]) 108 | 109 | @classmethod 110 | def create_from_dict(cls, payload: Dict[str, Any]): 111 | # Parse the data through the model class to validate and pass it back 112 | # as variables in this class. 113 | return cls(**payload) 114 | 115 | def create_payload(self): 116 | # If provided a payload and no forge_eid, we'll assume that it's the 117 | # full thing. Return that instead. 118 | if not self.forge_eid and self.payload: 119 | return self.payload 120 | 121 | model_attrs = get_payload_attrs(ForgeSubmitPayload) 122 | 123 | for_payload = {} 124 | for attr in model_attrs: 125 | obj = getattr(self, attr, None) 126 | if obj is not None: 127 | for_payload[attr] = obj 128 | 129 | return ForgeSubmitPayload(**for_payload) 130 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/generate_etch_signing_url.py: -------------------------------------------------------------------------------- 1 | from python_anvil.api_resources.mutations.base import BaseQuery 2 | from python_anvil.api_resources.payload import GenerateEtchSigningURLPayload 3 | 4 | 5 | class GenerateEtchSigningURL(BaseQuery): 6 | """Query class to handle retrieving a signing URL.""" 7 | 8 | mutation = """ 9 | mutation ($signerEid: String!, $clientUserId: String!) { 10 | generateEtchSignURL (signerEid: $signerEid, clientUserId: $clientUserId) 11 | } 12 | """ 13 | 14 | def __init__(self, signer_eid: str, client_user_id: str): 15 | self.signer_eid = signer_eid 16 | self.client_user_id = client_user_id 17 | 18 | def create_payload(self): 19 | return GenerateEtchSigningURLPayload( 20 | signer_eid=self.signer_eid, client_user_id=self.client_user_id 21 | ) 22 | -------------------------------------------------------------------------------- /python_anvil/api_resources/mutations/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from python_anvil.api_resources.base import BaseModel 4 | 5 | 6 | def get_payload_attrs(payload_model: Type[BaseModel]) -> List[str]: 7 | return list(payload_model.model_fields.keys()) 8 | -------------------------------------------------------------------------------- /python_anvil/api_resources/payload.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=no-self-argument 2 | 3 | import sys 4 | from io import BufferedIOBase 5 | 6 | # Disabling pylint no-name-in-module because this is the documented way to 7 | # import `BaseModel` and it's not broken, so let's keep it. 8 | from pydantic import ( # pylint: disable=no-name-in-module 9 | ConfigDict, 10 | Field, 11 | HttpUrl, 12 | field_validator, 13 | ) 14 | from typing import Any, Dict, List, Optional, Union 15 | 16 | from python_anvil.models import FileCompatibleBaseModel 17 | 18 | from .base import BaseModel 19 | 20 | 21 | if sys.version_info >= (3, 8): 22 | from typing import Literal # pylint: disable=no-name-in-module 23 | else: 24 | from typing_extensions import Literal 25 | 26 | 27 | class EmbeddedLogo(BaseModel): 28 | src: str 29 | max_width: Optional[int] = None 30 | max_height: Optional[int] = None 31 | 32 | 33 | class FillPDFPayload(BaseModel): 34 | data: Union[List[Dict[str, Any]], Dict[str, Any]] 35 | title: Optional[str] = None 36 | font_size: Optional[int] = None 37 | text_color: Optional[str] = None 38 | 39 | @field_validator("data") 40 | @classmethod 41 | def data_cannot_be_empty(cls, v): 42 | if isinstance(v, dict) and len(v) == 0: 43 | raise ValueError("cannot be empty") 44 | return v 45 | 46 | 47 | class GeneratePDFPayload(BaseModel): 48 | data: Union[List[Dict[str, Any]], Dict[Literal["html", "css"], str]] 49 | logo: Optional[EmbeddedLogo] = None 50 | title: Optional[str] = None 51 | type: Optional[Literal["markdown", "html"]] = "markdown" 52 | page: Optional[Dict[str, Any]] = None 53 | font_size: Optional[int] = None 54 | font_family: Optional[str] = None 55 | text_color: Optional[str] = None 56 | 57 | 58 | class GenerateEtchSigningURLPayload(BaseModel): 59 | signer_eid: str 60 | client_user_id: str 61 | 62 | 63 | class SignerField(BaseModel): 64 | file_id: str 65 | field_id: str 66 | 67 | 68 | class EtchSigner(BaseModel): 69 | """Dataclass representing etch signers.""" 70 | 71 | name: str 72 | email: str 73 | fields: List[SignerField] 74 | signer_type: str = "email" 75 | # id will be generated if `None` 76 | id: Optional[str] = None 77 | routing_order: Optional[int] = None 78 | redirect_url: Optional[str] = Field(None, alias="redirectURL") 79 | accept_each_field: Optional[bool] = None 80 | enable_emails: Optional[List[str]] = None 81 | # signature_mode can be "draw" or "text" (default: text) 82 | signature_mode: Optional[str] = None 83 | 84 | 85 | class SignatureField(BaseModel): 86 | id: str 87 | type: str 88 | page_num: int 89 | # Should be in a format similar to: 90 | # { x: 100.00, y: 121.21, width: 33.00 } 91 | rect: Dict[str, float] 92 | 93 | 94 | class Base64Upload(BaseModel): 95 | data: str 96 | filename: str 97 | mimetype: str = "application/pdf" 98 | 99 | 100 | class TableColumnAlignment(BaseModel): 101 | align: Optional[Literal["left", "center", "right"]] = None 102 | width: Optional[str] = None 103 | 104 | 105 | # https://www.useanvil.com/docs/api/generate-pdf#table 106 | class MarkdownTable(BaseModel): 107 | rows: List[List[str]] 108 | 109 | # defaults to `True` if not provided. 110 | # set to false for no header row on the table 111 | first_row_headers: Optional[bool] = None 112 | 113 | # defaults to `False` if not provided. 114 | # set to true to display gridlines in-between rows or columns 115 | row_grid_lines: Optional[bool] = True 116 | column_grid_lines: Optional[bool] = False 117 | 118 | # defaults to 'top' if not provided. 119 | # adjust vertical alignment of table text 120 | # accepts 'top', 'center', or 'bottom' 121 | vertical_align: Optional[Literal["top", "center", "bottom"]] = "center" 122 | 123 | # (optional) columnOptions - An array of columnOption objects. 124 | # You do not need to specify all columns. Accepts an 125 | # empty object indicating no overrides on the 126 | # specified column. 127 | # 128 | # Supported keys for columnOption: 129 | # align (optional) - adjust horizontal alignment of table text 130 | # accepts 'left', 'center', or 'right'; defaults to 'left' 131 | # width (optional) - adjust the width of the column 132 | # accepts width in pixels or as percentage of the table width 133 | column_options: Optional[List[TableColumnAlignment]] = None 134 | 135 | 136 | # https://www.useanvil.com/docs/api/object-references/#verbatimfield 137 | class MarkdownContent(BaseModel): 138 | label: Optional[str] = None 139 | heading: Optional[str] = None 140 | content: Optional[str] = None 141 | table: Optional[MarkdownTable] = None 142 | font_size: int = 14 143 | text_color: str = "#000000" 144 | 145 | 146 | class DocumentMarkup(BaseModel): 147 | """Dataclass representing a document with HTML/CSS markup.""" 148 | 149 | id: str 150 | filename: str 151 | markup: Dict[Literal["html", "css"], str] 152 | fields: Optional[List[SignatureField]] = None 153 | title: Optional[str] = None 154 | font_size: int = 14 155 | text_color: str = "#000000" 156 | 157 | 158 | class DocumentMarkdown(BaseModel): 159 | """Dataclass representing a document with Markdown.""" 160 | 161 | id: str 162 | filename: str 163 | # NOTE: Order matters here in the Union[]. 164 | # If `SignatureField` is not first, the types are similar enough that it 165 | # will use `MarkdownContent` instead. 166 | fields: Optional[List[Union[SignatureField, MarkdownContent]]] = None 167 | title: Optional[str] = None 168 | font_size: int = 14 169 | text_color: str = "#000000" 170 | 171 | 172 | class DocumentUpload(FileCompatibleBaseModel): 173 | """Dataclass representing an uploaded document.""" 174 | 175 | id: str 176 | title: str 177 | # Previously "UploadableFile", however, that seems to cause weird upload 178 | # issues where a PDF file would have its first few bytes removed. 179 | # We're now relying on the backend to validate this property instead of on 180 | # the client library side. 181 | # This might be a bug on the `pydantic` side(?) when this object gets 182 | # converted into a dict. 183 | 184 | # NOTE: This field name is referenced in the models.py file, if you change it you 185 | # must change the reference 186 | file: Any = None 187 | fields: List[SignatureField] 188 | font_size: int = 14 189 | text_color: str = "#000000" 190 | model_config = ConfigDict(arbitrary_types_allowed=True) 191 | 192 | 193 | class EtchCastRef(BaseModel): 194 | """Dataclass representing an existing template used as a reference.""" 195 | 196 | id: str 197 | cast_eid: str 198 | 199 | 200 | class CreateEtchFilePayload(BaseModel): 201 | payloads: Union[str, Dict[str, FillPDFPayload]] 202 | 203 | 204 | class CreateEtchPacketPayload(FileCompatibleBaseModel): 205 | """ 206 | Payload for createEtchPacket. 207 | 208 | See the full packet payload defined here: 209 | https://www.useanvil.com/docs/api/e-signatures#tying-it-all-together 210 | """ 211 | 212 | name: str 213 | signers: List[EtchSigner] 214 | # NOTE: This is a list of `AttachableEtchFile` objects, but we need to 215 | # override the default `FileCompatibleBaseModel` to handle multipart/form-data 216 | # uploads correctly. This field name is referenced in the models.py file. 217 | files: List["AttachableEtchFile"] 218 | signature_email_subject: Optional[str] = None 219 | signature_email_body: Optional[str] = None 220 | is_draft: Optional[bool] = False 221 | is_test: Optional[bool] = True 222 | merge_pdfs: Optional[bool] = Field(None, alias="mergePDFs") 223 | data: Optional[CreateEtchFilePayload] = None 224 | signature_page_options: Optional[Dict[Any, Any]] = None 225 | webhook_url: Optional[str] = Field(None, alias="webhookURL") 226 | reply_to_name: Optional[Any] = None 227 | reply_to_email: Optional[Any] = None 228 | enable_emails: Optional[Union[bool, List[str]]] = None 229 | create_cast_templates_from_uploads: Optional[bool] = None 230 | duplicate_casts: Optional[bool] = None 231 | 232 | 233 | class ForgeSubmitPayload(BaseModel): 234 | """ 235 | Payload for forgeSubmit. 236 | 237 | See full payload defined here: 238 | https://www.useanvil.com/docs/api/graphql/reference/#operation-forgesubmit-Mutations 239 | """ 240 | 241 | forge_eid: str 242 | payload: Dict[str, Any] 243 | weld_data_eid: Optional[str] = None 244 | submission_eid: Optional[str] = None 245 | # Defaults to True when not provided/is None 246 | enforce_payload_valid_on_create: Optional[bool] = None 247 | current_step: Optional[int] = None 248 | complete: Optional[bool] = None 249 | # Note that if using a development API key, this will be forced to `True` 250 | # even when `False` is used in the payload. 251 | is_test: Optional[bool] = True 252 | timezone: Optional[str] = None 253 | webhook_url: Optional[HttpUrl] = Field(None, alias="webhookURL") 254 | group_array_id: Optional[str] = None 255 | group_array_index: Optional[int] = None 256 | 257 | 258 | UploadableFile = Union[Base64Upload, BufferedIOBase] 259 | AttachableEtchFile = Union[ 260 | DocumentUpload, EtchCastRef, DocumentMarkup, DocumentMarkdown 261 | ] 262 | 263 | # Classes below use types wrapped in quotes avoid a circular dependency/weird 264 | # variable assignment locations with the aliases above. We need to manually 265 | # update the refs for them to point to the right things. 266 | DocumentUpload.model_rebuild() 267 | CreateEtchPacketPayload.model_rebuild() 268 | -------------------------------------------------------------------------------- /python_anvil/api_resources/requests.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from python_anvil.constants import VALID_HOSTS 4 | from python_anvil.http import HTTPClient 5 | 6 | 7 | class AnvilRequest: 8 | show_headers = False 9 | _client: HTTPClient 10 | 11 | def get_url(self): 12 | raise NotImplementedError 13 | 14 | def _request(self, method, url, **kwargs): 15 | if not self._client: 16 | raise AssertionError( 17 | "Client has not been initialized. Please use the constructors " 18 | "provided by the other request implementations." 19 | ) 20 | 21 | if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]: 22 | raise ValueError("Invalid HTTP method provided") 23 | 24 | full_url = "/".join([self.get_url(), url]) if len(url) > 0 else self.get_url() 25 | 26 | return self._client.request(method, full_url, **kwargs) 27 | 28 | def handle_error(self, response, status_code, headers): 29 | extra = None 30 | if self.show_headers: 31 | extra = headers 32 | 33 | if hasattr(response, "decode"): 34 | message = f"Error: {status_code}: {response.decode()} {extra}" 35 | else: 36 | message = f"Error: {status_code}: {response} {extra}" 37 | 38 | # pylint: disable=broad-exception-raised 39 | raise Exception(message) 40 | 41 | def process_response(self, response, status_code, headers, **kwargs): 42 | res = response 43 | if not 200 <= status_code < 300: 44 | self.handle_error(response, status_code, headers) 45 | 46 | debug = kwargs.pop("debug", False) 47 | 48 | # Include headers alongside the response. 49 | # This is useful for figuring out rate limits outside of this library's 50 | # scope and to manage waiting. 51 | include_headers = kwargs.pop("include_headers", False) 52 | if debug or include_headers: 53 | return {"response": res, "headers": headers} 54 | 55 | return res 56 | 57 | 58 | class BaseAnvilHttpRequest(AnvilRequest): 59 | def __init__(self, client, options=None): 60 | self._client = client 61 | self._options = options 62 | 63 | def get_url(self): 64 | raise NotImplementedError 65 | 66 | def get(self, url, params=None, **kwargs): 67 | retry = kwargs.pop("retry", True) 68 | content, status_code, headers = self._request( 69 | "GET", url, params=params, retry=retry 70 | ) 71 | return self.process_response(content, status_code, headers, **kwargs) 72 | 73 | def post(self, url, data=None, **kwargs): 74 | retry = kwargs.pop("retry", True) 75 | params = kwargs.pop("params", None) 76 | content, status_code, headers = self._request( 77 | "POST", url, json=data, retry=retry, params=params 78 | ) 79 | return self.process_response(content, status_code, headers, **kwargs) 80 | 81 | 82 | class GraphqlRequest(AnvilRequest): 83 | """Create a GraphQL request. 84 | 85 | .. deprecated :: 2.0.0 86 | Use `python_anvil.http.GQLClient` to make GraphQL queries and mutations. 87 | """ 88 | 89 | API_HOST = "https://graphql.useanvil.com" 90 | 91 | def __init__(self, client: HTTPClient): 92 | self._client = client 93 | 94 | def get_url(self): 95 | return f"{self.API_HOST}" 96 | 97 | def post(self, query, variables=None, **kwargs): 98 | return self.run_query("POST", query, variables=variables, **kwargs) 99 | 100 | def post_multipart(self, files=None, **kwargs): 101 | return self.run_query("POST", None, files=files, is_multipart=True, **kwargs) 102 | 103 | def run_query( 104 | self, method, query, variables=None, files=None, is_multipart=False, **kwargs 105 | ): 106 | if not query and not files: 107 | raise AssertionError( 108 | "Either `query` or `files` must be passed into this method." 109 | ) 110 | data: Dict[str, Any] = {} 111 | 112 | if query: 113 | data["query"] = query 114 | 115 | if files and is_multipart: 116 | # Make sure `data` is nothing when we're doing a multipart request. 117 | data = {} 118 | elif variables: 119 | data["variables"] = variables 120 | 121 | # Optional debug kwargs. 122 | # At this point, only the CLI will pass this in as a 123 | # "show me everything" sort of switch. 124 | debug = kwargs.pop("debug", False) 125 | include_headers = kwargs.pop("include_headers", False) 126 | 127 | content, status_code, headers = self._request( 128 | method, 129 | # URL blank here since graphql requests don't append to url 130 | '', 131 | # Queries need to be wrapped by curly braces(?) based on the 132 | # current API implementation. 133 | # The current library for graphql query generation doesn't do this(?) 134 | json=data, 135 | files=files, 136 | parse_json=True, 137 | **kwargs, 138 | ) 139 | 140 | return self.process_response( 141 | content, 142 | status_code, 143 | headers, 144 | debug=debug, 145 | include_headers=include_headers, 146 | **kwargs, 147 | ) 148 | 149 | 150 | class RestRequest(BaseAnvilHttpRequest): 151 | API_HOST = "https://app.useanvil.com" 152 | API_BASE = "api" 153 | API_VERSION = "v1" 154 | 155 | def get_url(self): 156 | return f"{self.API_HOST}/{self.API_BASE}/{self.API_VERSION}" 157 | 158 | 159 | class PlainRequest(BaseAnvilHttpRequest): 160 | API_HOST = "https://app.useanvil.com" 161 | API_BASE = "api" 162 | 163 | def get_url(self): 164 | return f"{self.API_HOST}/{self.API_BASE}" 165 | 166 | 167 | class FullyQualifiedRequest(BaseAnvilHttpRequest): 168 | """A request class that validates URLs point to Anvil domains.""" 169 | 170 | def get_url(self): 171 | return "" # Not used since we expect full URLs 172 | 173 | def _validate_url(self, url): 174 | if not any(url.startswith(host) for host in VALID_HOSTS): 175 | raise ValueError(f"URL must start with one of: {', '.join(VALID_HOSTS)}") 176 | 177 | def get(self, url, params=None, **kwargs): 178 | self._validate_url(url) 179 | return super().get(url, params, **kwargs) 180 | 181 | def post(self, url, data=None, **kwargs): 182 | self._validate_url(url) 183 | return super().post(url, data, **kwargs) 184 | -------------------------------------------------------------------------------- /python_anvil/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | from csv import DictReader 4 | from logging import getLogger 5 | from tabulate import tabulate 6 | from time import sleep 7 | from typing import List 8 | 9 | from python_anvil import utils 10 | 11 | from .api import Anvil 12 | from .api_resources.payload import FillPDFPayload 13 | 14 | 15 | logger = getLogger(__name__) 16 | 17 | 18 | def get_api_key(): 19 | return os.environ.get("ANVIL_API_KEY") 20 | 21 | 22 | def contains_headers(res): 23 | return isinstance(res, dict) and "headers" in res 24 | 25 | 26 | def process_response(res): 27 | return res["response"], res["headers"] 28 | 29 | 30 | @click.group() 31 | @click.option("--debug/--no-debug", default=False) 32 | @click.pass_context 33 | def cli(ctx: click.Context, debug=False): 34 | ctx.ensure_object(dict) 35 | 36 | key = get_api_key() 37 | if not key: 38 | raise ValueError("$ANVIL_API_KEY must be defined in your environment variables") 39 | 40 | anvil = Anvil(key) 41 | ctx.obj["anvil"] = anvil 42 | ctx.obj["debug"] = debug 43 | 44 | 45 | @cli.command("current-user", help="Show details about your API user") 46 | @click.pass_context 47 | def current_user(ctx): 48 | anvil = ctx.obj["anvil"] 49 | debug = ctx.obj["debug"] 50 | res = anvil.get_current_user(debug=debug) 51 | 52 | if contains_headers(res): 53 | res, headers = process_response(res) 54 | if debug: 55 | click.echo(headers) 56 | 57 | click.echo(f"User data: \n\n {res}") 58 | 59 | 60 | @cli.command() 61 | @click.option( 62 | "-i", "-in", "input_filename", help="Filename of input payload", required=True 63 | ) 64 | @click.option( 65 | "-o", 66 | "--out", 67 | "out_filename", 68 | help="Filename of output PDF", 69 | required=True, 70 | ) 71 | @click.pass_context 72 | def generate_pdf(ctx, input_filename, out_filename): 73 | """Generate a PDF.""" 74 | anvil = ctx.obj["anvil"] 75 | debug = ctx.obj["debug"] 76 | 77 | with click.open_file(input_filename, "r") as infile: 78 | res = anvil.generate_pdf(infile.read(), debug=debug) 79 | 80 | if contains_headers(res): 81 | res, headers = process_response(res) 82 | if debug: 83 | click.echo(headers) 84 | 85 | with click.open_file(out_filename, "wb") as file: 86 | file.write(res) 87 | 88 | 89 | @cli.command() 90 | @click.option("-l", "--list", "list_all", help="List all available welds", is_flag=True) 91 | @click.argument("eid", default="") 92 | @click.pass_context 93 | def weld(ctx, eid, list_all): 94 | """Fetch weld info or list of welds.""" 95 | anvil = ctx.obj["anvil"] 96 | debug = ctx.obj["debug"] 97 | 98 | if list_all: 99 | res = anvil.get_welds(debug=debug) 100 | if contains_headers(res): 101 | res, headers = process_response(res) 102 | if debug: 103 | click.echo(headers) 104 | 105 | data = [(w["eid"], w.get("slug"), w.get("name"), w.get("forges")) for w in res] 106 | click.echo(tabulate(data, tablefmt="pretty", headers=["eid", "slug", "title"])) 107 | return 108 | 109 | if not eid: 110 | # pylint: disable=broad-exception-raised 111 | raise Exception("You need to pass in a weld eid") 112 | 113 | res = anvil.get_weld(eid) 114 | print(res) 115 | 116 | 117 | @cli.command() 118 | @click.option( 119 | "-l", 120 | "--list", 121 | "list_templates", 122 | help="List available casts marked as templates", 123 | is_flag=True, 124 | ) 125 | @click.option( 126 | "-a", "--all", "list_all", help="List all casts, even non-templates", is_flag=True 127 | ) 128 | @click.option("--version_number", help="Get the specified version of this cast") 129 | @click.argument("eid", default="") 130 | @click.pass_context 131 | def cast(ctx, eid, version_number, list_all, list_templates): 132 | """Fetch Cast data given a Cast eid.""" 133 | anvil = ctx.obj["anvil"] # type: Anvil 134 | debug = ctx.obj["debug"] 135 | 136 | if not eid and not (list_templates or list_all): 137 | raise AssertionError("Cast eid or --list/--all option required") 138 | 139 | if list_all or list_templates: 140 | res = anvil.get_casts(show_all=list_all, debug=debug) 141 | 142 | if contains_headers(res): 143 | res, headers = process_response(res) 144 | if debug: 145 | click.echo(headers) 146 | 147 | data = [[c["eid"], c["title"]] for c in res] 148 | click.echo(tabulate(data, headers=["eid", "title"])) 149 | return 150 | 151 | if eid: 152 | click.echo(f"Getting cast with eid '{eid}' \n") 153 | _res = anvil.get_cast(eid, version_number=version_number, debug=debug) 154 | 155 | if contains_headers(_res): 156 | res, headers = process_response(res) 157 | if debug: 158 | click.echo(headers) 159 | 160 | def get_field_info(cc): 161 | return tabulate(cc.get("fields", [])) 162 | 163 | if not _res: 164 | click.echo(f"Cast with eid: {eid} not found") 165 | return 166 | 167 | table_data = [[_res["eid"], _res["title"], get_field_info(_res["fieldInfo"])]] 168 | click.echo(tabulate(table_data, tablefmt="pretty", headers=list(_res.keys()))) 169 | 170 | 171 | @cli.command("fill-pdf") 172 | @click.argument("template_id") 173 | @click.option( 174 | "-o", 175 | "--out", 176 | "out_filename", 177 | required=True, 178 | help="Filename of output PDF", 179 | ) 180 | @click.option( 181 | "-i", 182 | "--input", 183 | "payload_csv", 184 | required=True, 185 | help="Filename of input CSV that provides data", 186 | ) 187 | @click.pass_context 188 | def fill_pdf(ctx, template_id, out_filename, payload_csv): 189 | """Fill PDF template with data.""" 190 | anvil = ctx.obj["anvil"] 191 | debug = ctx.obj["debug"] 192 | 193 | if all([template_id, out_filename, payload_csv]): 194 | payloads = [] # type: List[FillPDFPayload] 195 | with click.open_file(payload_csv, "r") as csv_file: 196 | reader = DictReader(csv_file) 197 | # NOTE: This is potentially problematic for larger datasets and/or 198 | # very long csv files, but not sure if the use-case is there yet.. 199 | # 200 | # Once memory/execution times are a problem for this command, the 201 | # `progressbar()` can be removed below and we could just work on 202 | # each csv line individually without loading it all into memory 203 | # as we are doing here (or with `list()`). But then that removes 204 | # the nice progress bar, so..trade-offs! 205 | for row in reader: 206 | payloads.append(FillPDFPayload(data=dict(row))) 207 | 208 | with click.progressbar(payloads, label="Filling PDFs and saving") as ps: 209 | indexed_files = utils.build_batch_filenames(out_filename) 210 | for payload in ps: 211 | res = anvil.fill_pdf(template_id, payload.model_dump(), debug=debug) 212 | 213 | if contains_headers(res): 214 | res, headers = process_response(res) 215 | if debug: 216 | click.echo(headers) 217 | 218 | next_file = next(indexed_files) 219 | click.echo(f"\nWriting {next_file}") 220 | with click.open_file(next_file, "wb") as file: 221 | file.write(res) 222 | sleep(1) 223 | 224 | 225 | @cli.command("create-etch") 226 | @click.option( 227 | "-p", 228 | "--payload", 229 | "payload", 230 | type=click.File('rb'), 231 | required=True, 232 | help="File that contains JSON payload", 233 | ) 234 | @click.pass_context 235 | def create_etch(ctx, payload): 236 | """Create an etch packet with a JSON file. 237 | 238 | Example usage: 239 | # For existing files 240 | > $ ANVIL_API_KEY=mykey anvil create-etch --payload=my_payload_file.json 241 | 242 | # You can also get data from STDIN 243 | > $ ANVIL_API_KEY=mykey anvil create-etch --payload - 244 | """ 245 | anvil = ctx.obj["anvil"] 246 | debug = ctx.obj["debug"] 247 | res = anvil.create_etch_packet(json=payload.read(), debug=debug) 248 | 249 | if contains_headers(res): 250 | res, headers = process_response(res) 251 | if debug: 252 | click.echo(headers) 253 | 254 | if "data" in res: 255 | click.echo( 256 | f"Etch packet created with id: {res['data']['createEtchPacket']['eid']}" 257 | ) 258 | else: 259 | click.echo(res) 260 | 261 | 262 | @cli.command("generate-etch-url", help="Generate an etch url for a signer") 263 | @click.option( 264 | "-c", 265 | "--client", 266 | "client_user_id", 267 | required=True, 268 | help="The signer's user id in your system belongs here", 269 | ) 270 | @click.option( 271 | "-s", 272 | "--signer", 273 | "signer_eid", 274 | required=True, 275 | help="The eid of the next signer belongs here. The signer's eid can be " 276 | "found in the response of the `createEtchPacket` mutation", 277 | ) 278 | @click.pass_context 279 | def generate_etch_url(ctx, signer_eid, client_user_id): 280 | anvil = ctx.obj["anvil"] 281 | debug = ctx.obj["debug"] 282 | res = anvil.generate_etch_signing_url( 283 | signer_eid=signer_eid, client_user_id=client_user_id, debug=debug 284 | ) 285 | 286 | if contains_headers(res): 287 | res, headers = process_response(res) 288 | if debug: 289 | click.echo(headers) 290 | 291 | url = res.get("data", {}).get("generateEtchSignURL") 292 | click.echo(f"Signing URL is: {url}") 293 | 294 | 295 | @cli.command("download-documents", help="Download etch documents") 296 | @click.option( 297 | "-d", 298 | "--document-group", 299 | "document_group_eid", 300 | required=True, 301 | help="The documentGroupEid can be found in the response of the " 302 | "createEtchPacket or sendEtchPacket mutations.", 303 | ) 304 | @click.option( 305 | "-f", "--filename", "filename", help="Optional filename for the downloaded zip file" 306 | ) 307 | @click.option( 308 | "--stdout/--no-stdout", 309 | help="Instead of writing to a file, output data to STDOUT", 310 | default=False, 311 | ) 312 | @click.pass_context 313 | def download_documents(ctx, document_group_eid, filename, stdout): 314 | anvil = ctx.obj["anvil"] 315 | debug = ctx.obj["debug"] 316 | res = anvil.download_documents(document_group_eid, debug=debug) 317 | 318 | if contains_headers(res): 319 | res, headers = process_response(res) 320 | if debug: 321 | click.echo(headers) 322 | 323 | if not stdout: 324 | if not filename: 325 | filename = f"{document_group_eid}.zip" 326 | 327 | with click.open_file(filename, 'wb') as out_file: 328 | out_file.write(res) 329 | click.echo(f"Saved as '{click.format_filename(filename)}'") 330 | else: 331 | click.echo(res) 332 | 333 | 334 | @cli.command('gql-query', help="Run a raw graphql query") 335 | @click.option( 336 | "-q", 337 | "--query", 338 | "query", 339 | required=True, 340 | help="The query body. This is the 'query' part of the JSON payload", 341 | ) 342 | @click.option( 343 | "-v", 344 | "--variables", 345 | "variables", 346 | help="The query variables. This is the 'variables' part of the JSON payload", 347 | ) 348 | @click.pass_context 349 | def gql_query(ctx, query, variables): 350 | anvil = ctx.obj["anvil"] 351 | debug = ctx.obj["debug"] 352 | res = anvil.query(query, variables=variables, debug=debug) 353 | 354 | if contains_headers(res): 355 | res, headers = process_response(res) 356 | if debug: 357 | click.echo(headers) 358 | 359 | click.echo(res) 360 | 361 | 362 | if __name__ == "__main__": # pragma: no cover 363 | cli() # pylint: disable=no-value-for-parameter 364 | -------------------------------------------------------------------------------- /python_anvil/constants.py: -------------------------------------------------------------------------------- 1 | """Basic constants used in the library.""" 2 | 3 | GRAPHQL_ENDPOINT: str = "https://graphql.useanvil.com" 4 | REST_ENDPOINT = "https://app.useanvil.com/api/v1" 5 | ANVIL_HOST = "https://app.useanvil.com" 6 | 7 | VALID_HOSTS = [ 8 | ANVIL_HOST, 9 | REST_ENDPOINT, 10 | GRAPHQL_ENDPOINT, 11 | ] 12 | 13 | RETRIES_LIMIT = 5 14 | REQUESTS_LIMIT = { 15 | "dev": { 16 | "calls": 2, 17 | "seconds": 1, 18 | }, 19 | "prod": { 20 | "calls": 40, 21 | "seconds": 1, 22 | }, 23 | } 24 | 25 | RATELIMIT_ENV = "dev" 26 | -------------------------------------------------------------------------------- /python_anvil/exceptions.py: -------------------------------------------------------------------------------- 1 | class AnvilException(BaseException): 2 | pass 3 | 4 | 5 | class AnvilRequestException(AnvilException): 6 | pass 7 | -------------------------------------------------------------------------------- /python_anvil/http.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # import json 4 | import requests 5 | from base64 import b64encode 6 | from gql import Client 7 | from gql.dsl import DSLSchema 8 | from gql.transport.requests import RequestsHTTPTransport 9 | from logging import getLogger 10 | from ratelimit import limits, sleep_and_retry 11 | from ratelimit.exception import RateLimitException 12 | from requests.auth import HTTPBasicAuth 13 | from typing import Optional 14 | 15 | from python_anvil.exceptions import AnvilRequestException 16 | 17 | from .constants import GRAPHQL_ENDPOINT, RATELIMIT_ENV, REQUESTS_LIMIT, RETRIES_LIMIT 18 | 19 | 20 | logger = getLogger(__name__) 21 | 22 | 23 | def _handle_request_error(e: Exception): 24 | raise e 25 | 26 | 27 | def get_local_schema(raise_on_error=False) -> Optional[str]: 28 | """ 29 | Retrieve local GraphQL schema. 30 | 31 | :param raise_on_error: 32 | :return: 33 | """ 34 | try: 35 | file_dir = os.path.dirname(os.path.realpath(__file__)) 36 | file_path = os.path.join(file_dir, "..", "schema", "anvil_schema.graphql") 37 | with open(file_path, encoding="utf-8") as file: 38 | schema = file.read() 39 | except Exception: # pylint: disable 40 | logger.warning( 41 | "Unable to find local schema. Will not use schema for local " 42 | "validation. Use `fetch_schema_from_transport=True` to allow " 43 | "fetching the remote schema." 44 | ) 45 | if raise_on_error: 46 | raise 47 | schema = None 48 | 49 | return schema 50 | 51 | 52 | def get_gql_ds(client: Client) -> DSLSchema: 53 | if not client.schema: 54 | raise ValueError("Client does not have a valid GraphQL schema.") 55 | return DSLSchema(client.schema) 56 | 57 | 58 | # FIXME: when gql 3.6.0 is stable, we can use this to paper over pre-graphql 59 | # handler errors. https://github.com/graphql-python/gql/releases/tag/v3.6.0b1 60 | # def json_deserialize (response_text): 61 | # try: 62 | # return json.loads(response_text) 63 | # except Exception as e: 64 | # return {"errors": [{"message": response_text}]} 65 | 66 | 67 | class GQLClient: 68 | """GraphQL client factory class.""" 69 | 70 | @staticmethod 71 | def get_client( 72 | api_key: str, 73 | environment: str = "dev", # pylint: disable=unused-argument 74 | endpoint_url: Optional[str] = None, 75 | fetch_schema_from_transport: bool = False, 76 | force_local_schema: bool = False, 77 | ) -> Client: 78 | auth = HTTPBasicAuth(username=api_key, password="") 79 | endpoint_url = endpoint_url or GRAPHQL_ENDPOINT 80 | transport = RequestsHTTPTransport( 81 | retries=RETRIES_LIMIT, 82 | auth=auth, 83 | url=endpoint_url, 84 | verify=True, 85 | # FIXME: when gql 3.6.0 is stable... see note above 86 | # json_deserialize=json_deserialize, 87 | ) 88 | 89 | schema = None 90 | if force_local_schema or not fetch_schema_from_transport: 91 | schema = get_local_schema(raise_on_error=False) 92 | 93 | return Client( 94 | schema=schema, 95 | transport=transport, 96 | fetch_schema_from_transport=fetch_schema_from_transport, 97 | ) 98 | 99 | 100 | class HTTPClient: 101 | def __init__(self, api_key=None, environment="dev"): 102 | self._session = requests.Session() 103 | self.api_key = api_key 104 | global RATELIMIT_ENV # pylint: disable=global-statement 105 | RATELIMIT_ENV = environment 106 | 107 | def get_auth(self, encode=False) -> str: 108 | # TODO: Handle OAuth + API_KEY 109 | if not self.api_key: 110 | raise AttributeError("You must have an API key") 111 | 112 | # By default, the `requests` package will base64encode things with 113 | # the `HTTPBasicAuth` method, so no need to handle that here, but the 114 | # option is here if you _really_ want it. 115 | if encode: 116 | return b64encode(f"{self.api_key}:".encode()).decode() 117 | 118 | return self.api_key 119 | 120 | @sleep_and_retry 121 | @limits( 122 | calls=REQUESTS_LIMIT[RATELIMIT_ENV]["calls"], 123 | period=REQUESTS_LIMIT[RATELIMIT_ENV]["seconds"], 124 | ) 125 | def do_request( 126 | self, 127 | method, 128 | url, 129 | headers=None, 130 | data=None, 131 | auth=None, 132 | params=None, 133 | retry=True, 134 | files=None, 135 | **kwargs, 136 | ) -> requests.Response: 137 | for _ in range(5): 138 | # Retry a max of 5 times in case of hitting any rate limit errors 139 | res = self._session.request( 140 | method, 141 | url, 142 | headers=headers, 143 | data=data, 144 | auth=auth, 145 | params=params, 146 | files=files, 147 | **kwargs, 148 | ) 149 | 150 | if res.status_code == 429: 151 | time_to_wait = int(res.headers.get("Retry-After", 1)) 152 | if retry: 153 | logger.warning( 154 | "Rate-limited: request not accepted. Retrying in " 155 | "%i second%s.", 156 | time_to_wait, 157 | 's' if time_to_wait > 1 else '', 158 | ) 159 | 160 | # This exception will raise up to the `sleep_and_retry` decorator 161 | # which will handle waiting for `time_to_wait` seconds. 162 | raise RateLimitException("Retrying", period_remaining=time_to_wait) 163 | 164 | raise AnvilRequestException( 165 | f"Rate limit exceeded. Retry after {time_to_wait} seconds." 166 | ) 167 | 168 | break 169 | 170 | return res 171 | 172 | def request( 173 | self, 174 | method, 175 | url, 176 | headers=None, 177 | data=None, 178 | auth=None, 179 | params=None, 180 | retry=True, 181 | files=None, 182 | **kwargs, 183 | ): 184 | """Make an HTTP request. 185 | 186 | :param method: HTTP method to use 187 | :param url: URL to make the request on. 188 | :param headers: 189 | :param data: 190 | :param auth: 191 | :param params: 192 | :param files: 193 | :param retry: Whether to retry on any rate-limited requests 194 | :param kwargs: 195 | :return: 196 | """ 197 | parse_json = kwargs.pop("parse_json", False) 198 | if self.api_key and not auth: 199 | auth = HTTPBasicAuth(self.get_auth(), "") 200 | 201 | try: 202 | res = self.do_request( 203 | method, 204 | url, 205 | headers=headers, 206 | data=data, 207 | auth=auth, 208 | params=params, 209 | retry=retry, 210 | files=files, 211 | **kwargs, 212 | ) 213 | 214 | if parse_json and res.headers.get("Content-Type") == "application/json": 215 | content = res.json() 216 | else: 217 | # This actually reads the content and can potentially cause issues 218 | # depending on the content. 219 | # The structure of this method is very similar to Stripe's requests 220 | # HTTP client: https://github.com/stripe/stripe-python/blob/afa872c538bee0a1e14c8e131df52dd3c24ff05a/stripe/http_client.py#L304-L308 221 | content = res.content 222 | status_code = res.status_code 223 | except Exception as e: # pylint: disable=broad-except 224 | _handle_request_error(e) 225 | 226 | return content, status_code, res.headers 227 | -------------------------------------------------------------------------------- /python_anvil/models.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | from io import BufferedReader, BytesIO 4 | from mimetypes import guess_type 5 | from pydantic import BaseModel, ConfigDict 6 | 7 | from python_anvil.api_resources.base import underscore_to_camel 8 | 9 | 10 | class FileCompatibleBaseModel(BaseModel): 11 | """ 12 | Patched model_dump to extract file objects from SerializationIterator. 13 | 14 | Becaus of Pydantic V2. Return as BufferedReader or base64 encoded dict as needed. 15 | """ 16 | 17 | # Allow extra fields even if it is not defined. This will allow models 18 | # to be more flexible if features are added in the Anvil API, but 19 | # explicit support hasn't been added yet to this library. 20 | model_config = ConfigDict( 21 | alias_generator=underscore_to_camel, populate_by_name=True, extra="allow" 22 | ) 23 | 24 | def _iterator_to_buffered_reader(self, value): 25 | content = bytearray() 26 | try: 27 | while True: 28 | content.extend(next(value)) 29 | except StopIteration: 30 | # Create a BytesIO with the content 31 | bio = BytesIO(bytes(content)) 32 | # Create a BufferedReader with the content 33 | reader = BufferedReader(bio) # type: ignore[arg-type] 34 | return reader 35 | 36 | def _check_if_serialization_iterator(self, value): 37 | return str(type(value).__name__) == 'SerializationIterator' and hasattr( 38 | value, '__next__' 39 | ) 40 | 41 | def _process_file_data(self, file_obj): 42 | """Process file object into base64 encoded dict format.""" 43 | # Read the file data and encode it as base64 44 | file_content = file_obj.read() 45 | 46 | # Get filename - handle both regular files and BytesIO objects 47 | filename = getattr(file_obj, 'name', "document.pdf") 48 | 49 | if isinstance(filename, (bytes, bytearray)): 50 | filename = filename.decode('utf-8') 51 | 52 | # manage mimetype based on file extension 53 | mimetype = guess_type(filename)[0] or 'application/pdf' 54 | 55 | return { 56 | 'data': base64.b64encode(file_content).decode('utf-8'), 57 | 'mimetype': mimetype, 58 | 'filename': os.path.basename(filename), 59 | } 60 | 61 | def model_dump(self, **kwargs): 62 | data = super().model_dump(**kwargs) 63 | for key, value in data.items(): 64 | if key == 'file' and self._check_if_serialization_iterator(value): 65 | # Direct file case 66 | file_obj = self._iterator_to_buffered_reader(value) 67 | data[key] = self._process_file_data(file_obj) 68 | elif key == 'files' and isinstance(value, list): 69 | # List of objects case 70 | for index, item in enumerate(value): 71 | if isinstance(item, dict) and 'file' in item: 72 | if self._check_if_serialization_iterator(item['file']): 73 | file_obj = self._iterator_to_buffered_reader(item['file']) 74 | data[key][index]['file'] = self._process_file_data(file_obj) 75 | return data 76 | -------------------------------------------------------------------------------- /python_anvil/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. 2 | -------------------------------------------------------------------------------- /python_anvil/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the package.""" 2 | -------------------------------------------------------------------------------- /python_anvil/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Unit tests configuration file.""" 2 | 3 | import log 4 | 5 | 6 | def pytest_configure(config): 7 | """Disable verbose output when running tests.""" 8 | log.init(debug=True) 9 | 10 | terminal = config.pluginmanager.getplugin('terminal') 11 | terminal.TerminalReporter.showfspath = False 12 | -------------------------------------------------------------------------------- /python_anvil/tests/payloads.py: -------------------------------------------------------------------------------- 1 | ETCH_TEST_PAYLOAD = dict( 2 | name="Packet name", 3 | signature_email_subject="The subject", 4 | signers=[ 5 | dict( 6 | name="Joe Doe", 7 | email="joe@example.com", 8 | fields=[dict(fileId="existingCast", fieldId="signMe")], 9 | ) 10 | ], 11 | files=[ 12 | dict( 13 | id="someFile", 14 | title="Sign This", 15 | file=dict( 16 | data="Some Base64 Thing", 17 | filename="someFile.pdf", 18 | mimetype="application/pdf", 19 | ), 20 | fields=[ 21 | dict( 22 | id="signField", 23 | type="signature", 24 | pageNum=0, 25 | rect=dict(x=100, y=100, width=100, height=100), 26 | ) 27 | ], 28 | ) 29 | ], 30 | ) 31 | 32 | EXPECTED_FILES = [ 33 | { 34 | 'id': 'someFile', 35 | 'title': 'Sign This', 36 | 'file': { 37 | 'data': 'Some Base64 Thing', 38 | 'filename': 'someFile.pdf', 39 | 'mimetype': 'application/pdf', 40 | }, 41 | 'fields': [ 42 | { 43 | 'id': 'signField', 44 | 'type': 'signature', 45 | 'pageNum': 0, 46 | 'rect': {'x': 100, 'y': 100, 'width': 100, 'height': 100}, 47 | } 48 | ], 49 | 'fontSize': 14, 50 | 'textColor': '#000000', 51 | } 52 | ] 53 | 54 | EXPECTED_ETCH_TEST_PAYLOAD = { 55 | 'name': 'Packet name', 56 | 'signatureEmailSubject': 'The subject', 57 | 'signers': [ 58 | { 59 | 'name': 'Joe Doe', 60 | 'email': 'joe@example.com', 61 | 'fields': [{'fileId': 'existingCast', 'fieldId': 'signMe'}], 62 | 'id': 'signer-mock-generated', 63 | 'routingOrder': 1, 64 | 'signerType': 'email', 65 | } 66 | ], 67 | 'isDraft': False, 68 | 'isTest': True, 69 | 'data': {'payloads': {}}, 70 | 'signaturePageOptions': {}, 71 | 'files': EXPECTED_FILES, 72 | } 73 | 74 | EXPECTED_ETCH_TEST_PAYLOAD_JSON = { 75 | 'name': 'Packet name', 76 | 'signatureEmailSubject': 'The subject', 77 | 'signers': [ 78 | { 79 | 'name': 'Joe Doe', 80 | 'email': 'joe@example.com', 81 | 'fields': [{'fileId': 'existingCast', 'fieldId': 'signMe'}], 82 | 'id': '', 83 | 'routingOrder': 1, 84 | 'signerType': 'email', 85 | } 86 | ], 87 | 'isDraft': False, 88 | 'isTest': True, 89 | 'data': None, 90 | 'signaturePageOptions': None, 91 | 'files': EXPECTED_FILES, 92 | } 93 | -------------------------------------------------------------------------------- /python_anvil/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned 2 | 3 | import json 4 | import pytest 5 | from click.testing import CliRunner 6 | from unittest import mock 7 | 8 | from python_anvil.cli import cli 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | return CliRunner() 14 | 15 | 16 | def set_key(monkeypatch): 17 | monkeypatch.setenv("ANVIL_API_KEY", "MY_KEY") 18 | 19 | 20 | def describe_cli(): 21 | @mock.patch("python_anvil.api.Anvil.get_current_user") 22 | def it_handles_no_key(anvil, runner): 23 | res = runner.invoke(cli, ["current-user"]) 24 | assert anvil.call_count == 0 25 | assert isinstance(res.exception, ValueError) 26 | 27 | @mock.patch("python_anvil.api.Anvil.get_current_user") 28 | def it_handles_key(anvil, runner, monkeypatch): 29 | set_key(monkeypatch) 30 | res = runner.invoke(cli, ["current-user"]) 31 | assert anvil.call_count == 1 32 | assert not isinstance(res.exception, ValueError) 33 | 34 | def describe_current_user(): 35 | @mock.patch("python_anvil.api.Anvil.query") 36 | def it_queries(query, runner, monkeypatch): 37 | set_key(monkeypatch) 38 | query.return_value = {"currentUser": {"name": "Cameron"}} 39 | 40 | res = runner.invoke(cli, ['current-user']) 41 | assert "{'name': 'Cameron'}" in res.output 42 | assert "User data:" in res.output 43 | assert query.call_count == 1 44 | 45 | @mock.patch("python_anvil.api.Anvil.query") 46 | def it_handles_headers(query, runner, monkeypatch): 47 | set_key(monkeypatch) 48 | query.return_value = { 49 | "response": {"currentUser": {"name": "Cameron"}}, 50 | "headers": {"Header-1": "val1", "Header-2": "val2"}, 51 | } 52 | 53 | res = runner.invoke(cli, ['--debug', 'current-user']) 54 | assert "{'name': 'Cameron'}" in res.output 55 | assert "User data:" in res.output 56 | assert "{'Header-1': 'val1'," in res.output 57 | assert query.call_count == 1 58 | 59 | def describe_generate_pdf(): 60 | @mock.patch("python_anvil.api.Anvil.generate_pdf") 61 | def it_handles_files(generate_pdf, runner, monkeypatch): 62 | set_key(monkeypatch) 63 | 64 | in_data = json.dumps({"data": "", "title": "My Title"}) 65 | generate_pdf.return_value = "Some bytes" 66 | mock_open = mock.mock_open(read_data=in_data) 67 | 68 | with mock.patch("click.open_file", mock_open) as m: 69 | res = runner.invoke( 70 | cli, ['generate-pdf', '-i', 'infile', '-o', 'outfile'] 71 | ) 72 | generate_pdf.assert_called_once_with(in_data, debug=False) 73 | m().write.assert_called_once_with("Some bytes") 74 | 75 | def describe_gql_query(): 76 | @mock.patch("python_anvil.api.Anvil.query") 77 | def it_works_query_only(query, runner, monkeypatch): 78 | set_key(monkeypatch) 79 | 80 | query.return_value = dict( 81 | eid="abc123", 82 | name="Some User", 83 | ) 84 | 85 | query_str = """ 86 | query SomeQuery { 87 | someQuery { eid name } 88 | } 89 | """ 90 | 91 | runner.invoke(cli, ['gql-query', '-q', query_str]) 92 | query.assert_called_once_with(query_str, variables=None, debug=False) 93 | 94 | @mock.patch("python_anvil.api.Anvil.query") 95 | def it_works_query_and_variables(query, runner, monkeypatch): 96 | set_key(monkeypatch) 97 | 98 | query.return_value = dict( 99 | eid="abc123", 100 | name="Some User", 101 | ) 102 | 103 | query_str = """ 104 | query SomeQuery ($eid: String) { 105 | someQuery(eid: $eid) { eid name } 106 | } 107 | """ 108 | 109 | variables = json.dumps(dict(eid="abc123")) 110 | 111 | runner.invoke(cli, ['gql-query', '-q', query_str, '-v', variables]) 112 | query.assert_called_once_with(query_str, variables=variables, debug=False) 113 | -------------------------------------------------------------------------------- /python_anvil/tests/test_http.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned,singleton-comparison 2 | import pytest 3 | from typing import Dict 4 | from unittest import mock 5 | 6 | from python_anvil.exceptions import AnvilRequestException 7 | from python_anvil.http import HTTPClient 8 | 9 | 10 | class HTTPResponse: 11 | status_code = 200 12 | content = "" 13 | headers: Dict = {} 14 | 15 | 16 | def describe_http_client(): 17 | @pytest.fixture 18 | def mock_response(): 19 | class MockResponse: 20 | status_code = 429 21 | headers = {"Retry-After": 1} 22 | 23 | return MockResponse 24 | 25 | def test_client(): 26 | client = HTTPClient() 27 | assert isinstance(client, HTTPClient) 28 | 29 | def describe_get_auth(): 30 | def test_no_key(): 31 | """Test that no key will raise an exception.""" 32 | client = HTTPClient() 33 | with pytest.raises(AttributeError): 34 | client.get_auth() 35 | 36 | def test_key(): 37 | key = "my_secret_ket!!!11!!" 38 | client = HTTPClient(api_key=key) 39 | assert client.get_auth() == key 40 | 41 | @mock.patch('python_anvil.http.b64encode') 42 | def test_encoded_key(mock_b64): 43 | key = "my_secret_ket!!!11!!" 44 | client = HTTPClient(api_key=key) 45 | client.get_auth(encode=True) 46 | mock_b64.assert_called_once() 47 | 48 | def describe_request(): 49 | @mock.patch("python_anvil.http.HTTPBasicAuth") 50 | @mock.patch("python_anvil.http.HTTPClient.do_request") 51 | def test_default_args(do_request, basic_auth): 52 | basic_auth.return_value = "my_auth" 53 | response = HTTPResponse() 54 | do_request.return_value = response 55 | 56 | client = HTTPClient(api_key="my_key") 57 | res = client.request("GET", "http://localhost") 58 | 59 | assert res == (response.content, response.status_code, response.headers) 60 | do_request.assert_called_once_with( 61 | "GET", 62 | "http://localhost", 63 | headers=None, 64 | data=None, 65 | auth="my_auth", 66 | params=None, 67 | retry=True, 68 | files=None, 69 | ) 70 | 71 | def describe_do_request(): 72 | @mock.patch("python_anvil.http.requests.Session") 73 | def test_default_args(session): 74 | mock_session = mock.MagicMock() 75 | session.return_value = mock_session 76 | client = HTTPClient(api_key="my_key") 77 | client.do_request("GET", "http://localhost") 78 | 79 | # Should only be called once, never retried. 80 | mock_session.request.assert_called_once_with( 81 | "GET", 82 | "http://localhost", 83 | headers=None, 84 | data=None, 85 | auth=None, 86 | params=None, 87 | files=None, 88 | ) 89 | 90 | @mock.patch("python_anvil.http.RateLimitException") 91 | @mock.patch("python_anvil.http.requests.Session") 92 | def test_default_args_with_retry(session, ratelimit_exc, mock_response): 93 | class MockException(Exception): 94 | pass 95 | 96 | mock_session = mock.MagicMock() 97 | mock_session.request.return_value = mock_response() 98 | session.return_value = mock_session 99 | ratelimit_exc.return_value = MockException() 100 | 101 | client = HTTPClient(api_key="my_key") 102 | with pytest.raises(MockException): 103 | client.do_request("GET", "http://localhost", retry=True) 104 | 105 | assert ratelimit_exc.call_count == 1 106 | 107 | # Should only be called once, would retry but RateLimitException 108 | # is mocked here. 109 | mock_session.request.assert_called_once_with( 110 | "GET", 111 | "http://localhost", 112 | headers=None, 113 | data=None, 114 | auth=None, 115 | params=None, 116 | files=None, 117 | ) 118 | 119 | @mock.patch("python_anvil.http.RateLimitException") 120 | @mock.patch("python_anvil.http.requests.Session") 121 | def test_default_args_without_retry(session, ratelimit_exc, mock_response): 122 | class MockException(Exception): 123 | pass 124 | 125 | mock_session = mock.MagicMock() 126 | mock_session.request.return_value = mock_response() 127 | session.return_value = mock_session 128 | ratelimit_exc.return_value = MockException() 129 | 130 | client = HTTPClient(api_key="my_key") 131 | with pytest.raises(AnvilRequestException): 132 | client.do_request("GET", "http://localhost", retry=False) 133 | 134 | assert ratelimit_exc.call_count == 0 135 | 136 | # Should only be called once, never retried. 137 | mock_session.request.assert_called_once_with( 138 | "GET", 139 | "http://localhost", 140 | headers=None, 141 | data=None, 142 | auth=None, 143 | params=None, 144 | files=None, 145 | ) 146 | -------------------------------------------------------------------------------- /python_anvil/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pytest 3 | from pydantic import BaseModel 4 | from typing import Any, List, Optional 5 | 6 | from python_anvil.models import FileCompatibleBaseModel 7 | 8 | 9 | def test_file_compat_base_model_handles_regular_data(): 10 | class TestModel(FileCompatibleBaseModel): 11 | name: str 12 | value: int 13 | 14 | model = TestModel(name="test", value=42) 15 | data = model.model_dump() 16 | assert data == {"name": "test", "value": 42} 17 | 18 | 19 | def test_file_compat_base_model_preserves_file_objects(): 20 | class FileModel(FileCompatibleBaseModel): 21 | file: Any = None 22 | 23 | # Create a test file object 24 | with open(__file__, 'rb') as test_file: 25 | model = FileModel(file=test_file) 26 | data = model.model_dump() 27 | 28 | # Verify we got a dictionary with the expected structure 29 | assert isinstance(data['file'], dict) 30 | assert 'data' in data['file'] 31 | assert 'mimetype' in data['file'] 32 | assert 'filename' in data['file'] 33 | 34 | # Verify the content matches 35 | with open(__file__, 'rb') as original_file: 36 | original_content = original_file.read() 37 | decoded_content = base64.b64decode(data['file']['data'].encode('utf-8')) 38 | assert ( 39 | decoded_content == original_content 40 | ), "File content should match original" 41 | 42 | 43 | def test_file_compat_base_model_validates_types(): 44 | class TestModel(FileCompatibleBaseModel): 45 | name: str 46 | age: int 47 | 48 | # Should work with valid types 49 | model = TestModel(name="Alice", age=30) 50 | assert model.name == "Alice" 51 | assert model.age == 30 52 | 53 | # Should raise validation error for wrong types 54 | with pytest.raises(ValueError): 55 | TestModel(name="Alice", age="thirty") 56 | 57 | 58 | def test_file_compat_base_model_handles_optional_fields(): 59 | class TestModel(FileCompatibleBaseModel): 60 | required: str 61 | optional: Optional[str] = None 62 | 63 | # Should work with just required field 64 | model = TestModel(required="test") 65 | assert model.required == "test" 66 | assert model.optional is None 67 | 68 | # Should work with both fields 69 | model = TestModel(required="test", optional="present") 70 | assert model.optional == "present" 71 | 72 | 73 | def test_file_compat_base_model_handles_nested_models(): 74 | class NestedModel(BaseModel): 75 | value: str 76 | 77 | class ParentModel(FileCompatibleBaseModel): 78 | nested: NestedModel 79 | 80 | nested = NestedModel(value="test") 81 | model = ParentModel(nested=nested) 82 | 83 | data = model.model_dump() 84 | assert data == {"nested": {"value": "test"}} 85 | 86 | 87 | def test_file_compat_base_model_handles_lists(): 88 | class TestModel(FileCompatibleBaseModel): 89 | items: List[str] 90 | 91 | model = TestModel(items=["a", "b", "c"]) 92 | data = model.model_dump() 93 | assert data == {"items": ["a", "b", "c"]} 94 | 95 | 96 | def test_document_upload_handles_file_objects(): 97 | # pylint: disable-next=import-outside-toplevel 98 | from python_anvil.api_resources.payload import DocumentUpload, SignatureField 99 | 100 | # Create a sample signature field 101 | field = SignatureField( 102 | id="sig1", 103 | type="signature", 104 | page_num=1, 105 | rect={"x": 100.0, "y": 100.0, "width": 100.0}, 106 | ) 107 | 108 | # Test with a file object 109 | with open(__file__, 'rb') as test_file: 110 | doc = DocumentUpload( 111 | id="doc1", title="Test Document", file=test_file, fields=[field] 112 | ) 113 | 114 | data = doc.model_dump() 115 | 116 | # Verify file is converted to expected dictionary format 117 | assert isinstance(data['file'], dict) 118 | assert 'data' in data['file'] 119 | assert 'mimetype' in data['file'] 120 | assert 'filename' in data['file'] 121 | 122 | # Verify content matches 123 | with open(__file__, 'rb') as original_file: 124 | original_content = original_file.read() 125 | decoded_content = base64.b64decode(data['file']['data'].encode('utf-8')) 126 | assert decoded_content == original_content 127 | 128 | # Verify other fields are correct 129 | assert data['id'] == "doc1" 130 | assert data['title'] == "Test Document" 131 | assert len(data['fields']) == 1 132 | assert data['fields'][0]['id'] == "sig1" 133 | 134 | 135 | def test_create_etch_packet_payload_handles_nested_file_objects(): 136 | # pylint: disable-next=import-outside-toplevel 137 | from python_anvil.api_resources.payload import ( 138 | CreateEtchPacketPayload, 139 | DocumentUpload, 140 | EtchSigner, 141 | SignatureField, 142 | ) 143 | 144 | # Create a sample signature field 145 | field = SignatureField( 146 | id="sig1", 147 | type="signature", 148 | page_num=1, 149 | rect={"x": 100.0, "y": 100.0, "width": 100.0}, 150 | ) 151 | 152 | # Create a signer 153 | signer = EtchSigner( 154 | name="Test Signer", 155 | email="test@example.com", 156 | fields=[{"file_id": "doc1", "field_id": "sig1"}], 157 | ) 158 | 159 | # Test with a file object 160 | with open(__file__, 'rb') as test_file: 161 | # Create a DocumentUpload instance 162 | doc = DocumentUpload( 163 | id="doc1", title="Test Document", file=test_file, fields=[field] 164 | ) 165 | 166 | # Create the packet payload 167 | packet = CreateEtchPacketPayload( 168 | name="Test Packet", signers=[signer], files=[doc], is_test=True 169 | ) 170 | 171 | # Dump the model 172 | data = packet.model_dump() 173 | 174 | # Verify the structure 175 | assert data['name'] == "Test Packet" 176 | assert len(data['files']) == 1 177 | assert len(data['signers']) == 1 178 | 179 | # Verify file handling in the nested DocumentUpload 180 | file_data = data['files'][0] 181 | assert file_data['id'] == "doc1" 182 | assert file_data['title'] == "Test Document" 183 | assert isinstance(file_data['file'], dict) 184 | assert 'data' in file_data['file'] 185 | assert 'mimetype' in file_data['file'] 186 | assert 'filename' in file_data['file'] 187 | 188 | # Verify content matches 189 | with open(__file__, 'rb') as original_file: 190 | original_content = original_file.read() 191 | decoded_content = base64.b64decode( 192 | file_data['file']['data'].encode('utf-8') 193 | ) 194 | assert decoded_content == original_content 195 | 196 | 197 | def test_create_etch_packet_payload_handles_multiple_files(): 198 | # pylint: disable-next=import-outside-toplevel 199 | from python_anvil.api_resources.payload import ( 200 | CreateEtchPacketPayload, 201 | DocumentUpload, 202 | EtchSigner, 203 | SignatureField, 204 | ) 205 | 206 | # Create signature fields 207 | field1 = SignatureField( 208 | id="sig1", 209 | type="signature", 210 | page_num=1, 211 | rect={"x": 100.0, "y": 100.0, "width": 100.0}, 212 | ) 213 | 214 | field2 = SignatureField( 215 | id="sig2", 216 | type="signature", 217 | page_num=1, 218 | rect={"x": 200.0, "y": 200.0, "width": 100.0}, 219 | ) 220 | 221 | signer = EtchSigner( 222 | name="Test Signer", 223 | email="test@example.com", 224 | fields=[ 225 | {"file_id": "doc1", "field_id": "sig1"}, 226 | {"file_id": "doc2", "field_id": "sig2"}, 227 | ], 228 | ) 229 | 230 | # Test with multiple file objects 231 | with open(__file__, 'rb') as test_file1, open(__file__, 'rb') as test_file2: 232 | doc1 = DocumentUpload( 233 | id="doc1", title="Test Document 1", file=test_file1, fields=[field1] 234 | ) 235 | 236 | doc2 = DocumentUpload( 237 | id="doc2", title="Test Document 2", file=test_file2, fields=[field2] 238 | ) 239 | 240 | packet = CreateEtchPacketPayload( 241 | name="Test Packet", signers=[signer], files=[doc1, doc2], is_test=True 242 | ) 243 | 244 | data = packet.model_dump() 245 | 246 | # Verify structure 247 | assert len(data['files']) == 2 248 | 249 | # Verify both files are properly handled 250 | for i, file_data in enumerate(data['files'], 1): 251 | assert file_data['id'] == f"doc{i}" 252 | assert file_data['title'] == f"Test Document {i}" 253 | assert isinstance(file_data['file'], dict) 254 | assert 'data' in file_data['file'] 255 | assert 'mimetype' in file_data['file'] 256 | assert 'filename' in file_data['file'] 257 | 258 | # Verify content matches 259 | with open(__file__, 'rb') as original_file: 260 | original_content = original_file.read() 261 | decoded_content = base64.b64decode( 262 | file_data['file']['data'].encode('utf-8') 263 | ) 264 | assert decoded_content == original_content 265 | -------------------------------------------------------------------------------- /python_anvil/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned,singleton-comparison 2 | 3 | from python_anvil.utils import build_batch_filenames, camel_to_snake, create_unique_id 4 | 5 | 6 | def describe_build_batch_filenames(): 7 | def test_with_normal_filename(): 8 | gen = build_batch_filenames("somefile.txt") 9 | assert next(gen) == "somefile-0.txt" 10 | assert next(gen) == "somefile-1.txt" 11 | 12 | def test_with_start_index(): 13 | gen = build_batch_filenames("somefile.txt", start_idx=100) 14 | assert next(gen) == "somefile-100.txt" 15 | assert next(gen) == "somefile-101.txt" 16 | 17 | def test_with_separator(): 18 | gen = build_batch_filenames("somefile.txt", separator=":::") 19 | assert next(gen) == "somefile:::0.txt" 20 | 21 | def test_with_all(): 22 | gen = build_batch_filenames("somefile.txt", start_idx=555, separator=":::") 23 | assert next(gen) == "somefile:::555.txt" 24 | 25 | 26 | def describe_create_unique_id(): 27 | def test_no_prefix(): 28 | assert create_unique_id().startswith("field-") 29 | 30 | def test_prefix(): 31 | prefix = "somePrefix+++--" 32 | assert create_unique_id(prefix).startswith(prefix) 33 | 34 | 35 | def describe_camel_to_snake(): 36 | def test_it(): 37 | assert camel_to_snake('oneToTwo') == 'one_to_two' 38 | assert camel_to_snake('one') == 'one' 39 | assert camel_to_snake('TwoTwo') == 'two_two' 40 | -------------------------------------------------------------------------------- /python_anvil/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | from logging import getLogger 4 | from os import path 5 | 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | def build_batch_filenames(filename: str, start_idx=0, separator: str = "-"): 11 | """ 12 | Create a generator for filenames in sequential order. 13 | 14 | Example: 15 | build_batch_filenames('somefile.pdf') will yield filenames: 16 | * somefile-1.pdf 17 | * somefile-2.pdf 18 | * somefile-3.pdf 19 | :param filename: Full filename, including extension 20 | :param start_idx: Starting index number 21 | :param separator: 22 | :return: 23 | """ 24 | idx = start_idx or 0 25 | sep = separator or "-" 26 | file_part, ext = path.splitext(filename) 27 | 28 | while True: 29 | yield f"{file_part}{sep}{idx}{ext}" 30 | idx += 1 31 | 32 | 33 | def create_unique_id(prefix: str = "field") -> str: 34 | """Create a prefixed unique id.""" 35 | return f"{prefix}-{uuid.uuid4().hex}" 36 | 37 | 38 | def remove_empty_items(dict_obj: dict): 39 | """Remove null values from a dict.""" 40 | return {k: v for k, v in dict_obj.items() if v is not None} 41 | 42 | 43 | def camel_to_snake(name: str) -> str: 44 | return re.sub(r'(?=0.12 17 | twine 18 | commands = 19 | poetry build 20 | twine check dist/* 21 | --------------------------------------------------------------------------------