Skip to content

Commit 4a5a7d7

Browse files
authored
Move CSV utils into gm4.utils (#944)
Makes these CSV utils available to all Gamemode 4 modules.
1 parent f754033 commit 4a5a7d7

File tree

2 files changed

+179
-167
lines changed

2 files changed

+179
-167
lines changed

gm4/utils.py

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import subprocess
22
import warnings
33
from dataclasses import dataclass, asdict
4-
from typing import Any
4+
from typing import Any, List
55
from functools import total_ordering
6+
import csv
7+
from pathlib import Path
68

79
def run(cmd: list[str]|str) -> str:
810
"""Run a shell command and return the stdout."""
@@ -81,3 +83,168 @@ class NoneAttribute():
8183
"""Object which returns None for any arbitrary attribute access. Used for default members"""
8284
def __getattribute__(self, __name: str) -> None:
8385
return None
86+
87+
88+
# CSV READING UTILS
89+
class CSVCell(str):
90+
"""
91+
String wrapper for contents of a CSVCell, supports interpreting the content as different formats.
92+
"""
93+
94+
DEC = 'dec' # for numbers formatted 16777215
95+
HEX = 'hex' # for numbers formatted #AB0EFF
96+
FLOAT = 'float' # for numbers formatted [0.5, 0.2, 0.9]
97+
98+
def as_integer(self) -> int:
99+
"""
100+
Interprets the string contained in this CSVCell as an integer.
101+
Supported formats are:
102+
- base 10 integers (no prefix)
103+
- prefixed hex color codes (# prefix and 6 digits)
104+
- prefixed hex, octal, or binary (0x, 0o, or 0b and some amount of digits)
105+
- bool (True or False, case insensitive)
106+
Returns a integer representation of the value.
107+
"""
108+
if self.startswith('#') and len(self) == 7: # alternative way of marking base 16 (hex colors)
109+
return CSVCell('0x' + self.lstrip('#')).as_integer()
110+
if self.startswith('0x'): # check if the string is in base 2
111+
return int(self, 16)
112+
if self.startswith('0o'): # check if the string is in base 8
113+
return int(self, 8)
114+
if self.startswith('0b'): # check if the string is in base 16
115+
return int(self, 2)
116+
if self.casefold() == 'true':
117+
return 1
118+
if self.casefold() == 'false':
119+
return 0
120+
return int(self) # default case, interpret as base 10
121+
122+
def to_color_code(self, encoding: str) -> 'CSVCell':
123+
"""
124+
Interprets the string contained in this CSVCell as a color code using the given encoding and returns a new CSVCell with that interpretation as its content.
125+
E.g. if the CSVCell this function was called on contains '#4AA0C7' and 'CSVCell.DEC' is given as an encoding, a new CSVCell with content '4890823' is returned.
126+
"""
127+
if encoding == CSVCell.HEX:
128+
return CSVCell('#' + hex(self.as_integer()).lstrip('0x'))
129+
if encoding == CSVCell.DEC:
130+
return CSVCell(self.as_integer())
131+
if encoding == CSVCell.FLOAT:
132+
dec = self.as_integer()
133+
return CSVCell([(dec >> 16) / 255, ((dec >> 8) & 0xFF) / 255, (dec & 0xFF) / 255])
134+
raise ValueError(
135+
f"Invalid encoding '{encoding}'. Must be '{CSVCell.DEC}', '{CSVCell.HEX}', or '{CSVCell.FLOAT}'.")
136+
137+
138+
class CSVRow():
139+
"""
140+
Read-only dict wrapper which represents a row of data from a .csv file.
141+
"""
142+
143+
def __init__(self, column_names: List[str] | None = None, data: List[CSVCell] | None = None) -> None:
144+
"""
145+
Initialize a new CSVRow object using the supplied column names and data. CSVRow objects are read-only by design.
146+
If no data and no column names are supplied the resulting CSVRow object will evaluate to false in boolean expressions.
147+
148+
Access data within this CSVRow via the `get(key, default)` method or using `[<key: str>]`.
149+
"""
150+
if not column_names:
151+
column_names = []
152+
if not data:
153+
data = []
154+
155+
if len(column_names) != len(data):
156+
raise ValueError(
157+
f"Could not build CSVRow from supplied column names and data; Number of supplied column names ({len(column_names)}) does not match number of supplied data entries ({len(data)}).")
158+
159+
self._data = {column_names[column_index]
160+
: value for column_index, value in enumerate(data)}
161+
162+
def __bool__(self):
163+
"""
164+
Allow for the use of CSVRow instances in if statements; If the CSVRow has no keys it is equivalent to `False`.
165+
"""
166+
return len(self._data.keys()) != 0
167+
168+
def __getitem__(self, key: str):
169+
try:
170+
return self._data[key]
171+
except KeyError as ke:
172+
raise ValueError(
173+
f"Failed to select column named '{ke.args[0]}' from CSVRow with columns {[key for key in self._data]}.")
174+
175+
def __repr__(self) -> str:
176+
return str(self._data)
177+
178+
def get(self, key: str, default: str | Any) -> CSVCell:
179+
"""
180+
Returns the value corrosponding to the key if it exists and is not the empty string.
181+
Else returns the provided default. The provided default is cast to a string internally.
182+
"""
183+
value = self._data.get(key, CSVCell(default))
184+
if value:
185+
return value
186+
else:
187+
return CSVCell(default)
188+
189+
190+
class CSV():
191+
"""
192+
List-of-Rows representation of a .csv file which can be iteraded over using for ... in.
193+
Optimized for row-first access, i.e. select a row, then a column.
194+
Also provides a `find_row` function for column-first, i.e. select a column, then a row, access.
195+
However, the latter is is more expensive.
196+
197+
All access methods return CSVRow objects which are dynamically created upon calling an access method.
198+
"""
199+
200+
@staticmethod
201+
def from_file(path: Path) -> 'CSV':
202+
"""
203+
Reads in a csv file and returns a list of rows. Each row consists of a dictionary which contains labeled values.
204+
"""
205+
with open(path, mode='r') as file:
206+
csv_file = csv.reader(file)
207+
header = next(csv_file)
208+
209+
return CSV(column_names=header, rows=[[CSVCell(cell) for cell in row] for row in csv_file])
210+
211+
def __init__(self, column_names: List[str], rows: List[List[CSVCell]]) -> None:
212+
"""
213+
Initialize a new CSV from a list of column names (headers) and a list of rows.
214+
The latter contain actual data, whilst the former only holds names of columns.
215+
"""
216+
self._column_names = column_names
217+
self._rows = rows
218+
219+
def __iter__(self):
220+
self.__current = 0
221+
self.__last = len(self._rows)
222+
return self
223+
224+
def __next__(self) -> CSVRow:
225+
current = self.__current
226+
self.__current += 1
227+
if current < self.__last:
228+
return CSVRow(self._column_names, self._rows[current])
229+
raise StopIteration()
230+
231+
def __getitem__(self, row_index: int):
232+
return CSVRow(self._column_names, self._rows[row_index])
233+
234+
def __repr__(self):
235+
return str([CSVRow(self._column_names, data) for data in self._rows])
236+
237+
def find_row(self, value: str, by_column: str | int = 0) -> CSVRow:
238+
"""
239+
Finds and returns the first row in this CSV which has `value` in column `by_column`. `by_column` can either be a str, in which case it is treated
240+
as a column name and the header line is searched for a matching string, or an int n, in which case the nth column is selected.
241+
`by_column` defaults to `0`.
242+
Returns an empty `CSVRow` if no match was found.
243+
"""
244+
if isinstance(by_column, str):
245+
by_column = self._column_names.index(by_column)
246+
247+
for row in self._rows:
248+
if row[by_column] == value:
249+
return CSVRow(self._column_names, row)
250+
return CSVRow()

gm4_zauber_cauldrons/generate.py

Lines changed: 11 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,11 @@
1-
from typing import List, Dict, Any
1+
from typing import Dict, Any
22
from pathlib import Path
33
from itertools import product
4-
import csv
4+
from gm4.utils import CSV, CSVCell
55
import json
66

77
from beet import Context, subproject
88

9-
10-
class CSVCell(str):
11-
"""
12-
String wrapper which supports color encoding translation.
13-
"""
14-
15-
DEC = 'dec' # for numbers formatted 16777215
16-
HEX = 'hex' # for numbers formatted #AB0EFF
17-
FLOAT = 'float' # for numbers formatted [0.5, 0.2, 0.9]
18-
19-
def as_integer(self) -> int:
20-
"""
21-
Interprets the string contained in this CSVCell as an integer.
22-
Tries to detect the base automatically.
23-
"""
24-
if self.startswith('#') and len(self) == 7: # alternative way of marking base 16 (hex colors)
25-
return CSVCell('0x' + self.lstrip('#')).as_integer()
26-
if self.startswith('0x'): # check if the string is in base 2
27-
return int(self, 16)
28-
if self.startswith('0o'): # check if the string is in base 8
29-
return int(self, 8)
30-
if self.startswith('0b'): # check if the string is in base 16
31-
return int(self, 2)
32-
return int(self) # string must be base 10
33-
34-
def to_color_code(self, encoding: str) -> 'CSVCell':
35-
"""
36-
Outputs the string contained in this CSVCell formatted as a color code, e.g. #4AA0C7 if 'HEX' is given.
37-
"""
38-
if encoding == CSVCell.HEX:
39-
return CSVCell('#' + hex(self.as_integer()).lstrip('0x'))
40-
if encoding == CSVCell.DEC:
41-
return CSVCell(self.as_integer())
42-
if encoding == CSVCell.FLOAT:
43-
dec = self.as_integer()
44-
return CSVCell([(dec >> 16) / 255, ((dec >> 8) & 0xFF) / 255, (dec & 0xFF) / 255])
45-
raise ValueError(
46-
f"Invalid encoding '{encoding}'. Must be '{CSVCell.DEC}', '{CSVCell.HEX}', or '{CSVCell.FLOAT}'.")
47-
48-
49-
class CSVRow():
50-
"""
51-
Read-only dict wrapper which represents a row of data from a .csv file.
52-
"""
53-
54-
def __init__(self, column_names: List[str] | None = None, data: List[CSVCell] | None = None) -> None:
55-
"""
56-
Initialize a new CSVRow object using the supplied column names and data. CSVRow objects are read-only by design.
57-
If no data and no column names are supplied the resulting CSVRow object will evaluate to false in boolean expressions.
58-
59-
Access data within this CSVRow via the `get(key, default)` method or using `[<key: str>]`.
60-
"""
61-
if not column_names:
62-
column_names = []
63-
if not data:
64-
data = []
65-
66-
if len(column_names) != len(data):
67-
raise ValueError(
68-
f"Could not build CSVRow from supplied column names and data; Number of supplied column names ({len(column_names)}) does not match number of supplied data entries ({len(data)}).")
69-
70-
self._data = {column_names[column_index]: value for column_index, value in enumerate(data)}
71-
72-
def __bool__(self):
73-
"""
74-
Allow for the use of CSVRow instances in if statements; If the CSVRow has no keys it is equivalent to `False`.
75-
"""
76-
return len(self._data.keys()) != 0
77-
78-
def __getitem__(self, key: str):
79-
try:
80-
return self._data[key]
81-
except KeyError as ke:
82-
raise ValueError(
83-
f"Failed to select column named '{ke.args[0]}' from CSVRow with columns {[key for key in self._data]}.")
84-
85-
def __repr__(self) -> str:
86-
return str(self._data)
87-
88-
def get(self, key: str, default: str | Any) -> CSVCell:
89-
"""
90-
Returns the value corrosponding to the key if it exists and is not the empty string.
91-
Else returns the provided default. The provided default is cast to a string internally.
92-
"""
93-
value = self._data.get(key, CSVCell(default))
94-
if value:
95-
return value
96-
else:
97-
return CSVCell(default)
98-
99-
100-
class CSV():
101-
"""
102-
List-of-Rows representation of a .csv file which can be iteraded over using for ... in.
103-
Optimized for row-first access, i.e. select a row, then a column.
104-
Also provides a `find_row` function for column-first, i.e. select a column, then a row, access.
105-
However, the latter is is more expensive.
106-
107-
All access methods return CSVRow objects which are dynamically created upon calling an access method.
108-
"""
109-
110-
def __init__(self, column_names: List[str], rows: List[List[CSVCell]]) -> None:
111-
"""
112-
Initialize a new CSV from a list of column names (headers) and a list of rows.
113-
The latter contain actual data, whilst the former only holds names of columns.
114-
"""
115-
self._column_names = column_names
116-
self._rows = rows
117-
118-
def __iter__(self):
119-
self.__current = 0
120-
self.__last = len(self._rows)
121-
return self
122-
123-
def __next__(self) -> CSVRow:
124-
current = self.__current
125-
self.__current += 1
126-
if current < self.__last:
127-
return CSVRow(self._column_names, self._rows[current])
128-
raise StopIteration()
129-
130-
def __getitem__(self, row_index: int):
131-
return CSVRow(self._column_names, self._rows[row_index])
132-
133-
def __repr__(self):
134-
return str([CSVRow(self._column_names, data) for data in self._rows])
135-
136-
def find_row(self, value: str, by_column: str | int = 0) -> CSVRow:
137-
"""
138-
Finds and returns the first row in this CSV which has `value` in column `by_column`. `by_column` can either be a str, in which case it is treated
139-
as a column name and the header line is searched for a matching string, or an int n, in which case the nth column is selected.
140-
`by_column` defaults to `0`.
141-
Returns an empty `CSVRow` if no match was found.
142-
"""
143-
if isinstance(by_column, str):
144-
by_column = self._column_names.index(by_column)
145-
146-
for row in self._rows:
147-
if row[by_column] == value:
148-
return CSVRow(self._column_names, row)
149-
return CSVRow()
150-
151-
152-
def read_csv(path: Path) -> CSV:
153-
"""
154-
Reads in a csv file and returns a list of rows. Each row consists of a dictionary which contains labeled values.
155-
"""
156-
with open(path, mode='r') as file:
157-
csv_file = csv.reader(file)
158-
header = next(csv_file)
159-
160-
return CSV(column_names=header, rows=[[CSVCell(cell) for cell in row] for row in csv_file])
161-
162-
1639
def read_json(path: Path) -> Any:
16410
"""
16511
Reads in a json file and returns a python object representing the json.
@@ -168,31 +14,30 @@ def read_json(path: Path) -> Any:
16814
json_file = json.load(file)
16915
return json_file
17016

171-
17217
def beet_default(ctx: Context):
17318

17419
# read raw data
175-
armor_flavors: CSV = read_csv(
20+
armor_flavors: CSV = CSV.from_file(
17621
Path('gm4_zauber_cauldrons', 'raw', 'armor_flavors.csv'))
177-
armor_pieces: CSV = read_csv(
22+
armor_pieces: CSV = CSV.from_file(
17823
Path('gm4_zauber_cauldrons', 'raw', 'armor_pieces.csv'))
179-
crystal_effects: CSV = read_csv(
24+
crystal_effects: CSV = CSV.from_file(
18025
Path('gm4_zauber_cauldrons', 'raw', 'crystal_effects.csv'))
18126
crystal_lores: Any = read_json(
18227
Path('gm4_zauber_cauldrons', 'raw', 'crystal_lores.json'))
183-
flower_types: CSV = read_csv(
28+
flower_types: CSV = CSV.from_file(
18429
Path('gm4_zauber_cauldrons', 'raw', 'flower_types.csv'))
185-
magicol_colors: CSV = read_csv(
30+
magicol_colors: CSV = CSV.from_file(
18631
Path('gm4_zauber_cauldrons', 'raw', 'magicol_colors.csv'))
187-
potion_bottles: CSV = read_csv(
32+
potion_bottles: CSV = CSV.from_file(
18833
Path('gm4_zauber_cauldrons', 'raw', 'potion_bottles.csv'))
189-
potion_effects: CSV = read_csv(
34+
potion_effects: CSV = CSV.from_file(
19035
Path('gm4_zauber_cauldrons', 'raw', 'potion_effects.csv'))
191-
potion_bottles: CSV = read_csv(
36+
potion_bottles: CSV = CSV.from_file(
19237
Path('gm4_zauber_cauldrons', 'raw', 'potion_bottles.csv'))
19338
potion_lores: Any = read_json(
19439
Path('gm4_zauber_cauldrons', 'raw', 'potion_lores.json'))
195-
weather_modifiers: CSV = read_csv(
40+
weather_modifiers: CSV = CSV.from_file(
19641
Path('gm4_zauber_cauldrons', 'raw', 'weather_modifiers.csv'))
19742

19843
# generate files

0 commit comments

Comments
 (0)