absfuyu 5.4.0__py3-none-any.whl → 5.6.0__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.

Potentially problematic release.


This version of absfuyu might be problematic. Click here for more details.

Files changed (75) hide show
  1. absfuyu/__init__.py +1 -1
  2. absfuyu/__main__.py +2 -2
  3. absfuyu/cli/__init__.py +2 -2
  4. absfuyu/cli/color.py +2 -2
  5. absfuyu/cli/config_group.py +2 -2
  6. absfuyu/cli/do_group.py +2 -2
  7. absfuyu/cli/game_group.py +2 -2
  8. absfuyu/cli/tool_group.py +2 -2
  9. absfuyu/config/__init__.py +2 -2
  10. absfuyu/core/__init__.py +4 -4
  11. absfuyu/core/baseclass.py +518 -154
  12. absfuyu/core/baseclass2.py +3 -3
  13. absfuyu/core/decorator.py +2 -2
  14. absfuyu/core/docstring.py +2 -2
  15. absfuyu/core/dummy_cli.py +2 -2
  16. absfuyu/core/dummy_func.py +2 -2
  17. absfuyu/dxt/__init__.py +2 -2
  18. absfuyu/dxt/dictext.py +5 -11
  19. absfuyu/dxt/dxt_support.py +2 -2
  20. absfuyu/dxt/intext.py +4 -4
  21. absfuyu/dxt/listext.py +69 -14
  22. absfuyu/dxt/strext.py +5 -5
  23. absfuyu/extra/__init__.py +2 -2
  24. absfuyu/extra/beautiful.py +2 -2
  25. absfuyu/extra/da/__init__.py +3 -3
  26. absfuyu/extra/da/dadf.py +4 -4
  27. absfuyu/extra/da/dadf_base.py +2 -2
  28. absfuyu/extra/da/df_func.py +2 -2
  29. absfuyu/extra/da/mplt.py +2 -2
  30. absfuyu/extra/data_analysis.py +2 -2
  31. absfuyu/extra/pdf.py +89 -0
  32. absfuyu/fun/__init__.py +2 -2
  33. absfuyu/fun/rubik.py +2 -2
  34. absfuyu/fun/tarot.py +2 -2
  35. absfuyu/game/__init__.py +2 -2
  36. absfuyu/game/game_stat.py +2 -2
  37. absfuyu/game/sudoku.py +2 -2
  38. absfuyu/game/tictactoe.py +2 -2
  39. absfuyu/game/wordle.py +2 -2
  40. absfuyu/general/__init__.py +2 -2
  41. absfuyu/general/content.py +4 -4
  42. absfuyu/general/human.py +2 -2
  43. absfuyu/general/shape.py +2 -2
  44. absfuyu/logger.py +2 -2
  45. absfuyu/pkg_data/__init__.py +2 -2
  46. absfuyu/pkg_data/deprecated.py +2 -2
  47. absfuyu/sort.py +2 -2
  48. absfuyu/tools/__init__.py +2 -2
  49. absfuyu/tools/checksum.py +2 -2
  50. absfuyu/tools/converter.py +2 -2
  51. absfuyu/tools/generator.py +4 -4
  52. absfuyu/tools/inspector.py +325 -71
  53. absfuyu/tools/keygen.py +2 -2
  54. absfuyu/tools/obfuscator.py +4 -4
  55. absfuyu/tools/passwordlib.py +2 -2
  56. absfuyu/tools/shutdownizer.py +2 -2
  57. absfuyu/tools/sw.py +514 -0
  58. absfuyu/tools/web.py +2 -2
  59. absfuyu/typings.py +2 -2
  60. absfuyu/util/__init__.py +14 -3
  61. absfuyu/util/api.py +2 -2
  62. absfuyu/util/json_method.py +2 -2
  63. absfuyu/util/lunar.py +2 -2
  64. absfuyu/util/path.py +53 -4
  65. absfuyu/util/performance.py +2 -2
  66. absfuyu/util/shorten_number.py +2 -2
  67. absfuyu/util/text_table.py +33 -14
  68. absfuyu/util/zipped.py +2 -2
  69. absfuyu/version.py +3 -3
  70. {absfuyu-5.4.0.dist-info → absfuyu-5.6.0.dist-info}/METADATA +7 -2
  71. absfuyu-5.6.0.dist-info/RECORD +79 -0
  72. absfuyu-5.4.0.dist-info/RECORD +0 -77
  73. {absfuyu-5.4.0.dist-info → absfuyu-5.6.0.dist-info}/WHEEL +0 -0
  74. {absfuyu-5.4.0.dist-info → absfuyu-5.6.0.dist-info}/entry_points.txt +0 -0
  75. {absfuyu-5.4.0.dist-info → absfuyu-5.6.0.dist-info}/licenses/LICENSE +0 -0
