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 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