pyoframe 0.1.4__py3-none-any.whl → 0.2.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.
pyoframe/__init__.py CHANGED
@@ -8,6 +8,11 @@ from pyoframe.core import Constraint, Expression, Set, Variable, sum, sum_by
8
8
  from pyoframe.model import Model
9
9
  from pyoframe.monkey_patch import patch_dataframe_libraries
10
10
 
11
+ try:
12
+ from pyoframe._version import __version__, __version_tuple__ # noqa: F401
13
+ except ModuleNotFoundError: # pragma: no cover
14
+ pass
15
+
11
16
  patch_dataframe_libraries()
12
17
 
13
18
  __all__ = [
pyoframe/_arithmetic.py CHANGED
@@ -10,7 +10,6 @@ from pyoframe.constants import (
10
10
  COEF_KEY,
11
11
  CONST_TERM,
12
12
  KEY_TYPE,
13
- POLARS_VERSION,
14
13
  QUAD_VAR_KEY,
15
14
  RESERVED_COL_KEYS,
16
15
  VAR_KEY,
@@ -81,14 +80,14 @@ def _multiply_expressions_core(self: "Expression", other: "Expression") -> "Expr
81
80
  self, other = other, self
82
81
  self_degree, other_degree = other_degree, self_degree
83
82
  if other_degree == 1:
84
- assert (
85
- self_degree == 1
86
- ), "This should always be true since the sum of degrees must be <=2."
83
+ assert self_degree == 1, (
84
+ "This should always be true since the sum of degrees must be <=2."
85
+ )
87
86
  return _quadratic_multiplication(self, other)
88
87
 
89
- assert (
90
- other_degree == 0
91
- ), "This should always be true since other cases have already been handled."
88
+ assert other_degree == 0, (
89
+ "This should always be true since other cases have already been handled."
90
+ )
92
91
  multiplier = other.data.drop(
93
92
  VAR_KEY
94
93
  ) # QUAD_VAR_KEY doesn't need to be dropped since we know it doesn't exist
@@ -248,12 +247,12 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
248
247
  left_data = left.data.join(get_indices(right), how="inner", on=dims)
249
248
  right_data = right.data.join(get_indices(left), how="inner", on=dims)
250
249
  elif strat == (UnmatchedStrategy.UNSET, UnmatchedStrategy.UNSET):
251
- assert (
252
- not Config.disable_unmatched_checks
253
- ), "This code should not be reached when unmatched checks are disabled."
250
+ assert not Config.disable_unmatched_checks, (
251
+ "This code should not be reached when unmatched checks are disabled."
252
+ )
254
253
  outer_join = get_indices(left).join(
255
254
  get_indices(right),
256
- how="full" if POLARS_VERSION.major >= 1 else "outer",
255
+ how="full",
257
256
  on=dims,
258
257
  )
259
258
  if outer_join.get_column(dims[0]).null_count() > 0:
@@ -280,9 +279,9 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
280
279
  + str(left_data.filter(left_data.get_column(COEF_KEY).is_null()))
281
280
  )
282
281
  elif strat == (UnmatchedStrategy.KEEP, UnmatchedStrategy.UNSET):
283
- assert (
284
- not Config.disable_unmatched_checks
285
- ), "This code should not be reached when unmatched checks are disabled."
282
+ assert not Config.disable_unmatched_checks, (
283
+ "This code should not be reached when unmatched checks are disabled."
284
+ )
286
285
  unmatched = right.data.join(get_indices(left), how="anti", on=dims)
287
286
  if len(unmatched) > 0:
288
287
  raise PyoframeError(
pyoframe/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.1'
32
+ __version_tuple__ = version_tuple = (0, 2, 1)
33
+
34
+ __commit_id__ = commit_id = None
pyoframe/constants.py CHANGED
@@ -8,11 +8,6 @@ from typing import Literal, Optional
8
8
 
9
9
  import polars as pl
10
10
  import pyoptinterface as poi
11
- from packaging import version
12
-
13
- # Constant to help split our logic depending on the polars version in use.
14
- # This approach is compatible with polars-lts-cpu.
15
- POLARS_VERSION = version.parse(pl.__version__)
16
11
 
17
12
  COEF_KEY = "__coeff"
18
13
  VAR_KEY = "__variable_id"
@@ -20,6 +15,7 @@ QUAD_VAR_KEY = "__quadratic_variable_id"
20
15
  CONSTRAINT_KEY = "__constraint_id"
21
16
  SOLUTION_KEY = "solution"
22
17
  DUAL_KEY = "dual"
18
+ SUPPORTED_SOLVERS = ["gurobi", "highs"]
23
19
  SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs"]
24
20
  KEY_TYPE = pl.UInt32
25
21
 
@@ -44,7 +40,7 @@ class _ConfigMeta(type):
44
40
  cls._defaults = {
45
41
  k: v
46
42
  for k, v in dct.items()
47
- if not k.startswith("_") and type(v) != classmethod
43
+ if not k.startswith("_") and type(v) != classmethod # noqa: E721 (didn't want to mess with it since it works)
48
44
  }
49
45
 
50
46
 
@@ -59,10 +55,19 @@ class Config(metaclass=_ConfigMeta):
59
55
  print_uses_variable_names: bool = True
60
56
  print_max_line_length: int = 80
61
57
  print_max_lines: int = 15
62
- # Number of elements to show when printing a set to the console (additional elements are replaced with ...)
63
58
  print_max_set_elements: int = 50
59
+ "Number of elements to show when printing a set to the console (additional elements are replaced with ...)"
60
+
64
61
  enable_is_duplicated_expression_safety_check: bool = False
65
62
 
63
+ integer_tolerance: float = 1e-8
64
+ """
65
+ For convenience, Pyoframe returns the solution of integer and binary variables as integers not floating point values.
66
+ To do so, Pyoframe must convert the solver-provided floating point values to integers. To avoid unexpected rounding errors,
67
+ Pyoframe uses this tolerance to check that the floating point result is an integer as expected. Overly tight tolerances can trigger
68
+ unexpected errors. Setting the tolerance to zero disables the check.
69
+ """
70
+
66
71
  @classmethod
67
72
  def reset_defaults(cls):
68
73
  """
pyoframe/core.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import warnings
4
3
  from abc import ABC, abstractmethod
5
4
  from typing import (
6
5
  TYPE_CHECKING,
@@ -33,7 +32,6 @@ from pyoframe.constants import (
33
32
  CONSTRAINT_KEY,
34
33
  DUAL_KEY,
35
34
  KEY_TYPE,
36
- POLARS_VERSION,
37
35
  QUAD_VAR_KEY,
38
36
  RESERVED_COL_KEYS,
39
37
  SOLUTION_KEY,
@@ -294,9 +292,9 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
294
292
  over_merged = over_frames[0]
295
293
 
296
294
  for df in over_frames[1:]:
297
- assert (
298
- set(over_merged.columns) & set(df.columns) == set()
299
- ), "All coordinates must have unique column names."
295
+ assert set(over_merged.columns) & set(df.columns) == set(), (
296
+ "All coordinates must have unique column names."
297
+ )
300
298
  over_merged = over_merged.join(df, how="cross")
301
299
  return over_merged
302
300
 
@@ -309,9 +307,9 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
309
307
 
310
308
  def __mul__(self, other):
311
309
  if isinstance(other, Set):
312
- assert (
313
- set(self.data.columns) & set(other.data.columns) == set()
314
- ), "Cannot multiply two sets with columns in common."
310
+ assert set(self.data.columns) & set(other.data.columns) == set(), (
311
+ "Cannot multiply two sets with columns in common."
312
+ )
315
313
  return Set(self.data, other.data)
316
314
  return super().__mul__(other)
317
315
 
@@ -346,18 +344,11 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
346
344
  elif isinstance(set, Constraint):
347
345
  df = set.data.select(set.dimensions_unsafe)
348
346
  elif isinstance(set, SupportsMath):
349
- if POLARS_VERSION.major < 1:
350
- df = (
351
- set.to_expr()
352
- .data.drop(RESERVED_COL_KEYS)
353
- .unique(maintain_order=True)
354
- )
355
- else:
356
- df = (
357
- set.to_expr()
358
- .data.drop(RESERVED_COL_KEYS, strict=False)
359
- .unique(maintain_order=True)
360
- )
347
+ df = (
348
+ set.to_expr()
349
+ .data.drop(RESERVED_COL_KEYS, strict=False)
350
+ .unique(maintain_order=True)
351
+ )
361
352
  elif isinstance(set, pd.Index):
362
353
  df = pl.from_pandas(pd.DataFrame(index=set).reset_index())
363
354
  elif isinstance(set, pd.DataFrame):
@@ -620,13 +611,13 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
620
611
  """
621
612
  df: pl.DataFrame = Set(set).data
622
613
  set_dims = _get_dimensions(df)
623
- assert (
624
- set_dims is not None
625
- ), "Cannot use .within() with a set with no dimensions."
614
+ assert set_dims is not None, (
615
+ "Cannot use .within() with a set with no dimensions."
616
+ )
626
617
  dims = self.dimensions
627
- assert (
628
- dims is not None
629
- ), "Cannot use .within() with an expression with no dimensions."
618
+ assert dims is not None, (
619
+ "Cannot use .within() with an expression with no dimensions."
620
+ )
630
621
  dims_in_common = [dim for dim in dims if dim in set_dims]
631
622
  by_dims = df.select(dims_in_common).unique(maintain_order=True)
632
623
  return self._new(self.data.join(by_dims, on=dims_in_common))
@@ -800,15 +791,9 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
800
791
  keys = keys.with_columns(
801
792
  pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
802
793
  )
803
- if POLARS_VERSION.major >= 1:
804
- data = data.join(
805
- keys, on=dim + self._variable_columns, how="full", coalesce=True
806
- )
807
- else:
808
- data = data.join(
809
- keys, on=dim + self._variable_columns, how="outer_coalesce"
810
- )
811
- data = data.with_columns(pl.col(COEF_KEY).fill_null(0.0))
794
+ data = data.join(
795
+ keys, on=dim + self._variable_columns, how="full", coalesce=True
796
+ ).with_columns(pl.col(COEF_KEY).fill_null(0.0))
812
797
 
813
798
  data = data.with_columns(
814
799
  pl.when(pl.col(VAR_KEY) == CONST_TERM)
@@ -826,10 +811,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
826
811
  constant_terms = constant_terms.drop(QUAD_VAR_KEY)
827
812
  if dims is not None:
828
813
  dims_df = self.data.select(dims).unique(maintain_order=True)
829
- if POLARS_VERSION.major >= 1:
830
- df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
831
- else:
832
- df = constant_terms.join(dims_df, on=dims, how="outer_coalesce")
814
+ df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
833
815
  return df.with_columns(pl.col(COEF_KEY).fill_null(0.0))
834
816
  else:
835
817
  if len(constant_terms) == 0:
@@ -870,9 +852,9 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
870
852
  >>> m.expr_2.evaluate()
871
853
  63.0
872
854
  """
873
- assert (
874
- self._model is not None
875
- ), "Expression must be added to the model to use .value"
855
+ assert self._model is not None, (
856
+ "Expression must be added to the model to use .value"
857
+ )
876
858
 
877
859
  df = self.data
878
860
  sm = self._model.poi
@@ -899,16 +881,23 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
899
881
  df = df.group_by(dims, maintain_order=True)
900
882
  return df.sum()
901
883
 
902
- def to_poi(self) -> poi.ScalarAffineFunction:
884
+ def to_poi(self) -> poi.ScalarAffineFunction | poi.ScalarQuadraticFunction:
903
885
  if self.dimensions is not None:
904
886
  raise ValueError(
905
887
  "Only non-dimensioned expressions can be converted to PyOptInterface."
906
888
  ) # pragma: no cover
907
889
 
908
- return poi.ScalarAffineFunction(
909
- coefficients=self.data.get_column(COEF_KEY).to_numpy(),
910
- variables=self.data.get_column(VAR_KEY).to_numpy(),
911
- )
890
+ if self.is_quadratic:
891
+ return poi.ScalarQuadraticFunction(
892
+ coefficients=self.data.get_column(COEF_KEY).to_numpy(),
893
+ var1s=self.data.get_column(VAR_KEY).to_numpy(),
894
+ var2s=self.data.get_column(QUAD_VAR_KEY).to_numpy(),
895
+ )
896
+ else:
897
+ return poi.ScalarAffineFunction(
898
+ coefficients=self.data.get_column(COEF_KEY).to_numpy(),
899
+ variables=self.data.get_column(VAR_KEY).to_numpy(),
900
+ )
912
901
 
913
902
  def to_str_table(self, include_const_term=True):
914
903
  data = self.data if include_const_term else self.variable_terms
@@ -951,10 +940,10 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
951
940
 
952
941
  if dimensions is not None:
953
942
  data = data.group_by(dimensions, maintain_order=True).agg(
954
- pl.col("expr").str.concat(delimiter=" ")
943
+ pl.col("expr").str.join(delimiter=" ")
955
944
  )
956
945
  else:
957
- data = data.select(pl.col("expr").str.concat(delimiter=" "))
946
+ data = data.select(pl.col("expr").str.join(delimiter=" "))
958
947
 
959
948
  # Remove leading +
960
949
  data = data.with_columns(pl.col("expr").str.strip_chars(characters=" +"))
@@ -1010,7 +999,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
1010
999
  include_const_term=include_const_term,
1011
1000
  )
1012
1001
  str_table = self.to_str_create_prefix(str_table)
1013
- result += str_table.select(pl.col("expr").str.concat(delimiter="\n")).item()
1002
+ result += str_table.select(pl.col("expr").str.join(delimiter="\n")).item()
1014
1003
 
1015
1004
  return result
1016
1005
 
@@ -1054,6 +1043,31 @@ def sum(
1054
1043
  over: Union[str, Sequence[str], SupportsToExpr],
1055
1044
  expr: Optional[SupportsToExpr] = None,
1056
1045
  ) -> "Expression":
1046
+ """
1047
+ Sum an expression over specified dimensions.
1048
+ If no dimensions are specified, the sum is taken over all of the expression's dimensions.
1049
+
1050
+ Examples:
1051
+ >>> expr = pl.DataFrame({
1052
+ ... "time": ["mon", "tue", "wed", "mon", "tue"],
1053
+ ... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
1054
+ ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
1055
+ ... }).to_expr()
1056
+ >>> expr
1057
+ <Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
1058
+ [mon,Toronto]: 1000000
1059
+ [tue,Toronto]: 3000000
1060
+ [wed,Toronto]: 2000000
1061
+ [mon,Vancouver]: 1000000
1062
+ [tue,Vancouver]: 2000000
1063
+ >>> pf.sum("time", expr)
1064
+ <Expression size=2 dimensions={'place': 2} terms=2>
1065
+ [Toronto]: 6000000
1066
+ [Vancouver]: 3000000
1067
+ >>> pf.sum(expr)
1068
+ <Expression size=1 dimensions={} terms=1>
1069
+ 9000000
1070
+ """
1057
1071
  if expr is None:
1058
1072
  assert isinstance(over, SupportsMath)
1059
1073
  over = over.to_expr()
@@ -1069,13 +1083,34 @@ def sum(
1069
1083
 
1070
1084
 
1071
1085
  def sum_by(by: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression":
1086
+ """
1087
+ Like `pf.sum()`, but the sum is taken over all dimensions except those specified in `by` (just like a groupby operation).
1088
+
1089
+ Examples:
1090
+ >>> expr = pl.DataFrame({
1091
+ ... "time": ["mon", "tue", "wed", "mon", "tue"],
1092
+ ... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
1093
+ ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
1094
+ ... }).to_expr()
1095
+ >>> expr
1096
+ <Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
1097
+ [mon,Toronto]: 1000000
1098
+ [tue,Toronto]: 3000000
1099
+ [wed,Toronto]: 2000000
1100
+ [mon,Vancouver]: 1000000
1101
+ [tue,Vancouver]: 2000000
1102
+ >>> pf.sum_by("place", expr)
1103
+ <Expression size=2 dimensions={'place': 2} terms=2>
1104
+ [Toronto]: 6000000
1105
+ [Vancouver]: 3000000
1106
+ """
1072
1107
  if isinstance(by, str):
1073
1108
  by = [by]
1074
1109
  expr = expr.to_expr()
1075
1110
  dimensions = expr.dimensions
1076
- assert (
1077
- dimensions is not None
1078
- ), "Cannot sum by dimensions with an expression with no dimensions."
1111
+ assert dimensions is not None, (
1112
+ "Cannot sum by dimensions with an expression with no dimensions."
1113
+ )
1079
1114
  remaining_dims = [dim for dim in dimensions if dim not in by]
1080
1115
  return sum(over=remaining_dims, expr=expr)
1081
1116
 
@@ -1112,16 +1147,22 @@ class Constraint(ModelElementWithId):
1112
1147
  except KeyError:
1113
1148
  setter = self._model.poi.set_constraint_raw_attribute
1114
1149
 
1150
+ constr_type = (
1151
+ poi.ConstraintType.Quadratic
1152
+ if self.lhs.is_quadratic
1153
+ else poi.ConstraintType.Linear
1154
+ )
1155
+
1115
1156
  if self.dimensions is None:
1116
1157
  for key in self.data.get_column(CONSTRAINT_KEY):
1117
- setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
1158
+ setter(poi.ConstraintIndex(constr_type, key), name, value)
1118
1159
  else:
1119
1160
  for key, value in (
1120
1161
  self.data.join(value, on=self.dimensions, how="inner")
1121
1162
  .select(pl.col(CONSTRAINT_KEY), pl.col(col_name))
1122
1163
  .iter_rows()
1123
1164
  ):
1124
- setter(poi.ConstraintIndex(poi.ConstraintType.Linear, key), name, value)
1165
+ setter(poi.ConstraintIndex(constr_type, key), name, value)
1125
1166
 
1126
1167
  @unwrap_single_values
1127
1168
  def _get_attribute(self, name):
@@ -1133,21 +1174,16 @@ class Constraint(ModelElementWithId):
1133
1174
  except KeyError:
1134
1175
  getter = self._model.poi.get_constraint_raw_attribute
1135
1176
 
1136
- with (
1137
- warnings.catch_warnings()
1138
- ): # map_elements without return_dtype= gives a warning
1139
- warnings.filterwarnings(
1140
- action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
1141
- )
1142
- return self.data.with_columns(
1143
- pl.col(CONSTRAINT_KEY)
1144
- .map_elements(
1145
- lambda v_id: getter(
1146
- poi.ConstraintIndex(poi.ConstraintType.Linear, v_id), name
1147
- )
1148
- )
1149
- .alias(col_name)
1150
- ).select(self.dimensions_unsafe + [col_name])
1177
+ constr_type = (
1178
+ poi.ConstraintType.Quadratic
1179
+ if self.lhs.is_quadratic
1180
+ else poi.ConstraintType.Linear
1181
+ )
1182
+
1183
+ ids = self.data.get_column(CONSTRAINT_KEY).to_list()
1184
+ attr = [getter(poi.ConstraintIndex(constr_type, v_id), name) for v_id in ids]
1185
+ data = self.data.with_columns(pl.Series(attr).alias(col_name))
1186
+ return data.select(self.dimensions_unsafe + [col_name])
1151
1187
 
1152
1188
  def on_add_to_model(self, model: "Model", name: str):
1153
1189
  super().on_add_to_model(model, name)
@@ -1329,9 +1365,9 @@ class Constraint(ModelElementWithId):
1329
1365
  return self
1330
1366
 
1331
1367
  var_name = f"{self.name}_relaxation"
1332
- assert not hasattr(
1333
- m, var_name
1334
- ), "Conflicting names, relaxation variable already exists on the model."
1368
+ assert not hasattr(m, var_name), (
1369
+ "Conflicting names, relaxation variable already exists on the model."
1370
+ )
1335
1371
  var = Variable(self, lb=0, ub=max)
1336
1372
  setattr(m, var_name, var)
1337
1373
 
@@ -1374,7 +1410,7 @@ class Constraint(ModelElementWithId):
1374
1410
  [str_table, rhs], how=("align" if dims else "horizontal")
1375
1411
  )
1376
1412
  constr_str = constr_str.select(
1377
- pl.concat_str("expr", pl.lit(f" {self.sense.value} "), "rhs").str.concat(
1413
+ pl.concat_str("expr", pl.lit(f" {self.sense.value} "), "rhs").str.join(
1378
1414
  delimiter="\n"
1379
1415
  )
1380
1416
  ).item()
@@ -1459,9 +1495,9 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1459
1495
  equals: Optional[SupportsMath] = None,
1460
1496
  ):
1461
1497
  if equals is not None:
1462
- assert (
1463
- len(indexing_sets) == 0
1464
- ), "Cannot specify both 'equals' and 'indexing_sets'"
1498
+ assert len(indexing_sets) == 0, (
1499
+ "Cannot specify both 'equals' and 'indexing_sets'"
1500
+ )
1465
1501
  indexing_sets = (equals,)
1466
1502
 
1467
1503
  data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
@@ -1510,17 +1546,10 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1510
1546
  except KeyError:
1511
1547
  getter = self._model.poi.get_variable_raw_attribute
1512
1548
 
1513
- with (
1514
- warnings.catch_warnings()
1515
- ): # map_elements without return_dtype= gives a warning
1516
- warnings.filterwarnings(
1517
- action="ignore", category=pl.exceptions.MapWithoutReturnDtypeWarning
1518
- )
1519
- return self.data.with_columns(
1520
- pl.col(VAR_KEY)
1521
- .map_elements(lambda v_id: getter(poi.VariableIndex(v_id), name))
1522
- .alias(col_name)
1523
- ).select(self.dimensions_unsafe + [col_name])
1549
+ ids = self.data.get_column(VAR_KEY).to_list()
1550
+ attr = [getter(poi.VariableIndex(v_id), name) for v_id in ids]
1551
+ data = self.data.with_columns(pl.Series(attr).alias(col_name))
1552
+ return data.select(self.dimensions_unsafe + [col_name])
1524
1553
 
1525
1554
  def _assign_ids(self):
1526
1555
  kwargs = dict(domain=self.vtype.to_poi())
@@ -1578,9 +1607,78 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1578
1607
  @property
1579
1608
  @unwrap_single_values
1580
1609
  def solution(self):
1581
- solution = self.attr.Value
1610
+ """
1611
+ Retrieve a variable's optimal value after the model has been solved.
1612
+ Returned as a DataFrame if the variable has dimensions, otherwise as a single value.
1613
+ Binary and integer variables are returned as integers.
1614
+
1615
+ Examples:
1616
+ >>> m = pf.Model()
1617
+ >>> m.var_continuous = pf.Variable({"dim1": [1, 2, 3]}, lb=5, ub=5)
1618
+ >>> m.var_integer = pf.Variable({"dim1": [1, 2, 3]}, lb=4.5, ub=5.5, vtype=VType.INTEGER)
1619
+ >>> m.var_dimensionless = pf.Variable(lb=4.5, ub=5.5, vtype=VType.INTEGER)
1620
+ >>> m.var_continuous.solution
1621
+ Traceback (most recent call last):
1622
+ ...
1623
+ RuntimeError: Failed to retrieve solution for variable. Are you sure the model has been solved?
1624
+ >>> m.optimize()
1625
+ >>> m.var_continuous.solution
1626
+ shape: (3, 2)
1627
+ ┌──────┬──────────┐
1628
+ │ dim1 ┆ solution │
1629
+ │ --- ┆ --- │
1630
+ │ i64 ┆ f64 │
1631
+ ╞══════╪══════════╡
1632
+ │ 1 ┆ 5.0 │
1633
+ │ 2 ┆ 5.0 │
1634
+ │ 3 ┆ 5.0 │
1635
+ └──────┴──────────┘
1636
+ >>> m.var_integer.solution
1637
+ shape: (3, 2)
1638
+ ┌──────┬──────────┐
1639
+ │ dim1 ┆ solution │
1640
+ │ --- ┆ --- │
1641
+ │ i64 ┆ i64 │
1642
+ ╞══════╪══════════╡
1643
+ │ 1 ┆ 5 │
1644
+ │ 2 ┆ 5 │
1645
+ │ 3 ┆ 5 │
1646
+ └──────┴──────────┘
1647
+ >>> m.var_dimensionless.solution
1648
+ 5
1649
+ """
1650
+ try:
1651
+ solution = self.attr.Value
1652
+ except RuntimeError as e:
1653
+ raise RuntimeError(
1654
+ "Failed to retrieve solution for variable. Are you sure the model has been solved?"
1655
+ ) from e
1582
1656
  if isinstance(solution, pl.DataFrame):
1583
1657
  solution = solution.rename({"Value": SOLUTION_KEY})
1658
+
1659
+ if self.vtype in [VType.BINARY, VType.INTEGER]:
1660
+ if isinstance(solution, pl.DataFrame):
1661
+ solution = solution.with_columns(
1662
+ pl.col("solution").alias("solution_float"),
1663
+ pl.col("solution").round().cast(pl.Int64),
1664
+ )
1665
+ if Config.integer_tolerance != 0:
1666
+ df = solution.filter(
1667
+ (pl.col("solution_float") - pl.col("solution")).abs()
1668
+ > Config.integer_tolerance
1669
+ )
1670
+ assert df.is_empty(), (
1671
+ f"Variable {self.name} has a non-integer value: {df}\nThis should not happen."
1672
+ )
1673
+ solution = solution.drop("solution_float")
1674
+ else:
1675
+ solution_float = solution
1676
+ solution = int(round(solution))
1677
+ if Config.integer_tolerance != 0:
1678
+ assert abs(solution - solution_float) < Config.integer_tolerance, (
1679
+ f"Value of variable {self.name} is not an integer: {solution}. This should not happen."
1680
+ )
1681
+
1584
1682
  return solution
1585
1683
 
1586
1684
  def __repr__(self):
@@ -1606,10 +1704,7 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1606
1704
 
1607
1705
  def to_expr(self) -> Expression:
1608
1706
  self._assert_has_ids()
1609
- if POLARS_VERSION.major < 1:
1610
- return self._new(self.data.drop(SOLUTION_KEY))
1611
- else:
1612
- return self._new(self.data.drop(SOLUTION_KEY, strict=False))
1707
+ return self._new(self.data.drop(SOLUTION_KEY, strict=False))
1613
1708
 
1614
1709
  def _new(self, data: pl.DataFrame):
1615
1710
  self._assert_has_ids()
@@ -1686,12 +1781,7 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1686
1781
  expr = self.to_expr()
1687
1782
  data = expr.data.rename({dim: "__prev"})
1688
1783
 
1689
- if POLARS_VERSION.major < 1:
1690
- data = data.join(
1691
- wrapped, left_on="__prev", right_on="__next", how="inner"
1692
- ).drop(["__prev", "__next"])
1693
- else:
1694
- data = data.join(
1695
- wrapped, left_on="__prev", right_on="__next", how="inner"
1696
- ).drop(["__prev", "__next"], strict=False)
1784
+ data = data.join(
1785
+ wrapped, left_on="__prev", right_on="__next", how="inner"
1786
+ ).drop(["__prev", "__next"], strict=False)
1697
1787
  return expr._new(data)
pyoframe/model.py CHANGED
@@ -8,6 +8,7 @@ import pyoptinterface as poi
8
8
  from pyoframe.constants import (
9
9
  CONST_TERM,
10
10
  SUPPORTED_SOLVER_TYPES,
11
+ SUPPORTED_SOLVERS,
11
12
  Config,
12
13
  ObjSense,
13
14
  ObjSenseValue,
@@ -34,6 +35,7 @@ class Model:
34
35
  Gurobi only: a dictionary of parameters to set when creating the Gurobi environment.
35
36
  use_var_names:
36
37
  Whether to pass variable names to the solver. Set to `True` if you'd like outputs from e.g. `Model.write()` to be legible.
38
+ Does not work with HiGHS (see [here](https://github.com/Bravos-Power/pyoframe/issues/102#issuecomment-2727521430)).
37
39
  sense:
38
40
  Either "min" or "max". Indicates whether it's a minmization or maximization problem.
39
41
  Typically, this parameter can be omitted (`None`) as it will automatically be
@@ -76,10 +78,10 @@ class Model:
76
78
 
77
79
  def __init__(
78
80
  self,
79
- name=None,
81
+ name: Optional[str] = None,
80
82
  solver: Optional[SUPPORTED_SOLVER_TYPES] = None,
81
83
  solver_env: Optional[Dict[str, str]] = None,
82
- use_var_names=False,
84
+ use_var_names: bool = False,
83
85
  sense: Union[ObjSense, ObjSenseValue, None] = None,
84
86
  ):
85
87
  self.poi, self.solver_name = Model.create_poi_model(solver, solver_env)
@@ -106,7 +108,7 @@ class Model:
106
108
  ):
107
109
  if solver is None:
108
110
  if Config.default_solver is None:
109
- for solver_option in ["highs", "gurobi"]:
111
+ for solver_option in SUPPORTED_SOLVERS:
110
112
  try:
111
113
  return cls.create_poi_model(solver_option, solver_env)
112
114
  except RuntimeError:
@@ -122,13 +124,13 @@ class Model:
122
124
  from pyoptinterface import gurobi
123
125
 
124
126
  if solver_env is None:
125
- model = gurobi.Model()
127
+ env = gurobi.Env()
126
128
  else:
127
129
  env = gurobi.Env(empty=True)
128
130
  for key, value in solver_env.items():
129
131
  env.set_raw_parameter(key, value)
130
132
  env.start()
131
- model = gurobi.Model(env)
133
+ model = gurobi.Model(env)
132
134
  elif solver == "highs":
133
135
  from pyoptinterface import highs
134
136
 
@@ -233,9 +235,9 @@ class Model:
233
235
  and __name not in Model._reserved_attributes
234
236
  ):
235
237
  if isinstance(__value, ModelElementWithId):
236
- assert not hasattr(
237
- self, __name
238
- ), f"Cannot create {__name} since it was already created."
238
+ assert not hasattr(self, __name), (
239
+ f"Cannot create {__name} since it was already created."
240
+ )
239
241
 
240
242
  __value.on_add_to_model(self, __name)
241
243
 
@@ -256,12 +258,32 @@ class Model:
256
258
  objective=bool(self.objective),
257
259
  )
258
260
 
259
- def write(self, file_path: Union[Path, str]):
261
+ def write(self, file_path: Union[Path, str], pretty: bool = False):
262
+ """
263
+ Output the model to a file.
264
+
265
+ Typical usage includes writing the solution to a `.sol` file as well as writing the problem to a `.lp` or `.mps` file.
266
+ Set `use_var_names` in your model constructor to `True` if you'd like the output to contain human-readable names (useful for debugging).
267
+
268
+ Parameters:
269
+ file_path:
270
+ The path to the file to write to.
271
+ pretty:
272
+ Only used when writing .sol files in HiGHS. If `True`, will use HiGH's pretty print columnar style which contains more information.
273
+ """
260
274
  file_path = Path(file_path)
261
275
  file_path.parent.mkdir(parents=True, exist_ok=True)
262
- self.poi.write(str(file_path))
276
+ kwargs = {}
277
+ if self.solver_name == "highs":
278
+ if self.use_var_names:
279
+ self.params.write_solution_style = 1
280
+ kwargs["pretty"] = pretty
281
+ self.poi.write(str(file_path), **kwargs)
263
282
 
264
283
  def optimize(self):
284
+ """
285
+ Optimize the model using your selected solver (e.g. Gurobi, HiGHS).
286
+ """
265
287
  self.poi.optimize()
266
288
 
267
289
  @for_solvers("gurobi")
@@ -282,11 +304,11 @@ class Model:
282
304
  >>> m.maximize = 3 * m.X + 2 * m.Y + m.Z
283
305
  >>> m.optimize()
284
306
  >>> m.X.solution, m.Y.solution, m.Z.solution
285
- (1.0, 9.0, 0.0)
307
+ (1, 9, 0.0)
286
308
  >>> m.my_constraint.dual
287
309
  Traceback (most recent call last):
288
310
  ...
289
- polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'Pi'
311
+ RuntimeError: Unable to retrieve attribute 'Pi'
290
312
  >>> m.convert_to_fixed()
291
313
  >>> m.optimize()
292
314
  >>> m.my_constraint.dual
@@ -322,44 +344,44 @@ class Model:
322
344
  >>> m.bad_constraint.attr.IIS
323
345
  Traceback (most recent call last):
324
346
  ...
325
- polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'IISConstr'
347
+ RuntimeError: Unable to retrieve attribute 'IISConstr'
326
348
  >>> m.compute_IIS()
327
349
  >>> m.bad_constraint.attr.IIS
328
350
  True
329
351
  """
330
352
  self.poi.computeIIS()
331
353
 
332
- @for_solvers("gurobi")
333
354
  def dispose(self):
334
355
  """
335
- Tries to close the solver connection by deleting the model and forcing the garbage collector to run.
356
+ Disposes of the model and cleans up the solver environment.
336
357
 
337
- Gurobi only. Once this method is called, this model is no longer usable.
358
+ When using Gurobi compute server, this cleanup will
359
+ ensure your run is not marked as 'ABORTED'.
338
360
 
339
- This method will not work if you have a variable that references self.poi.
340
- Unfortunately, this is a limitation from the underlying solver interface library.
341
- See https://github.com/metab0t/PyOptInterface/issues/36 for context.
361
+ Note that once the model is disposed, it cannot be used anymore.
342
362
 
343
363
  Examples:
344
- >>> m = pf.Model(solver="gurobi")
364
+ >>> m = pf.Model()
345
365
  >>> m.X = pf.Variable(ub=1)
346
366
  >>> m.maximize = m.X
347
367
  >>> m.optimize()
348
368
  >>> m.X.solution
349
369
  1.0
350
370
  >>> m.dispose()
351
- >>> m.X.solution
352
- Traceback (most recent call last):
353
- ...
354
- AttributeError: 'Model' object has no attribute 'poi'
355
371
  """
356
- import gc
357
-
358
- env = self.poi._env
359
- del self.poi
360
- gc.collect()
361
- del env
362
- gc.collect()
372
+ env = None
373
+ if hasattr(self.poi, "_env"):
374
+ env = self.poi._env
375
+ self.poi.close()
376
+ if env is not None:
377
+ env.close()
378
+
379
+ def __del__(self):
380
+ # This ensures that the model is closed *before* the environment is. This avoids the Gurobi warning:
381
+ # Warning: environment still referenced so free is deferred (Continue to use WLS)
382
+ # I include the hasattr check to avoid errors in case __init__ failed and poi was never set.
383
+ if hasattr(self, "poi"):
384
+ self.poi.close()
363
385
 
364
386
  def _set_param(self, name, value):
365
387
  self.poi.set_raw_parameter(name, value)
@@ -370,11 +392,17 @@ class Model:
370
392
  def _set_attr(self, name, value):
371
393
  try:
372
394
  self.poi.set_model_attribute(poi.ModelAttribute[name], value)
373
- except KeyError:
374
- self.poi.set_model_raw_attribute(name, value)
395
+ except KeyError as e:
396
+ if self.solver_name == "gurobi":
397
+ self.poi.set_model_raw_attribute(name, value)
398
+ else:
399
+ raise e
375
400
 
376
401
  def _get_attr(self, name):
377
402
  try:
378
403
  return self.poi.get_model_attribute(poi.ModelAttribute[name])
379
- except KeyError:
380
- return self.poi.get_model_raw_attribute(name)
404
+ except KeyError as e:
405
+ if self.solver_name == "gurobi":
406
+ return self.poi.get_model_raw_attribute(name)
407
+ else:
408
+ raise e
pyoframe/model_element.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from collections import defaultdict
5
4
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
5
 
7
6
  import polars as pl
@@ -22,9 +21,9 @@ if TYPE_CHECKING: # pragma: no cover
22
21
  class ModelElement(ABC):
23
22
  def __init__(self, data: pl.DataFrame, **kwargs) -> None:
24
23
  # Sanity checks, no duplicate column names
25
- assert len(data.columns) == len(
26
- set(data.columns)
27
- ), "Duplicate column names found."
24
+ assert len(data.columns) == len(set(data.columns)), (
25
+ "Duplicate column names found."
26
+ )
28
27
 
29
28
  cols = _get_dimensions(data)
30
29
  if cols is None:
pyoframe/util.py CHANGED
@@ -379,9 +379,9 @@ class NamedVariableMapper:
379
379
 
380
380
  def _element_to_map(self, element) -> pl.DataFrame:
381
381
  element_name = element.name # type: ignore
382
- assert (
383
- element_name is not None
384
- ), "Element must have a name to be used in a named mapping."
382
+ assert element_name is not None, (
383
+ "Element must have a name to be used in a named mapping."
384
+ )
385
385
  element._assert_has_ids()
386
386
  return concat_dimensions(
387
387
  element.data.select(element.dimensions_unsafe + [VAR_KEY]),
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: pyoframe
3
- Version: 0.1.4
3
+ Version: 0.2.1
4
4
  Summary: Blazing fast linear program interface
5
5
  Author-email: Bravos Power <dev@bravospower.com>
6
+ License-Expression: MIT
6
7
  Project-URL: Homepage, https://bravos-power.github.io/pyoframe/
7
8
  Project-URL: documentation, https://bravos-power.github.io/pyoframe/
8
9
  Project-URL: repository, https://github.com/Bravos-Power/pyoframe/
@@ -10,40 +11,40 @@ Project-URL: Issues, https://github.com/Bravos-Power/pyoframe/issues
10
11
  Classifier: Programming Language :: Python :: 3
11
12
  Classifier: Operating System :: OS Independent
12
13
  Classifier: Development Status :: 3 - Alpha
13
- Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Natural Language :: English
15
15
  Requires-Python: >=3.9
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: polars<2,>=0.20
18
+ Requires-Dist: polars~=1.0
19
19
  Requires-Dist: numpy
20
20
  Requires-Dist: pyarrow
21
21
  Requires-Dist: pandas
22
- Requires-Dist: packaging
23
- Requires-Dist: pyoptinterface~=0.4
22
+ Requires-Dist: pyoptinterface<1,>=0.4.1
24
23
  Provides-Extra: dev
25
- Requires-Dist: black[jupyter]; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: polars>=1.30.0; extra == "dev"
26
26
  Requires-Dist: bumpver; extra == "dev"
27
- Requires-Dist: isort; extra == "dev"
28
27
  Requires-Dist: pip-tools; extra == "dev"
29
28
  Requires-Dist: pytest; extra == "dev"
30
29
  Requires-Dist: pytest-cov; extra == "dev"
31
30
  Requires-Dist: pre-commit; extra == "dev"
32
31
  Requires-Dist: gurobipy; extra == "dev"
33
32
  Requires-Dist: highsbox; extra == "dev"
34
- Requires-Dist: pre-commit; extra == "dev"
35
33
  Requires-Dist: coverage; extra == "dev"
36
- Provides-Extra: docs
37
- Requires-Dist: mkdocs-material==9.*; extra == "docs"
38
- Requires-Dist: mkdocstrings[python]; extra == "docs"
39
- Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == "docs"
40
- Requires-Dist: mkdocs-git-committers-plugin-2; extra == "docs"
41
- Requires-Dist: mkdocs-gen-files; extra == "docs"
42
- Requires-Dist: mkdocs-section-index; extra == "docs"
43
- Requires-Dist: mkdocs-literate-nav; extra == "docs"
44
- Requires-Dist: mkdocs-table-reader-plugin; extra == "docs"
34
+ Requires-Dist: ipykernel; extra == "dev"
35
+ Requires-Dist: pytest-markdown-docs; extra == "dev"
36
+ Requires-Dist: mkdocs-material==9.*; extra == "dev"
37
+ Requires-Dist: mkdocstrings[python]; extra == "dev"
38
+ Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == "dev"
39
+ Requires-Dist: mkdocs-git-committers-plugin-2; extra == "dev"
40
+ Requires-Dist: mkdocs-gen-files; extra == "dev"
41
+ Requires-Dist: mkdocs-section-index; extra == "dev"
42
+ Requires-Dist: mkdocs-literate-nav; extra == "dev"
43
+ Requires-Dist: mkdocs-table-reader-plugin; extra == "dev"
44
+ Requires-Dist: markdown-hide-code>=0.1.1; extra == "dev"
45
45
  Provides-Extra: highs
46
46
  Requires-Dist: highsbox; extra == "highs"
47
+ Dynamic: license-file
47
48
 
48
49
  # Pyoframe: Fast and low-memory linear programming models
49
50
 
@@ -0,0 +1,15 @@
1
+ pyoframe/__init__.py,sha256=YswFUwm6GX98dXeT99hqxWqYWLELS71JZf1OpT1kvCg,619
2
+ pyoframe/_arithmetic.py,sha256=agJm2Sl4EjEG7q4n2YHka4mGfCQI3LjOXLaW6oCfGiQ,17222
3
+ pyoframe/_version.py,sha256=vYqoJTG51NOUmYyL0xt8asRK8vUT4lGAdal_EZ59mvw,704
4
+ pyoframe/constants.py,sha256=WBCmhunavNVwJcmg9ojnA6TVJCLSrgWVE4YKZnhZNz4,4192
5
+ pyoframe/core.py,sha256=fjCu4eY7QJSFvVfCNtMq-o_spoo76FWO4AviCssHGoo,66925
6
+ pyoframe/model.py,sha256=a7pEwagVxHC1ZUMr8ifO4n0ca5Ways3wip-Wps0rlcg,14257
7
+ pyoframe/model_element.py,sha256=YmAdx4yM5irGTiZ5uQmDa-u05QdFKngIFy8qNnogvzo,5911
8
+ pyoframe/monkey_patch.py,sha256=9IfS14G6IPabmM9z80jzi_D4Rq0Mdx5aUCA39Yi2tgE,2044
9
+ pyoframe/objective.py,sha256=PBWxj30QkFlsvY6ijZ6KjyKdrJARD4to0ieF6GUqaQU,3238
10
+ pyoframe/util.py,sha256=dHIwAyyD9wn36yM8IOlrboTGUGA7STq3IBTxfYSOPjU,13480
11
+ pyoframe-0.2.1.dist-info/licenses/LICENSE,sha256=u_Spw4ynlwTMRZeCX-uacv_hBU547pBygiA6d2ONNV4,1074
12
+ pyoframe-0.2.1.dist-info/METADATA,sha256=_rmMdRjfEkv-1xn9UkjVubVxNUqSKgluVmXz7nSaRnA,3607
13
+ pyoframe-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ pyoframe-0.2.1.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
15
+ pyoframe-0.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright 2024 Bravos Power
3
+ Copyright (c) 2024-2025 Bravos Power
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,14 +0,0 @@
1
- pyoframe/__init__.py,sha256=nEN0OgqhevtsvxEiPbJLzwPojf3ngYAoT90M_1mc4kM,477
2
- pyoframe/_arithmetic.py,sha256=vqzMZip1kTkUUMyHohUyKJxx2nEeQIH7N57fwevLWaQ,17284
3
- pyoframe/constants.py,sha256=Dy1sCzykZlmkbvgsc5ckujqXPuYmsKkD3stANU0qr5Y,3784
4
- pyoframe/core.py,sha256=4heBn7RGOWV-Yg0OoiLr8sGBaygpTx-D-pLuC4nC0Dw,62865
5
- pyoframe/model.py,sha256=7nVH2oZeTuk53l76PLNElsiHEgmbTvrf7lGIa9_CYm4,13011
6
- pyoframe/model_element.py,sha256=RBmsE2FOct3UL2N2LZLfWYv2wL_QKqNmKmmR_pD_ERs,5945
7
- pyoframe/monkey_patch.py,sha256=9IfS14G6IPabmM9z80jzi_D4Rq0Mdx5aUCA39Yi2tgE,2044
8
- pyoframe/objective.py,sha256=PBWxj30QkFlsvY6ijZ6KjyKdrJARD4to0ieF6GUqaQU,3238
9
- pyoframe/util.py,sha256=Oyk8xh6FJHlb04X_cM4lN0UzdnKLXAMrKfyOf7IexiA,13480
10
- pyoframe-0.1.4.dist-info/LICENSE,sha256=dkwA40ZzT-3x6eu2a6mf_o7PNSqHbdsyaFNhLxGHNQs,1065
11
- pyoframe-0.1.4.dist-info/METADATA,sha256=v3ZH_JvXSg4cN515Qo0FvZviXIC0l-Ja5tgq5IdgwLI,3558
12
- pyoframe-0.1.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
13
- pyoframe-0.1.4.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
14
- pyoframe-0.1.4.dist-info/RECORD,,