OptlangHelper 0.0.3__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.
@@ -0,0 +1,15 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from __future__ import absolute_import
4
+
5
+ import logging
6
+
7
+ __author__ = "Andrew Freiburger"
8
+ __email__ = "afreiburger@anl.gov"
9
+ __version__ = "0.0.3"
10
+
11
+ logger = logging.getLogger(__name__)
12
+ logger.debug("OptlangHelper %s", __version__)
13
+
14
+ from .optlanghelper import (OptlangHelper, GLPKHelper, CPLEXHelper, GurobiHelper, MatrixHelper,
15
+ Bounds, tupVariable, tupConstraint, tupObjective, define_model_auto)
@@ -0,0 +1,254 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Created on Thu Aug 18 10:26:32 2022
4
+ @author: Andrew Freiburger
5
+ """
6
+ from collections import namedtuple
7
+ from importlib import import_module
8
+ from optlang import Model
9
+ from typing import Iterable, Union
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ Bounds = namedtuple("Bounds", ("lb", "ub"), defaults=(0,1000))
15
+ tupVariable = namedtuple("tupVariable", ("name", "bounds", "type"), defaults=("varName", Bounds(), "continuous"))
16
+ tupConstraint = namedtuple("tupConstraint", ("name", "bounds", "expr"), defaults=("consName", Bounds(0,0), None))
17
+ tupObjective = namedtuple("tupObjective", ("name", "expr", "direction"), defaults=("objectiveName", None, "max"))
18
+
19
+ def isIterable(term):
20
+ try:
21
+ iter(term)
22
+ if type(term) is not str: return True
23
+ return False
24
+ except: return False
25
+
26
+ def isnumber(obj):
27
+ try: float(obj) ; return True
28
+ except: return False
29
+
30
+ def define_term(value):
31
+ if isnumber(value):
32
+ return {"type":"Number", "value": value}
33
+ if isinstance(value, str):
34
+ return {"type":"Symbol", "name": value}
35
+ logger.error(f"The {value} of type {type(value)} is not known.")
36
+
37
+ def get_expression_template(expr):
38
+ if isinstance(expr, list):
39
+ return {"type": "Add", "args": []}
40
+ return {"type": expr["operation"], "args": []}
41
+
42
+
43
+ class OptlangHelper:
44
+ """Bulk construction of an optlang model from tuple/dict descriptions.
45
+
46
+ ``interface`` pins the optlang solver interface (``"glpk"``, ``"cplex"``,
47
+ ``"gurobi"``); when None, optlang's preferred available interface is used
48
+ (whatever ``from optlang import Model`` resolves to)."""
49
+
50
+ interface = None
51
+
52
+ @classmethod
53
+ def _model_cls(cls):
54
+ if cls.interface is None:
55
+ return Model
56
+ try:
57
+ return import_module(f"optlang.{cls.interface}_interface").Model
58
+ except Exception as exc:
59
+ raise ImportError(
60
+ f"The optlang {cls.interface} interface is unavailable: {exc}") from exc
61
+
62
+ @staticmethod
63
+ def add_variables(var_name:str, var_bounds:(list, tuple), var_type:str="continuous"):
64
+ assert var_bounds[0] <= var_bounds[1], f"The {var_name} variable lower bound {var_bounds[0]} is greater than the upper bound {var_bounds[1]}. The lower bound must be less than the upper bound."
65
+ return {"name": var_name.replace(" ", "_"), "lb": var_bounds[0], "ub": var_bounds[1], "type": var_type}
66
+
67
+ @classmethod
68
+ def add_constraint(cls, cons_name:str, cons_bounds:(list, tuple), cons_expr:dict):
69
+ assert cons_bounds[0] is None or cons_bounds[1] is None or cons_bounds[0] <= cons_bounds[1], f"The {cons_name} constraint lower bound {cons_bounds[0]} is greater than the upper bound {cons_bounds[1]}. The lower bound must be less than the upper bound."
70
+ return {"name": cons_name.replace(" ", "_"),
71
+ "expression": cls._define_expression(cons_expr),
72
+ "lb": cons_bounds[0], "ub": cons_bounds[1], "indicator_variable": None, "active_when": 1}
73
+
74
+ @classmethod
75
+ def add_objective(cls, obj_name:str, objective_expr:Union[dict, list], direction:str):
76
+ if isinstance(objective_expr, list):
77
+ obj_expr = {"type": "Add", "args": [
78
+ cls._define_expression(expr) for expr in objective_expr]}
79
+ elif isinstance(objective_expr, dict):
80
+ obj_expr = {"type": objective_expr["operation"],
81
+ "args": [define_term(term) for term in objective_expr["elements"]]}
82
+ return {"name": obj_name.replace(" ", "_"), "expression": obj_expr, "direction": direction}
83
+
84
+ @classmethod
85
+ def define_model(cls, model_name, variables, constraints, objective, optlang=False):
86
+ model = {'name':model_name, 'variables':[], 'constraints':[]}
87
+ for var in variables:
88
+ var = list(var)
89
+ if len(var) == 2: var.append("continuous")
90
+ model["variables"].append(cls.add_variables(var[0], var[1], var[2]))
91
+ for cons in constraints:
92
+ model["constraints"].append(cls.add_constraint(cons[0], cons[1], cons[2]))
93
+ model["objective"] = cls.add_objective(objective[0], objective[1], objective[2])
94
+ if optlang: return cls._model_cls().from_json(model)
95
+ return model
96
+
97
+ @classmethod
98
+ def _define_expression(cls, expr:dict):
99
+ expression = get_expression_template(expr)
100
+ level1_coef = 0
101
+ for ele in expr["elements"]:
102
+ if not isnumber(ele) and not isinstance(ele, str):
103
+ arguments = []
104
+ level2_coef = 0
105
+ for ele2 in ele["elements"]:
106
+ if not isnumber(ele2) and not isinstance(ele2, str):
107
+ arguments.append(cls._define_expression(ele2))
108
+ elif isinstance(ele2, str): arguments.append(define_term(ele2))
109
+ else: level2_coef += float(ele2)
110
+ expression["args"].append(get_expression_template(ele))
111
+ if level2_coef != 0: arguments.append(define_term(level2_coef))
112
+ expression["args"][-1]["args"] = arguments
113
+ elif isinstance(ele, str): expression["args"].append(define_term(ele))
114
+ else: level1_coef += float(ele)
115
+ if level1_coef != 0: expression["args"].append(define_term(level1_coef))
116
+ return expression
117
+
118
+ @staticmethod
119
+ def dot_product(zipped_to_sum, heuns_coefs=None):
120
+ # ensure that the lengths are compatible for heun's dot-products
121
+ if heuns_coefs is not None:
122
+ coefs = heuns_coefs if isinstance(heuns_coefs, (list, set)) else heuns_coefs.tolist()
123
+ zipped_length = len(zipped_to_sum); coefs_length = len(coefs)
124
+ if zipped_length != coefs_length:
125
+ raise IndexError(f"ERROR: The length of zipped elements {zipped_length}"
126
+ f" is unequal to that of coefficients {coefs_length}")
127
+
128
+ elements = []
129
+ for index, (term1, term2) in enumerate(zipped_to_sum):
130
+ if heuns_coefs is not None:
131
+ elements.extend([{"operation": "Mul", "elements": [heuns_coefs[index], term1]},
132
+ {"operation": "Mul", "elements": [heuns_coefs[index], term2]}])
133
+ else:
134
+ elements.append({"operation": "Mul", "elements": [term1, term2]})
135
+ return elements
136
+
137
+
138
+ class GLPKHelper(OptlangHelper):
139
+ interface = "glpk"
140
+
141
+ class CPLEXHelper(OptlangHelper):
142
+ interface = "cplex"
143
+
144
+ class GurobiHelper(OptlangHelper):
145
+ interface = "gurobi"
146
+
147
+
148
+ # --------------------------------------------------------------------------- matrix path
149
+ def _linear_terms(expr, var_index):
150
+ """Flatten a tuple-format linear expression into ({var_idx: coef}, constant).
151
+
152
+ Handles the shapes produced by ``dot_product``/``tupConstraint``: an Add of
153
+ Mul-dicts, bare variable names, and numeric constants; Muls may hold the
154
+ coefficient and variable in either order. Nested Adds are recursed."""
155
+ coefs, const = {}, 0.0
156
+ elements = expr["elements"] if isinstance(expr, dict) else expr
157
+ for ele in elements:
158
+ if isinstance(ele, str):
159
+ coefs[var_index[ele]] = coefs.get(var_index[ele], 0.0) + 1.0
160
+ elif isnumber(ele):
161
+ const += float(ele)
162
+ elif isinstance(ele, dict) and ele.get("operation") == "Mul":
163
+ coef, name = 1.0, None
164
+ for ele2 in ele["elements"]:
165
+ if isinstance(ele2, str): name = ele2
166
+ elif isnumber(ele2): coef *= float(ele2)
167
+ else: raise ValueError(f"Non-linear or nested Mul term is not supported by the matrix path: {ele}")
168
+ if name is None: const += coef
169
+ else: coefs[var_index[name]] = coefs.get(var_index[name], 0.0) + coef
170
+ elif isinstance(ele, dict) and ele.get("operation") == "Add":
171
+ sub_coefs, sub_const = _linear_terms(ele, var_index)
172
+ const += sub_const
173
+ for j, c in sub_coefs.items(): coefs[j] = coefs.get(j, 0.0) + c
174
+ else:
175
+ raise ValueError(f"Unsupported expression element for the matrix path: {ele}")
176
+ return coefs, const
177
+
178
+
179
+ class MatrixHelper:
180
+ """CSR/gurobipy construction for very large LPs.
181
+
182
+ optlang's per-constraint interfaces scale super-linearly (~n^1.8 measured on
183
+ Gurobi: 48 s at 1e5 constraints, ~90 min extrapolated at 1.4e6), while
184
+ gurobipy ingests a CSR matrix in C time (seconds at 1.4e6 constraints).
185
+ Requires numpy, scipy, and gurobipy (``pip install OptlangHelper[matrix]``).
186
+ Returns a ``gurobipy.Model`` (``.optimize()``; status 2 == optimal), NOT an
187
+ optlang model."""
188
+
189
+ @staticmethod
190
+ def define_model(model_name, variables, constraints, objective, verbose=False):
191
+ import numpy as np
192
+ from scipy.sparse import coo_matrix
193
+ import gurobipy as gp
194
+
195
+ names, lbs, ubs = [], [], []
196
+ for var in variables:
197
+ var = list(var)
198
+ name = var[0].replace(" ", "_")
199
+ names.append(name); lbs.append(float(var[1][0])); ubs.append(float(var[1][1]))
200
+ var_index = {n: j for j, n in enumerate(names)}
201
+
202
+ rows_eq, rows_le, rows_ge = [], [], [] # (coefs, rhs)
203
+ for cons in constraints:
204
+ _, bounds, expr = cons[0], cons[1], cons[2]
205
+ coefs, const = _linear_terms(expr, var_index)
206
+ lb = None if bounds[0] is None else float(bounds[0]) - const
207
+ ub = None if bounds[1] is None else float(bounds[1]) - const
208
+ if lb is not None and ub is not None and lb == ub:
209
+ rows_eq.append((coefs, lb))
210
+ else:
211
+ if ub is not None: rows_le.append((coefs, ub))
212
+ if lb is not None: rows_ge.append((coefs, lb))
213
+
214
+ env = gp.Env(empty=True); env.setParam("OutputFlag", 1 if verbose else 0); env.start()
215
+ model = gp.Model(model_name, env=env)
216
+ x = model.addMVar(len(names), lb=np.array(lbs), ub=np.array(ubs))
217
+ model._var_names = names
218
+ model._var_index = var_index
219
+
220
+ def add_block(rows, sense):
221
+ if not rows: return
222
+ r, c, v, rhs = [], [], [], []
223
+ for i, (coefs, b) in enumerate(rows):
224
+ rhs.append(b)
225
+ for j, coef in coefs.items():
226
+ r.append(i); c.append(j); v.append(coef)
227
+ S = coo_matrix((v, (r, c)), shape=(len(rows), len(names))).tocsr()
228
+ model.addMConstr(S, x, sense, np.array(rhs))
229
+ add_block(rows_eq, "="); add_block(rows_le, "<"); add_block(rows_ge, ">")
230
+
231
+ obj_expr = objective[1] if isinstance(objective[1], (list, dict)) else [objective[1]]
232
+ if isinstance(obj_expr, list):
233
+ obj_expr = {"elements": obj_expr, "operation": "Add"}
234
+ ocoefs, oconst = _linear_terms(obj_expr, var_index)
235
+ lin = gp.LinExpr(oconst)
236
+ for j, coef in ocoefs.items(): lin += coef * x[j]
237
+ model.setObjective(lin, gp.GRB.MAXIMIZE if objective[2] in ("max", "maximize") else gp.GRB.MINIMIZE)
238
+ model._mvar = x
239
+ return model
240
+
241
+
242
+ def define_model_auto(model_name, variables, constraints, objective, limit=300000, helper=None):
243
+ """Size-aware dispatch: optlang construction up to ``limit`` constraints,
244
+ CSR/gurobipy (``MatrixHelper``) beyond it.
245
+
246
+ Below the limit an OPTLANG model is returned (from ``helper`` or
247
+ ``OptlangHelper``); above it a GUROBIPY model is returned — both expose
248
+ ``.optimize()``, but their status/attribute APIs differ, so branch on
249
+ ``isinstance`` or on ``len(constraints) > limit`` when reading results."""
250
+ if len(constraints) <= limit:
251
+ return (helper or OptlangHelper).define_model(model_name, variables, constraints, objective, optlang=True)
252
+ logger.info(f"{model_name}: {len(constraints)} constraints exceeds limit={limit}; "
253
+ "building through MatrixHelper (gurobipy CSR)")
254
+ return MatrixHelper.define_model(model_name, variables, constraints, objective)
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: OptlangHelper
3
+ Version: 0.0.3
4
+ Summary: Python package for building Linear Programming models that are compatible with Optlang
5
+ Home-page: https://github.com/Freiburgermsu/OptlangHelper
6
+ Author: Andrew Freiburger
7
+ Author-email: afreiburger@anl.gov
8
+ Project-URL: Issues, https://github.com/Freiburgermsu/OptlangHelper/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Natural Language :: English
18
+ Description-Content-Type: text/x-rst
19
+ Requires-Dist: optlang
20
+ Provides-Extra: matrix
21
+ Requires-Dist: numpy; extra == "matrix"
22
+ Requires-Dist: scipy; extra == "matrix"
23
+ Requires-Dist: gurobipy; extra == "matrix"
24
+ Dynamic: author
25
+ Dynamic: author-email
26
+ Dynamic: classifier
27
+ Dynamic: description
28
+ Dynamic: description-content-type
29
+ Dynamic: home-page
30
+ Dynamic: project-url
31
+ Dynamic: provides-extra
32
+ Dynamic: requires-dist
33
+ Dynamic: summary
34
+
35
+
36
+
37
+ Efficiently creating optimization models
38
+ ________________________________________________________________________
39
+
40
+ |PyPI version| |License|
41
+
42
+ .. |Supported Python Versions| image:: https://img.shields.io/pypi/pyversions/optlanghelper)
43
+ :target: https://pypi.org/project/optlanghelper/
44
+ :alt: Python versions
45
+
46
+ .. |PyPI version| image:: https://img.shields.io/pypi/v/optlanghelper.svg?logo=PyPI&logoColor=brightgreen
47
+ :target: https://pypi.org/project/optlanghelper/
48
+ :alt: PyPI version
49
+
50
+ .. |Actions Status| image:: https://github.com/freiburgermsu/optlanghelper/workflows/Test%20optlanghelper/badge.svg
51
+ :target: https://github.com/freiburgermsu/optlanghelper/actions
52
+ :alt: Actions Status
53
+
54
+ .. |License| image:: https://img.shields.io/badge/License-MIT-blue.svg
55
+ :target: https://opensource.org/licenses/MIT
56
+ :alt: License
57
+
58
+ .. .. |Downloads| image:: https://pepy.tech/badge/modelseedpy
59
+ .. :target: https://pepy.tech/project/modelseedpy
60
+ .. :alt: Downloads
61
+
62
+
63
+ .. note::
64
+
65
+ This project is under active development, and may be subject to losing back-compatibility.
66
+
67
+ ----------------------
68
+ Installation
69
+ ----------------------
70
+
71
+ OptlangHelper can be installed via ``pip`` through the ``PyPI`` channel::
72
+
73
+ pip install optlanghelper
74
+
75
+
76
+ ----------------------
77
+ Usage
78
+ ----------------------
79
+
80
+ The OptlangHelper package is a collection of functions that make it easier to build linear programming models using the Optlang package. This is accomplished through dictionaries of variables, constraints, and an objective that are added to a model at the end of iterating through all conditions. Since each solver -- such as GLPK, CPLEX, and GUROBI -- differently defines variables and constraints, a separate class (although using the same function design and arguments) is provided for each of these solvers. This requires the user to correctly select the proper OptlangHelper class that will construct the appropriate model for their solver.
81
+
82
+ ****************
83
+ GLPK
84
+ ****************
85
+
86
+ The GLPK class is used to construct an optlang model for the GLPK solver. This is the simplest of the solvers and is employed by default for optlang without specification for CPlex or Gurobi.
87
+
88
+ ++++++++++++++++++++++
89
+ Named Tuples
90
+ ++++++++++++++++++++++
91
+
92
+ There are several NamedTuples that are defined in OptlangHelper and assist with defining variables, constraints, and the objective.
93
+
94
+ ``Bounds`` object has attributes of
95
+
96
+ - ``lb``, the lower bound of the entity associated with this bound: default is 0.
97
+ - ``up``, the upper bound of the associated entity: default is 1000.
98
+
99
+
100
+ ``tupVariable`` object has attributes of
101
+
102
+ - ``name``, the name of the variable represented by this tuple
103
+ - ``bounds``, the lower and upper limit bounds associated with the tuple: (0,1000) is the default
104
+ - ``type``, the variable type: "integer"; "binary"; "continuous" is the default.
105
+
106
+
107
+ ``tupConstraint`` object has attributes of
108
+
109
+ - ``name``, the name of the variable represented by this tuple
110
+ - ``bounds``, the lower and upper limit bounds associated with the tuple: (0,0) is the default
111
+ - ``expr``, the constraint expression: ``None`` is the default.
112
+
113
+
114
+ ``tupObjective`` object has attributes of
115
+
116
+ - ``name``, the name of the variable represented by this tuple
117
+ - ``expr``, the objective expression: ``None`` is the default.
118
+ - ``direction``, the optimization direction: "max" is the default.
119
+
120
+ All of the above objects are fed into the ``define_model`` function
121
+
122
+ - ``model_name``, the name of the model
123
+ - ``variables``, the tupVariable objects for the model.
124
+ - ``constraints``, the tupConstraint objects for the model.
125
+ - ``objective``, the tupObjective object for the model.
126
+ - ``optlang``, specifies whether an optlang model is returned (``True``), or the raw dictionary (``False``) by default.
127
+
128
+ This function calls all of the class functions and returns either the GLPK model as as dictionary or an optlang object.
129
+
130
+
131
+ ++++++++++++++++++++++
132
+ Example
133
+ ++++++++++++++++++++++
134
+
135
+ The following blocks define the intended usage of the GLPK class.
136
+
137
+
138
+ .. code-block:: python
139
+
140
+ from optlanghelper import tupVariable, tupConstraint, tupObjective, define_model
141
+
142
+ # define the variables
143
+ variables = {}
144
+ for var in vars:
145
+ variables[var.name] = tupVariable(var.name, Bounds(0, 5), "continuous")
146
+ variables[var.name+"_bin"] = tupVariable(var.name+"_bin", Bounds(0, 1), "binary")
147
+
148
+ # define the constraints
149
+ constraints = {}
150
+ for name, content in constraint_info.items():
151
+ lb, ub = content["low_bound"], content["high_bound"]
152
+ consExpr = {}
153
+ ## define the constraint expression
154
+ for varName, coef in var_info.items():
155
+ if varName not in consCoefs: continue
156
+ coef2 = consCoefs[varName]
157
+ consExpr[varName].update({"elements": [varName, coef2], "operation": "Mul"})
158
+ ## create the constraint tuple
159
+ constraints[nutrient] = tupConstraint(name=nutrient, bounds=Bounds(lb, ub), expr={"elements": list(consExpr.values()), "operation": "Add"})
160
+
161
+ for varName in var_info.keys():
162
+ constraints[varName+"_bin"] = tupConstraint(varName+"_bin", bounds=Bounds(0,None),
163
+ expr={
164
+ "elements": [
165
+ variables[varName].bounds.ub,
166
+ {"elements": [-1, variables[varName].name,], "operation": "Mul"},
167
+ {"elements": [-variables[varName].bounds.ub, variables[varName+"_bin"].name], "operation": "Mul"}],
168
+ "operation": "Add"})
169
+
170
+ # define the objective
171
+ objective = tupObjective("< optimization name>", [], "min")
172
+ for varName, coef in var_info.items():
173
+ objective.expr.append({
174
+ "elements": [
175
+ {"elements": [variables[varName].name, coef],
176
+ "operation": "Mul"}],
177
+ "operation": "Add"
178
+ })
179
+
180
+ # create an optlang model from all of the variables, constraints, and objective defined above
181
+ model = define_model("< model name>", list(variables.values()), list(constraints.values()), objective, True)
182
+
183
+
184
+ Release 0.0.3
185
+ ----------------------
186
+
187
+ Bug fixes
188
+
189
+ + the published 0.0.2 wheel was unimportable (``__init__`` imported from the package
190
+ name instead of the submodule) and crashed on nested expressions (a stale internal
191
+ reference to the pre-rename class); both are fixed and covered by tests.
192
+ + ``__version__`` is synchronized with the package metadata, and the import-time
193
+ ``print`` is now a debug log line.
194
+
195
+ New
196
+
197
+ + the historic ``OptlangHelper`` class is restored as the solver-agnostic base;
198
+ ``GLPKHelper`` / ``CPLEXHelper`` / ``GurobiHelper`` are interface-pinned subclasses
199
+ (previously the latter two were empty stubs).
200
+ + ``MatrixHelper.define_model`` builds very large LPs as a sparse CSR matrix directly
201
+ through ``gurobipy`` (install with ``pip install OptlangHelper[matrix]``): optlang's
202
+ per-constraint interfaces scale super-linearly (~n^1.8 measured on Gurobi — 48 s at
203
+ 1e5 constraints, ~90 min extrapolated at 1.4e6), while the CSR path ingests 1.4e6
204
+ constraints in seconds.
205
+ + ``define_model_auto(name, variables, constraints, objective, limit=300000)``
206
+ dispatches by size: an optlang model at or below ``limit`` constraints, a gurobipy
207
+ model above it (both expose ``.optimize()``; their result APIs differ).
208
+
209
+ .. code-block:: python
210
+
211
+ from optlanghelper import define_model_auto
212
+
213
+ model = define_model_auto("community_fba", variables, constraints, objective)
214
+ model.optimize()
@@ -0,0 +1,6 @@
1
+ optlanghelper/__init__.py,sha256=Oc74Eqs8H6GCwfvfokBW3MjbJ8UFg_ASpY1TIW54sQs,448
2
+ optlanghelper/optlanghelper.py,sha256=6-8ErwT3THsfm-fuUEvp33MQ7MRQKIzlpDGZd0FfA1U,11949
3
+ optlanghelper-0.0.3.dist-info/METADATA,sha256=digciYF_1pVEzoqlYooHHO7XC2GYzXoTmBC1DRTTBHU,8741
4
+ optlanghelper-0.0.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ optlanghelper-0.0.3.dist-info/top_level.txt,sha256=C9TGd-LQ2lpHk_VliXn3f_8u7pdq9ougRebrE3JvOEk,14
6
+ optlanghelper-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ optlanghelper