google-form-poster 0.1.1__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ env/
2
+ .env
3
+ data/
4
+ __pycache__/
5
+
6
+ # build artifacts
7
+ dist/
8
+ *.egg-info/
9
+ *.egg/
10
+ build/
11
+
12
+ # pytest
13
+ .pytest_cache/
14
+ .cache/
@@ -0,0 +1 @@
1
+ 3.12
File without changes
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: google-form-poster
3
+ Version: 0.1.1
4
+ Summary: Submit data rows to Google Forms via POST requests
5
+ Project-URL: Homepage, https://github.com/obabawale/google-form-poster.git
6
+ Project-URL: Source, https://github.com/obabawale/google-form-poster.git
7
+ Author-email: Olami <olami@example.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: automation,form-submission,google-forms
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: pandas>=2.0
20
+ Requires-Dist: python-dotenv>=1.0
21
+ Requires-Dist: requests>=2.31
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Google Form Poster
27
+
28
+ Submit data rows to a Google Form via POST requests.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install google-form-poster
34
+ ```
35
+
36
+ ## Usage — as a library
37
+
38
+ ```python
39
+ from post_to_google_form import post_data
40
+
41
+ url = "https://docs.google.com/forms/d/e/.../viewform"
42
+
43
+ # Data keys match entry IDs directly
44
+ data = [{"entry.12345": "Alice", "entry.67890": "alice@example.com"}]
45
+ results = post_data(url, data)
46
+
47
+ # Or use key_mappings to translate your keys
48
+ key_mappings = {"name": "entry.12345", "email": "entry.67890"}
49
+ data = [{"name": "Alice", "email": "alice@example.com"}]
50
+ results = post_data(url, data, key_mappings)
51
+
52
+ # results = [
53
+ # {"index": 0, "status_code": 200, "payload": {"entry.12345": "Alice", ...}},
54
+ # ]
55
+ ```
56
+
57
+ `forms.gle` short links are automatically resolved:
58
+
59
+ ```python
60
+ post_data("https://forms.gle/abc123", data)
61
+ ```
62
+
63
+ ## Usage — CLI
64
+
65
+ ```bash
66
+ post-to-google-form --file data.json --url https://forms.gle/abc123
67
+
68
+ # With key mappings:
69
+ post-to-google-form --file data.json --url https://forms.gle/abc123 \
70
+ --key-mappings mappings.json
71
+ ```
72
+
73
+ All flags fall back to environment variables (`FILE_PATH`, `GOOGLE_FORM_URL`, `KEY_MAPPINGS`), loaded from `.env` if present.
@@ -0,0 +1,48 @@
1
+ # Google Form Poster
2
+
3
+ Submit data rows to a Google Form via POST requests.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install google-form-poster
9
+ ```
10
+
11
+ ## Usage — as a library
12
+
13
+ ```python
14
+ from post_to_google_form import post_data
15
+
16
+ url = "https://docs.google.com/forms/d/e/.../viewform"
17
+
18
+ # Data keys match entry IDs directly
19
+ data = [{"entry.12345": "Alice", "entry.67890": "alice@example.com"}]
20
+ results = post_data(url, data)
21
+
22
+ # Or use key_mappings to translate your keys
23
+ key_mappings = {"name": "entry.12345", "email": "entry.67890"}
24
+ data = [{"name": "Alice", "email": "alice@example.com"}]
25
+ results = post_data(url, data, key_mappings)
26
+
27
+ # results = [
28
+ # {"index": 0, "status_code": 200, "payload": {"entry.12345": "Alice", ...}},
29
+ # ]
30
+ ```
31
+
32
+ `forms.gle` short links are automatically resolved:
33
+
34
+ ```python
35
+ post_data("https://forms.gle/abc123", data)
36
+ ```
37
+
38
+ ## Usage — CLI
39
+
40
+ ```bash
41
+ post-to-google-form --file data.json --url https://forms.gle/abc123
42
+
43
+ # With key mappings:
44
+ post-to-google-form --file data.json --url https://forms.gle/abc123 \
45
+ --key-mappings mappings.json
46
+ ```
47
+
48
+ All flags fall back to environment variables (`FILE_PATH`, `GOOGLE_FORM_URL`, `KEY_MAPPINGS`), loaded from `.env` if present.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "google-form-poster"
7
+ version = "0.1.1"
8
+ description = "Submit data rows to Google Forms via POST requests"
9
+ license = { text = "MIT" }
10
+ license-files = ["LICENSE"]
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ authors = [
14
+ { name = "Olami", email = "olami@example.com" },
15
+ ]
16
+ keywords = ["google-forms", "automation", "form-submission"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ ]
26
+ dependencies = [
27
+ "pandas>=2.0",
28
+ "requests>=2.31",
29
+ "python-dotenv>=1.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/obabawale/google-form-poster.git"
34
+ Source = "https://github.com/obabawale/google-form-poster.git"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8.0"]
38
+
39
+ [project.scripts]
40
+ post-to-google-form = "post_to_google_form.cli:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/post_to_google_form"]
44
+
45
+ [tool.hatch.metadata]
46
+ allow-direct-references = true
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ from post_to_google_form._submit import build_payload, post_data
2
+ from post_to_google_form._url import get_form_response_url
3
+
4
+ __all__ = [
5
+ "build_payload",
6
+ "get_form_response_url",
7
+ "post_data",
8
+ ]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ import requests
7
+
8
+ from post_to_google_form._url import get_form_response_url
9
+
10
+
11
+ def build_payload(
12
+ row: dict[str, Any],
13
+ key_mappings: dict[str, str] | None = None,
14
+ ) -> dict[str, str]:
15
+ """Map a row's keys to Google Form entry IDs.
16
+
17
+ If *key_mappings* is provided, only mapped keys are kept and renamed.
18
+ Otherwise the row is returned as-is (keys are assumed to be entry IDs).
19
+ """
20
+ if key_mappings is None:
21
+ return {k: str(v) for k, v in row.items()}
22
+
23
+ return {key_mappings[k]: str(v) for k, v in row.items() if k in key_mappings}
24
+
25
+
26
+ def post_data(
27
+ url: str,
28
+ data: list[dict[str, Any]] | pd.DataFrame,
29
+ key_mappings: dict[str, str] | None = None,
30
+ ) -> list[dict[str, Any]]:
31
+ """Submit data rows to a Google Form.
32
+
33
+ Parameters
34
+ ----------
35
+ url:
36
+ Google Form URL — a ``viewform``, ``formResponse``, or ``forms.gle``
37
+ short link. It will be resolved to the correct endpoint automatically.
38
+ data:
39
+ Rows to submit. Each dict's keys should match the keys in
40
+ *key_mappings* (or be entry IDs directly if no mappings are given).
41
+ key_mappings:
42
+ Optional mapping from your data keys to Google Form entry IDs.
43
+ ``{"name": "entry.12345", "email": "entry.67890"}``
44
+
45
+ Returns
46
+ -------
47
+ list[dict[str, Any]]
48
+ One dict per row with keys ``"index"``, ``"status_code"``, and
49
+ ``"payload"`` (the data that was actually POSTed).
50
+ """
51
+ submit_url = get_form_response_url(url)
52
+
53
+ if isinstance(data, pd.DataFrame):
54
+ data = data.to_dict(orient="records")
55
+
56
+ results: list[dict[str, Any]] = []
57
+ for i, row in enumerate(data):
58
+ payload = build_payload(row, key_mappings)
59
+ resp = requests.post(submit_url, data=payload, timeout=20)
60
+ results.append({
61
+ "index": i,
62
+ "status_code": resp.status_code,
63
+ "payload": payload,
64
+ })
65
+
66
+ return results
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlparse
4
+
5
+ import requests
6
+
7
+
8
+ def get_form_response_url(url: str) -> str:
9
+ """Resolve a Google Forms view URL to its formResponse endpoint."""
10
+ parsed = urlparse(url)
11
+
12
+ if "forms.gle" in parsed.netloc:
13
+ resolved = requests.get(url, allow_redirects=True, timeout=15).url
14
+ parsed = urlparse(resolved)
15
+
16
+ if parsed.path.endswith("/viewform"):
17
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path.replace('/viewform', '/formResponse')}"
18
+
19
+ if parsed.path.endswith("/formResponse"):
20
+ return url
21
+
22
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}/formResponse"
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ import pandas as pd
9
+ from dotenv import load_dotenv
10
+
11
+ from post_to_google_form._submit import build_payload, post_data
12
+ from post_to_google_form._url import get_form_response_url
13
+
14
+
15
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description="Submit data rows from a JSON file to a Google Form."
18
+ )
19
+ parser.add_argument(
20
+ "--file",
21
+ help="Path to the JSON data file (default: $FILE_PATH)",
22
+ )
23
+ parser.add_argument(
24
+ "--url",
25
+ help="Google Form URL (default: $GOOGLE_FORM_URL)",
26
+ )
27
+ parser.add_argument(
28
+ "--key-mappings",
29
+ help="Path to a JSON file mapping your data keys to entry IDs "
30
+ "(default: $KEY_MAPPINGS)",
31
+ )
32
+ return parser.parse_args(argv)
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> None:
36
+ args = parse_args(argv)
37
+ load_dotenv()
38
+
39
+ file_path = args.file or os.getenv("FILE_PATH", "")
40
+ form_url = args.url or os.getenv("GOOGLE_FORM_URL", "")
41
+ mappings_path = args.key_mappings or os.getenv("KEY_MAPPINGS", "")
42
+
43
+ if not file_path:
44
+ print("Error: FILE_PATH is required")
45
+ sys.exit(1)
46
+
47
+ if not form_url:
48
+ print("Error: GOOGLE_FORM_URL is required")
49
+ sys.exit(1)
50
+
51
+ try:
52
+ df = pd.read_json(file_path)
53
+ except (FileNotFoundError, ValueError) as e:
54
+ print(f"Error reading {file_path}: {e}")
55
+ sys.exit(1)
56
+
57
+ key_mappings: dict[str, str] | None = None
58
+ if mappings_path:
59
+ with open(mappings_path) as f:
60
+ key_mappings = json.load(f)
61
+
62
+ submit_url = get_form_response_url(form_url)
63
+ print(f"Submit URL: {submit_url}")
64
+
65
+ results = post_data(submit_url, df, key_mappings)
66
+ for r in results:
67
+ status = r["status_code"]
68
+ payload = r["payload"]
69
+ if status >= 400:
70
+ print(f"Submission failed for row {r['index'] + 1}.")
71
+ print(f"Failed payload: {payload}")
72
+ print(f"Row {r['index'] + 1}: payload={payload}, status={status}")
73
+
74
+ print("Done!")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
File without changes
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pandas as pd
8
+ import pytest
9
+
10
+ from post_to_google_form._submit import build_payload, post_data
11
+ from post_to_google_form._url import get_form_response_url
12
+ from post_to_google_form.cli import main, parse_args
13
+
14
+
15
+ class TestBuildPayload:
16
+ def test_no_mappings_passes_row_through(self) -> None:
17
+ row = {"entry.1": "Mr", "entry.2": "Alice"}
18
+ result = build_payload(row)
19
+ assert result == {"entry.1": "Mr", "entry.2": "Alice"}
20
+
21
+ def test_with_mappings_renames_keys(self) -> None:
22
+ mappings = {"title": "entry.1", "name": "entry.2"}
23
+ row = {"title": "Mr", "name": "Alice", "extra": "ignored"}
24
+ result = build_payload(row, mappings)
25
+ assert result == {"entry.1": "Mr", "entry.2": "Alice"}
26
+
27
+ def test_unmapped_keys_are_omitted(self) -> None:
28
+ mappings = {"title": "entry.1"}
29
+ row = {"title": "Mr", "name": "Alice"}
30
+ result = build_payload(row, mappings)
31
+ assert result == {"entry.1": "Mr"}
32
+
33
+ def test_coerces_values_to_strings(self) -> None:
34
+ row = {"entry.1": 42, "entry.2": None}
35
+ result = build_payload(row)
36
+ assert result == {"entry.1": "42", "entry.2": "None"}
37
+
38
+
39
+ class TestPostData:
40
+ @patch("post_to_google_form._submit.requests.post")
41
+ def test_direct_keys(self, mock_post: MagicMock) -> None:
42
+ mock_post.return_value.status_code = 200
43
+ data = [{"entry.1": "Mr"}, {"entry.1": "Ms"}]
44
+ results = post_data("https://example.com/formResponse", data)
45
+ assert len(results) == 2
46
+ assert results[0]["index"] == 0
47
+ assert results[0]["status_code"] == 200
48
+ assert results[0]["payload"] == {"entry.1": "Mr"}
49
+
50
+ @patch("post_to_google_form._submit.requests.post")
51
+ def test_with_mappings(self, mock_post: MagicMock) -> None:
52
+ mock_post.return_value.status_code = 200
53
+ key_mappings = {"title": "entry.1", "name": "entry.2"}
54
+ data = [{"title": "Mr", "name": "Alice"}, {"title": "Ms", "name": "Bob"}]
55
+ results = post_data("https://example.com/formResponse", data, key_mappings)
56
+ assert len(results) == 2
57
+ assert results[0]["payload"] == {"entry.1": "Mr", "entry.2": "Alice"}
58
+ assert results[1]["payload"] == {"entry.1": "Ms", "entry.2": "Bob"}
59
+
60
+ @patch("post_to_google_form._submit.requests.post")
61
+ def test_with_dataframe(self, mock_post: MagicMock) -> None:
62
+ mock_post.return_value.status_code = 200
63
+ df = pd.DataFrame([{"entry.1": "A"}])
64
+ results = post_data("https://example.com/formResponse", df)
65
+ assert len(results) == 1
66
+
67
+ @patch("post_to_google_form._submit.get_form_response_url")
68
+ @patch("post_to_google_form._submit.requests.post")
69
+ def test_resolves_url(
70
+ self, mock_post: MagicMock, mock_resolve: MagicMock
71
+ ) -> None:
72
+ mock_resolve.return_value = "https://example.com/formResponse"
73
+ mock_post.return_value.status_code = 200
74
+ post_data("https://forms.gle/abc", [{"e.1": "v"}])
75
+ mock_resolve.assert_called_once_with("https://forms.gle/abc")
76
+ mock_post.assert_called_once_with(
77
+ "https://example.com/formResponse",
78
+ data={"e.1": "v"},
79
+ timeout=20,
80
+ )
81
+
82
+
83
+ class TestGetFormResponseUrl:
84
+ def test_already_form_response(self) -> None:
85
+ url = "https://docs.google.com/forms/d/e/ID/formResponse"
86
+ assert get_form_response_url(url) == url
87
+
88
+ def test_viewform_to_form_response(self) -> None:
89
+ url = "https://docs.google.com/forms/d/e/ID/viewform"
90
+ expected = "https://docs.google.com/forms/d/e/ID/formResponse"
91
+ assert get_form_response_url(url) == expected
92
+
93
+ def test_bare_path_appends_form_response(self) -> None:
94
+ url = "https://docs.google.com/forms/d/e/ID"
95
+ expected = "https://docs.google.com/forms/d/e/ID/formResponse"
96
+ assert get_form_response_url(url) == expected
97
+
98
+ @patch("requests.get")
99
+ def test_forms_gle_resolved(self, mock_get: MagicMock) -> None:
100
+ mock_get.return_value.url = "https://docs.google.com/forms/d/e/ID/viewform"
101
+ short = "https://forms.gle/abc123"
102
+ expected = "https://docs.google.com/forms/d/e/ID/formResponse"
103
+ assert get_form_response_url(short) == expected
104
+ mock_get.assert_called_once_with(short, allow_redirects=True, timeout=15)
105
+
106
+
107
+ class TestParseArgs:
108
+ def test_defaults(self) -> None:
109
+ args = parse_args([])
110
+ assert args.file is None
111
+ assert args.url is None
112
+ assert args.key_mappings is None
113
+
114
+ def test_with_flags(self) -> None:
115
+ args = parse_args([
116
+ "--file", "data.json",
117
+ "--url", "https://example.com",
118
+ "--key-mappings", "mapping.json",
119
+ ])
120
+ assert args.file == "data.json"
121
+ assert args.url == "https://example.com"
122
+ assert args.key_mappings == "mapping.json"
123
+
124
+
125
+ class TestMain:
126
+ def test_missing_file_exits(self) -> None:
127
+ with pytest.raises(SystemExit):
128
+ main(["--url", "https://example.com/formResponse"])
129
+
130
+ @patch("post_to_google_form.cli.load_dotenv")
131
+ def test_missing_url_exits(
132
+ self, mock_load_dotenv: MagicMock, monkeypatch: pytest.MonkeyPatch
133
+ ) -> None:
134
+ monkeypatch.setenv("GOOGLE_FORM_URL", "")
135
+ data_file = Path(__file__).parent / "test_data.json"
136
+ json.dump([{"entry.1": "Test"}], data_file.open("w"))
137
+ try:
138
+ with pytest.raises(SystemExit):
139
+ main(["--file", str(data_file)])
140
+ finally:
141
+ data_file.unlink(missing_ok=True)
142
+
143
+ @patch("post_to_google_form._submit.requests.post")
144
+ def test_submits_rows(self, mock_post: MagicMock) -> None:
145
+ mock_post.return_value.status_code = 200
146
+ rows = [{"entry.1": "Alice"}, {"entry.1": "Bob"}]
147
+ data_file = Path(__file__).parent / "test_data.json"
148
+ json.dump(rows, data_file.open("w"))
149
+ try:
150
+ main(["--file", str(data_file), "--url", "https://example.com/formResponse"])
151
+ finally:
152
+ data_file.unlink(missing_ok=True)
153
+ assert mock_post.call_count == 2
154
+
155
+ @patch("post_to_google_form._submit.requests.post")
156
+ def test_reports_failure(self, mock_post: MagicMock) -> None:
157
+ mock_post.return_value.status_code = 400
158
+ rows = [{"entry.1": "Carol"}]
159
+ data_file = Path(__file__).parent / "test_data.json"
160
+ json.dump(rows, data_file.open("w"))
161
+ try:
162
+ main(["--file", str(data_file), "--url", "https://example.com/formResponse"])
163
+ finally:
164
+ data_file.unlink(missing_ok=True)
165
+ mock_post.assert_called_once()
166
+
167
+ def test_read_error_exits(self) -> None:
168
+ with pytest.raises(SystemExit):
169
+ main(["--file", "/nonexistent/path.json", "--url", "https://example.com/formResponse"])