mopt 0.0.1__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.
mopt/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """MOpt: mathematical optimization solvers.
2
+
3
+ Implements solvers for linear programming (Simplex, ...), nonlinear
4
+ programming (line search, Newton's method, ...), and other classes of
5
+ optimization problems.
6
+ """
7
+
8
+ from mopt.base_classes import BaseOptimizer, OptimizeResult
9
+
10
+ __version__ = "0.0.1"
11
+
12
+ __all__ = ["BaseOptimizer", "OptimizeResult"]
@@ -0,0 +1,5 @@
1
+ """Automatic differentiation helpers (optional backends)."""
2
+
3
+ from mopt.autodiff.torch_adapter import from_torch, torch_function, torch_gradient
4
+
5
+ __all__ = ["from_torch", "torch_function", "torch_gradient"]
@@ -0,0 +1,75 @@
1
+ """Exact gradients via PyTorch autodiff, behind a NumPy-facing interface.
2
+
3
+ PyTorch is an optional dependency (``pip install mopt[torch]``); it is
4
+ imported lazily so the rest of mopt works without it. Functions passed to
5
+ this module must be written with torch operations — autodiff cannot see
6
+ through plain-NumPy code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ import numpy as np
14
+
15
+ from mopt.nonlinear import NLPProblem
16
+
17
+
18
+ def _torch():
19
+ try:
20
+ import torch
21
+ except ImportError as exc: # pragma: no cover
22
+ raise ImportError(
23
+ "PyTorch is required for mopt.autodiff.torch_adapter; "
24
+ "install it with: pip install mopt[torch]"
25
+ ) from exc
26
+ return torch
27
+
28
+
29
+ def torch_function(f) -> Callable[[np.ndarray], float]:
30
+ """Wrap a torch-written scalar function as a NumPy-facing objective."""
31
+
32
+ def f_np(x: np.ndarray) -> float:
33
+ torch = _torch()
34
+ with torch.no_grad():
35
+ return float(f(torch.as_tensor(np.asarray(x, dtype=float))))
36
+
37
+ return f_np
38
+
39
+
40
+ def torch_gradient(f) -> Callable[[np.ndarray], np.ndarray]:
41
+ """Wrap a torch-written scalar function as a NumPy-facing gradient.
42
+
43
+ The returned callable evaluates the exact gradient of ``f`` by reverse-
44
+ mode autodiff: one forward pass builds the computation graph, one
45
+ backward pass applies the chain rule — the full gradient costs roughly
46
+ one extra evaluation of ``f`` regardless of the dimension.
47
+
48
+ Parameters
49
+ ----------
50
+ f : callable
51
+ Maps a 1-D float64 torch tensor to a scalar torch tensor, using
52
+ torch operations throughout.
53
+
54
+ Returns
55
+ -------
56
+ callable
57
+ ``grad(x: np.ndarray) -> np.ndarray`` with float64 precision.
58
+ """
59
+
60
+ def grad(x: np.ndarray) -> np.ndarray:
61
+ torch = _torch()
62
+ xt = torch.tensor(np.asarray(x, dtype=float), requires_grad=True)
63
+ (g,) = torch.autograd.grad(f(xt), xt)
64
+ return g.numpy()
65
+
66
+ return grad
67
+
68
+
69
+ def from_torch(f, x0) -> NLPProblem:
70
+ """Build an :class:`~mopt.nonlinear.NLPProblem` from a torch-written ``f``.
71
+
72
+ The objective and its exact autodiff gradient are both wired in, so the
73
+ resulting problem never falls back to finite differences.
74
+ """
75
+ return NLPProblem(f=torch_function(f), x0=x0, grad=torch_gradient(f))
mopt/base_classes.py ADDED
@@ -0,0 +1,66 @@
1
+ """Base classes shared by every MOpt optimizer.
2
+
3
+ All solvers follow one contract: a Problem object describes *what* to
4
+ optimize, and each optimizer implements ``solve(problem) -> OptimizeResult``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+
16
+ @dataclass
17
+ class OptimizeResult:
18
+ """Outcome of an optimization run, returned by every solver.
19
+
20
+ Attributes
21
+ ----------
22
+ x : np.ndarray or None
23
+ The solution found, or None when the run failed (e.g. the problem
24
+ is infeasible or unbounded).
25
+ fun : float or None
26
+ Objective value at ``x``, or None when there is no solution.
27
+ success : bool
28
+ Whether the solver terminated with a valid solution.
29
+ message : str
30
+ Human-readable description of the termination cause.
31
+ n_iter : int
32
+ Number of iterations performed.
33
+ """
34
+
35
+ x: np.ndarray | None
36
+ fun: float | None
37
+ success: bool
38
+ message: str = ""
39
+ n_iter: int = 0
40
+
41
+
42
+ class BaseOptimizer(ABC):
43
+ """Abstract base class that all MOpt optimizers must inherit.
44
+
45
+ Subclasses implement :meth:`solve`; they must not mutate the problem
46
+ they are given, and must report failures (infeasible, unbounded,
47
+ iteration limit, ...) via ``OptimizeResult.success`` and
48
+ ``OptimizeResult.message`` rather than by raising.
49
+ """
50
+
51
+ @abstractmethod
52
+ def solve(self, problem: Any) -> OptimizeResult:
53
+ """Solve the given problem.
54
+
55
+ Parameters
56
+ ----------
57
+ problem : Any
58
+ A problem description understood by this solver. Concrete
59
+ Problem classes are introduced alongside the first solver for
60
+ each problem class.
61
+
62
+ Returns
63
+ -------
64
+ OptimizeResult
65
+ The solution and termination details.
66
+ """
@@ -0,0 +1,6 @@
1
+ """Linear programming problems and solvers."""
2
+
3
+ from mopt.linear.problem import LPProblem
4
+ from mopt.linear.simplex import Simplex
5
+
6
+ __all__ = ["LPProblem", "Simplex"]
mopt/linear/problem.py ADDED
@@ -0,0 +1,59 @@
1
+ """Linear programming problem description."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass
11
+ class LPProblem:
12
+ """A linear program.
13
+
14
+ Describes the problem
15
+
16
+ .. math::
17
+
18
+ \\min_x \\; c^T x
19
+ \\quad \\text{s.t.} \\quad
20
+ A_{ub} x \\le b_{ub}, \\;
21
+ A_{eq} x = b_{eq}, \\;
22
+ x \\ge 0
23
+
24
+ Attributes
25
+ ----------
26
+ c : np.ndarray, shape (n,)
27
+ Objective coefficients (minimized).
28
+ A_ub, b_ub : np.ndarray or None
29
+ Inequality constraints ``A_ub @ x <= b_ub``, shapes (m_ub, n) and (m_ub,).
30
+ A_eq, b_eq : np.ndarray or None
31
+ Equality constraints ``A_eq @ x == b_eq``, shapes (m_eq, n) and (m_eq,).
32
+ """
33
+
34
+ c: np.ndarray
35
+ A_ub: np.ndarray | None = None
36
+ b_ub: np.ndarray | None = None
37
+ A_eq: np.ndarray | None = None
38
+ b_eq: np.ndarray | None = None
39
+
40
+ def __post_init__(self):
41
+ self.c = np.atleast_1d(np.asarray(self.c, dtype=float))
42
+ if self.c.ndim != 1:
43
+ raise ValueError("c must be a 1-D array.")
44
+ n = self.c.size
45
+ for name in ("ub", "eq"):
46
+ A = getattr(self, f"A_{name}")
47
+ b = getattr(self, f"b_{name}")
48
+ if (A is None) != (b is None):
49
+ raise ValueError(f"A_{name} and b_{name} must be given together.")
50
+ if A is None:
51
+ continue
52
+ A = np.atleast_2d(np.asarray(A, dtype=float))
53
+ b = np.atleast_1d(np.asarray(b, dtype=float))
54
+ if A.shape != (b.size, n):
55
+ raise ValueError(
56
+ f"A_{name} has shape {A.shape}, expected ({b.size}, {n})."
57
+ )
58
+ setattr(self, f"A_{name}", A)
59
+ setattr(self, f"b_{name}", b)
mopt/linear/simplex.py ADDED
@@ -0,0 +1,161 @@
1
+ """Two-phase primal simplex method on a dense tableau.
2
+
3
+ The problem is first brought to standard form ``A x = b, x >= 0, b >= 0`` by
4
+ adding one slack variable per inequality row and negating rows with negative
5
+ right-hand sides. Phase 1 then minimizes the sum of artificial variables to
6
+ find a basic feasible solution (a positive optimum proves infeasibility);
7
+ phase 2 minimizes the original objective from that starting basis. Pivots
8
+ follow Bland's rule (smallest-index entering and leaving variable), which
9
+ guarantees termination even on degenerate problems.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ from mopt.base_classes import BaseOptimizer, OptimizeResult
17
+ from mopt.linear.problem import LPProblem
18
+
19
+
20
+ def _pivot(T: np.ndarray, basis: list[int], row: int, col: int) -> None:
21
+ """Pivot the tableau so that ``col`` becomes basic in ``row``."""
22
+ T[row] /= T[row, col]
23
+ factors = T[:, col].copy()
24
+ factors[row] = 0.0
25
+ T -= np.outer(factors, T[row])
26
+ basis[row] = col
27
+
28
+
29
+ class Simplex(BaseOptimizer):
30
+ """Two-phase tableau simplex solver for :class:`LPProblem`.
31
+
32
+ Parameters
33
+ ----------
34
+ max_iter : int
35
+ Maximum number of pivots across both phases.
36
+ tol : float
37
+ Numerical tolerance for optimality, ratio, and feasibility tests.
38
+ """
39
+
40
+ def __init__(self, max_iter: int = 10_000, tol: float = 1e-9):
41
+ self.max_iter = max_iter
42
+ self.tol = tol
43
+
44
+ def solve(self, problem: LPProblem) -> OptimizeResult:
45
+ c, A, b = self._standard_form(problem)
46
+ m, n_total = A.shape
47
+
48
+ # Phase 1: minimize the sum of one artificial variable per row,
49
+ # starting from the all-artificial basis. Tableau layout: one row per
50
+ # constraint plus a reduced-cost row; last column is the RHS, and
51
+ # T[-1, -1] holds minus the current objective value.
52
+ T = np.zeros((m + 1, n_total + m + 1))
53
+ T[:m, :n_total] = A
54
+ T[:m, n_total:-1] = np.eye(m)
55
+ T[:m, -1] = b
56
+ T[-1, :n_total] = -A.sum(axis=0)
57
+ T[-1, -1] = -b.sum()
58
+ basis = list(range(n_total, n_total + m))
59
+
60
+ status, it1 = self._iterate(T, basis, self.max_iter)
61
+ if status == "maxiter":
62
+ return OptimizeResult(
63
+ x=None, fun=None, success=False,
64
+ message="Iteration limit reached in phase 1.", n_iter=it1,
65
+ )
66
+ if -T[-1, -1] > self.tol * (1.0 + np.abs(b).sum()):
67
+ return OptimizeResult(
68
+ x=None, fun=None, success=False,
69
+ message="Problem is infeasible.", n_iter=it1,
70
+ )
71
+
72
+ # Drive leftover artificials out of the basis (they sit at value 0
73
+ # after a feasible phase 1); a row with no usable pivot is redundant.
74
+ drop = []
75
+ for i in range(m):
76
+ if basis[i] >= n_total:
77
+ j = int(np.argmax(np.abs(T[i, :n_total])))
78
+ if abs(T[i, j]) > self.tol:
79
+ _pivot(T, basis, i, j)
80
+ else:
81
+ drop.append(i)
82
+ if drop:
83
+ T = np.delete(T, drop, axis=0)
84
+ basis = [bj for i, bj in enumerate(basis) if i not in drop]
85
+ T = np.hstack([T[:, :n_total], T[:, -1:]])
86
+
87
+ # Phase 2: restore the original objective, priced out over the basis.
88
+ T[-1, :-1] = c
89
+ T[-1, -1] = 0.0
90
+ for i, j in enumerate(basis):
91
+ if c[j] != 0.0:
92
+ T[-1] -= c[j] * T[i]
93
+
94
+ status, it2 = self._iterate(T, basis, self.max_iter - it1)
95
+ n_iter = it1 + it2
96
+ if status == "unbounded":
97
+ return OptimizeResult(
98
+ x=None, fun=None, success=False,
99
+ message="Problem is unbounded.", n_iter=n_iter,
100
+ )
101
+ if status == "maxiter":
102
+ return OptimizeResult(
103
+ x=None, fun=None, success=False,
104
+ message="Iteration limit reached in phase 2.", n_iter=n_iter,
105
+ )
106
+
107
+ x_full = np.zeros(n_total)
108
+ x_full[basis] = T[:-1, -1]
109
+ x = x_full[: problem.c.size]
110
+ return OptimizeResult(
111
+ x=x, fun=float(problem.c @ x), success=True,
112
+ message="Optimal solution found.", n_iter=n_iter,
113
+ )
114
+
115
+ def _iterate(self, T: np.ndarray, basis: list[int], max_iter: int):
116
+ """Run simplex pivots until optimal, unbounded, or out of budget."""
117
+ m = len(basis)
118
+ for it in range(max_iter):
119
+ negative = np.where(T[-1, :-1] < -self.tol)[0]
120
+ if negative.size == 0:
121
+ return "optimal", it
122
+ col = int(negative[0]) # Bland's rule: smallest index enters
123
+ column = T[:m, col]
124
+ eligible = column > self.tol
125
+ if not eligible.any():
126
+ return "unbounded", it
127
+ ratios = np.full(m, np.inf)
128
+ ratios[eligible] = T[:m, -1][eligible] / column[eligible]
129
+ ties = np.where(ratios <= ratios.min() + self.tol)[0]
130
+ row = int(min(ties, key=lambda i: basis[i])) # smallest leaves
131
+ _pivot(T, basis, row, col)
132
+ return "maxiter", max_iter
133
+
134
+ @staticmethod
135
+ def _standard_form(problem: LPProblem):
136
+ """Return ``(c, A, b)`` for the equivalent ``A x = b, x >= 0, b >= 0``.
137
+
138
+ One slack variable is appended per inequality row; rows with a
139
+ negative right-hand side are negated.
140
+ """
141
+ n = problem.c.size
142
+ m_ub = 0 if problem.A_ub is None else problem.A_ub.shape[0]
143
+ rows, rhs = [], []
144
+ if m_ub:
145
+ rows.append(np.hstack([problem.A_ub, np.eye(m_ub)]))
146
+ rhs.append(problem.b_ub)
147
+ if problem.A_eq is not None:
148
+ m_eq = problem.A_eq.shape[0]
149
+ rows.append(np.hstack([problem.A_eq, np.zeros((m_eq, m_ub))]))
150
+ rhs.append(problem.b_eq)
151
+ if rows:
152
+ A = np.vstack(rows)
153
+ b = np.concatenate(rhs).astype(float)
154
+ else:
155
+ A = np.empty((0, n + m_ub))
156
+ b = np.empty(0)
157
+ neg = b < 0
158
+ A[neg] *= -1
159
+ b[neg] *= -1
160
+ c = np.concatenate([problem.c, np.zeros(m_ub)])
161
+ return c, A, b
@@ -0,0 +1,6 @@
1
+ """Nonlinear programming problems and solvers."""
2
+
3
+ from mopt.nonlinear.finite_diff import finite_difference_gradient
4
+ from mopt.nonlinear.problem import NLPProblem
5
+
6
+ __all__ = ["NLPProblem", "finite_difference_gradient"]
@@ -0,0 +1,53 @@
1
+ """Numerical differentiation by central finite differences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ import numpy as np
8
+
9
+
10
+ def finite_difference_gradient(
11
+ f: Callable[[np.ndarray], float],
12
+ x: np.ndarray,
13
+ h: float | None = None,
14
+ ) -> np.ndarray:
15
+ """Approximate the gradient of ``f`` at ``x`` by central differences.
16
+
17
+ Each component is estimated as
18
+
19
+ .. math::
20
+
21
+ \\partial_i f(x) \\approx
22
+ \\frac{f(x + h_i e_i) - f(x - h_i e_i)}{2 h_i},
23
+
24
+ with a per-coordinate step ``h_i = h * (1 + |x_i|)`` so the step scales
25
+ with the magnitude of ``x``. The truncation error is O(h^2), at the cost
26
+ of ``2 n`` evaluations of ``f``.
27
+
28
+ Parameters
29
+ ----------
30
+ f : callable
31
+ Scalar-valued function of a 1-D array.
32
+ x : np.ndarray, shape (n,)
33
+ Point at which to differentiate.
34
+ h : float, optional
35
+ Base step size. Defaults to ``eps ** (1/3)`` (about 6e-6), the
36
+ rule-of-thumb optimum balancing truncation and rounding error for
37
+ central differences.
38
+
39
+ Returns
40
+ -------
41
+ np.ndarray, shape (n,)
42
+ Gradient estimate.
43
+ """
44
+ x = np.asarray(x, dtype=float)
45
+ if h is None:
46
+ h = float(np.finfo(float).eps) ** (1 / 3)
47
+ steps = h * (1.0 + np.abs(x))
48
+ g = np.empty(x.size)
49
+ for i in range(x.size):
50
+ e = np.zeros_like(x)
51
+ e[i] = steps[i]
52
+ g[i] = (f(x + e) - f(x - e)) / (2.0 * steps[i])
53
+ return g
@@ -0,0 +1,58 @@
1
+ """Nonlinear programming problem description."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable
7
+
8
+ import numpy as np
9
+
10
+ from mopt.nonlinear.finite_diff import finite_difference_gradient
11
+
12
+
13
+ @dataclass
14
+ class NLPProblem:
15
+ """An unconstrained nonlinear program.
16
+
17
+ Describes the problem
18
+
19
+ .. math::
20
+
21
+ \\min_{x \\in \\mathbb{R}^n} \\; f(x)
22
+
23
+ Attributes
24
+ ----------
25
+ f : callable
26
+ Objective ``f(x) -> float`` for a 1-D array ``x``.
27
+ x0 : np.ndarray, shape (n,)
28
+ Starting point; also fixes the problem dimension.
29
+ grad : callable or None
30
+ Gradient ``grad(x) -> np.ndarray`` of shape (n,). When None,
31
+ :meth:`gradient` falls back to central finite differences of ``f``.
32
+ hess : callable or None
33
+ Hessian ``hess(x) -> np.ndarray`` of shape (n, n). Optional; needed
34
+ only by second-order solvers such as Newton's method.
35
+ """
36
+
37
+ f: Callable[[np.ndarray], float]
38
+ x0: np.ndarray
39
+ grad: Callable[[np.ndarray], np.ndarray] | None = None
40
+ hess: Callable[[np.ndarray], np.ndarray] | None = None
41
+
42
+ def __post_init__(self):
43
+ if not callable(self.f):
44
+ raise TypeError("f must be callable.")
45
+ self.x0 = np.atleast_1d(np.asarray(self.x0, dtype=float))
46
+ if self.x0.ndim != 1:
47
+ raise ValueError("x0 must be a 1-D array.")
48
+
49
+ def gradient(self, x: np.ndarray) -> np.ndarray:
50
+ """Gradient of ``f`` at ``x``.
51
+
52
+ Uses the user-supplied ``grad`` when given, otherwise central
53
+ finite differences of ``f``. Solvers should call this rather than
54
+ ``grad`` directly so the fallback applies uniformly.
55
+ """
56
+ if self.grad is not None:
57
+ return np.asarray(self.grad(x), dtype=float)
58
+ return finite_difference_gradient(self.f, x)
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: mopt
3
+ Version: 0.0.1
4
+ Summary: Mathematical optimization solvers: linear programming, nonlinear programming, and more
5
+ Project-URL: Homepage, https://github.com/Trminh06-work/MOpt
6
+ Project-URL: Issues, https://github.com/Trminh06-work/MOpt/issues
7
+ Author-email: Trần Bảo Minh <trminh06.work@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: numpy>=1.24
16
+ Provides-Extra: torch
17
+ Requires-Dist: torch; extra == 'torch'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # MOpt
21
+
22
+ Mathematical optimization solvers in Python, implemented from scratch.
23
+
24
+ MOpt aims to cover the major classes of optimization problems behind a single,
25
+ consistent solver interface:
26
+
27
+ - **Linear programming** — Simplex method, and more
28
+ - **Nonlinear programming** — line search, gradient descent, Newton's method, and more
29
+ - **Planned** — convex optimization, metaheuristics, matheuristics
30
+
31
+ > ⚠️ Early development. The API is not yet stable and solvers are being added
32
+ > incrementally.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install mopt
38
+ ```
39
+
40
+ Or install the latest development version from GitHub:
41
+
42
+ ```bash
43
+ pip install git+https://github.com/Trminh06-work/MOpt.git
44
+ ```
45
+
46
+ ## License
47
+
48
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ mopt/__init__.py,sha256=c3DcEd7efBYI5KbUz4QCLd6XjyKl6bws8hq9uHp49as,341
2
+ mopt/base_classes.py,sha256=-MjilgUoCRfVic1xHg4X8Fp5Xf-qGUuaquV3KVUPHrQ,1871
3
+ mopt/autodiff/__init__.py,sha256=J7S3MxhTNjhvHHMuVptjNEdyvpGuoNEXeBxqHhayM24,207
4
+ mopt/autodiff/torch_adapter.py,sha256=S0SxHR2wD7D_rUo1M7U44dZ9CqHLMy0CGKq7tj1GeU0,2315
5
+ mopt/linear/__init__.py,sha256=Rj3yfcXO59_KJaBuJlUCXQW83RV-l5auNyhYxlWzRyo,166
6
+ mopt/linear/problem.py,sha256=TiIaYBS3YaxraueTC4F1RY6dm-4Cmrc0lmqP6vwuCe4,1756
7
+ mopt/linear/simplex.py,sha256=I_8gcIz7I9cRGFJ9k1U6QqLItBSAtFuJfRkz0kRUzY8,6118
8
+ mopt/nonlinear/__init__.py,sha256=s0qiVw9a65iokxLF66xkz49GqfZKQKHN1loIAkCWWng,219
9
+ mopt/nonlinear/finite_diff.py,sha256=Xoo_-nBO7EiXP8gZCoN2LOKNS6sQ67j4iXv35JXSUak,1455
10
+ mopt/nonlinear/problem.py,sha256=h_pTKs95F741Tx9h8x37MUwIWAf13IT6YMkhU9t820E,1838
11
+ mopt-0.0.1.dist-info/METADATA,sha256=O8Q-7_OQRR46Ln-9uznXvkT-WkarpPpE95FUxmlC0O0,1452
12
+ mopt-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ mopt-0.0.1.dist-info/licenses/LICENSE,sha256=koiPNKlqANQS_0wef1_Koe6Yf5XchsqGw0bsVqZ95KY,1074
14
+ mopt-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trần Bảo Minh
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.