woffu-client 0.0.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.
- woffu_client-0.0.1/LICENSE +21 -0
- woffu_client-0.0.1/PKG-INFO +52 -0
- woffu_client-0.0.1/README.md +36 -0
- woffu_client-0.0.1/pyproject.toml +79 -0
- woffu_client-0.0.1/setup.cfg +4 -0
- woffu_client-0.0.1/src/woffu_client/__init__.py +4 -0
- woffu_client-0.0.1/src/woffu_client/cli.py +143 -0
- woffu_client-0.0.1/src/woffu_client/stdrequests_session.py +273 -0
- woffu_client-0.0.1/src/woffu_client/woffu_api_client.py +618 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/PKG-INFO +52 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/SOURCES.txt +14 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/dependency_links.txt +1 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/entry_points.txt +2 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/requires.txt +5 -0
- woffu_client-0.0.1/src/woffu_client.egg-info/top_level.txt +1 -0
- woffu_client-0.0.1/tests/test_stdrequests_session.py +755 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marc Palacín
|
|
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,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: woffu_client
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Woffu API client with access to several endpoints.
|
|
5
|
+
Author-email: Marc Palacín Marfil <marc.palacin@bsc.es>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ProtossGP32/woffu-client
|
|
8
|
+
Requires-Python: <3.13,>=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: tzlocal==5.3.1
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest==8.1.1; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov==5.0.0; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# woffu-client
|
|
18
|
+
Woffu API client with access to several endpoints.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### Development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> TODO: Instructions for pre-built package
|
|
29
|
+
|
|
30
|
+
## Usage:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
$ woffu-cli -h
|
|
34
|
+
usage: woffu-cli [-h] [--config CONFIG] [--interactive INTERACTIVE] {download-all-documents,get-status,sign,request-credentials} ...
|
|
35
|
+
|
|
36
|
+
CLI interface for Woffu API client
|
|
37
|
+
|
|
38
|
+
options:
|
|
39
|
+
-h, --help show this help message and exit
|
|
40
|
+
--config CONFIG Authentication file path (default: /home/${USER}/.config/woffu/woffu_auth.json)
|
|
41
|
+
--interactive INTERACTIVE
|
|
42
|
+
Set session as interactive or non-interactive (default: True)
|
|
43
|
+
|
|
44
|
+
actions:
|
|
45
|
+
{download-all-documents,get-status,sign,request-credentials}
|
|
46
|
+
download-all-documents
|
|
47
|
+
Download all documents from Woffu
|
|
48
|
+
get-status Get current status and current day's total amount of worked hours
|
|
49
|
+
sign Send sing in or sign out request based on the '--sign-type' argument
|
|
50
|
+
request-credentials
|
|
51
|
+
Request credentials from Woffu. For non-interactive sessions, set username and password as environment variables WOFFU_USERNAME and WOFFU_PASSWORD.
|
|
52
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# woffu-client
|
|
2
|
+
Woffu API client with access to several endpoints.
|
|
3
|
+
|
|
4
|
+
## Installation
|
|
5
|
+
|
|
6
|
+
### Development
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> TODO: Instructions for pre-built package
|
|
13
|
+
|
|
14
|
+
## Usage:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
$ woffu-cli -h
|
|
18
|
+
usage: woffu-cli [-h] [--config CONFIG] [--interactive INTERACTIVE] {download-all-documents,get-status,sign,request-credentials} ...
|
|
19
|
+
|
|
20
|
+
CLI interface for Woffu API client
|
|
21
|
+
|
|
22
|
+
options:
|
|
23
|
+
-h, --help show this help message and exit
|
|
24
|
+
--config CONFIG Authentication file path (default: /home/${USER}/.config/woffu/woffu_auth.json)
|
|
25
|
+
--interactive INTERACTIVE
|
|
26
|
+
Set session as interactive or non-interactive (default: True)
|
|
27
|
+
|
|
28
|
+
actions:
|
|
29
|
+
{download-all-documents,get-status,sign,request-credentials}
|
|
30
|
+
download-all-documents
|
|
31
|
+
Download all documents from Woffu
|
|
32
|
+
get-status Get current status and current day's total amount of worked hours
|
|
33
|
+
sign Send sing in or sign out request based on the '--sign-type' argument
|
|
34
|
+
request-credentials
|
|
35
|
+
Request credentials from Woffu. For non-interactive sessions, set username and password as environment variables WOFFU_USERNAME and WOFFU_PASSWORD.
|
|
36
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "woffu_client"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
requires-python = ">=3.10,<3.13"
|
|
9
|
+
description = "Woffu API client with access to several endpoints."
|
|
10
|
+
authors = [{name="Marc Palacín Marfil", email="marc.palacin@bsc.es"}]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
dependencies = [
|
|
14
|
+
"tzlocal==5.3.1"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest==8.1.1",
|
|
20
|
+
"pytest-cov==5.0.0"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/ProtossGP32/woffu-client"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
woffu-cli = "woffu_client.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.coverage.run]
|
|
37
|
+
branch = true
|
|
38
|
+
omit = [
|
|
39
|
+
# Omit test directory
|
|
40
|
+
"./tests/*"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.coverage.report]
|
|
44
|
+
# Regexes for lines to exclude from consideration
|
|
45
|
+
exclude_also = [
|
|
46
|
+
# Don't complain about missing debug-only code:
|
|
47
|
+
"def __repr__",
|
|
48
|
+
"if self\\.debug",
|
|
49
|
+
|
|
50
|
+
# Don't complain if tests don't hit defensive assertion code:
|
|
51
|
+
"raise AssertionError",
|
|
52
|
+
"raise NotImplementedError",
|
|
53
|
+
|
|
54
|
+
# Don't complain if non-runnable code isn't run:
|
|
55
|
+
"if 0:",
|
|
56
|
+
"if __name__ == .__main__.:",
|
|
57
|
+
|
|
58
|
+
# Don't complain about abstract methods, they aren't run:
|
|
59
|
+
"@(abc\\.)?abstractmethod",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
ignore_errors = true
|
|
63
|
+
skip_empty = true
|
|
64
|
+
|
|
65
|
+
[tool.coverage.html]
|
|
66
|
+
directory = "coverage_html_report"
|
|
67
|
+
|
|
68
|
+
[tool.twine]
|
|
69
|
+
repository = "pypi"
|
|
70
|
+
|
|
71
|
+
[tool.twine.repositories.pypi]
|
|
72
|
+
repository = "https://upload.pypi.org/legacy/"
|
|
73
|
+
username = "__token__"
|
|
74
|
+
password = "{env:PYPI_API_TOKEN}"
|
|
75
|
+
|
|
76
|
+
[tool.twine.repositories.github]
|
|
77
|
+
repository = "https://pypi.pkg.github.com/${OWNER}/"
|
|
78
|
+
username = "{env:GITHUB_ACTOR}"
|
|
79
|
+
password = "{env:GITHUB_TOKEN}"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from woffu_client import WoffuAPIClient # adjust import path
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONFIG = Path.home() / ".config/woffu/woffu_auth.json"
|
|
8
|
+
DEFAULT_OUTPUT_DIR = Path.home() / "Documents/woffu/docs"
|
|
9
|
+
DEFAULT_SUMMARY_REPORTS_DIR = Path.home() / "Documents/woffu/summary_reports"
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="woffu-cli",
|
|
14
|
+
description="CLI interface for Woffu API client"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--config",
|
|
19
|
+
required=False,
|
|
20
|
+
type=Path,
|
|
21
|
+
help=f"Authentication file path (default: {DEFAULT_CONFIG})",
|
|
22
|
+
default=DEFAULT_CONFIG
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--non-interactive",
|
|
27
|
+
required=False,
|
|
28
|
+
action='store_true',
|
|
29
|
+
help=f"Set session as non-interactive",
|
|
30
|
+
default=False
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
subparsers = parser.add_subparsers(title="actions", dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
# ---- download_all_files ----
|
|
36
|
+
dl_parser = subparsers.add_parser(
|
|
37
|
+
"download-all-documents", help="Download all documents from Woffu"
|
|
38
|
+
)
|
|
39
|
+
dl_parser.add_argument(
|
|
40
|
+
"--output-dir",
|
|
41
|
+
type=Path,
|
|
42
|
+
default=DEFAULT_OUTPUT_DIR,
|
|
43
|
+
help=f"Directory to save downloaded files (default: {DEFAULT_OUTPUT_DIR})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ---- get_status ----
|
|
47
|
+
status_parser = subparsers.add_parser(
|
|
48
|
+
"get-status", help="Get current status and current day's total amount of worked hours"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ---- sign ----
|
|
52
|
+
sign_parser = subparsers.add_parser(
|
|
53
|
+
"sign", help="Send sign in or sign out request based on the '--sign-type' argument"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
sign_parser.add_argument(
|
|
57
|
+
"--sign-type",
|
|
58
|
+
type=str,
|
|
59
|
+
default="any",
|
|
60
|
+
help="Sign type to send. It can be either 'in', 'out' or 'any' (default: 'any')"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ---- get_status ----
|
|
64
|
+
status_parser = subparsers.add_parser(
|
|
65
|
+
"request-credentials", help="Request credentials from Woffu. For non-interactive sessions, set username and password as environment variables WOFFU_USERNAME and WOFFU_PASSWORD."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ---- get_status ----
|
|
69
|
+
summary_report_parser = subparsers.add_parser(
|
|
70
|
+
"summary-report", help="Summary report of work hours for a given time window"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
summary_report_parser.add_argument(
|
|
74
|
+
"--from-date",
|
|
75
|
+
type=str,
|
|
76
|
+
required=True,
|
|
77
|
+
help="Start date of the time window. Format YYYY-mm-dd"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
summary_report_parser.add_argument(
|
|
81
|
+
"--to-date",
|
|
82
|
+
type=str,
|
|
83
|
+
required=True,
|
|
84
|
+
help="End date of the time window. Format YYYY-mm-dd"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
summary_report_parser.add_argument(
|
|
88
|
+
"--output-dir",
|
|
89
|
+
type=Path,
|
|
90
|
+
default=DEFAULT_SUMMARY_REPORTS_DIR,
|
|
91
|
+
help=f"Directory to save exported CSV file (default: {DEFAULT_SUMMARY_REPORTS_DIR})"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
args = parser.parse_args()
|
|
96
|
+
|
|
97
|
+
# Instantiate client
|
|
98
|
+
client = WoffuAPIClient(config=args.config, interactive=not args.non_interactive)
|
|
99
|
+
match args.command:
|
|
100
|
+
case "download-all-documents":
|
|
101
|
+
try:
|
|
102
|
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
client.download_all_documents(output_dir=args.output_dir)
|
|
104
|
+
print(f"✅ Files downloaded to {args.output_dir}")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(f"❌ Error downloading files: {e}", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
case "get-status":
|
|
109
|
+
try:
|
|
110
|
+
worked_hours, running_status = client.get_status()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f"❌ Error retrieving status: {e}", file=sys.stderr)
|
|
113
|
+
case "sign":
|
|
114
|
+
try:
|
|
115
|
+
_ = client.sign(type=args.sign_type)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
print(f"❌ Error sending sign command: {e}", file=sys.stderr)
|
|
118
|
+
case "request-credentials":
|
|
119
|
+
try:
|
|
120
|
+
client._request_credentials()
|
|
121
|
+
client._save_credentials()
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print(f"❌ Error requesting new credentials: {e}", file=sys.stderr)
|
|
124
|
+
case "summary-report":
|
|
125
|
+
try:
|
|
126
|
+
summary_report = client.get_summary_report(
|
|
127
|
+
from_date=args.from_date,
|
|
128
|
+
to_date=args.to_date,
|
|
129
|
+
)
|
|
130
|
+
client.export_summary_to_csv(
|
|
131
|
+
summary_report=summary_report,
|
|
132
|
+
from_date=args.from_date,
|
|
133
|
+
to_date=args.to_date,
|
|
134
|
+
output_path=args.output_dir
|
|
135
|
+
)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
print(f"❌ Error retrieving summary report: {e}", file=sys.stderr)
|
|
138
|
+
case _:
|
|
139
|
+
print(f"❌ Unknown command: {args.command}", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import urllib.parse
|
|
3
|
+
import json as jsonlib
|
|
4
|
+
import time
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
from typing import Any, Optional, Dict, Union, AsyncGenerator, Tuple, cast, Iterator
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
import http.cookiejar
|
|
10
|
+
from urllib.error import URLError, HTTPError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTTPResponse:
|
|
14
|
+
_raw: Any
|
|
15
|
+
status: int
|
|
16
|
+
headers: Dict[str, str]
|
|
17
|
+
_stream: bool
|
|
18
|
+
_cached_content: Optional[bytes]
|
|
19
|
+
|
|
20
|
+
def __init__(self, raw_resp: Any, status: int, headers: Dict[str, str], stream: bool = False) -> None:
|
|
21
|
+
self._raw = raw_resp
|
|
22
|
+
self.status = status
|
|
23
|
+
self.headers = headers
|
|
24
|
+
self._stream = stream
|
|
25
|
+
self._cached_content = None
|
|
26
|
+
|
|
27
|
+
def text(self) -> str:
|
|
28
|
+
"""Return response body decoded to text respecting charset if any."""
|
|
29
|
+
encoding = "utf-8"
|
|
30
|
+
content_type = self.headers.get("Content-Type", "")
|
|
31
|
+
if "charset=" in content_type:
|
|
32
|
+
try:
|
|
33
|
+
enc = content_type.split("charset=")[1].split(";")[0].strip()
|
|
34
|
+
if enc:
|
|
35
|
+
encoding = enc
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return self.content().decode(encoding, errors="replace")
|
|
39
|
+
|
|
40
|
+
def json(self) -> Any:
|
|
41
|
+
"""Parse response body as JSON."""
|
|
42
|
+
return jsonlib.loads(self.text())
|
|
43
|
+
|
|
44
|
+
def content(self) -> bytes:
|
|
45
|
+
"""Return the entire response body as bytes."""
|
|
46
|
+
if self._cached_content is None:
|
|
47
|
+
if self._stream:
|
|
48
|
+
# Read all at once if stream=True by consuming the iterator
|
|
49
|
+
self._cached_content = b"".join(self.iter_content())
|
|
50
|
+
else:
|
|
51
|
+
self._cached_content = self._raw.read()
|
|
52
|
+
return cast(bytes, self._cached_content)
|
|
53
|
+
|
|
54
|
+
def iter_content(self, chunk_size: Optional[int] = 1024) -> Iterator[bytes]:
|
|
55
|
+
if self._raw is None:
|
|
56
|
+
yield b""
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if not self._stream:
|
|
60
|
+
yield self.content()
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if chunk_size is None:
|
|
64
|
+
# Read all content at once and yield it once
|
|
65
|
+
chunk = self._raw.read()
|
|
66
|
+
if chunk:
|
|
67
|
+
yield chunk
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if chunk_size <= 0:
|
|
71
|
+
# Yield empty bytes and stop
|
|
72
|
+
yield b""
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
chunk = self._raw.read(chunk_size)
|
|
77
|
+
if not chunk:
|
|
78
|
+
break
|
|
79
|
+
yield chunk
|
|
80
|
+
|
|
81
|
+
async def aiter_content(self, chunk_size: Optional[int] = 8192) -> AsyncGenerator[bytes, None]:
|
|
82
|
+
"""Async chunked iterator (reads in thread to avoid blocking event loop)."""
|
|
83
|
+
# Defensive fallback if chunk_size is None or invalid
|
|
84
|
+
if chunk_size is None or chunk_size <= 0:
|
|
85
|
+
chunk_size = 8192
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
chunk = await asyncio.to_thread(self._raw.read, chunk_size)
|
|
89
|
+
if not chunk:
|
|
90
|
+
break
|
|
91
|
+
yield chunk
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""Close the underlying raw response if it supports close."""
|
|
95
|
+
close_method = getattr(self._raw, "close", None)
|
|
96
|
+
if callable(close_method):
|
|
97
|
+
close_method()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Session:
|
|
101
|
+
headers: Dict[str, str]
|
|
102
|
+
params: Dict[str, str]
|
|
103
|
+
timeout: int
|
|
104
|
+
retries: int
|
|
105
|
+
stream: bool
|
|
106
|
+
_cookie_jar: http.cookiejar.CookieJar
|
|
107
|
+
_opener: urllib.request.OpenerDirector
|
|
108
|
+
opener: urllib.request.OpenerDirector
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
headers: Optional[Dict[str, str]] = None,
|
|
113
|
+
params: Optional[Dict[str, str]] = None,
|
|
114
|
+
timeout: int = 10,
|
|
115
|
+
retries: int = 3,
|
|
116
|
+
stream: bool = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.headers = dict(headers or {})
|
|
119
|
+
self.params = dict(params or {})
|
|
120
|
+
self.timeout = timeout
|
|
121
|
+
self.retries = retries
|
|
122
|
+
self.stream = stream
|
|
123
|
+
|
|
124
|
+
# Cookie handling
|
|
125
|
+
self._cookie_jar = http.cookiejar.CookieJar()
|
|
126
|
+
self._opener = urllib.request.build_opener(
|
|
127
|
+
urllib.request.HTTPCookieProcessor(self._cookie_jar),
|
|
128
|
+
urllib.request.HTTPRedirectHandler(),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Allow user to set a custom opener later if desired
|
|
132
|
+
self.opener = self._opener
|
|
133
|
+
|
|
134
|
+
def _apply_auth_header(self, headers: Dict[str, str], auth: Optional[Tuple[str, str]]) -> None:
|
|
135
|
+
if auth:
|
|
136
|
+
user, pwd = auth
|
|
137
|
+
token = base64.b64encode(f"{user}:{pwd}".encode("utf-8")).decode("ascii")
|
|
138
|
+
headers.setdefault("Authorization", f"Basic {token}")
|
|
139
|
+
|
|
140
|
+
def request(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
url: str,
|
|
144
|
+
params: Optional[Dict[str, str]] = None,
|
|
145
|
+
data: Optional[Union[dict, str, bytes]] = None,
|
|
146
|
+
json: Optional[Any] = None, # <-- added json parameter
|
|
147
|
+
headers: Optional[Dict[str, str]] = None,
|
|
148
|
+
timeout: Optional[int] = None,
|
|
149
|
+
retries: Optional[int] = None,
|
|
150
|
+
stream: Optional[bool] = None,
|
|
151
|
+
auth: Optional[Tuple[str, str]] = None,
|
|
152
|
+
) -> HTTPResponse:
|
|
153
|
+
# Merge defaults
|
|
154
|
+
timeout = self.timeout if timeout is None else timeout
|
|
155
|
+
retries = self.retries if retries is None else retries
|
|
156
|
+
stream = self.stream if stream is None else stream
|
|
157
|
+
|
|
158
|
+
# Build headers and params
|
|
159
|
+
final_headers = dict(self.headers) # session headers
|
|
160
|
+
if headers:
|
|
161
|
+
final_headers.update(headers)
|
|
162
|
+
self._apply_auth_header(final_headers, auth)
|
|
163
|
+
|
|
164
|
+
final_params = dict(self.params)
|
|
165
|
+
if params:
|
|
166
|
+
final_params.update(params)
|
|
167
|
+
if final_params:
|
|
168
|
+
url = url + ("&" if "?" in url else "?") + urllib.parse.urlencode(final_params)
|
|
169
|
+
|
|
170
|
+
# Prepare body
|
|
171
|
+
body_bytes: Optional[bytes] = None
|
|
172
|
+
|
|
173
|
+
if json is not None:
|
|
174
|
+
# JSON mode takes precedence over data
|
|
175
|
+
final_headers.setdefault("Content-Type", "application/json")
|
|
176
|
+
body_bytes = jsonlib.dumps(json).encode("utf-8")
|
|
177
|
+
elif data is not None:
|
|
178
|
+
if isinstance(data, dict):
|
|
179
|
+
# Default: form-encoded
|
|
180
|
+
final_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
181
|
+
body_bytes = urllib.parse.urlencode(data).encode("utf-8")
|
|
182
|
+
elif isinstance(data, str):
|
|
183
|
+
body_bytes = data.encode("utf-8")
|
|
184
|
+
final_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
185
|
+
elif isinstance(data, (bytes, bytearray, memoryview)):
|
|
186
|
+
# Accept bytearray and memoryview as raw bytes
|
|
187
|
+
body_bytes = bytes(data) # convert to bytes if needed
|
|
188
|
+
elif hasattr(data, "read") and callable(data.read):
|
|
189
|
+
# Assume file-like, read bytes
|
|
190
|
+
body_bytes = data.read()
|
|
191
|
+
if not isinstance(body_bytes, bytes):
|
|
192
|
+
raise TypeError("file-like object's read() must return bytes")
|
|
193
|
+
elif isinstance(data, Iterable):
|
|
194
|
+
# Accept iterable of bytes chunks; join them
|
|
195
|
+
body_bytes = b"".join(data)
|
|
196
|
+
else:
|
|
197
|
+
raise TypeError("data must be dict, str, or bytes")
|
|
198
|
+
|
|
199
|
+
last_exc: Optional[Exception] = None
|
|
200
|
+
for attempt in range(retries):
|
|
201
|
+
try:
|
|
202
|
+
req = urllib.request.Request(
|
|
203
|
+
url, data=body_bytes, headers=final_headers, method=method.upper()
|
|
204
|
+
)
|
|
205
|
+
raw_resp = self.opener.open(req, timeout=timeout)
|
|
206
|
+
return HTTPResponse(raw_resp, raw_resp.getcode(), dict(raw_resp.getheaders()), stream=stream)
|
|
207
|
+
except (HTTPError, URLError, OSError) as e:
|
|
208
|
+
last_exc = e
|
|
209
|
+
if isinstance(e, HTTPError):
|
|
210
|
+
raw_resp = cast(Any, e)
|
|
211
|
+
return HTTPResponse(raw_resp, e.code, dict(e.headers or {}), stream=stream)
|
|
212
|
+
if attempt < retries - 1:
|
|
213
|
+
time.sleep(1)
|
|
214
|
+
continue
|
|
215
|
+
raise last_exc
|
|
216
|
+
|
|
217
|
+
# Defensive fallback (should never reach here)
|
|
218
|
+
raise RuntimeError("Request failed unexpectedly without raising an exception")
|
|
219
|
+
|
|
220
|
+
# Convenience sync methods
|
|
221
|
+
def get(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
222
|
+
return self.request("GET", url, **kwargs)
|
|
223
|
+
|
|
224
|
+
def post(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
225
|
+
return self.request("POST", url, **kwargs)
|
|
226
|
+
|
|
227
|
+
def put(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
228
|
+
return self.request("PUT", url, **kwargs)
|
|
229
|
+
|
|
230
|
+
def patch(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
231
|
+
return self.request("PATCH", url, **kwargs)
|
|
232
|
+
|
|
233
|
+
def delete(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
234
|
+
return self.request("DELETE", url, **kwargs)
|
|
235
|
+
|
|
236
|
+
# Async wrappers using asyncio.to_thread to avoid blocking the event loop
|
|
237
|
+
async def async_request(self, method: str, url: str, **kwargs: Any) -> HTTPResponse:
|
|
238
|
+
return await asyncio.to_thread(self.request, method, url, **kwargs)
|
|
239
|
+
|
|
240
|
+
async def async_get(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
241
|
+
return await self.async_request("GET", url, **kwargs)
|
|
242
|
+
|
|
243
|
+
async def async_post(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
244
|
+
return await self.async_request("POST", url, **kwargs)
|
|
245
|
+
|
|
246
|
+
async def async_put(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
247
|
+
return await self.async_request("PUT", url, **kwargs)
|
|
248
|
+
|
|
249
|
+
async def async_patch(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
250
|
+
return await self.async_request("PATCH", url, **kwargs)
|
|
251
|
+
|
|
252
|
+
async def async_delete(self, url: str, **kwargs: Any) -> HTTPResponse:
|
|
253
|
+
return await self.async_request("DELETE", url, **kwargs)
|
|
254
|
+
|
|
255
|
+
# Context manager support
|
|
256
|
+
def __enter__(self) -> "Session":
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def __exit__(self, exc_type: Optional[type], exc: Optional[BaseException], tb: Optional[Any]) -> bool:
|
|
260
|
+
# nothing special to close; cookiejar/opener don't need explicit close
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
def close(self) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Close the session by clearing cookies and closing any underlying resources.
|
|
266
|
+
This is just for API consistency, this isn't needed if using a Context manager"""
|
|
267
|
+
# Clear all cookies
|
|
268
|
+
self._cookie_jar.clear()
|
|
269
|
+
|
|
270
|
+
# Try to close the opener if it has a close method (some custom openers might)
|
|
271
|
+
close_method = getattr(self.opener, "close", None)
|
|
272
|
+
if callable(close_method):
|
|
273
|
+
close_method()
|