Files
2025-12-09 12:13:01 +01:00

295 lines
9.1 KiB
Python

"""
Formatting utilities for the edgar library.
This module contains various formatting functions for dates, numbers, and strings.
"""
import datetime
import re
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional, Union
import humanize
from rich.text import Text
def moneyfmt(value, places=0, curr='$', sep=',', dp='.',
pos='', neg='-'):
"""Convert Decimal to a money formatted string.
Args:
value: The decimal value to format
places: Number of decimal places to show (default: 0)
curr: Optional currency symbol (default: '$')
sep: Thousands separator (default: ',')
dp: Decimal point indicator (default: '.')
pos: Sign for positive numbers (default: '')
neg: Sign for negative numbers (default: '-')
Examples:
>>> moneyfmt(Decimal('-1234567.8901'), curr='$')
'-$1,234,567.89'
>>> moneyfmt(Decimal('123456789'), sep=' ')
'123 456 789.00'
"""
q = Decimal(10) ** -places # 2 places --> '0.01'
sign, digits, exp = value.quantize(q, rounding=ROUND_HALF_UP).as_tuple()
result = []
digits = list(map(str, digits))
build, next = result.append, digits.pop
# Add trailing zeros if needed
for i in range(places):
build(next() if digits else '0')
# Add decimal point if needed
if places:
build(dp)
# Add digits before decimal point
if not digits:
build('0')
else:
i = 0
while digits:
build(next())
i += 1
if i == 3 and digits:
i = 0
build(sep)
# Add currency symbol and sign
build(curr)
if sign:
build(neg)
else:
build(pos)
return ''.join(reversed(result))
def datefmt(value: Union[datetime.datetime, str], fmt: str = "%Y-%m-%d") -> str:
"""Format a date as a string"""
if isinstance(value, str):
# if value matches %Y%m%d, then parse it
if re.match(r"^\d{8}$", value):
value = datetime.datetime.strptime(value, "%Y%m%d")
# If value matches %Y%m%d%H%M%s, then parse it
elif re.match(r"^\d{14}$", value):
value = datetime.datetime.strptime(value, "%Y%m%d%H%M%S")
elif re.match(r"^\d{4}-\d{2}-\d{2}$", value):
value = datetime.datetime.strptime(value, "%Y-%m-%d")
return value.strftime(fmt)
else:
return value.strftime(fmt)
def display_size(size: Optional[Union[int, str]]) -> str:
"""
:return the size in KB or MB as a string
"""
if size:
if isinstance(size, int) or size.isdigit():
return humanize.naturalsize(int(size), binary=True).replace("i", "")
return ""
def split_camel_case(item):
# Check if the string is all uppercase or all lowercase
if item.isupper() or item.islower():
return item
else:
# Split at the boundary between uppercase and lowercase, and between lowercase and uppercase
words = re.findall(r'[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+|[A-Z]?[a-z]+|\W+', item)
# Join the words, preserving consecutive uppercase words
result = []
for i, word in enumerate(words):
if i > 0 and word.isupper() and words[i - 1].isupper():
result[-1] += word
else:
result.append(word)
return ' '.join(result)
def yes_no(value: bool) -> str:
"""Convert a boolean to 'Yes' or 'No'.
Args:
value: Boolean value
Returns:
'Yes' if True, 'No' if False
"""
return "Yes" if value else "No"
def reverse_name(name):
# Split the name into parts
parts = name.split()
# Return immediately if there's only one name part
if len(parts) == 1:
return parts[0].title()
# Handle the cases where there's a 'Jr', 'Sr', 'II', 'III', 'MD', etc., or 'ET AL'
special_parts = ['Jr', 'JR', 'Sr', 'SR', 'II', 'III', 'MD', 'ET', 'AL', 'et', 'al']
special_parts_with_period = [part + '.' for part in special_parts if part not in ['II', 'III']] + special_parts
special_part_indices = [i for i, part in enumerate(parts) if part in special_parts_with_period or (
i > 0 and parts[i - 1].rstrip('.') + ' ' + part.rstrip('.') == 'ET AL')]
# Extract the special parts and the main name parts
special_parts_list = [parts[i] for i in special_part_indices]
main_name_parts = [part for i, part in enumerate(parts) if i not in special_part_indices]
# Handle initials in the name
if len(main_name_parts) > 2 and (('.' in main_name_parts[-2] or len(main_name_parts[-2]) == 1)):
main_name_parts = [' '.join(main_name_parts[:-2]).title()] + [
f"{main_name_parts[-1].title()} {main_name_parts[-2]}"]
else:
main_name_parts = [part.title() if len(part) > 2 else part for part in main_name_parts]
# Reverse the main name parts
reversed_main_parts = [part for part in main_name_parts[1:]] + [main_name_parts[0]]
reversed_name = " ".join(reversed_main_parts)
# Append the special parts to the reversed name, maintaining their original case
if special_parts_list:
reversed_name += " " + " ".join(special_parts_list)
return reversed_name
def accession_number_text(accession: str) -> Text:
"""Format an SEC accession number with color highlighting.
Args:
accession: SEC accession number (e.g., '0001234567-25-000123')
Returns:
Rich Text object with colored parts:
- Leading zeros in grey54
- Year in bright_blue
- Trailing zeros in grey54
"""
if not accession:
return Text()
# Split the accession number into its components
parts = accession.split('-')
if len(parts) != 3:
return Text(accession) # Return unformatted if not in expected format
cik_part, year_part, seq_part = parts
# Find leading zeros in CIK
cik_zeros = len(cik_part) - len(cik_part.lstrip('0'))
cik_value = cik_part[cik_zeros:]
# Find leading zeros in sequence
seq_zeros = len(seq_part) - len(seq_part.lstrip('0'))
seq_value = seq_part[seq_zeros:]
# Assemble the colored text
return Text.assemble(
("0" * cik_zeros, "dim"),
(cik_value, "bold white"),
("-", None),
(year_part, "bright_blue"),
("-", None),
("0" * seq_zeros, "dim"),
(seq_value, "bold white")
)
def cik_text(cik: Union[str, int]) -> Text:
"""Format a CIK number with color highlighting for leading zeros.
Args:
cik: CIK number as string or int (e.g., '320193' or 320193)
Returns:
Rich Text object with colored parts:
- Leading zeros in dim grey
- CIK value in bold white
Examples:
>>> cik_text(320193)
Text('0000320193') with leading zeros dimmed
>>> cik_text('0000320193')
Text('0000320193') with leading zeros dimmed
"""
if cik is None or cik == '':
return Text()
# Convert to string and pad to 10 digits
cik_str = str(cik).zfill(10)
# Find leading zeros
leading_zeros = len(cik_str) - len(cik_str.lstrip('0'))
cik_value = cik_str[leading_zeros:]
# Assemble the colored text
if leading_zeros > 0 and cik_value:
# Normal case: some leading zeros and a value
return Text.assemble(
("0" * leading_zeros, "dim"),
(cik_value, "bold")
)
elif leading_zeros == len(cik_str):
# All zeros - show them all dimmed
return Text(cik_str, "dim")
else:
# No leading zeros
return Text(cik_str, "bold white")
def accepted_time_text(accepted_datetime) -> Text:
"""Format accepted datetime for current filings with visual emphasis.
Args:
accepted_datetime: datetime object from filing acceptance
Returns:
Rich Text object with color-coded time components:
- Date in dim (often the same for recent filings)
- Hour in bright color (key differentiator)
- Minutes and seconds with emphasis
"""
if not accepted_datetime:
return Text("N/A", style="dim")
# Convert to datetime if needed
if not isinstance(accepted_datetime, datetime.datetime):
try:
accepted_datetime = datetime.datetime.fromisoformat(str(accepted_datetime))
except:
return Text(str(accepted_datetime))
# Format components
date_str = accepted_datetime.strftime("%Y-%m-%d")
hour_str = accepted_datetime.strftime("%H")
minute_str = accepted_datetime.strftime("%M")
second_str = accepted_datetime.strftime("%S")
# Determine colors based on time of day
hour_int = int(hour_str)
if 16 <= hour_int <= 17: # 4-5 PM (common filing time)
hour_color = "yellow"
elif hour_int >= 18: # After hours
hour_color = "bright_red"
elif hour_int < 9: # Pre-market
hour_color = "bright_cyan"
else: # Regular hours
hour_color = "bright_green"
# Assemble with visual hierarchy
return Text.assemble(
(date_str, "dim"),
(" ", None),
(hour_str, f"bold {hour_color}"),
(":", "dim"),
(minute_str, "bold white"),
(":", "dim"),
(second_str, "white")
)