Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions assets/data/constants.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Bot Constants Configuration
# This file contains all the constants used throughout the Tux Discord bot

[embed_colors]
DEFAULT = 16044058
INFO = 12634869
WARNING = 16634507
ERROR = 16067173
SUCCESS = 10407530
POLL = 14724968
CASE = 16217742
NOTE = 16752228

[embed_icons]
DEFAULT = "https://i.imgur.com/owW4EZk.png"
INFO = "https://i.imgur.com/8GRtR2G.png"
SUCCESS = "https://i.imgur.com/JsNbN7D.png"
ERROR = "https://i.imgur.com/zZjuWaU.png"
CASE = "https://i.imgur.com/c43cwnV.png"
NOTE = "https://i.imgur.com/VqPFbil.png"
POLL = "https://i.imgur.com/pkPeG5q.png"
ACTIVE_CASE = "https://github.com/allthingslinux/tux/blob/main/assets/embeds/active_case.png?raw=true"
INACTIVE_CASE = "https://github.com/allthingslinux/tux/blob/main/assets/embeds/inactive_case.png?raw=true"
ADD = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/added.png?raw=true"
REMOVE = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/removed.png?raw=true"
BAN = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/ban.png?raw=true"
JAIL = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/jail.png?raw=true"
KICK = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/kick.png?raw=true"
TIMEOUT = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/timeout.png?raw=true"
WARN = "https://github.com/allthingslinux/tux/blob/main/assets/emojis/warn.png?raw=true"

[embed_limits]
max_name_length = 256
max_desc_length = 4096
max_fields = 25
total_max = 6000
field_value_length = 1024

[discord_limits]
nickname_max_length = 32
context_menu_name_length = 32
slash_cmd_name_length = 32
slash_cmd_max_desc_length = 100
slash_cmd_max_options = 25
slash_option_name_length = 100

[interaction_limits]
action_row_max_items = 5
selects_max_options = 25
select_max_name_length = 100

[defaults]
reason = "No reason provided"
delete_after = 30

[snippet_config]
max_name_length = 20
allowed_chars_regex = "^[a-zA-Z0-9-]+$"
pagination_limit = 10

[afk_config]
prefix = "[AFK] "
truncation_suffix = "..."

[eight_ball_config]
question_length_limit = 120
response_wrap_width = 30

[bookmark_config]
add_emoji = "🔖"
remove_emoji = "🗑️"
74 changes: 59 additions & 15 deletions tests/unit/tux/utils/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,76 @@ class TestConstants:

def test_embed_limits(self):
"""Test that embed limit constants are correctly defined."""
assert Constants.EMBED_MAX_NAME_LENGTH == 256
assert Constants.EMBED_MAX_DESC_LENGTH == 4096
assert Constants.EMBED_MAX_FIELDS == 25
assert Constants.EMBED_TOTAL_MAX == 6000
assert Constants.EMBED_FIELD_VALUE_LENGTH == 1024
assert CONST.EMBED_MAX_NAME_LENGTH == 256
assert CONST.EMBED_MAX_DESC_LENGTH == 4096
assert CONST.EMBED_MAX_FIELDS == 25
assert CONST.EMBED_TOTAL_MAX == 6000
assert CONST.EMBED_FIELD_VALUE_LENGTH == 1024

def test_default_reason(self):
"""Test that default reason is correctly defined."""
assert Constants.DEFAULT_REASON == "No reason provided"
def test_discord_limits(self):
"""Test Discord-related limit constants."""
assert CONST.NICKNAME_MAX_LENGTH == 32
assert CONST.CONTEXT_MENU_NAME_LENGTH == 32
assert CONST.SLASH_CMD_NAME_LENGTH == 32
assert CONST.SLASH_CMD_MAX_DESC_LENGTH == 100
assert CONST.SLASH_CMD_MAX_OPTIONS == 25
assert CONST.SLASH_OPTION_NAME_LENGTH == 100

def test_interaction_limits(self):
"""Test interaction-related constants."""
assert CONST.ACTION_ROW_MAX_ITEMS == 5
assert CONST.SELECTS_MAX_OPTIONS == 25
assert CONST.SELECT_MAX_NAME_LENGTH == 100

def test_default_values(self):
"""Test default value constants."""
assert CONST.DEFAULT_REASON == "No reason provided"
assert CONST.DEFAULT_DELETE_AFTER == 30

def test_const_instance(self):
"""Test that CONST is an instance of Constants."""
assert isinstance(CONST, Constants)