absfuyu/tools/sw.py ADDED
@@ -0,0 +1,514 @@
1
+ """
2
+ Absufyu: Software
3
+ -----------------
4
+ Software, pyinstaller related stuff
5
+
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
+ """
9
+
10
+ # Module level
11
+ # ---------------------------------------------------------------------------
12
+ __all__ = [
13
+ # Function
14
+ "get_system_info",
15
+ "get_pyinstaller_exe_dir",
16
+ "get_pyinstaller_resource_path",
17
+ "HWIDgen",
18
+ "LicenseKeySystem",
19
+ "BasicSoftwareProtection",
20
+ # Support
21
+ "SystemInfo",
22
+ "LicenseKey",
23
+ ]
24
+
25
+
26
+ # Library
27
+ # ---------------------------------------------------------------------------
28
+ import base64
29
+ import hashlib
30
+ import hmac
31
+ import json
32
+ import os
33
+ import platform
34
+ import socket
35
+ import subprocess
36
+ import sys
37
+ import uuid
38
+ from datetime import datetime
39
+ from pathlib import Path
40
+ from typing import Literal, NamedTuple, TypedDict
41
+
42
+ from absfuyu.core.baseclass import BaseClass
43
+ from absfuyu.dxt import Text
44
+ from absfuyu.tools.converter import Base64EncodeDecode
45
+ from absfuyu.util import stop_after_day
46
+
47
+
48
+ # System Info
49
+ # ---------------------------------------------------------------------------
50
+ class SystemInfo(NamedTuple):
51
+ """System info"""
52
+
53
+ os_type: str | Literal["Windows", "Linux", "Darwin"]
54
+ os_kernel: str
55
+ os_arch: str
56
+ system_name: str
57
+
58
+
59
+ def get_system_info() -> SystemInfo:
60
+ """
61
+ Returns the current operating system info.
62
+
63
+ The function attempts to retrieve the computer name using `platform.node()`.
64
+ If that fails or returns an empty string, it falls back to environment variables
65
+ or `socket.gethostname()` as a last resort, depending on the OS.
66
+
67
+ Returns
68
+ -------
69
+ SystemInfo
70
+ A tuple containing:
71
+ - OS name (e.g., 'Windows', 'Linux', 'Darwin')
72
+ - OS kernel (version)
73
+ - OS arch
74
+ - Computer name (hostname)
75
+ """
76
+ os_name = platform.system()
77
+
78
+ # Get computer name
79
+ try:
80
+ computer_name = platform.node()
81
+ if not computer_name:
82
+ raise ValueError("Empty name")
83
+ except ValueError:
84
+ if os_name == "Windows":
85
+ computer_name = os.environ.get("COMPUTERNAME", "Unknown")
86
+ else:
87
+ computer_name = os.environ.get("HOSTNAME", socket.gethostname())
88
+
89
+ # Return
90
+ return SystemInfo(
91
+ os_name, platform.version(), platform.machine().lower(), computer_name
92
+ )
93
+
94
+
95
+ # Pyinstaller
96
+ # ---------------------------------------------------------------------------
97
+ def get_pyinstaller_exe_dir() -> Path:
98
+ """
99
+ Returns the directory where the current script or executable resides.
100
+
101
+ This function is useful for locating resources relative to the running script or
102
+ bundled executable (e.g., when using PyInstaller). It checks if the script is
103
+ running in a "frozen" state (as an executable), and returns the appropriate
104
+ directory accordingly.
105
+
106
+ Returns
107
+ -------
108
+ Path
109
+ A `pathlib.Path` object representing the directory containing the
110
+ executable or the current script file.
111
+ """
112
+ if getattr(sys, "frozen", False):
113
+ return Path(sys.executable).parent
114
+ else:
115
+ return Path(__file__).resolve().parent
116
+
117
+
118
+ def get_pyinstaller_resource_path(relative_path: str) -> Path:
119
+ r"""
120
+ Get the absolute path to a resource file, compatible with both development
121
+ environments and PyInstaller-packaged executables.
122
+
123
+ When running from a PyInstaller bundle, this function resolves the path relative
124
+ to the temporary `_MEIPASS` folder. During normal execution, it resolves the path
125
+ relative to the current script's directory.
126
+
127
+ Parameters
128
+ ----------
129
+ relative_path : str
130
+ Relative path to the resource file or directory.
131
+
132
+ Returns
133
+ -------
134
+ Path
135
+ A `pathlib.Path` object pointing to the absolute location of the resource.
136
+
137
+
138
+ Example:
139
+ --------
140
+ >>> get_pyinstaller_resource_path("assets/logo.png")
141
+ <path>\assets\logo.png
142
+ """
143
+ if hasattr(sys, "_MEIPASS"):
144
+ # PyInstaller temp folder
145
+ base_path = Path(getattr(sys, "_MEIPASS")) # type: ignore[attr-defined]
146
+ else:
147
+ base_path = Path(__file__).resolve().parent
148
+
149
+ return base_path / relative_path
150
+
151
+
152
+ # Key System
153
+ # ---------------------------------------------------------------------------
154
+ class HWIDgen(BaseClass):
155
+ """
156
+ Generate Hardware ID (HWID)
157
+
158
+
159
+ Example:
160
+ --------
161
+ >>> HWIDgen.generate()
162
+ """
163
+
164
+ def __init__(self) -> None:
165
+ pass
166
+
167
+ @classmethod
168
+ def generate(cls) -> str:
169
+ """Generate HWID for current system"""
170
+ os_type = platform.system().lower()
171
+ if os_type == "windows":
172
+ return cls._get_windows_hwid()
173
+ elif os_type == "linux":
174
+ return cls._get_linux_hwid()
175
+ else:
176
+ return cls._get_hwid_mac()
177
+
178
+ @staticmethod
179
+ def _get_hwid_mac() -> str:
180
+ """HWID: MAC address"""
181
+ mac = uuid.getnode() # 48-bit MAC address
182
+ mac_str = ":".join(("%012X" % mac)[i : i + 2] for i in range(0, 12, 2))
183
+ hwid = hashlib.sha256(mac_str.encode()).hexdigest()
184
+ return hwid
185
+
186
+ @staticmethod
187
+ def _get_windows_hwid() -> str:
188
+ try:
189
+ # Get BIOS serial number
190
+ bios = (
191
+ subprocess.check_output("wmic bios get serialnumber", shell=True)
192
+ .decode()
193
+ .split("\n")[1]
194
+ .strip()
195
+ )
196
+
197
+ # Get Motherboard serial
198
+ board = (
199
+ subprocess.check_output("wmic baseboard get serialnumber", shell=True)
200
+ .decode()
201
+ .split("\n")[1]
202
+ .strip()
203
+ )
204
+
205
+ # Get Disk serial
206
+ disk = (
207
+ subprocess.check_output("wmic diskdrive get serialnumber", shell=True)
208
+ .decode()
209
+ .split("\n")[1]
210
+ .strip()
211
+ )
212
+
213
+ raw = bios + board + disk
214
+ hwid = hashlib.sha256(raw.encode()).hexdigest()
215
+ return hwid
216
+ except Exception as e:
217
+ return f"Error getting HWID: {e}"
218
+
219
+ @staticmethod
220
+ def _get_linux_hwid() -> str:
221
+ try:
222
+ disk_info = subprocess.check_output(
223
+ "udevadm info --query=all --name=/dev/sda", shell=True
224
+ ).decode()
225
+ serial = ""
226
+ for line in disk_info.splitlines():
227
+ if "ID_SERIAL_SHORT" in line:
228
+ serial = line.split("=")[1]
229
+ break
230
+
231
+ mac = uuid.getnode()
232
+ mac_str = ":".join(("%012X" % mac)[i : i + 2] for i in range(0, 12, 2))
233
+
234
+ raw = serial + mac_str
235
+ hwid = hashlib.sha256(raw.encode()).hexdigest()
236
+ return hwid
237
+ except Exception as e:
238
+ return f"Error getting HWID: {e}"
239
+
240
+
241
+ class LicenseKey(TypedDict):
242
+ name: str
243
+ expiry: str
244
+ signature: str
245
+
246
+
247
+ class LicenseKeySystem(BaseClass):
248
+
249
+ def __init__(self, name: str, expiry: str, secret_key: str) -> None:
250
+ """
251
+ License Key implementation
252
+
253
+ Parameters
254
+ ----------
255
+ name : str
256
+ Name of license holder
257
+
258
+ expiry : str
259
+ Expiry date (in yyyy-mm-dd format)
260
+
261
+ secret_key : str
262
+ Secret key to make license key
263
+ """
264
+ self._name = name
265
+ self._expiry = expiry
266
+ self._secret = secret_key.encode("utf-8")
267
+
268
+ # Make key
269
+ def make_key(self, *, hwid_overwrite: str | None = None) -> str:
270
+ """
271
+ Make a license key in these following steps:
272
+ 1. Generate HWID
273
+ 2. Combine name, expiry, HWID -> sign it
274
+ 3. Base64-encode the signature
275
+ 4. Store name, expiry, signature in JSON -> Base64 -> Hex
276
+ 5. Divide by 30 chars per line
277
+ 6. Wrap in BEGIN/END KEY
278
+
279
+ Parameters
280
+ ----------
281
+ hwid_overwrite : str | None, optional
282
+ Overwrite the HWID, by default None
283
+
284
+ Returns
285
+ -------
286
+ str
287
+ License key
288
+ """
289
+ # Prepare
290
+ hwid = (
291
+ HWIDgen.generate() if hwid_overwrite is None else hwid_overwrite
292
+ ) # Get HWID
293
+ msg_data = f"{self._name}|{self._expiry}|{hwid}" # Make msg
294
+ signature = hmac.new(self._secret, msg_data.encode(), hashlib.sha256).digest()
295
+ encoded_sig = base64.urlsafe_b64encode(signature).decode()
296
+
297
+ # Key format
298
+ key: LicenseKey = {
299
+ "name": self._name,
300
+ "expiry": self._expiry,
301
+ "signature": encoded_sig,
302
+ }
303
+ # This convert to .json -> Base64 -> Hex
304
+ encoded_key = Text(Base64EncodeDecode.encode(json.dumps(key))).to_hex(raw=True)
305
+ output_key = (
306
+ "BEGIN KEY".center(30, "=")
307
+ + "\n"
308
+ + "\n".join(Text(encoded_key).divide(30))
309
+ + "\n"
310
+ + "END KEY".center(30, "=")
311
+ )
312
+ return output_key
313
+
314
+ # Check key
315
+ @staticmethod
316
+ def _parse_license_key(license_key: str) -> LicenseKey:
317
+ """
318
+ Parse a formatted license key
319
+
320
+ Parameters
321
+ ----------
322
+ license_key : str
323
+ License key
324
+
325
+ Returns
326
+ -------
327
+ LicenseKey
328
+ Parsed license key
329
+ """
330
+ try:
331
+ lines = license_key.strip().split("\n")[1:-1] # Remove BEGIN/END KEY lines
332
+ raw_hex = "".join(lines)
333
+ decoded_json = Base64EncodeDecode.decode(
334
+ bytes.fromhex(raw_hex).decode("utf-8")
335
+ )
336
+ return json.loads(decoded_json)
337
+ except Exception as e:
338
+ raise ValueError("Invalid license key format") from e
339
+
340
+ @classmethod
341
+ def verify_license(
342
+ cls,
343
+ license_key: str,
344
+ secret_key: str | None = None,
345
+ hwid_overwrite: str | None = None,
346
+ ) -> bool:
347
+ """
348
+ Verify a license key
349
+
350
+ Parameters
351
+ ----------
352
+ license_key : str
353
+ License key
354
+
355
+ secret_key : str | None, optional
356
+ Secret key, by default None
357
+
358
+ hwid_overwrite : str | None, optional
359
+ HWID, by default None
360
+
361
+ Returns
362
+ -------
363
+ bool
364
+ _description_
365
+ """
366
+
367
+ # Prep
368
+ secret = "" if secret_key is None else secret_key
369
+ hwid = HWIDgen.generate() if hwid_overwrite is None else hwid_overwrite
370
+ parsed_license_key = cls._parse_license_key(license_key)
371
+
372
+ try:
373
+ msg_data = (
374
+ f"{parsed_license_key['name']}|{parsed_license_key['expiry']}|{hwid}"
375
+ )
376
+
377
+ expected_sig = hmac.new(
378
+ secret.encode("utf-8"), msg_data.encode(), hashlib.sha256
379
+ ).digest()
380
+ expected_encoded_sig = base64.urlsafe_b64encode(expected_sig).decode()
381
+
382
+ return parsed_license_key["signature"] == expected_encoded_sig
383
+ except Exception:
384
+ return False
385
+
386
+
387
+ # Software
388
+ # ---------------------------------------------------------------------------
389
+ class BasicSoftwareProtection(BaseClass):
390
+ """
391
+ Basic software protection
392
+
393
+ This check valid license before run any app. Recommended to put at start of the code
394
+
395
+ Usage:
396
+ ------
397
+ >>> t = BasicSoftwareProtection(get_pyinstaller_exe_dir())
398
+ >>> t.add_secret("Test Key")
399
+ >>> t.check_valid_license()
400
+ """
401
+
402
+ def __init__(
403
+ self,
404
+ cwd: str | Path,
405
+ name: str | None = None,
406
+ version: str | None = None,
407
+ author: str | None = None,
408
+ author_email: str | None = None,
409
+ ) -> None:
410
+ """
411
+ Basic software protection.
412
+
413
+ Parameters
414
+ ----------
415
+ cwd : str | Path
416
+ Current working directory
417
+
418
+ name : str | None, optional
419
+ Name of the software, by default None
420
+
421
+ version : str | None, optional
422
+ Version of the software, by default None
423
+
424
+ author : str | None, optional
425
+ Author of the software, by default None
426
+
427
+ author_email : str | None, optional
428
+ Author's email of the software, by default None
429
+ """
430
+ self._cwd = Path(cwd)
431
+ self._author = "" if author is None else author
432
+ self._author_email = "" if author_email is None else author_email
433
+ self._software_name = "" if name is None else name
434
+ self._software_version = "" if version is None else version
435
+ self._secret = ""
436
+
437
+ # Metadata
438
+ @property
439
+ def cwd(self) -> Path:
440
+ """Current working directory"""
441
+ return self._cwd
442
+
443
+ @property
444
+ def software_name(self) -> str:
445
+ """Name of the software"""
446
+ return self._software_name
447
+
448
+ @software_name.setter
449
+ def software_name(self, value: str) -> None:
450
+ # Logic to validate name
451
+ self._software_name = value
452
+
453
+ @property
454
+ def author(self) -> str:
455
+ """Author of the software"""
456
+ return self._author
457
+
458
+ @property
459
+ def version(self) -> str:
460
+ """Version of the software"""
461
+ return self._software_version
462
+
463
+ # Protection
464
+ def add_secret(self, secret: str) -> None:
465
+ """
466
+ Add secret
467
+
468
+ Parameters
469
+ ----------
470
+ secret : str
471
+ secret
472
+ """
473
+ self._secret = secret
474
+
475
+ def check_valid_license(self, generate_helper: bool = True) -> None:
476
+ try:
477
+ # Get license file
478
+ license_file = list(self._cwd.glob("*.zlic"))[0]
479
+ with license_file.open() as f:
480
+ # Load data
481
+ data = "".join(f.readlines())
482
+ except IndexError:
483
+ if generate_helper:
484
+ self.generate_license_helper()
485
+ raise SystemExit("License file not found!")
486
+ except ValueError:
487
+ raise SystemExit("Invalid license key format!")
488
+ else:
489
+ # Verify license
490
+ if LicenseKeySystem.verify_license(data, secret_key=self._secret):
491
+ parsed_date = datetime.strptime(
492
+ LicenseKeySystem._parse_license_key(data)["expiry"], "%Y-%m-%d"
493
+ )
494
+ stop_after_day(
495
+ parsed_date.year,
496
+ parsed_date.month,
497
+ parsed_date.day,
498
+ custom_msg="License expired!",
499
+ )
500
+ else: # Invalid license
501
+ raise SystemExit("Invalid license!")
502
+
503
+ # Make key
504
+ def _make_key(self, name: str, expiry: str, secret: str):
505
+ path = self._cwd.joinpath("license.zlic")
506
+ engine = LicenseKeySystem(name, expiry, secret)
507
+ with path.open("w", encoding="utf-8") as f:
508
+ f.write(engine.make_key())
509
+
510
+ def generate_license_helper(self):
511
+ """Gather HWID and make it into a file"""
512
+ path = self._cwd.joinpath("license.helper")
513
+ with path.open("w", encoding="utf-8") as f:
514
+ f.write(HWIDgen.generate())
absfuyu/tools/web.py CHANGED
@@ -3,8 +3,8 @@ Absfuyu: Web
3
3
  ------------
