import re
from decimal import Decimal
from datetime import datetime
import attr
from lxml import etree
import pytz
from pathlib import Path
from zope.interface import implementer
from .. import handhistory as hh
from ..card import Card
from ..hand import Combo
from ..constants import Limit, Game, GameType, Currency, Action, MoneyType
__all__ = ["PokerStarsHandHistory", "Notes"]
@implementer(hh.IStreet)
class _Street(hh._BaseStreet):
def _parse_cards(self, boardline):
self.cards = (Card(boardline[1:3]), Card(boardline[4:6]), Card(boardline[7:9]))
def _parse_actions(self, actionlines):
actions = []
for line in actionlines:
if line.startswith("Uncalled bet"):
action = self._parse_uncalled(line)
elif "collected" in line:
action = self._parse_collected(line)
elif "doesn't show hand" in line:
action = self._parse_muck(line)
elif ' said, "' in line: # skip chat lines
continue
elif ":" in line:
action = self._parse_player_action(line)
else:
raise RuntimeError("bad action line: " + line)
actions.append(hh._PlayerAction(*action))
self.actions = tuple(actions) if actions else None
def _parse_uncalled(self, line):
first_paren_index = line.find("(")
second_paren_index = line.find(")")
amount = line[first_paren_index + 1 : second_paren_index]
name_start_index = line.find("to ") + 3
name = line[name_start_index:]
return name, Action.RETURN, Decimal(amount)
def _parse_collected(self, line):
first_space_index = line.find(" ")
name = line[:first_space_index]
second_space_index = line.find(" ", first_space_index + 1)
third_space_index = line.find(" ", second_space_index + 1)
amount = line[second_space_index + 1 : third_space_index]
self.pot = Decimal(amount)
return name, Action.WIN, self.pot
def _parse_muck(self, line):
colon_index = line.find(":")
name = line[:colon_index]
return name, Action.MUCK, None
def _parse_player_action(self, line):
name, _, action = line.partition(": ")
action, _, amount = action.partition(" ")
amount, _, _ = amount.partition(" ")
if amount:
return name, Action(action), Decimal(amount)
else:
return name, Action(action), None
[docs]@implementer(hh.IHandHistory)
class PokerStarsHandHistory(hh._SplittableHandHistoryMixin, hh._BaseHandHistory):
"""Parses PokerStars Tournament hands."""
_DATE_FORMAT = "%Y/%m/%d %H:%M:%S ET"
_TZ = pytz.timezone("US/Eastern") # ET
_split_re = re.compile(r" ?\*\*\* ?\n?|\n")
_header_re = re.compile(
r"""
^PokerStars\s+ # Poker Room
Hand\s+\#(?P<ident>\d+):\s+ # Hand history id
(Tournament\s+\#(?P<tournament_ident>\d+),\s+ # Tournament Number
((?P<freeroll>Freeroll)|( # buyin is Freeroll
\$?(?P<buyin>\d+(\.\d+)?) # or buyin
(\+\$?(?P<rake>\d+(\.\d+)?))? # and rake
(\s+(?P<currency>[A-Z]+))? # and currency
))\s+
)?
(?P<game>.+?)\s+ # game
(?P<limit>(?:Pot\s+|No\s+|)Limit)\s+ # limit
(-\s+Level\s+(?P<tournament_level>\S+)\s+)? # Level (optional)
\(
(((?P<sb>\d+)/(?P<bb>\d+))|( # tournament blinds
\$(?P<cash_sb>\d+(\.\d+)?)/ # cash small blind
\$(?P<cash_bb>\d+(\.\d+)?) # cash big blind
(\s+(?P<cash_currency>\S+))? # cash currency
))
\)\s+
-\s+.+?\s+ # localized date
\[(?P<date>.+?)\] # ET date
""",
re.VERBOSE,
)
_table_re = re.compile(
r"^Table '(.*)' (\d+)-max Seat #(?P<button>\d+) is the button"
)
_seat_re = re.compile(
r"^Seat (?P<seat>\d+): (?P<name>.+?) \(\$?(?P<stack>\d+(\.\d+)?) in chips\)"
) # noqa
_hero_re = re.compile(r"^Dealt to (?P<hero_name>.+?) \[(..) (..)\]")
_pot_re = re.compile(r"^Total pot (\d+(?:\.\d+)?) .*\| Rake (\d+(?:\.\d+)?)")
_winner_re = re.compile(r"^Seat (\d+): (.+?) collected \((\d+(?:\.\d+)?)\)")
_showdown_re = re.compile(r"^Seat (\d+): (.+?) showed \[.+?\] and won")
_ante_re = re.compile(r".*posts the ante (\d+(?:\.\d+)?)")
_board_re = re.compile(r"(?<=[\[ ])(..)(?=[\] ])")
def parse_header(self):
# sections[0] is before HOLE CARDS
# sections[-1] is before SUMMARY
self._split_raw()
match = self._header_re.match(self._splitted[0])
self.extra = dict()
self.ident = match.group("ident")
# We cannot use the knowledege of the game type to pick between the blind
# and cash blind captures because a cash game play money blind looks exactly
# like a tournament blind
self.sb = Decimal(match.group("sb") or match.group("cash_sb"))
self.bb = Decimal(match.group("bb") or match.group("cash_bb"))
if match.group("tournament_ident"):
self.game_type = GameType.TOUR
self.tournament_ident = match.group("tournament_ident")
self.tournament_level = match.group("tournament_level")
currency = match.group("currency")
self.buyin = Decimal(match.group("buyin") or 0)
self.rake = Decimal(match.group("rake") or 0)
else:
self.game_type = GameType.CASH
self.tournament_ident = None
self.tournament_level = None
currency = match.group("cash_currency")
self.buyin = None
self.rake = None
if match.group("freeroll") and not currency:
currency = "USD"
if not currency:
self.extra["money_type"] = MoneyType.PLAY
self.currency = None
else:
self.extra["money_type"] = MoneyType.REAL
self.currency = Currency(currency)
self.game = Game(match.group("game"))
self.limit = Limit(match.group("limit"))
self._parse_date(match.group("date"))
self.header_parsed = True
def parse(self):
"""Parses the body of the hand history, but first parse header if not yet parsed."""
if not self.header_parsed:
self.parse_header()
self._parse_table()
self._parse_players()
self._parse_button()
self._parse_hero()
self._parse_preflop()
self._parse_flop()
self._parse_street("turn")
self._parse_street("river")
self._parse_showdown()
self._parse_pot()
self._parse_board()
self._parse_winners()
self._del_split_vars()
self.parsed = True
def _parse_table(self):
self._table_match = self._table_re.match(self._splitted[1])
self.table_name = self._table_match.group(1)
self.max_players = int(self._table_match.group(2))
def _parse_players(self):
self.players = self._init_seats(self.max_players)
for line in self._splitted[2:]:
match = self._seat_re.match(line)
# we reached the end of the players section
if not match:
break
index = int(match.group("seat")) - 1
self.players[index] = hh._Player(
name=match.group("name"),
stack=int(match.group("stack")),
seat=int(match.group("seat")),
combo=None,
)
def _parse_button(self):
button_seat = int(self._table_match.group("button"))
self.button = self.players[button_seat - 1]
def _parse_hero(self):
hole_cards_line = self._splitted[self._sections[0] + 2]
match = self._hero_re.match(hole_cards_line)
hero, hero_index = self._get_hero_from_players(match.group("hero_name"))
hero.combo = Combo(match.group(2) + match.group(3))
self.hero = self.players[hero_index] = hero
if self.button.name == self.hero.name:
self.button = hero
def _parse_preflop(self):
start = self._sections[0] + 3
stop = self._sections[1]
self.preflop_actions = tuple(self._splitted[start:stop])
def _parse_flop(self):
try:
start = self._splitted.index("FLOP") + 1
except ValueError:
self.flop = None
return
stop = self._splitted.index("", start)
floplines = self._splitted[start:stop]
self.flop = _Street(floplines)
def _parse_street(self, street):
try:
start = self._splitted.index(street.upper()) + 2
stop = self._splitted.index("", start)
street_actions = self._splitted[start:stop]
setattr(
self,
f"{street.lower()}_actions",
tuple(street_actions) if street_actions else None,
)
except ValueError:
setattr(self, street, None)
setattr(self, f"{street.lower()}_actions", None)
def _parse_showdown(self):
self.show_down = "SHOW DOWN" in self._splitted
def _parse_pot(self):
potline = self._splitted[self._sections[-1] + 2]
match = self._pot_re.match(potline)
self.total_pot = int(match.group(1))
def _parse_board(self):
boardline = self._splitted[self._sections[-1] + 3]
if not boardline.startswith("Board"):
return
cards = self._board_re.findall(boardline)
self.turn = Card(cards[3]) if len(cards) > 3 else None
self.river = Card(cards[4]) if len(cards) > 4 else None
def _parse_winners(self):
winners = set()
start = self._sections[-1] + 4
for line in self._splitted[start:]:
if not self.show_down and "collected" in line:
match = self._winner_re.match(line)
winners.add(match.group(2))
elif self.show_down and "won" in line:
match = self._showdown_re.match(line)
winners.add(match.group(2))
self.winners = tuple(winners)
[docs]@attr.s(slots=True)
class _Label:
"""Labels in Player notes."""
id = attr.ib()
color = attr.ib()
name = attr.ib()
[docs]@attr.s(slots=True)
class _Note:
"""Player note."""
player = attr.ib()
label = attr.ib()
update = attr.ib()
text = attr.ib()
[docs]class NoteNotFoundError(ValueError):
"""Note not found for player."""
[docs]class LabelNotFoundError(ValueError):
"""Label not found in the player notes."""
[docs]class Notes:
"""Class for parsing pokerstars XML notes."""
_color_re = re.compile("^[0-9A-F]{6}$")
def __init__(self, notes: str):
self.raw = notes
parser = etree.XMLParser(recover=True, resolve_entities=False)
self.root = etree.XML(notes.encode(), parser)
def __str__(self):
return etree.tostring(
self.root, xml_declaration=True, encoding="UTF-8", pretty_print=True
).decode()
[docs] @classmethod
def from_file(cls, filename):
"""Make an instance from a XML file."""
return cls(Path(filename).open().read())
@property
def players(self):
"""Tuple of player names."""
return tuple(note.get("player") for note in self.root.iter("note"))
@property
def label_names(self):
"""Tuple of label names."""
return tuple(label.text for label in self.root.iter("label"))
@property
def notes(self):
"""Tuple of notes.."""
return tuple(self._get_note_data(note) for note in self.root.iter("note"))
@property
def labels(self):
"""Tuple of labels."""
return tuple(
_Label(label.get("id"), label.get("color"), label.text)
for label in self.root.iter("label")
)
[docs] def get_note_text(self, player):
"""Return note text for the player."""
note = self._find_note(player)
return note.text
[docs] def get_note(self, player):
"""Return :class:`_Note` tuple for the player."""
return self._get_note_data(self._find_note(player))
[docs] def add_note(self, player, text, label=None, update=None):
"""Add a note to the xml. If update param is None, it will be the current time."""
if label is not None and (label not in self.label_names):
raise LabelNotFoundError(f"Invalid label: {label}")
if update is None:
update = datetime.utcnow()
# converted to timestamp, rounded to ones
update = update.strftime("%s")
label_id = self._get_label_id(label)
new_note = etree.Element("note", player=player, label=label_id, update=update)
new_note.text = text
self.root.append(new_note)
[docs] def append_note(self, player, text):
"""Append text to an already existing note."""
note = self._find_note(player)
note.text += text
[docs] def prepend_note(self, player, text):
"""Prepend text to an already existing note."""
note = self._find_note(player)
note.text = text + note.text
[docs] def replace_note(self, player, text):
"""Replace note text with text. (Overwrites previous note!)"""
note = self._find_note(player)
note.text = text
def change_note_label(self, player, label):
label_id = self._get_label_id(label)
note = self._find_note(player)
note.attrib["label"] = label_id
[docs] def del_note(self, player):
"""Delete a note by player name."""
self.root.remove(self._find_note(player))
def _find_note(self, player):
# if player name contains a double quote, the search phrase would be invalid.
# " entitiy is searched with ", e.g. "bootei" is searched with '"bootei"'
quote = "'" if '"' in player else '"'
note = self.root.find(f"note[@player={quote}{player}{quote}]")
if note is None:
raise NoteNotFoundError(player)
return note
def _get_note_data(self, note):
labels = {label.get("id"): label.text for label in self.root.iter("label")}
label = note.get("label")
label = labels[label] if label != "-1" else None
timestamp = note.get("update")
if timestamp:
timestamp = int(timestamp)
update = datetime.utcfromtimestamp(timestamp).replace(tzinfo=pytz.UTC)
else:
update = None
return _Note(note.get("player"), label, update, note.text)
[docs] def get_label(self, name):
"""Find the label by name."""
label_tag = self._find_label(name)
return _Label(label_tag.get("id"), label_tag.get("color"), label_tag.text)
[docs] def add_label(self, name, color):
"""Add a new label. It's id will automatically be calculated."""
color_upper = color.upper()
if not self._color_re.match(color_upper):
raise ValueError(f"Invalid color: {color}")
labels_tag = self.root[0]
last_id = int(labels_tag[-1].get("id"))
new_id = str(last_id + 1)
new_label = etree.Element("label", id=new_id, color=color_upper)
new_label.text = name
labels_tag.append(new_label)
[docs] def del_label(self, name):
"""Delete a label by name."""
labels_tag = self.root[0]
labels_tag.remove(self._find_label(name))
def _find_label(self, name):
labels_tag = self.root[0]
try:
return labels_tag.xpath('label[text()="%s"]' % name)[0]
except IndexError:
raise LabelNotFoundError(name)
def _get_label_id(self, name):
return self._find_label(name).get("id") if name else "-1"
[docs] def save(self, filename):
"""Save the note XML to a file."""
with open(filename, "w") as fp:
fp.write(str(self))