def test_snippet_constants(self):
"""Test snippet-related constants."""
assert Constants.SNIPPET_MAX_NAME_LENGTH == 20
assert Constants.SNIPPET_ALLOWED_CHARS_REGEX == r"^[a-zA-Z0-9-]+$"
assert Constants.SNIPPET_PAGINATION_LIMIT == 10
assert CONST.SNIPPET_MAX_NAME_LENGTH == 20
assert CONST.SNIPPET_ALLOWED_CHARS_REGEX == "^[a-zA-Z0-9-]+$"
assert CONST.SNIPPET_PAGINATION_LIMIT == 10
Comment on lines 41 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Test coverage for regex pattern usage is missing.

Please add tests that use the regex to check both valid and invalid snippet names, ensuring the pattern works as expected.

Suggested change
def test_snippet_constants(self):
"""Test snippet-related constants."""
assert Constants.SNIPPET_MAX_NAME_LENGTH == 20
assert Constants.SNIPPET_ALLOWED_CHARS_REGEX == r"^[a-zA-Z0-9-]+$"
assert Constants.SNIPPET_PAGINATION_LIMIT == 10
assert CONST.SNIPPET_MAX_NAME_LENGTH == 20
assert CONST.SNIPPET_ALLOWED_CHARS_REGEX == "^[a-zA-Z0-9-]+$"
assert CONST.SNIPPET_PAGINATION_LIMIT == 10
def test_snippet_constants(self):
"""Test snippet-related constants."""
assert CONST.SNIPPET_MAX_NAME_LENGTH == 20
assert CONST.SNIPPET_ALLOWED_CHARS_REGEX == "^[a-zA-Z0-9-]+$"
assert CONST.SNIPPET_PAGINATION_LIMIT == 10
def test_snippet_name_regex(self):
"""Test that the snippet name regex matches valid names and rejects invalid ones."""
import re
pattern = re.compile(CONST.SNIPPET_ALLOWED_CHARS_REGEX)
valid_names = [
"snippet1",
"Snippet-2",
"A1B2C3",
"test-123",
"abcDEF-456"
]
invalid_names = [
"snippet_1", # underscore not allowed
"snippet 1", # space not allowed
"snippet!", # exclamation not allowed
"snippet@", # special char not allowed
"snippet.name" # dot not allowed
]
for name in valid_names:
assert pattern.fullmatch(name), f"Valid name did not match: {name}"
for name in invalid_names:
assert not pattern.fullmatch(name), f"Invalid name matched: {name}"


def test_afk_constants(self):
"""Test AFK-related constants."""
assert Constants.AFK_PREFIX == "[AFK] "
assert Constants.AFK_TRUNCATION_SUFFIX == "..."
assert CONST.AFK_PREFIX == "[AFK] "
assert CONST.AFK_TRUNCATION_SUFFIX == "..."

def test_eight_ball_constants(self):
"""Test 8ball-related constants."""
assert Constants.EIGHT_BALL_QUESTION_LENGTH_LIMIT == 120
assert Constants.EIGHT_BALL_RESPONSE_WRAP_WIDTH == 30
assert CONST.EIGHT_BALL_QUESTION_LENGTH_LIMIT == 120
assert CONST.EIGHT_BALL_RESPONSE_WRAP_WIDTH == 30

def test_bookmark_constants(self):
"""Test bookmark-related constants."""
assert CONST.ADD_BOOKMARK == "🔖"
assert CONST.REMOVE_BOOKMARK == "🗑️"

def test_embed_colors_loaded(self):
"""Test that embed colors are correctly loaded from TOML."""
assert isinstance(CONST.EMBED_COLORS, dict)
assert "DEFAULT" in CONST.EMBED_COLORS
assert "INFO" in CONST.EMBED_COLORS
assert "ERROR" in CONST.EMBED_COLORS
assert isinstance(CONST.EMBED_COLORS["DEFAULT"], int)

def test_embed_icons_loaded(self):
"""Test that embed icons are correctly loaded from TOML."""
assert isinstance(CONST.EMBED_ICONS, dict)
assert "DEFAULT" in CONST.EMBED_ICONS
assert "INFO" in CONST.EMBED_ICONS
assert "ERROR" in CONST.EMBED_ICONS
assert isinstance(CONST.EMBED_ICONS["DEFAULT"], str)
assert CONST.EMBED_ICONS["DEFAULT"].startswith("https://")

def test_constants_file_loading(self):
"""Test that creating a new Constants instance loads data correctly."""
new_const = Constants()
assert new_const.DEFAULT_REASON == CONST.DEFAULT_REASON
assert new_const.EMBED_COLORS == CONST.EMBED_COLORS
133 changes: 57 additions & 76 deletions tux/utils/constants.py
Original file line number Diff line number Diff line change
@@ -1,83 +1,64 @@
from typing import Final
import tomllib
from typing import Any, Final