4
4
  Web, ``request``, ``BeautifulSoup`` stuff
5
5
 
6
- Version: 5.4.0
7
- Date updated: 21/03/2025 (dd/mm/yyyy)
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
  # Library
absfuyu/typings.py CHANGED
@@ -3,8 +3,8 @@ Absfuyu: Core
3
3
  -------------
4
4
  Pre-defined typing
5
5
 
6
- Version: 5.4.0
7
- Date updated: 21/03/2025 (dd/mm/yyyy)
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
  # Module Package
absfuyu/util/__init__.py CHANGED
@@ -3,8 +3,8 @@ Absufyu: Utilities
3
3
  ------------------
4
4
  Some random utilities
5
5
 
6
- Version: 5.4.0
7
- Date updated: 21/03/2025 (dd/mm/yyyy)
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
  # Module Package
@@ -163,9 +163,14 @@ def set_min_max(
163
163
  return current_value
164
164
 
165
165
 
166
+ @versionchanged("5.6.0", reason="New `custom_msg` parameter")
166
167
  @versionadded("3.2.0")
167
168
  def stop_after_day(
168
- year: int | None = None, month: int | None = None, day: int | None = None
169
+ year: int | None = None,
170
+ month: int | None = None,
171
+ day: int | None = None,
172
+ *,
173
+ custom_msg: str | None = None,
169
174
  ) -> None:
170
175
  """
171
176
  Stop working after specified day.
@@ -184,6 +189,10 @@ def stop_after_day(
184
189
  day : int
185
190
  Desired day
186
191
  (Default: ``None`` - 1 day trial)
192
+
193
+ custom_msg : str
194
+ Custom exit message
195
+ (Default: ``None``)
187
196
  """
188
197
  # None checking - By default: 1 day trial
189
198
  now = datetime.now()
@@ -198,6 +207,8 @@ def stop_after_day(
198
207
  end_date = datetime(year, month, day)
199
208
  result = end_date - now
200
209
  if result.days < 0:
210
+ if custom_msg:
211
+ raise SystemExit(custom_msg)
201
212
  raise SystemExit("End of time")
202
213
 
203
214
 
absfuyu/util/api.py CHANGED
@@ -3,8 +3,8 @@ Absufyu: API
3
3
  ------------
4
4
  Fetch data stuff
5
5
 
6
- Version: 5.4.0
7
- Date updated: 21/03/2025 (dd/mm/yyyy)
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
  # Module level
@@ -3,8 +3,8 @@ Absfuyu: Json Method
3
3
  --------------------
4
4
  ``.json`` file handling
5
5
 
6
- Version: 5.4.0
7
- Date updated: 21/03/2025 (dd/mm/yyyy)
6
+ Version: 5.6.0
7
+ Date updated: 12/09/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
  # Module level
absfuyu/util/lunar.py CHANGED
@@ -4,8 +4,8 @@ Absfuyu: Lunar calendar
4
4
  -----------------------
5
5
  Convert to lunar calendar
6
6
 
7
- Version: 5.4.0
8
- Date updated: 21/03/2025 (dd/mm/yyyy)
7
+ Version: 5.6.0
8
+ Date updated: 12/09/2025 (dd/mm/yyyy)
9
9
 
10
10
  Source:
11
11
  -------