bepfe-export-parser 0.6.3__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lance Watermelon
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.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: bepfe-export-parser
3
+ Version: 0.6.3
4
+ Summary: Parses CSV files exported from Bepfe race manager software.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Lars Wagner
8
+ Author-email: wagner1975@web.de
9
+ Requires-Python: >=3.12,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Description-Content-Type: text/markdown
16
+
17
+ # bepfe-export-parser
18
+ Python library for parsing CSV files exported from Bepfe racing software.
19
+
20
+ To be continued......
@@ -0,0 +1,4 @@
1
+ # bepfe-export-parser
2
+ Python library for parsing CSV files exported from Bepfe racing software.
3
+
4
+ To be continued......
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "bepfe-export-parser"
3
+ version = "0.6.3"
4
+ description = "Parses CSV files exported from Bepfe race manager software."
5
+ authors = [
6
+ {name = "Lars Wagner",email = "wagner1975@web.de"}
7
+ ]
8
+ license = {text = "MIT"}
9
+ readme = "README.md"
10
+ requires-python = ">=3.12,<4.0"
11
+ dependencies = [
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
16
+ build-backend = "poetry.core.masonry.api"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "mypy (>=1.19.1,<2.0.0)",
21
+ "pytest (>=9.0.2,<10.0.0)",
22
+ "ruff (>=0.14.13,<0.15.0)",
23
+ "pytest-mock (>=3.15.1,<4.0.0)",
24
+ "pytest-cov (>=7.1.0,<8.0.0)",
25
+ "coverage (>=7.14.1,<8.0.0)",
26
+ "python-semantic-release (>=10.5.3,<11.0.0)",
27
+ ]
28
+
29
+ [tool.semantic_release]
30
+ version_toml = ["pyproject.toml:project.version"]
31
+
32
+ # to have a first version when no github version tags are available yet
33
+ initial_version = "0.1.0"
34
+
35
+ # commit message conventions
36
+ commit_parser = "conventional"
37
+
38
+ # how to format the git tag
39
+ tag_format = "v{version}"
40
+
41
+ # uses this command for building the package
42
+ build_command = "poetry build"
43
+
44
+ # allow versions starting like 0.x.y
45
+ allow_zero_version = true
46
+
47
+ upload_to_vcs_release = true
48
+
49
+ [tool.semantic_release.remote]
50
+ name = "origin"
51
+
52
+ [tool.semantic_release.branches.main]
53
+ match = "main"
54
+ prerelease = false
55
+
56
+ [tool.semantic_release.branches.develop]
57
+ match = "develop"
58
+ prerelease = true
59
+ prerelease_token = "rc"
60
+
61
+ [tool.mypy]
62
+ strict = true
63
+ files = "./src"
64
+ exclude = ".*__init__\\.py$"
65
+
66
+ [tool.pytest]
67
+ pythonpath = ["./src"]
68
+ testpaths = ["tests"]
69
+
70
+ [tool.coverage.run]
71
+ source = ["bepfe_export_parser"]
72
+ omit = [
73
+ "tests/*",
74
+ ]
75
+
76
+ [tool.coverage.report]
77
+ show_missing = true
78
+ skip_covered = false
@@ -0,0 +1,6 @@
1
+ class ConvertingError(Exception):
2
+ pass
3
+
4
+
5
+ class InvalidRowError(Exception):
6
+ pass
@@ -0,0 +1,67 @@
1
+ from decimal import Decimal
2
+
3
+
4
+ def convert_comma_string_to_decimal(s: str) -> Decimal:
5
+ return Decimal(s.replace(",", "."))
6
+
7
+
8
+ def convert_time_string_to_decimal(s: str) -> Decimal:
9
+ result = Decimal("0.0")
10
+
11
+ point_parts = s.split(".")
12
+ if len(point_parts) > 2:
13
+ raise IndexError("Too many point separated parts")
14
+ if len(point_parts) > 1:
15
+ result = result + Decimal(f"0.{point_parts[1]}")
16
+
17
+ colon_parts = point_parts[0].split(":")
18
+ if len(colon_parts) > 3:
19
+ raise IndexError("Too many colon separated parts")
20
+ colon_parts.reverse()
21
+ if len(colon_parts) > 0:
22
+ result += int(colon_parts[0])
23
+ if len(colon_parts) > 1:
24
+ result += int(colon_parts[1]) * 60
25
+ if len(colon_parts) > 2:
26
+ result += int(colon_parts[2]) * 3600
27
+
28
+ return result
29
+
30
+
31
+ def normalize_date_string(date_str: str, separator: str = ".") -> str:
32
+ if len(separator) < 1:
33
+ raise ValueError("separator is invalid")
34
+
35
+ day, month, year = date_str.split(separator)
36
+
37
+ len_day = len(day)
38
+ if len_day < 1 or len_day > 2:
39
+ raise ValueError("day string has invalid length")
40
+ if not day.isnumeric():
41
+ raise ValueError("day string is not numeric")
42
+ d = int(day)
43
+ if d < 1 or d > 31:
44
+ raise ValueError("day value is out of range")
45
+
46
+ len_month = len(month)
47
+ if len_month < 1 or len_month > 2:
48
+ raise ValueError("month string has invalid length")
49
+ if not month.isnumeric():
50
+ raise ValueError("month string is not numeric")
51
+ m = int(month)
52
+ if m < 1 or m > 12:
53
+ raise ValueError("month value is out of range")
54
+
55
+ len_year = len(year)
56
+ if len_year != 2 and len_year != 4:
57
+ raise ValueError("year string has invalid length")
58
+ if not year.isnumeric():
59
+ raise ValueError("year string is not numeric")
60
+ y = int(year)
61
+ if len_year == 2:
62
+ offset = 2000 if y < 80 else 1900
63
+ y += offset
64
+ if y < 1000:
65
+ raise ValueError("year value is out of range")
66
+
67
+ return separator.join([f"{int(d):02d}", f"{int(m):02d}", f"{int(y):04d}"])
@@ -0,0 +1,31 @@
1
+ from decimal import Decimal
2
+
3
+ from bepfe_export_parser.exceptions import ConvertingError
4
+ from bepfe_export_parser.util import (
5
+ convert_comma_string_to_decimal,
6
+ convert_time_string_to_decimal,
7
+ )
8
+
9
+
10
+ def convert_str_to_int(s: str, err_msg: str | None = None) -> int:
11
+ try:
12
+ return int(s)
13
+ except Exception as exc:
14
+ new_exc = ConvertingError(err_msg) if err_msg else ConvertingError()
15
+ raise new_exc from exc
16
+
17
+
18
+ def convert_comma_str_to_decimal(s: str, err_msg: str | None = None) -> Decimal:
19
+ try:
20
+ return convert_comma_string_to_decimal(s)
21
+ except Exception as exc:
22
+ new_exc = ConvertingError(err_msg) if err_msg else ConvertingError()
23
+ raise new_exc from exc
24
+
25
+
26
+ def convert_time_str_to_decimal(s: str, err_msg: str | None = None) -> Decimal:
27
+ try:
28
+ return convert_time_string_to_decimal(s)
29
+ except Exception as exc:
30
+ new_exc = ConvertingError(err_msg) if err_msg else ConvertingError()
31
+ raise new_exc from exc
@@ -0,0 +1,55 @@
1
+ import csv
2
+ import re
3
+ import os
4
+
5
+ from datetime import date, datetime
6
+
7
+ from bepfe_export_parser.util import normalize_date_string
8
+ from bepfe_export_parser.win.model import FileData
9
+ from bepfe_export_parser.win.parse.base import ContentParser
10
+ from bepfe_export_parser.win.reader import StatefulRowReader
11
+
12
+
13
+ class FileDataExtractor:
14
+ def __init__(
15
+ self,
16
+ filepath: str,
17
+ newline: str = "",
18
+ encoding: str = "cp1252",
19
+ delimiter: str = ";",
20
+ ) -> None:
21
+ self._filepath = filepath
22
+ self._newline = newline
23
+ self._encoding = encoding
24
+ self._delimiter = delimiter
25
+
26
+ def _extract_date_from_filename(self) -> date | None:
27
+ result = None
28
+
29
+ filename = os.path.basename(self._filepath)
30
+
31
+ match = re.search(r"\d{2}\.\d{2}\.\d{4}", filename)
32
+ if not match:
33
+ match = re.search(r"\d{2}\.\d{2}\.\d{2}", filename)
34
+
35
+ if match:
36
+ date_str = normalize_date_string(match.group())
37
+ result = datetime.strptime(date_str, "%d.%m.%Y").date()
38
+
39
+ return result
40
+
41
+ def extract(self) -> FileData:
42
+ date_from_filename = self._extract_date_from_filename()
43
+
44
+ with open(
45
+ self._filepath, newline=self._newline, encoding=self._encoding
46
+ ) as stream:
47
+ content = ContentParser(
48
+ StatefulRowReader(csv.reader(stream, delimiter=self._delimiter))
49
+ ).parse()
50
+
51
+ return FileData(
52
+ filepath=self._filepath,
53
+ date_from_filename=date_from_filename,
54
+ content=content,
55
+ )
@@ -0,0 +1,121 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+ from decimal import Decimal
4
+ from enum import Enum
5
+
6
+
7
+ class RaceTypeEnum(Enum):
8
+ MULTI_RACE = "Serienrennen"
9
+
10
+
11
+ class RaceModeEnum(Enum):
12
+ F1 = "F1-Modus"
13
+ SLOT = "Slot-Modus"
14
+
15
+
16
+ class HeatLengthUnitEnum(Enum):
17
+ SECONDS = "SECONDS"
18
+ LAPS = "LAPS"
19
+
20
+
21
+ @dataclass
22
+ class PersonalizedLapsInHeat:
23
+ driver_name: str
24
+ heat_num: int
25
+ lane_num: int
26
+ lap_count: Decimal
27
+
28
+
29
+ @dataclass
30
+ class DetailedLapTimeInHeat:
31
+ heat_num: int
32
+ lane_num: int
33
+ lap_time: Decimal
34
+ lap_num: int
35
+
36
+
37
+ @dataclass
38
+ class PersonalizedLapTimeInHeat:
39
+ driver_name: str
40
+ heat_num: int
41
+ lane_num: int
42
+ lap_time: Decimal
43
+
44
+
45
+ @dataclass
46
+ class HeatLength:
47
+ value: int
48
+ unit: HeatLengthUnitEnum
49
+
50
+
51
+ @dataclass
52
+ class QualifyingEntry:
53
+ position_num: int
54
+ driver_name: str
55
+ lap_time: Decimal
56
+
57
+
58
+ @dataclass
59
+ class ShortResultEntry:
60
+ position_num: int
61
+ driver_name: str
62
+ total_lap_count: Decimal
63
+ total_time: Decimal
64
+ average_lap_time: Decimal
65
+ fastest_lap: DetailedLapTimeInHeat
66
+
67
+
68
+ @dataclass
69
+ class LapsOnLane:
70
+ round_num: int
71
+ # for multiple driver appearances on the same lane: first round = 1, second round = 2, ...
72
+ lane_num: int
73
+ # lane number: 1, 2, 3, ...
74
+ lap_count: Decimal
75
+ # lap count on lane at round
76
+
77
+
78
+ @dataclass
79
+ class LapsPerLaneOverviewEntry:
80
+ driver_name: str
81
+ # name of the driver
82
+ heats: list[LapsOnLane]
83
+ # values for all heats driven
84
+
85
+
86
+ @dataclass
87
+ class LapTimeOnLane:
88
+ round_num: int
89
+ # for multiple driver appearances on the same lane: first round = 1, second round = 2, ...
90
+ lane_num: int
91
+ # lane number: 1, 2, 3, ...
92
+ lap_time: Decimal
93
+ # lap time on lane at round
94
+
95
+
96
+ @dataclass
97
+ class FastestLapsOverviewEntry:
98
+ driver_name: str
99
+ # name of the driver
100
+ heats: list[LapTimeOnLane]
101
+ # values for all lanes driven
102
+
103
+
104
+ @dataclass
105
+ class Content:
106
+ race_type: RaceTypeEnum
107
+ race_mode: RaceModeEnum
108
+ heat_length: HeatLength
109
+ qualifying: list[QualifyingEntry]
110
+ best_heat: PersonalizedLapsInHeat | None
111
+ fastest_lap: PersonalizedLapTimeInHeat | None
112
+ short_result: list[ShortResultEntry]
113
+ laps_per_lane_overview: list[LapsPerLaneOverviewEntry]
114
+ fastest_laps_overview: list[FastestLapsOverviewEntry]
115
+
116
+
117
+ @dataclass
118
+ class FileData:
119
+ filepath: str
120
+ date_from_filename: date | None
121
+ content: Content
@@ -0,0 +1,18 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from bepfe_export_parser.win.reader import StatefulRowReader
5
+
6
+
7
+ class AbstractParser(ABC):
8
+ @abstractmethod
9
+ def parse(self) -> Any:
10
+ pass
11
+
12
+
13
+ class DefaultParser(AbstractParser):
14
+ def __init__(self, reader: StatefulRowReader) -> None:
15
+ self._reader = reader
16
+
17
+ def _get_reader(self) -> StatefulRowReader:
18
+ return self._reader
@@ -0,0 +1,103 @@
1
+ from typing import Any
2
+
3
+ from bepfe_export_parser.win.model import Content
4
+ from bepfe_export_parser.win.parse.abstract import DefaultParser
5
+ from bepfe_export_parser.win.parse.constants import (
6
+ BEST_HEAT_MARKER,
7
+ FASTEST_LAP_MARKER,
8
+ FASTEST_LAPS_OVERVIEW_MARKER,
9
+ HEAT_LENGTH_MARKER,
10
+ LAPS_PER_LANE_OVERVIEW_MARKER,
11
+ QUALIFYING_MARKER,
12
+ RACE_MODE_MARKER,
13
+ RACE_TYPE_MARKER,
14
+ SHORT_RESULT_MARKER,
15
+ )
16
+ from bepfe_export_parser.win.parse.heat_info import (
17
+ HeatLapsInfoParser,
18
+ HeatTimeInfoParser,
19
+ )
20
+ from bepfe_export_parser.win.parse.result import QualifyingParser, ShortResultParser
21
+ from bepfe_export_parser.win.parse.settings import (
22
+ HeatLengthParser,
23
+ RaceModeParser,
24
+ RaceTypeParser,
25
+ )
26
+ from bepfe_export_parser.win.parse.overview import (
27
+ FastestLapsOverviewParser,
28
+ LapsPerLaneOverviewParser,
29
+ )
30
+ from bepfe_export_parser.win.reader import StatefulRowReader
31
+
32
+
33
+ class ContentCollector:
34
+ _results: dict[str, Any]
35
+
36
+ def __init__(self) -> None:
37
+ self._results = {}
38
+
39
+ def add_result(self, key: str, result: Any) -> None:
40
+ self._results[key] = result
41
+
42
+ def construct_model(self) -> Content:
43
+ race_type = self._results.get(RACE_TYPE_MARKER)
44
+ if not race_type:
45
+ raise KeyError("race type result is missing")
46
+
47
+ race_mode = self._results.get(RACE_MODE_MARKER)
48
+ if not race_mode:
49
+ raise KeyError("race mode result is missing")
50
+
51
+ heat_length = self._results.get(HEAT_LENGTH_MARKER)
52
+ if not heat_length:
53
+ raise KeyError("heat length result is missing")
54
+
55
+ qualifying = self._results.get(QUALIFYING_MARKER, [])
56
+
57
+ best_heat = self._results.get(BEST_HEAT_MARKER)
58
+ fastest_lap = self._results.get(FASTEST_LAP_MARKER)
59
+ short_result = self._results.get(SHORT_RESULT_MARKER, [])
60
+
61
+ laps_per_lane_overview = self._results.get(LAPS_PER_LANE_OVERVIEW_MARKER, [])
62
+ fastest_laps_overview = self._results.get(FASTEST_LAPS_OVERVIEW_MARKER, [])
63
+
64
+ return Content(
65
+ race_type=race_type,
66
+ race_mode=race_mode,
67
+ heat_length=heat_length,
68
+ qualifying=qualifying,
69
+ best_heat=best_heat,
70
+ fastest_lap=fastest_lap,
71
+ short_result=short_result,
72
+ laps_per_lane_overview=laps_per_lane_overview,
73
+ fastest_laps_overview=fastest_laps_overview,
74
+ )
75
+
76
+
77
+ class ContentParser(DefaultParser):
78
+ def __init__(self, reader: StatefulRowReader) -> None:
79
+ super().__init__(reader)
80
+ self._mapped_parsers: dict[str, type[DefaultParser]] = {
81
+ RACE_TYPE_MARKER: RaceTypeParser,
82
+ RACE_MODE_MARKER: RaceModeParser,
83
+ HEAT_LENGTH_MARKER: HeatLengthParser,
84
+ QUALIFYING_MARKER: QualifyingParser,
85
+ BEST_HEAT_MARKER: HeatLapsInfoParser,
86
+ FASTEST_LAP_MARKER: HeatTimeInfoParser,
87
+ SHORT_RESULT_MARKER: ShortResultParser,
88
+ LAPS_PER_LANE_OVERVIEW_MARKER: LapsPerLaneOverviewParser,
89
+ FASTEST_LAPS_OVERVIEW_MARKER: FastestLapsOverviewParser,
90
+ }
91
+
92
+ def parse(self) -> Content:
93
+ collector = ContentCollector()
94
+ reader = self._get_reader()
95
+ while (current_row := reader.fetch_next_row()) is not None:
96
+ if len(current_row) < 1:
97
+ continue
98
+ marker = current_row[0]
99
+ current_parser = self._mapped_parsers.get(marker)
100
+ if current_parser:
101
+ collector.add_result(marker, current_parser(reader).parse())
102
+
103
+ return collector.construct_model()
@@ -0,0 +1,17 @@
1
+ HEAT_LABEL = "Lauf"
2
+ LANE_LABEL = "Spur"
3
+
4
+ ROUNDS_UNIT = "R"
5
+ SECONDS_UNIT = "s"
6
+
7
+ MINUTES_UNIT = "Minuten"
8
+
9
+ RACE_TYPE_MARKER = "Typ"
10
+ RACE_MODE_MARKER = "Modus"
11
+ HEAT_LENGTH_MARKER = "Vorgabe"
12
+ QUALIFYING_MARKER = "Qualifikation"
13
+ FASTEST_LAP_MARKER = "Bestzeit"
14
+ BEST_HEAT_MARKER = "Bester Lauf"
15
+ SHORT_RESULT_MARKER = "Kurzergebnis"
16
+ LAPS_PER_LANE_OVERVIEW_MARKER = "Ergebnis-Spurübersicht Runden"
17
+ FASTEST_LAPS_OVERVIEW_MARKER = "Bestzeiten-Übersicht"
@@ -0,0 +1,86 @@
1
+ from decimal import Decimal
2
+
3
+ from bepfe_export_parser.win.convert import (
4
+ convert_str_to_int,
5
+ convert_comma_str_to_decimal,
6
+ )
7
+ from bepfe_export_parser.win.model import (
8
+ PersonalizedLapsInHeat,
9
+ PersonalizedLapTimeInHeat,
10
+ )
11
+ from bepfe_export_parser.win.parse.abstract import DefaultParser
12
+ from bepfe_export_parser.win.parse.constants import (
13
+ HEAT_LABEL,
14
+ LANE_LABEL,
15
+ ROUNDS_UNIT,
16
+ SECONDS_UNIT,
17
+ )
18
+ from bepfe_export_parser.exceptions import ConvertingError
19
+
20
+
21
+ class HeatRelatedParser(DefaultParser):
22
+ @staticmethod
23
+ def _convert_int_with_label(text: str, label: str, col_name: str) -> int:
24
+ parts = text.split()
25
+
26
+ if len(parts) != 2:
27
+ raise ConvertingError(f"text in {col_name} column is invalid")
28
+
29
+ label_part, value_part = parts
30
+
31
+ if not label_part.startswith(label):
32
+ raise ConvertingError(f"label part in {col_name} column is invalid")
33
+
34
+ return convert_str_to_int(
35
+ value_part, f"value part in {col_name} column is invalid"
36
+ )
37
+
38
+ @staticmethod
39
+ def _convert_decimal_with_unit(text: str, unit: str, col_name: str) -> Decimal:
40
+ parts = text.split()
41
+
42
+ if len(parts) != 2:
43
+ raise ConvertingError(f"text in {col_name} column is invalid")
44
+
45
+ value_part, unit_part = parts
46
+
47
+ result = convert_comma_str_to_decimal(
48
+ value_part, f"value part in {col_name} column is invalid"
49
+ )
50
+
51
+ if unit_part.strip() != unit:
52
+ raise ConvertingError(f"unit part in {col_name} column is invalid")
53
+
54
+ return result
55
+
56
+ def _extract_heat_common_info(self) -> tuple[str, str, int, int]:
57
+ current_row = self._get_reader().get_current_row_checked(5)
58
+
59
+ driver_name = current_row[1]
60
+ mixed = current_row[2]
61
+ heat_num = HeatRelatedParser._convert_int_with_label(
62
+ current_row[3], HEAT_LABEL, "heat"
63
+ )
64
+ lane_num = HeatRelatedParser._convert_int_with_label(
65
+ current_row[4], LANE_LABEL, "lane"
66
+ )
67
+
68
+ return (driver_name, mixed, heat_num, lane_num)
69
+
70
+
71
+ class HeatLapsInfoParser(HeatRelatedParser):
72
+ def parse(self) -> PersonalizedLapsInHeat:
73
+ driver, text, heat, lane = self._extract_heat_common_info()
74
+ value = HeatRelatedParser._convert_decimal_with_unit(text, ROUNDS_UNIT, "laps")
75
+ return PersonalizedLapsInHeat(
76
+ driver_name=driver, heat_num=heat, lane_num=lane, lap_count=value
77
+ )
78
+
79
+
80
+ class HeatTimeInfoParser(HeatRelatedParser):
81
+ def parse(self) -> PersonalizedLapTimeInHeat:
82
+ driver, text, heat, lane = self._extract_heat_common_info()
83
+ value = HeatRelatedParser._convert_decimal_with_unit(text, SECONDS_UNIT, "time")
84
+ return PersonalizedLapTimeInHeat(
85
+ driver_name=driver, heat_num=heat, lane_num=lane, lap_time=value
86
+ )
@@ -0,0 +1,127 @@
1
+ from decimal import Decimal
2
+
3
+ from bepfe_export_parser.exceptions import InvalidRowError
4
+ from bepfe_export_parser.win.convert import (
5
+ convert_comma_str_to_decimal,
6
+ convert_str_to_int,
7
+ )
8
+ from bepfe_export_parser.win.model import (
9
+ FastestLapsOverviewEntry,
10
+ LapTimeOnLane,
11
+ LapsOnLane,
12
+ LapsPerLaneOverviewEntry,
13
+ )
14
+ from bepfe_export_parser.win.parse.abstract import DefaultParser
15
+ from bepfe_export_parser.win.reader import StatefulRowReader
16
+
17
+
18
+ class LanesRelatedOverviewParser:
19
+ _NUM_COLS_BEFORE_LANES = 2
20
+ _BROKEN_COL_STR = "'-"
21
+
22
+ def __init__(self, reader: StatefulRowReader) -> None:
23
+ self._reader = reader
24
+
25
+ def _fetch_header_lane_numbers(self) -> list[int]:
26
+ header_row = self._reader.fetch_next_row()
27
+ if header_row is None or len(header_row) == 0:
28
+ raise InvalidRowError("header is not available")
29
+
30
+ # header must contain at least three columns: blank, driver, first lane
31
+ header_len = len(header_row)
32
+ if header_len <= self._NUM_COLS_BEFORE_LANES:
33
+ raise InvalidRowError("header has too few columns")
34
+
35
+ # get the lane numbers from the header row
36
+ lane_numbers = []
37
+ i = self._NUM_COLS_BEFORE_LANES
38
+ while i < header_len:
39
+ col_str = header_row[i].strip()
40
+ if not col_str:
41
+ break
42
+ lane_numbers.append(
43
+ convert_str_to_int(col_str, "text in header lane column is invalid")
44
+ )
45
+ i = i + 1
46
+
47
+ return lane_numbers
48
+
49
+ def parse(self) -> list[tuple[str, list[tuple[int, int, Decimal]]]]:
50
+ entries = []
51
+
52
+ # header row with lane numbers must exist
53
+ lane_numbers = self._fetch_header_lane_numbers()
54
+ len_lanes = len(lane_numbers)
55
+
56
+ # the body must contain at least one row
57
+ current_row = self._reader.fetch_next_row()
58
+ if current_row is None or len(current_row) == 0:
59
+ raise InvalidRowError("row is not available")
60
+
61
+ # evaluate all body rows
62
+ while current_row is not None and len(current_row) > 0:
63
+ if len(current_row) < self._NUM_COLS_BEFORE_LANES + len_lanes:
64
+ raise InvalidRowError("row has too few columns")
65
+
66
+ driver_name = current_row[1].strip()
67
+ if len(driver_name) > 0:
68
+ round_num = 1
69
+ heats: list[tuple[int, int, Decimal]] = []
70
+ entries.append((driver_name, heats))
71
+ else:
72
+ round_num += 1
73
+
74
+ i = 0
75
+ while i < len_lanes:
76
+ col_idx = self._NUM_COLS_BEFORE_LANES + i
77
+ col_str = current_row[col_idx].strip()
78
+ if col_str and col_str != self._BROKEN_COL_STR:
79
+ lane_num = lane_numbers[i]
80
+ value = convert_comma_str_to_decimal(
81
+ col_str, "text in lane column is invalid"
82
+ )
83
+ heats.append((round_num, lane_num, value))
84
+ i = i + 1
85
+
86
+ current_row = self._reader.fetch_next_row()
87
+
88
+ return entries
89
+
90
+
91
+ class LapsPerLaneOverviewParser(DefaultParser):
92
+ def parse(self) -> list[LapsPerLaneOverviewEntry]:
93
+ return [
94
+ LapsPerLaneOverviewEntry(
95
+ driver_name=driver_name,
96
+ heats=[
97
+ LapsOnLane(
98
+ round_num=round_num, lane_num=lane_num, lap_count=lap_count
99
+ )
100
+ for round_num, lane_num, lap_count in heats
101
+ ],
102
+ )
103
+ for driver_name, heats in LanesRelatedOverviewParser(
104
+ self._get_reader()
105
+ ).parse()
106
+ ]
107
+
108
+
109
+ class FastestLapsOverviewParser(DefaultParser):
110
+ def __init__(self, reader: StatefulRowReader) -> None:
111
+ self._reader = reader
112
+
113
+ def parse(self) -> list[FastestLapsOverviewEntry]:
114
+ return [
115
+ FastestLapsOverviewEntry(
116
+ driver_name=driver_name,
117
+ heats=[
118
+ LapTimeOnLane(
119
+ round_num=round_num, lane_num=lane_num, lap_time=lap_time
120
+ )
121
+ for round_num, lane_num, lap_time in heats
122
+ ],
123
+ )
124
+ for driver_name, heats in LanesRelatedOverviewParser(
125
+ self._get_reader()
126
+ ).parse()
127
+ ]
@@ -0,0 +1,99 @@
1
+ from bepfe_export_parser.win.convert import (
2
+ convert_comma_str_to_decimal,
3
+ convert_str_to_int,
4
+ convert_time_str_to_decimal,
5
+ )
6
+ from bepfe_export_parser.win.model import (
7
+ DetailedLapTimeInHeat,
8
+ QualifyingEntry,
9
+ ShortResultEntry,
10
+ )
11
+ from bepfe_export_parser.exceptions import InvalidRowError
12
+ from bepfe_export_parser.win.parse.abstract import DefaultParser
13
+
14
+
15
+ class QualifyingParser(DefaultParser):
16
+ def parse(self) -> list[QualifyingEntry]:
17
+ entries = []
18
+
19
+ reader = self._get_reader()
20
+
21
+ current_row = reader.fetch_next_row()
22
+ if current_row is None or len(current_row) == 0:
23
+ raise InvalidRowError("row is not available")
24
+
25
+ while current_row is not None and len(current_row) > 0:
26
+ if len(current_row) < 3:
27
+ raise InvalidRowError("row has too few columns")
28
+
29
+ entries.append(
30
+ QualifyingEntry(
31
+ position_num=convert_str_to_int(
32
+ current_row[0], "text in position column is invalid"
33
+ ),
34
+ driver_name=current_row[1],
35
+ lap_time=convert_comma_str_to_decimal(
36
+ current_row[2], "text in lap time column is invalid"
37
+ ),
38
+ )
39
+ )
40
+ current_row = reader.fetch_next_row()
41
+
42
+ return entries
43
+
44
+
45
+ class ShortResultParser(DefaultParser):
46
+ def parse(self) -> list[ShortResultEntry]:
47
+ entries = []
48
+
49
+ reader = self._get_reader()
50
+
51
+ header_row = reader.fetch_next_row() # skip header
52
+ if header_row is None or len(header_row) == 0:
53
+ raise InvalidRowError("header is not available")
54
+
55
+ header_len = len(header_row)
56
+
57
+ current_row = reader.fetch_next_row()
58
+ if current_row is None or len(current_row) == 0:
59
+ raise InvalidRowError("row is not available")
60
+
61
+ while current_row is not None and len(current_row) > 0:
62
+ if len(current_row) < header_len:
63
+ raise InvalidRowError("row has too few columns")
64
+
65
+ entries.append(
66
+ ShortResultEntry(
67
+ position_num=convert_str_to_int(
68
+ current_row[0], "text in position column is invalid"
69
+ ),
70
+ driver_name=current_row[1],
71
+ total_lap_count=convert_comma_str_to_decimal(
72
+ current_row[2], "text in laps column is invalid"
73
+ ),
74
+ total_time=convert_time_str_to_decimal(
75
+ current_row[3], "text in time column is invalid"
76
+ ),
77
+ average_lap_time=convert_comma_str_to_decimal(
78
+ current_row[4], "text in average lap time column is invalid"
79
+ ),
80
+ fastest_lap=DetailedLapTimeInHeat(
81
+ lap_time=convert_comma_str_to_decimal(
82
+ current_row[5], "text in fastest lap time column is invalid"
83
+ ),
84
+ heat_num=convert_str_to_int(
85
+ current_row[6], "text in fastest lap heat column is invalid"
86
+ ),
87
+ lane_num=convert_str_to_int(
88
+ current_row[7], "text in fastest lap lane column is invalid"
89
+ ),
90
+ lap_num=convert_str_to_int(
91
+ current_row[8], "text in fastest lap lap column is invalid"
92
+ ),
93
+ ),
94
+ )
95
+ )
96
+
97
+ current_row = reader.fetch_next_row()
98
+
99
+ return entries
@@ -0,0 +1,59 @@
1
+ from bepfe_export_parser.exceptions import ConvertingError
2
+ from bepfe_export_parser.win.convert import convert_str_to_int
3
+ from bepfe_export_parser.win.model import (
4
+ HeatLength,
5
+ HeatLengthUnitEnum,
6
+ RaceModeEnum,
7
+ RaceTypeEnum,
8
+ )
9
+ from bepfe_export_parser.win.parse.abstract import DefaultParser
10
+ from bepfe_export_parser.win.parse.constants import MINUTES_UNIT
11
+
12
+
13
+ class HeatLengthParser(DefaultParser):
14
+ def parse(self) -> HeatLength:
15
+ parts = self._get_reader().get_current_row_checked(2)[1].split()
16
+
17
+ if len(parts) != 2:
18
+ raise ConvertingError("text in heat length column is invalid")
19
+
20
+ value_part, unit_part = parts
21
+
22
+ value = convert_str_to_int(
23
+ value_part, "value part in heat length column is invalid"
24
+ )
25
+
26
+ if unit_part.strip() != MINUTES_UNIT:
27
+ raise ConvertingError("unit part in heat length column is invalid")
28
+
29
+ seconds = value * 60
30
+
31
+ return HeatLength(value=seconds, unit=HeatLengthUnitEnum.SECONDS)
32
+
33
+
34
+ class RaceModeParser(DefaultParser):
35
+ def parse(self) -> RaceModeEnum:
36
+ text = self._get_reader().get_current_row_checked(2)[1]
37
+
38
+ race_mode = next(
39
+ (entry for entry in RaceModeEnum if entry.value == text),
40
+ None,
41
+ )
42
+ if race_mode is None:
43
+ raise ConvertingError("value in race mode column is invalid")
44
+
45
+ return race_mode
46
+
47
+
48
+ class RaceTypeParser(DefaultParser):
49
+ def parse(self) -> RaceTypeEnum:
50
+ text = self._get_reader().get_current_row_checked(2)[1]
51
+
52
+ race_type = next(
53
+ (entry for entry in RaceTypeEnum if entry.value == text),
54
+ None,
55
+ )
56
+ if race_type is None:
57
+ raise ConvertingError("value in race type column is invalid")
58
+
59
+ return race_type
@@ -0,0 +1,23 @@
1
+ from _csv import Reader
2
+ from bepfe_export_parser.exceptions import InvalidRowError
3
+
4
+
5
+ class StatefulRowReader:
6
+ def __init__(self, reader: Reader) -> None:
7
+ self._reader = reader
8
+ self._current_row: list[str] | None = None
9
+
10
+ def fetch_next_row(self) -> list[str] | None:
11
+ self._current_row = next(self._reader, None)
12
+ return self._current_row
13
+
14
+ def get_current_row(self) -> list[str] | None:
15
+ return self._current_row
16
+
17
+ def get_current_row_checked(self, min_length: int) -> list[str]:
18
+ row = self.get_current_row()
19
+ if row is None or len(row) == 0:
20
+ raise InvalidRowError("row is not available")
21
+ if len(row) < min_length:
22
+ raise InvalidRowError("row has too few columns")
23
+ return row