# TODO: move to assets/data/ potentially
from tux.utils.config import workspace_root


def _load_constants() -> dict[str, Any]:
"""Load constants from the TOML configuration file."""
constants_path = workspace_root / "assets" / "data" / "constants.toml"
with constants_path.open("rb") as f:
return tomllib.load(f)
Comment on lines +7 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider error handling for missing or malformed TOML files.

If the TOML file is missing or malformed, the function will raise an unhandled exception. Adding error handling could improve user feedback or allow for fallback behavior.

Suggested change
def _load_constants() -> dict[str, Any]:
"""Load constants from the TOML configuration file."""
constants_path = workspace_root / "assets" / "data" / "constants.toml"
with constants_path.open("rb") as f:
return tomllib.load(f)
def _load_constants() -> dict[str, Any]:
"""Load constants from the TOML configuration file.
Returns an empty dict if the file is missing or malformed.
"""
import logging
constants_path = workspace_root / "assets" / "data" / "constants.toml"
try:
with constants_path.open("rb") as f:
return tomllib.load(f)
except FileNotFoundError:
logging.error(f"Constants TOML file not found at {constants_path}")
return {}
except tomllib.TOMLDecodeError as e:
logging.error(f"Malformed TOML in constants file at {constants_path}: {e}")
return {}



class Constants:
# Color constants
EMBED_COLORS: Final[dict[str, int]] = {
"DEFAULT": 16044058,
"INFO": 12634869,
"WARNING": 16634507,
"ERROR": 16067173,
"SUCCESS": 10407530,
"POLL": 14724968,
"CASE": 16217742,
"NOTE": 16752228,
}

# Icon constants
EMBED_ICONS: Final[dict[str, str]] = {
"DEFAULT": "https://i.imgur.com/owW4EZk.png",
"INFO": "https://i.imgur.com/8GRtR2G.png",
"SUCCESS": "https://i.imgur.com/JsNbN7D.png",
"ERROR": "https://i.imgur.com/zZjuWaU.png",
"CASE": "https://i.imgur.com/c43cwnV.png",
"NOTE": "https://i.imgur.com/VqPFbil.png",
"POLL": "https://i.imgur.com/pkPeG5q.png",
"ACTIVE_CASE": "https://github.com/allthingslinux/tux/blob/main/assets/embeds/active_case.png?raw=true",
"INACTIVE_CASE": "https://github.com/allthingslinux/tux/blob/main/assets/embeds/inactive_case.png?raw=true",
"ADD": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/added.png?raw=true",
"REMOVE": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/removed.png?raw=true",
"BAN": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/ban.png?raw=true",
"JAIL": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/jail.png?raw=true",
"KICK": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/kick.png?raw=true",
"TIMEOUT": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/timeout.png?raw=true",
"WARN": "https://github.com/allthingslinux/tux/blob/main/assets/emojis/warn.png?raw=true",
}

# Embed limit constants
EMBED_MAX_NAME_LENGTH = 256
EMBED_MAX_DESC_LENGTH = 4096
EMBED_MAX_FIELDS = 25
EMBED_TOTAL_MAX = 6000
EMBED_FIELD_VALUE_LENGTH = 1024

NICKNAME_MAX_LENGTH = 32

# Interaction constants
ACTION_ROW_MAX_ITEMS = 5
SELECTS_MAX_OPTIONS = 25
SELECT_MAX_NAME_LENGTH = 100

# App commands constants
CONTEXT_MENU_NAME_LENGTH = 32
SLASH_CMD_NAME_LENGTH = 32
SLASH_CMD_MAX_DESC_LENGTH = 100
SLASH_CMD_MAX_OPTIONS = 25
SLASH_OPTION_NAME_LENGTH = 100

DEFAULT_REASON = "No reason provided"

# Snippet constants
SNIPPET_MAX_NAME_LENGTH = 20
SNIPPET_ALLOWED_CHARS_REGEX = r"^[a-zA-Z0-9-]+$"
SNIPPET_PAGINATION_LIMIT = 10

# Message timings
DEFAULT_DELETE_AFTER = 30

# AFK constants
AFK_PREFIX = "[AFK] "
AFK_TRUNCATION_SUFFIX = "..."

# 8ball constants
EIGHT_BALL_QUESTION_LENGTH_LIMIT = 120
EIGHT_BALL_RESPONSE_WRAP_WIDTH = 30

# Bookmark constants
ADD_BOOKMARK = "🔖"
REMOVE_BOOKMARK = "🗑️"
def __init__(self) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider using a flattening helper to automatically assign all constants as class attributes, reducing repetitive assignment code.

