gformlib 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gformlib/__init__.py +70 -0
- gformlib/builder.py +230 -0
- gformlib/client.py +501 -0
- gformlib/exceptions.py +123 -0
- gformlib/models.py +187 -0
- gformlib/py.typed +0 -0
- gformlib/utils.py +173 -0
- gformlib-0.1.1.dist-info/METADATA +212 -0
- gformlib-0.1.1.dist-info/RECORD +12 -0
- gformlib-0.1.1.dist-info/WHEEL +5 -0
- gformlib-0.1.1.dist-info/licenses/LICENSE +21 -0
- gformlib-0.1.1.dist-info/top_level.txt +1 -0
gformlib/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""gformlib – Google Forms from JSON.
|
|
2
|
+
|
|
3
|
+
A Python library for creating and managing Google Forms programmatically,
|
|
4
|
+
wrapping the official `Google Forms API v1`_.
|
|
5
|
+
|
|
6
|
+
.. _Google Forms API v1: https://developers.google.com/forms/api/reference/rest
|
|
7
|
+
|
|
8
|
+
Quickstart::
|
|
9
|
+
|
|
10
|
+
from gformlib import GoogleFormsClient
|
|
11
|
+
|
|
12
|
+
client = GoogleFormsClient.from_service_account("service_account.json")
|
|
13
|
+
info = client.create_form(
|
|
14
|
+
{
|
|
15
|
+
"title": "My Survey",
|
|
16
|
+
"questions": [
|
|
17
|
+
{"title": "Your name", "type": "short_answer", "required": True},
|
|
18
|
+
{
|
|
19
|
+
"title": "Rating",
|
|
20
|
+
"type": "scale",
|
|
21
|
+
"low": 1,
|
|
22
|
+
"high": 5,
|
|
23
|
+
"low_label": "Poor",
|
|
24
|
+
"high_label": "Excellent",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
print(info.responder_uri)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from .builder import FormBuilder
|
|
33
|
+
from .client import DEFAULT_SCOPES, GoogleFormsClient
|
|
34
|
+
from .exceptions import (
|
|
35
|
+
APIError,
|
|
36
|
+
AuthenticationError,
|
|
37
|
+
FormCreationError,
|
|
38
|
+
FormUpdateError,
|
|
39
|
+
GFormLibError,
|
|
40
|
+
InvalidConfigError,
|
|
41
|
+
)
|
|
42
|
+
from .models import FormConfig, FormInfo, QuestionConfig, QuestionType
|
|
43
|
+
from .utils import parse_form_config, parse_question
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Client
|
|
47
|
+
"GoogleFormsClient",
|
|
48
|
+
"DEFAULT_SCOPES",
|
|
49
|
+
# Builder
|
|
50
|
+
"FormBuilder",
|
|
51
|
+
# Models
|
|
52
|
+
"FormConfig",
|
|
53
|
+
"FormInfo",
|
|
54
|
+
"QuestionConfig",
|
|
55
|
+
"QuestionType",
|
|
56
|
+
# Utils
|
|
57
|
+
"parse_form_config",
|
|
58
|
+
"parse_question",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"GFormLibError",
|
|
61
|
+
"AuthenticationError",
|
|
62
|
+
"FormCreationError",
|
|
63
|
+
"FormUpdateError",
|
|
64
|
+
"InvalidConfigError",
|
|
65
|
+
"APIError",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
|
69
|
+
__author__ = "Andhitia Rama"
|
|
70
|
+
__email__ = "andhitia.r@gmail.com"
|
gformlib/builder.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Google Forms API request builder.
|
|
2
|
+
|
|
3
|
+
This module converts a validated :class:`~gformlib.models.FormConfig` into
|
|
4
|
+
the JSON structures expected by the
|
|
5
|
+
`Google Forms REST API v1 <https://developers.google.com/forms/api/reference/rest>`_.
|
|
6
|
+
|
|
7
|
+
The main entry-point is :class:`FormBuilder`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from .models import FormConfig, QuestionConfig, QuestionType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FormBuilder:
|
|
18
|
+
"""Converts a :class:`~gformlib.models.FormConfig` into Google Forms API payloads.
|
|
19
|
+
|
|
20
|
+
This class is used internally by :class:`~gformlib.client.GoogleFormsClient`
|
|
21
|
+
but is also exposed publicly so that callers can inspect or further
|
|
22
|
+
customise the generated request bodies before submitting them.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
from gformlib.builder import FormBuilder
|
|
27
|
+
from gformlib.models import FormConfig, QuestionConfig, QuestionType
|
|
28
|
+
|
|
29
|
+
config = FormConfig(
|
|
30
|
+
title="Demo",
|
|
31
|
+
questions=[
|
|
32
|
+
QuestionConfig(
|
|
33
|
+
title="Favourite colour?",
|
|
34
|
+
question_type=QuestionType.SHORT_ANSWER,
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
builder = FormBuilder(config)
|
|
39
|
+
create_body = builder.build_create_body()
|
|
40
|
+
batch_body = builder.build_batch_update_body()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: FormConfig) -> None:
|
|
44
|
+
"""Initialise the builder with a validated form configuration.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: A :class:`~gformlib.models.FormConfig` instance.
|
|
48
|
+
Use :func:`~gformlib.utils.parse_form_config` to obtain
|
|
49
|
+
one from a raw dict.
|
|
50
|
+
"""
|
|
51
|
+
self._config = config
|
|
52
|
+
|
|
53
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
54
|
+
# Public helpers
|
|
55
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def build_create_body(self) -> Dict[str, Any]:
|
|
58
|
+
"""Build the request body for the ``forms().create()`` API call.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A dict suitable for passing as the ``body`` argument of
|
|
62
|
+
``service.forms().create(body=...)``. Contains only the form
|
|
63
|
+
title and document title; questions are added separately via
|
|
64
|
+
:meth:`build_batch_update_body`.
|
|
65
|
+
|
|
66
|
+
Example::
|
|
67
|
+
|
|
68
|
+
body = builder.build_create_body()
|
|
69
|
+
# {"info": {"title": "Demo", "documentTitle": "Demo"}}
|
|
70
|
+
"""
|
|
71
|
+
return {
|
|
72
|
+
"info": {
|
|
73
|
+
"title": self._config.title,
|
|
74
|
+
"documentTitle": self._config.document_title or self._config.title,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def build_batch_update_body(self) -> Dict[str, Any]:
|
|
79
|
+
"""Build the ``batchUpdate`` request body that adds all questions.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A dict suitable for passing as the ``body`` argument of
|
|
83
|
+
``service.forms().batchUpdate(formId=..., body=...)``.
|
|
84
|
+
Returns an empty ``{"requests": []}`` dict when the form has
|
|
85
|
+
no questions.
|
|
86
|
+
|
|
87
|
+
Example::
|
|
88
|
+
|
|
89
|
+
body = builder.build_batch_update_body()
|
|
90
|
+
# {"requests": [{"createItem": {...}}, ...]}
|
|
91
|
+
"""
|
|
92
|
+
requests: List[Dict[str, Any]] = []
|
|
93
|
+
|
|
94
|
+
# Optionally set the form description via an updateFormInfo request
|
|
95
|
+
if self._config.description:
|
|
96
|
+
requests.append(self._build_update_form_info_request())
|
|
97
|
+
|
|
98
|
+
for index, question in enumerate(self._config.questions):
|
|
99
|
+
requests.append(self._build_create_item_request(question, index))
|
|
100
|
+
|
|
101
|
+
return {"requests": requests}
|
|
102
|
+
|
|
103
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
104
|
+
# Private builders
|
|
105
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def _build_update_form_info_request(self) -> Dict[str, Any]:
|
|
108
|
+
"""Return a ``updateFormInfo`` mutation for the form description.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A single ``updateFormInfo`` request dict.
|
|
112
|
+
"""
|
|
113
|
+
return {
|
|
114
|
+
"updateFormInfo": {
|
|
115
|
+
"info": {"description": self._config.description},
|
|
116
|
+
"updateMask": "description",
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _build_create_item_request(self, question: QuestionConfig, index: int) -> Dict[str, Any]:
|
|
121
|
+
"""Return a ``createItem`` request for a single question.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
question: The question configuration to convert.
|
|
125
|
+
index: Zero-based insertion index (determines question order).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A ``createItem`` request dict.
|
|
129
|
+
"""
|
|
130
|
+
question_body = self._build_question_body(question)
|
|
131
|
+
|
|
132
|
+
item: Dict[str, Any] = {
|
|
133
|
+
"title": question.title,
|
|
134
|
+
"questionItem": {"question": question_body},
|
|
135
|
+
}
|
|
136
|
+
if question.description:
|
|
137
|
+
item["description"] = question.description
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"createItem": {
|
|
141
|
+
"item": item,
|
|
142
|
+
"location": {"index": index},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def _build_question_body(self, question: QuestionConfig) -> Dict[str, Any]:
|
|
147
|
+
"""Build the ``question`` sub-object inside a ``questionItem``.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
question: Source question configuration.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A ``question`` dict containing ``required`` and the
|
|
154
|
+
type-specific sub-key (e.g. ``textQuestion``,
|
|
155
|
+
``choiceQuestion``).
|
|
156
|
+
"""
|
|
157
|
+
body: Dict[str, Any] = {"required": question.required}
|
|
158
|
+
|
|
159
|
+
qtype = question.question_type
|
|
160
|
+
|
|
161
|
+
if qtype in (QuestionType.SHORT_ANSWER, QuestionType.PARAGRAPH):
|
|
162
|
+
body["textQuestion"] = {"paragraph": qtype == QuestionType.PARAGRAPH}
|
|
163
|
+
|
|
164
|
+
elif qtype in (
|
|
165
|
+
QuestionType.MULTIPLE_CHOICE,
|
|
166
|
+
QuestionType.CHECKBOXES,
|
|
167
|
+
QuestionType.DROPDOWN,
|
|
168
|
+
):
|
|
169
|
+
body["choiceQuestion"] = self._build_choice_question(question)
|
|
170
|
+
|
|
171
|
+
elif qtype == QuestionType.SCALE:
|
|
172
|
+
body["scaleQuestion"] = self._build_scale_question(question)
|
|
173
|
+
|
|
174
|
+
elif qtype == QuestionType.DATE:
|
|
175
|
+
body["dateQuestion"] = {
|
|
176
|
+
"includeTime": question.include_time,
|
|
177
|
+
"includeYear": question.include_year,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
elif qtype == QuestionType.TIME:
|
|
181
|
+
body["timeQuestion"] = {"duration": question.is_duration}
|
|
182
|
+
|
|
183
|
+
elif qtype == QuestionType.FILE_UPLOAD:
|
|
184
|
+
# FILE_UPLOAD requires the form to be in quiz mode and connected
|
|
185
|
+
# to Drive; we emit the minimal required payload here.
|
|
186
|
+
body["fileUploadQuestion"] = {}
|
|
187
|
+
|
|
188
|
+
return body
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _build_choice_question(question: QuestionConfig) -> Dict[str, Any]:
|
|
192
|
+
"""Build a ``choiceQuestion`` sub-object.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
question: Source question configuration. ``question.options``
|
|
196
|
+
must be a non-empty list.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A ``choiceQuestion`` dict.
|
|
200
|
+
"""
|
|
201
|
+
type_map = {
|
|
202
|
+
QuestionType.MULTIPLE_CHOICE: "RADIO",
|
|
203
|
+
QuestionType.CHECKBOXES: "CHECKBOX",
|
|
204
|
+
QuestionType.DROPDOWN: "DROP_DOWN",
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
"type": type_map[question.question_type],
|
|
208
|
+
"options": [{"value": opt} for opt in (question.options or [])],
|
|
209
|
+
"shuffle": question.shuffle_options,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _build_scale_question(question: QuestionConfig) -> Dict[str, Any]:
|
|
214
|
+
"""Build a ``scaleQuestion`` sub-object.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
question: Source question configuration.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
A ``scaleQuestion`` dict.
|
|
221
|
+
"""
|
|
222
|
+
payload: Dict[str, Any] = {
|
|
223
|
+
"low": question.low,
|
|
224
|
+
"high": question.high,
|
|
225
|
+
}
|
|
226
|
+
if question.low_label:
|
|
227
|
+
payload["lowLabel"] = question.low_label
|
|
228
|
+
if question.high_label:
|
|
229
|
+
payload["highLabel"] = question.high_label
|
|
230
|
+
return payload
|