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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ from .woffu_api_client import WoffuAPIClient
4
+ from .stdrequests_session import Session, HTTPResponse
@@ -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()