├── examples
├── __init__.py
├── cli
│ ├── __init__.py
│ └── fill_pdf.csv
├── my_local_file.pdf
├── pdf
│ └── blank_8_5x11.pdf
├── forge_submit.py
├── create_workflow_submission.py
├── generate_pdf.py
├── fill_pdf.py
├── make_graphql_request.py
├── create_etch_existing_cast.py
├── create_etch_markdown.py
├── create_etch_markup.py
├── create_etch_upload_file.py
└── create_etch_upload_file_multipart.py
├── docs
├── index.md
├── about
│ ├── credits.md
│ ├── license.md
│ ├── changelog.md
│ └── contributing.md
├── requirements.txt
├── advanced
│ ├── dynamic_queries.md
│ └── create_etch_packet.md
├── cli_usage.md
└── api_usage.md
├── python_anvil
├── api_resources
│ ├── __init__.py
│ ├── mutations
│ │ ├── __init__.py
│ │ ├── helpers.py
│ │ ├── base.py
│ │ ├── generate_etch_signing_url.py
│ │ ├── forge_submit.py
│ │ └── create_etch_packet.py
│ ├── base.py
│ ├── requests.py
│ └── payload.py
├── py.typed
├── tests
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_utils.py
│ ├── payloads.py
│ ├── test_cli.py
│ ├── test_http.py
│ └── test_models.py
├── exceptions.py
├── __init__.py
├── constants.py
├── utils.py
├── models.py
├── http.py
├── cli.py
└── api.py
├── .gitattributes
├── .python-version
├── .mypy.ini
├── tests
├── conftest.py
├── __init__.py
└── test_cli.py
├── CREDITS.md
├── .scrutinizer.yml
├── pytest.ini
├── .coveragerc
├── .pre-commit-config.yaml
├── .isort.cfg
├── .verchew.ini
├── bin
├── open
├── checksum
├── update
└── verchew
├── tox.ini
├── .pydocstyle.ini
├── .readthedocs.yml
├── .travis.yml
├── mkdocs.yml
├── .appveyor.yml
├── .gitignore
├── .github
└── workflows
│ └── main.yml
├── LICENSE.md
├── CONTRIBUTING.md
├── README.md
├── scent.py
├── pyproject.toml
├── CHANGELOG.md
├── Makefile
└── .pylint.ini
/examples/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ../README.md
--------------------------------------------------------------------------------
/examples/cli/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/about/credits.md:
--------------------------------------------------------------------------------
1 | ../../CREDITS.md
--------------------------------------------------------------------------------
/docs/about/license.md:
--------------------------------------------------------------------------------
1 | ../../LICENSE.md
--------------------------------------------------------------------------------
/python_anvil/api_resources/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/about/changelog.md:
--------------------------------------------------------------------------------
1 | ../../CHANGELOG.md
--------------------------------------------------------------------------------
/docs/about/contributing.md:
--------------------------------------------------------------------------------
1 | ../../CONTRIBUTING.md
--------------------------------------------------------------------------------
/python_anvil/py.typed:
--------------------------------------------------------------------------------
1 | # Marker file for PEP 561.
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | CHANGELOG.md merge=union
3 |
--------------------------------------------------------------------------------
/python_anvil/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Unit tests for the package."""
2 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.8.13
2 | 3.9.12
3 | 3.10.4
4 | 3.11.11
5 | 3.12.8
6 | 3.13.7
--------------------------------------------------------------------------------
/examples/cli/fill_pdf.csv:
--------------------------------------------------------------------------------
1 | shortText,name,date
2 | Test short,Bobby Jones,2028-01-03
3 |
--------------------------------------------------------------------------------
/examples/my_local_file.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anvilco/python-anvil/HEAD/examples/my_local_file.pdf
--------------------------------------------------------------------------------
/examples/pdf/blank_8_5x11.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anvilco/python-anvil/HEAD/examples/pdf/blank_8_5x11.pdf
--------------------------------------------------------------------------------
/python_anvil/exceptions.py:
--------------------------------------------------------------------------------
1 | class AnvilException(BaseException):
2 | pass
3 |
4 |
5 | class AnvilRequestException(AnvilException):
6 | pass
7 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Integration tests configuration file."""
2 |
3 | # pylint: disable=unused-import
4 |
5 | from python_anvil.tests.conftest import pytest_configure
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Integration tests for the package."""
2 |
3 | try:
4 | from IPython.terminal.debugger import TerminalPdb as Debugger
5 | except ImportError:
6 | from pdb import Pdb as Debugger # type: ignore
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = true
3 | envlist = py38, py39, py310, py311, py312
4 |
5 | [testenv]
6 | whitelist_externals = poetry
7 | passenv = TESTS
8 | commands =
9 | poetry install -v
10 | poetry run pytest {env:TESTS}
11 |
12 | [testenv:package]
13 | description = check sdist and wheel
14 | skip_install = true
15 | deps =
16 | poetry>=0.12
17 | twine
18 | commands =
19 | poetry build
20 | twine check dist/*
21 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned
2 |
3 | import pytest
4 | from click.testing import CliRunner
5 |
6 | from python_anvil.cli import cli
7 |
8 |
9 | @pytest.fixture
10 | def runner():
11 | return CliRunner()
12 |
13 |
14 | def describe_cli():
15 | def describe_conversion():
16 | def when_integer(runner):
17 | result = runner.invoke(cli, ['42'])
18 | assert result
19 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Run linters and tests
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - "master"
7 |
8 | jobs:
9 | build:
10 |
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Install poetry
20 | run: pipx install poetry
21 |
22 | - name: Set up Python ${{ matrix.python-version }}
23 | uses: actions/setup-python@v4
24 | with:
25 | python-version: ${{ matrix.python-version }}
26 | cache: 'poetry'
27 |
28 | - name: Install poetry dependencies
29 | run: poetry install
30 |
31 | - name: Check dependencies
32 | run: make doctor
33 |
34 | - name: Install dependencies
35 | run: make install
36 |
37 | - name: Check code
38 | run: make check
39 |
40 | - name: Test code
41 | run: make test
42 |
43 | # - name: Upload coverage
44 | # uses: codecov/codecov-action@v1
45 | # with:
46 | # fail_ci_if_error: true
47 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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'(? 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 |
4 | # Anvil API Library
5 |
6 | [](https://pypi.org/project/python-anvil)
7 | [](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 |
--------------------------------------------------------------------------------
/scent.py:
--------------------------------------------------------------------------------
1 | """Configuration file for sniffer."""
2 |
3 | import time
4 | import subprocess
5 |
6 | from sniffer.api import select_runnable, file_validator, runnable
7 |
8 | try:
9 | from pync import Notifier
10 | except ImportError:
11 | notify = None
12 | else:
13 | notify = Notifier.notify
14 |
15 |
16 | watch_paths = ["python_anvil", "tests"]
17 |
18 |
19 | class Options:
20 | group = int(time.time()) # unique per run
21 | show_coverage = False
22 | rerun_args = None
23 |
24 | targets = [
25 | (('make', 'test-unit', 'DISABLE_COVERAGE=true'), "Unit Tests", True),
26 | (('make', 'test-all'), "Integration Tests", False),
27 | (('make', 'check'), "Static Analysis", True),
28 | (('make', 'docs'), None, True),
29 | ]
30 |
31 |
32 | @select_runnable('run_targets')
33 | @file_validator
34 | def python_files(filename):
35 | return filename.endswith('.py') and '.py.' not in filename
36 |
37 |
38 | @select_runnable('run_targets')
39 | @file_validator
40 | def html_files(filename):
41 | return filename.split('.')[-1] in ['html', 'css', 'js']
42 |
43 |
44 | @runnable
45 | def run_targets(*args):
46 | """Run targets for Python."""
47 | Options.show_coverage = 'coverage' in args
48 |
49 | count = 0
50 | for count, (command, title, retry) in enumerate(Options.targets, start=1):
51 |
52 | success = call(command, title, retry)
53 | if not success:
54 | message = "✅ " * (count - 1) + "❌"
55 | show_notification(message, title)
56 |
57 | return False
58 |
59 | message = "✅ " * count
60 | title = "All Targets"
61 | show_notification(message, title)
62 | show_coverage()
63 |
64 | return True
65 |
66 |
67 | def call(command, title, retry):
68 | """Run a command-line program and display the result."""
69 | if Options.rerun_args:
70 | command, title, retry = Options.rerun_args
71 | Options.rerun_args = None
72 | success = call(command, title, retry)
73 | if not success:
74 | return False
75 |
76 | print("")
77 | print("$ %s" % ' '.join(command))
78 | failure = subprocess.call(command)
79 |
80 | if failure and retry:
81 | Options.rerun_args = command, title, retry
82 |
83 | return not failure
84 |
85 |
86 | def show_notification(message, title):
87 | """Show a user notification."""
88 | if notify and title:
89 | notify(message, title=title, group=Options.group)
90 |
91 |
92 | def show_coverage():
93 | """Launch the coverage report."""
94 | if Options.show_coverage:
95 | subprocess.call(['make', 'read-coverage'])
96 |
97 | Options.show_coverage = False
98 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 |
3 | name = "python_anvil"
4 | version = "5.1.2"
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 | "Programming Language :: Python :: 3.13",
32 | "Topic :: Software Development :: Libraries :: Python Modules",
33 | ]
34 |
35 | [tool.poetry.dependencies]
36 |
37 | python = ">=3.8.0,<3.14"
38 |
39 | click = "^8.0"
40 | requests = "^2.28.2"
41 | ratelimit = "^2.2.1"
42 | tabulate = "^0.9.0"
43 | pydantic = "^2.8"
44 | gql = { version = "3.6.0b2", extras = ["requests"] }
45 |
46 | [tool.poetry.group.dev.dependencies]
47 |
48 | # Formatters
49 | black = "^24.8.0"
50 | isort = "^5.11.4"
51 |
52 | # Linters
53 | pydocstyle = "^6.3.0"
54 | pylint = "^3.0"
55 |
56 | # FIXME: Upgrading mypy will require updates to aliased fields. e.g.
57 | #
58 | # class EtchSigner(BaseModel):
59 | # redirect_url: Optional[str] = Field(None, alias="redirectURL")
60 | #
61 | # Not sure what the solution is.
62 | mypy = "1.0.1"
63 |
64 |
65 | # Testing
66 | pytest = "^7.2.1"
67 | pytest-cov = "^4.0.0"
68 | pytest-describe = "^2.0"
69 | pytest-random = "^0.2"
70 | freezegun = "*"
71 |
72 | # Reports
73 | coveragespace = "*"
74 |
75 | # Documentation
76 | mkdocs = "^1.4.2"
77 | pygments = "^2.14.0"
78 |
79 | # Tooling
80 | pyinstaller = "^6.15.0"
81 | sniffer = "*"
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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_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 |
--------------------------------------------------------------------------------