You can collapse almost all of the repetitive self.FOO = ... lines into a small “flattening” helper. Because tomllib already gives you real int and str types you can skip all of the int(...) and str(...) calls, and then automatically turn every nested key into a UPPER_SNAKE attribute on your class.

For example, add a small utility:

def _flatten_constants(data: dict[str, Any]) -> dict[str, Any]:
    """Turn e.g. data['embed_limits']['max_name_length'] into {'EMBED_MAX_NAME_LENGTH': 256}."""
    out: dict[str, Any] = {}
    for section, mapping in data.items():
        # drop common suffixes and singularize
        prefix = (
            section
            .removesuffix("_limits")
            .removesuffix("_config")
            .removesuffix("s")
            .upper()
        )
        for key, val in mapping.items():
            name = f"{prefix}_{key}".upper()
            out[name] = val
    return out

Then your Constants class can become:

class Constants:
    def __init__(self):
        data = _load_constants()
        flats = _flatten_constants(data)

        # bulk‐assign everything
        for attr, val in flats.items():
            setattr(self, attr, val)

CONST = Constants()

This preserves your existing attribute names, removes dozens of self.X = int(...) lines, and will automatically pick up any new keys you add to the TOML later.

data = _load_constants()

self.EMBED_COLORS: Final[dict[str, int]] = data["embed_colors"]

self.EMBED_ICONS: Final[dict[str, str]] = data["embed_icons"]

embed_limits: dict[str, Any] = data["embed_limits"]
self.EMBED_MAX_NAME_LENGTH: Final[int] = int(embed_limits["max_name_length"])
self.EMBED_MAX_DESC_LENGTH: Final[int] = int(embed_limits["max_desc_length"])
self.EMBED_MAX_FIELDS: Final[int] = int(embed_limits["max_fields"])
self.EMBED_TOTAL_MAX: Final[int] = int(embed_limits["total_max"])
self.EMBED_FIELD_VALUE_LENGTH: Final[int] = int(embed_limits["field_value_length"])

discord_limits: dict[str, Any] = data["discord_limits"]
self.NICKNAME_MAX_LENGTH: Final[int] = int(discord_limits["nickname_max_length"])
self.CONTEXT_MENU_NAME_LENGTH: Final[int] = int(discord_limits["context_menu_name_length"])
self.SLASH_CMD_NAME_LENGTH: Final[int] = int(discord_limits["slash_cmd_name_length"])
self.SLASH_CMD_MAX_DESC_LENGTH: Final[int] = int(discord_limits["slash_cmd_max_desc_length"])
self.SLASH_CMD_MAX_OPTIONS: Final[int] = int(discord_limits["slash_cmd_max_options"])
self.SLASH_OPTION_NAME_LENGTH: Final[int] = int(discord_limits["slash_option_name_length"])

interaction_limits: dict[str, Any] = data["interaction_limits"]
self.ACTION_ROW_MAX_ITEMS: Final[int] = int(interaction_limits["action_row_max_items"])
self.SELECTS_MAX_OPTIONS: Final[int] = int(interaction_limits["selects_max_options"])
self.SELECT_MAX_NAME_LENGTH: Final[int] = int(interaction_limits["select_max_name_length"])

defaults: dict[str, Any] = data["defaults"]
self.DEFAULT_REASON: Final[str] = str(defaults["reason"])
self.DEFAULT_DELETE_AFTER: Final[int] = int(defaults["delete_after"])

snippet_config: dict[str, Any] = data["snippet_config"]
self.SNIPPET_MAX_NAME_LENGTH: Final[int] = int(snippet_config["max_name_length"])
self.SNIPPET_ALLOWED_CHARS_REGEX: Final[str] = str(snippet_config["allowed_chars_regex"])
self.SNIPPET_PAGINATION_LIMIT: Final[int] = int(snippet_config["pagination_limit"])

afk_config: dict[str, Any] = data["afk_config"]
self.AFK_PREFIX: Final[str] = str(afk_config["prefix"])
self.AFK_TRUNCATION_SUFFIX: Final[str] = str(afk_config["truncation_suffix"])

eight_ball_config: dict[str, Any] = data["eight_ball_config"]
self.EIGHT_BALL_QUESTION_LENGTH_LIMIT: Final[int] = int(eight_ball_config["question_length_limit"])
self.EIGHT_BALL_RESPONSE_WRAP_WIDTH: Final[int] = int(eight_ball_config["response_wrap_width"])

bookmark_config: dict[str, Any] = data["bookmark_config"]
self.ADD_BOOKMARK: Final[str] = str(bookmark_config["add_emoji"])
self.REMOVE_BOOKMARK: Final[str] = str(bookmark_config["remove_emoji"])


CONST = Constants()