tropmail 1.0.0__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,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python-version: ['3.10', '3.11', '3.12', '3.13']
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ cache: pip
29
+ cache-dependency-path: pyproject.toml
30
+ - run: pip install -e ".[dev]"
31
+ - run: ruff check src tests
32
+ - run: mypy
33
+ - run: pytest -q
@@ -0,0 +1,61 @@
1
+ name: Release
2
+
3
+ # Push a tag like v1.2.3 (must match pyproject.toml version).
4
+ on:
5
+ push:
6
+ tags: ['v*']
7
+ workflow_dispatch:
8
+ inputs:
9
+ dry_run:
10
+ description: Build without publishing
11
+ type: boolean
12
+ default: true
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.12'
25
+
26
+ - name: Verify the tag matches the packaged version
27
+ if: startsWith(github.ref, 'refs/tags/')
28
+ run: |
29
+ tag="${GITHUB_REF_NAME#v}"
30
+ packaged="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
31
+ if [ "$tag" != "$packaged" ]; then
32
+ echo "::error::tag v$tag does not match pyproject version $packaged"
33
+ exit 1
34
+ fi
35
+
36
+ - run: pip install -e ".[dev]" build
37
+ - run: ruff check src tests
38
+ - run: mypy
39
+ - run: pytest -q
40
+ - run: python -m build
41
+
42
+ - uses: actions/upload-artifact@v4
43
+ with:
44
+ name: python-dist
45
+ path: dist/
46
+
47
+ publish:
48
+ needs: build
49
+ if: startsWith(github.ref, 'refs/tags/')
50
+ runs-on: ubuntu-latest
51
+ environment:
52
+ name: pypi
53
+ url: https://pypi.org/p/tropmail
54
+ permissions:
55
+ id-token: write
56
+ steps:
57
+ - uses: actions/download-artifact@v4
58
+ with:
59
+ name: python-dist
60
+ path: dist/
61
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ *.whl
13
+ .DS_Store
14
+ .env
15
+ PUBLISH.md
tropmail-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TropMail
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ .PHONY: install test lint typecheck contract build clean
2
+
3
+ VENV := .venv
4
+ PY := $(VENV)/bin/python
5
+
6
+ $(VENV):
7
+ python3 -m venv $(VENV)
8
+ $(PY) -m pip install --quiet --upgrade pip
9
+ $(PY) -m pip install --quiet -e ".[dev]"
10
+
11
+ install: $(VENV)
12
+
13
+ test: $(VENV)
14
+ $(PY) -m pytest -q
15
+
16
+ lint: $(VENV)
17
+ $(PY) -m ruff check src tests examples
18
+
19
+ typecheck: $(VENV)
20
+ $(PY) -m mypy
21
+ $(PY) -m mypy examples
22
+
23
+ contract: $(VENV)
24
+ $(PY) -m pytest -q tests/test_contract.py
25
+
26
+ build: $(VENV)
27
+ $(PY) -m pip install --quiet build
28
+ $(PY) -m build
29
+
30
+ clean:
31
+ rm -rf $(VENV) dist build .pytest_cache .mypy_cache .ruff_cache
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.5
2
+ Name: tropmail
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the TropMail API
5
+ Project-URL: Homepage, https://tropmail.com
6
+ Project-URL: Documentation, https://docs.tropmail.com
7
+ Project-URL: Repository, https://github.com/tropmail/tropmail-python
8
+ Author-email: TropMail <support@tropmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,email,sdk,tropmail,webmail
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Email
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx<1,>=0.27
24
+ Requires-Dist: msgspec<1,>=0.18
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy<2,>=1.13; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
29
+ Requires-Dist: ruff<1,>=0.8; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # tropmail
33
+
34
+ Official Python SDK for the [TropMail API](https://api.tropmail.com).
35
+
36
+ Sync and async clients, fully typed, with automatic pagination, retries, and
37
+ client-side rate-limit pacing.
38
+
39
+ ```bash
40
+ pip install tropmail
41
+ ```
42
+
43
+ Requires Python 3.10+. The client reads `TROPMAIL_API_KEY` unless you pass the key.
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ from tropmail import TropMail
49
+
50
+ with TropMail() as client: # reads TROPMAIL_API_KEY
51
+ mailbox = client.mailbox.get()
52
+ print(f"{mailbox.email}: {mailbox.opened_count} opened")
53
+
54
+ for email in client.emails.iterate(status="Open"):
55
+ print(email.timestamp, email.from_.address, email.subject)
56
+ ```
57
+
58
+ Pass the key explicitly if you prefer:
59
+
60
+ ```python
61
+ client = TropMail("your32charalphanumericapikeyhere")
62
+ ```
63
+
64
+ ## Async
65
+
66
+ ```python
67
+ import asyncio
68
+ from tropmail import AsyncTropMail
69
+
70
+ async def main() -> None:
71
+ async with AsyncTropMail() as client:
72
+ async for email in client.emails.iterate(status="Favorite"):
73
+ print(email.subject)
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Reading an email
79
+
80
+ `get()` returns the HTML view by default. Pass the email object you already have
81
+ instead of a bare id and the SDK forwards its timestamp for a faster lookup.
82
+
83
+ ```python
84
+ page = client.emails.list(limit=10)
85
+ email = page.emails[0]
86
+
87
+ detail = client.emails.get(email, view="text")
88
+ print(detail.content)
89
+
90
+ for attachment in detail.attachments:
91
+ print(attachment.filename, attachment.size, attachment.scan_status)
92
+ ```
93
+
94
+ ### Markdown
95
+
96
+ The markdown view is generated on demand and the server can block for up to a
97
+ minute before answering `504`. `get_markdown()` handles that retry for you:
98
+
99
+ ```python
100
+ detail = client.emails.get_markdown(email)
101
+ print(detail.content)
102
+ ```
103
+
104
+ ## Actions
105
+
106
+ ```python
107
+ client.emails.favorite(email)
108
+ client.emails.close(email)
109
+ client.emails.block(email) # also blocks the sender for future mail
110
+ client.emails.clear_action(email) # clears the action, unblocking the sender
111
+ ```
112
+
113
+ All of them are shorthands for `update()`:
114
+
115
+ ```python
116
+ client.emails.update(email, email_state="Open", action_status="Favorite")
117
+ ```
118
+
119
+ ## Attachments
120
+
121
+ ```python
122
+ info = client.attachments.get(attachment_id)
123
+ print(info.filename, info.scan_status)
124
+
125
+ client.attachments.scan(attachment_id)
126
+ path = client.attachments.download_to(attachment_id, "/tmp/invoice.pdf")
127
+ ```
128
+
129
+ The download URL lives on a separate host, so no API credentials are ever sent to it.
130
+
131
+ ## Search
132
+
133
+ ```python
134
+ page = client.emails.search("invoice", limit=25)
135
+ for email in client.emails.search_iterate("invoice"):
136
+ print(email.subject)
137
+ ```
138
+
139
+ Note that the API always reports `total: 0` for search results. The iterators
140
+ page until they see a short page rather than trusting `total`.
141
+
142
+ ## Errors
143
+
144
+ ```python
145
+ from tropmail import (
146
+ AuthenticationError, NotFoundError, RateLimitError,
147
+ TierError, TropMailError,
148
+ )
149
+
150
+ try:
151
+ client.emails.get("does-not-exist")
152
+ except NotFoundError as exc:
153
+ print(exc.status, exc.request_id)
154
+ except RateLimitError as exc:
155
+ print("retry after", exc.retry_after)
156
+ except TropMailError as exc:
157
+ print("request failed:", exc)
158
+ ```
159
+
160
+ Every error carries `status`, `message`, and the `request_id` echoed by the API,
161
+ which is what support needs to trace a call.
162
+
163
+ | Exception | HTTP |
164
+ |---|---|
165
+ | `ValidationError` | 400 |
166
+ | `AuthenticationError` | 401 |
167
+ | `TierError` | 403 |
168
+ | `NotFoundError` | 404 |
169
+ | `RateLimitError` | 429 |
170
+ | `MarkdownTimeoutError` | 504 |
171
+ | `ServerError` | 5xx |
172
+ | `ConnectionError` | transport failure |
173
+
174
+ ## Rate limits
175
+
176
+ Budgets are per mailbox, per second: Pro 3, Ultimate 10, Enterprise 50.
177
+
178
+ The client reads the limit from response headers and paces itself with a token
179
+ bucket so you stay under the budget instead of collecting `429`s. Inspect the
180
+ current window at any time:
181
+
182
+ ```python
183
+ print(client.rate_limit) # RateLimitInfo(limit=3, remaining=2, reset=1767225600)
184
+ ```
185
+
186
+ Disable pacing with `TropMail(throttle=False)` if you manage concurrency yourself.
187
+
188
+ ## Configuration
189
+
190
+ ```python
191
+ client = TropMail(
192
+ api_key=None, # else TROPMAIL_API_KEY
193
+ base_url="https://api.tropmail.com/api/v1",
194
+ timeout=120.0, # markdown can block ~60s
195
+ max_retries=3,
196
+ throttle=True,
197
+ )
198
+ ```
199
+
200
+ Retries use exponential backoff with full jitter on 429, 502, 503, 504, and
201
+ transport errors. Reads retry automatically; mutations never do.
202
+
203
+ ## Examples
204
+
205
+ Runnable scripts live in [`examples/`](examples/): a quickstart, a concurrent
206
+ async inbox reader, and attachment downloading.
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ make install
212
+ make test
213
+ make lint
214
+ make typecheck
215
+ ```
216
+
217
+ ## Docs
218
+
219
+ Guides and API reference: [docs.tropmail.com](https://docs.tropmail.com/sdks/python/).
220
+
221
+ ## License
222
+
223
+ MIT
@@ -0,0 +1,192 @@
1
+ # tropmail
2
+
3
+ Official Python SDK for the [TropMail API](https://api.tropmail.com).
4
+
5
+ Sync and async clients, fully typed, with automatic pagination, retries, and
6
+ client-side rate-limit pacing.
7
+
8
+ ```bash
9
+ pip install tropmail
10
+ ```
11
+
12
+ Requires Python 3.10+. The client reads `TROPMAIL_API_KEY` unless you pass the key.
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ from tropmail import TropMail
18
+
19
+ with TropMail() as client: # reads TROPMAIL_API_KEY
20
+ mailbox = client.mailbox.get()
21
+ print(f"{mailbox.email}: {mailbox.opened_count} opened")
22
+
23
+ for email in client.emails.iterate(status="Open"):
24
+ print(email.timestamp, email.from_.address, email.subject)
25
+ ```
26
+
27
+ Pass the key explicitly if you prefer:
28
+
29
+ ```python
30
+ client = TropMail("your32charalphanumericapikeyhere")
31
+ ```
32
+
33
+ ## Async
34
+
35
+ ```python
36
+ import asyncio
37
+ from tropmail import AsyncTropMail
38
+
39
+ async def main() -> None:
40
+ async with AsyncTropMail() as client:
41
+ async for email in client.emails.iterate(status="Favorite"):
42
+ print(email.subject)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ ## Reading an email
48
+
49
+ `get()` returns the HTML view by default. Pass the email object you already have
50
+ instead of a bare id and the SDK forwards its timestamp for a faster lookup.
51
+
52
+ ```python
53
+ page = client.emails.list(limit=10)
54
+ email = page.emails[0]
55
+
56
+ detail = client.emails.get(email, view="text")
57
+ print(detail.content)
58
+
59
+ for attachment in detail.attachments:
60
+ print(attachment.filename, attachment.size, attachment.scan_status)
61
+ ```
62
+
63
+ ### Markdown
64
+
65
+ The markdown view is generated on demand and the server can block for up to a
66
+ minute before answering `504`. `get_markdown()` handles that retry for you:
67
+
68
+ ```python
69
+ detail = client.emails.get_markdown(email)
70
+ print(detail.content)
71
+ ```
72
+
73
+ ## Actions
74
+
75
+ ```python
76
+ client.emails.favorite(email)
77
+ client.emails.close(email)
78
+ client.emails.block(email) # also blocks the sender for future mail
79
+ client.emails.clear_action(email) # clears the action, unblocking the sender
80
+ ```
81
+
82
+ All of them are shorthands for `update()`:
83
+
84
+ ```python
85
+ client.emails.update(email, email_state="Open", action_status="Favorite")
86
+ ```
87
+
88
+ ## Attachments
89
+
90
+ ```python
91
+ info = client.attachments.get(attachment_id)
92
+ print(info.filename, info.scan_status)
93
+
94
+ client.attachments.scan(attachment_id)
95
+ path = client.attachments.download_to(attachment_id, "/tmp/invoice.pdf")
96
+ ```
97
+
98
+ The download URL lives on a separate host, so no API credentials are ever sent to it.
99
+
100
+ ## Search
101
+
102
+ ```python
103
+ page = client.emails.search("invoice", limit=25)
104
+ for email in client.emails.search_iterate("invoice"):
105
+ print(email.subject)
106
+ ```
107
+
108
+ Note that the API always reports `total: 0` for search results. The iterators
109
+ page until they see a short page rather than trusting `total`.
110
+
111
+ ## Errors
112
+
113
+ ```python
114
+ from tropmail import (
115
+ AuthenticationError, NotFoundError, RateLimitError,
116
+ TierError, TropMailError,
117
+ )
118
+
119
+ try:
120
+ client.emails.get("does-not-exist")
121
+ except NotFoundError as exc:
122
+ print(exc.status, exc.request_id)
123
+ except RateLimitError as exc:
124
+ print("retry after", exc.retry_after)
125
+ except TropMailError as exc:
126
+ print("request failed:", exc)
127
+ ```
128
+
129
+ Every error carries `status`, `message`, and the `request_id` echoed by the API,
130
+ which is what support needs to trace a call.
131
+
132
+ | Exception | HTTP |
133
+ |---|---|
134
+ | `ValidationError` | 400 |
135
+ | `AuthenticationError` | 401 |
136
+ | `TierError` | 403 |
137
+ | `NotFoundError` | 404 |
138
+ | `RateLimitError` | 429 |
139
+ | `MarkdownTimeoutError` | 504 |
140
+ | `ServerError` | 5xx |
141
+ | `ConnectionError` | transport failure |
142
+
143
+ ## Rate limits
144
+
145
+ Budgets are per mailbox, per second: Pro 3, Ultimate 10, Enterprise 50.
146
+
147
+ The client reads the limit from response headers and paces itself with a token
148
+ bucket so you stay under the budget instead of collecting `429`s. Inspect the
149
+ current window at any time:
150
+
151
+ ```python
152
+ print(client.rate_limit) # RateLimitInfo(limit=3, remaining=2, reset=1767225600)
153
+ ```
154
+
155
+ Disable pacing with `TropMail(throttle=False)` if you manage concurrency yourself.
156
+
157
+ ## Configuration
158
+
159
+ ```python
160
+ client = TropMail(
161
+ api_key=None, # else TROPMAIL_API_KEY
162
+ base_url="https://api.tropmail.com/api/v1",
163
+ timeout=120.0, # markdown can block ~60s
164
+ max_retries=3,
165
+ throttle=True,
166
+ )
167
+ ```
168
+
169
+ Retries use exponential backoff with full jitter on 429, 502, 503, 504, and
170
+ transport errors. Reads retry automatically; mutations never do.
171
+
172
+ ## Examples
173
+
174
+ Runnable scripts live in [`examples/`](examples/): a quickstart, a concurrent
175
+ async inbox reader, and attachment downloading.
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ make install
181
+ make test
182
+ make lint
183
+ make typecheck
184
+ ```
185
+
186
+ ## Docs
187
+
188
+ Guides and API reference: [docs.tropmail.com](https://docs.tropmail.com/sdks/python/).
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,21 @@
1
+ # Python examples
2
+
3
+ ```bash
4
+ pip install tropmail
5
+ export TROPMAIL_API_KEY=your32charalphanumericapikeyhere
6
+ ```
7
+
8
+ | File | What it shows |
9
+ |---|---|
10
+ | [`quickstart.py`](quickstart.py) | mailbox summary, listing, reading one message, typed errors |
11
+ | [`async_inbox.py`](async_inbox.py) | `AsyncTropMail`, auto-paging `async for`, concurrent reads under the rate limit |
12
+ | [`save_attachments.py`](save_attachments.py) | scanning attachments and streaming them to disk |
13
+
14
+ ```bash
15
+ python examples/quickstart.py
16
+ python examples/async_inbox.py
17
+ python examples/save_attachments.py <email-id> ./downloads
18
+ ```
19
+
20
+ These are type-checked in CI (`make lint typecheck`), so they cannot drift from
21
+ the SDK.
@@ -0,0 +1,52 @@
1
+ """Fetch several message bodies concurrently with the async client.
2
+
3
+ export TROPMAIL_API_KEY=...
4
+ python examples/async_inbox.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ from tropmail import AsyncTropMail, Email, EmailDetail, TropMailError
12
+
13
+
14
+ async def read(client: AsyncTropMail, email: Email) -> EmailDetail | None:
15
+ """Fetch one body, returning None instead of raising so one failure does
16
+ not sink the whole batch."""
17
+ try:
18
+ return await client.emails.get(email, view="text")
19
+ except TropMailError as error:
20
+ print(f"{email.id}: {error}")
21
+ return None
22
+
23
+
24
+ async def main() -> None:
25
+ async with AsyncTropMail() as client:
26
+ mailbox = await client.mailbox.get()
27
+ print(f"{mailbox.email}\n")
28
+
29
+ # Collect the unread messages, capped so the example stays quick.
30
+ unread: list[Email] = []
31
+ async for email in client.emails.iterate(status="Open"):
32
+ unread.append(email)
33
+ if len(unread) == 10:
34
+ break
35
+
36
+ if not unread:
37
+ print("Nothing unread.")
38
+ return
39
+
40
+ # The client's token bucket paces these against the tier's per-second
41
+ # budget, so firing them all at once will not produce 429s.
42
+ details = await asyncio.gather(*(read(client, email) for email in unread))
43
+
44
+ for email, detail in zip(unread, details, strict=True):
45
+ if detail is None:
46
+ continue
47
+ preview = " ".join(detail.content.split())[:80]
48
+ print(f"{email.from_.address:<24} {email.subject[:30]:<30} {preview}")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ asyncio.run(main())
@@ -0,0 +1,47 @@
1
+ """Print a mailbox summary and read the newest message.
2
+
3
+ export TROPMAIL_API_KEY=...
4
+ python examples/quickstart.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from tropmail import NotFoundError, TropMail, TropMailError
10
+
11
+
12
+ def main() -> None:
13
+ # No api_key argument: the client reads TROPMAIL_API_KEY and validates the
14
+ # format locally, so a malformed key fails before spending a round trip.
15
+ with TropMail() as client:
16
+ mailbox = client.mailbox.get()
17
+ print(
18
+ f"{mailbox.email} — {mailbox.opened_count} open, "
19
+ f"{mailbox.closed_count} closed, {mailbox.favorite_count} favorite\n"
20
+ )
21
+
22
+ page = client.emails.list(limit=5)
23
+ if not page.emails:
24
+ print("The mailbox is empty.")
25
+ return
26
+
27
+ for email in page.emails:
28
+ print(f"{email.from_.address:<24} {email.subject[:40]:<40} {email.timestamp}")
29
+
30
+ # Passing the email object rather than its id forwards the timestamp
31
+ # for a faster lookup.
32
+ newest = page.emails[0]
33
+ try:
34
+ detail = client.emails.get(newest, view="text")
35
+ except NotFoundError:
36
+ print("\nThat message was deleted between listing and reading it.")
37
+ return
38
+ except TropMailError as error:
39
+ print(f"\nRead failed ({error.status}, request {error.request_id}): {error}")
40
+ return
41
+
42
+ print(f"\n--- {detail.subject} ---")
43
+ print(detail.content[:500])
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()