diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index ebddb3d..4ddae05 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,7 +1,53 @@ \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 4b73363..5b8cccc 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/.idea/pycord-reactive-views.iml b/.idea/pycord-reactive-views.iml index 98bfa2a..8d8930c 100644 --- a/.idea/pycord-reactive-views.iml +++ b/.idea/pycord-reactive-views.iml @@ -3,8 +3,9 @@ + - + diff --git a/examples/counter.py b/examples/counter.py new file mode 100644 index 0000000..6fb4c8b --- /dev/null +++ b/examples/counter.py @@ -0,0 +1,53 @@ +# ruff: noqa: INP001 +import os + +import discord +from dotenv import load_dotenv +from pycord_reactive_views import ReactiveButton, ReactiveValue, ReactiveView + +load_dotenv() + +bot = discord.Bot() + + +class Counter(ReactiveView): + """A simple counter view that increments a counter when a button is clicked.""" + + def __init__(self): + super().__init__() + self.counter = 0 + self.counter_button = ReactiveButton( + label=ReactiveValue(lambda: str(self.counter), "0"), + style=ReactiveValue( + lambda: discord.ButtonStyle.primary if self.counter % 2 == 0 else discord.ButtonStyle.secondary, + discord.ButtonStyle.primary, + ), + ) + self.reset_button = ReactiveButton( + label="Reset", + style=discord.ButtonStyle.danger, + disabled=ReactiveValue(lambda: self.counter == 0, default=True), + ) + self.counter_button.callback = self._button_callback + self.reset_button.callback = self._reset_callback + self.add_item(self.counter_button) + self.add_item(self.reset_button) + + async def _button_callback(self, interaction: discord.Interaction) -> None: + await interaction.response.defer() + self.counter += 1 + await self.update() + + async def _reset_callback(self, interaction: discord.Interaction) -> None: + await interaction.response.defer() + self.counter = 0 + await self.update() + + +@bot.slash_command() +async def counter(ctx: discord.ApplicationContext) -> None: + """Send the counter view.""" + await Counter().send(ctx) + + +bot.run(os.getenv("TOKEN")) diff --git a/examples/test.py b/examples/test.py deleted file mode 100644 index 14e026f..0000000 --- a/examples/test.py +++ /dev/null @@ -1,19 +0,0 @@ -import discord -from pycord_reactive_views import ReactiveButton, ReactiveView - -bot = discord.Bot() - - -class Counter(ReactiveView): - """A simple counter view that increments a counter when a button is clicked.""" - - def __init__(self): - super().__init__() - self.counter = 0 - self.counter_button = ReactiveButton(value=lambda: self.counter) - self.counter_button.callback = self._button_callback - self.add_item(self.counter_button) - - async def _button_callback(self, interaction: discord.Interaction): - await interaction.response.defer() - self.counter += 1 diff --git a/pdm.lock b/pdm.lock index e4ea8e4..db55ab6 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:fe19e8fbc71b42f70e7f42def3e0dad93de1c2fcf9e5a252b4fa606c3ea5b2f7" +content_hash = "sha256:ce0276a2b471885bb42d49ce0aa48f39564637bfc4dbc67a04a8fc57e0920415" [[metadata.targets]] requires_python = ">=3.11" @@ -224,6 +224,17 @@ files = [ {file = "py_cord-2.6.0.tar.gz", hash = "sha256:bbc0349542965d05e4b18cc4424136206430a8cc911fda12a0a57df6fdf9cd9c"}, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +requires_python = ">=3.8" +summary = "Read key-value pairs from a .env file and set them as environment variables" +groups = ["dev"] +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + [[package]] name = "ruff" version = "0.5.5" diff --git a/pyproject.toml b/pyproject.toml index c00d3ad..029a233 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,20 +33,144 @@ name = "pycord_reactive_views" [tool.ruff] line-length = 120 target-version = "py311" -select = ["E", "F", "I", "N", "W", "B", "C", "D"] -ignore = ["D100", "D104", "D107"] - -[tool.ruff.pydocstyle] -convention = "google" +fix = true [tool.ruff.per-file-ignores] "__init__.py" = ["F401"] +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + "C90", # mccabe + "CPY", # flake8-copyright + "EM", # flake8-errmsg + "SLF", # flake8-self + "ARG", # flake8-unused-arguments + "TD", # flake8-todos + "FIX", # flake8-fixme + "PD", # pandas-vet + + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in __init__ + "D203", # Blank line required before class docstring + "D213", # Multi-line summary should start at the second line (incompatible with D212) + "D301", # Use r""" if any backslashes in a docstring + "D401", # First line of docstring should be in imperative mood + "D404", # First word of the docstring should not be "This" + "D405", # Section name should be properly capitalized + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D408", # Section underline should be in the line following the section's name + "D409", # Section underline should match the length of its name + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "D412", # No blank lines allowed between a section header and its content + "D413", # Missing blank line after last section + "D414", # Section has no content + "D416", # Section name should end with a colon + "D417", # Missing argument description in the docstring + + "ANN101", # Missing type annotation for self in method + "ANN102", # Missing type annotation for cls in classmethod + "ANN204", # Missing return type annotation for special method + "ANN401", # Dynamically typed expressions (typing.Any) disallowed + + "SIM102", # use a single if statement instead of nested if statements + "SIM108", # Use ternary operator {contents} instead of if-else-block + + "G001", # Logging statement uses str.format + "G004", # Logging statement uses f-string + "G003", # Logging statement uses + + + "B904", # Raise without `from` within an `except` clause + + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "PLR2004", # Using unnamed numerical constants + "PGH003", # Using specific rule codes in type ignores + "E731", # Don't asign a lambda expression, use a def + "S311", # Use `secrets` for random number generation, not `random` + "TRY003", # Avoid specifying long messages outside the exception class + + # Redundant rules with ruff-format: + "E111", # Indentation of a non-multiple of 4 spaces + "E114", # Comment with indentation of a non-multiple of 4 spaces + "E117", # Cheks for over-indented code + "D206", # Checks for docstrings indented with tabs + "D300", # Checks for docstring that use ''' instead of """ + "Q000", # Checks of inline strings that use wrong quotes (' instead of ") + "Q001", # Multiline string that use wrong quotes (''' instead of """) + "Q002", # Checks for docstrings that use wrong quotes (''' instead of """) + "Q003", # Checks for avoidable escaped quotes ("\"" -> '"') + "COM812", # Missing trailing comma (in multi-line lists/tuples/...) + "COM819", # Prohibited trailing comma (in single-line lists/tuples/...) + "ISC001", # Single line implicit string concatenation ("hi" "hey" -> "hihey") + "ISC002", # Multi line implicit string concatenation + + "DOC501" +] + +[tool.ruff.lint.isort] +order-by-type = false +case-sensitive = true +combine-as-imports = true + +# Redundant rules with ruff-format +force-single-line = false # forces all imports to appear on their own line +force-wrap-aliases = false # Split imports with multiple members and at least one alias +lines-after-imports = -1 # The number of blank lines to place after imports +lines-between-types = 0 # Number of lines to place between "direct" and import from imports +split-on-trailing-comma = false # if last member of multiline import has a comma, don't fold it to single line + +[tool.ruff.lint.pylint] +max-args = 15 +max-branches = 15 +max-locals = 15 +max-nested-blocks = 5 +max-returns = 8 +max-statements = 75 + +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = [ + "ANN", # annotations + "D", # docstrings + "S101", # Use of assert +] +".github/scripts/**.py" = [ + "INP001", # Implicit namespace package +] +"alembic-migrations/env.py" = [ + "INP001", # Implicit namespace package +] +"alembic-migrations/versions/*" = [ + "INP001", # Implicit namespace package + "D103", # Missing docstring in public function + "D400", # First line should end with a period + "D415", # First line should end with a period, question mark, or exclamation point +] + +[tool.ruff.format] +line-ending = "lf" + [tool.basedpyright] include = ["src"] exclude = ["**/__pycache__"] venv = "env311" -pythonVersion = "3.11" +pythonPlatform = "All" +pythonVersion = "3.12" +typeCheckingMode = "all" + +reportAny = false +reportUnusedCallResult = false + +reportUnknownArgumentType = false +reportUnknownVariableType = false +reportUnknownMemberType = false +reportUnknownParameterType = false +reportUnknownLambdaType = false [tool.pdm] distribution = true @@ -54,6 +178,7 @@ distribution = true dev = [ "basedpyright>=1.15.0", "ruff>=0.5.5", + "python-dotenv>=1.0.1", ] diff --git a/src/README.md b/src/README.md deleted file mode 100644 index bc5390e..0000000 --- a/src/README.md +++ /dev/null @@ -1 +0,0 @@ -This directoy stores each Python Package. diff --git a/src/pycord_reactive_views/__init__.py b/src/pycord_reactive_views/__init__.py index e69de29..a532c4d 100644 --- a/src/pycord_reactive_views/__init__.py +++ b/src/pycord_reactive_views/__init__.py @@ -0,0 +1,5 @@ +from .components import ReactiveButton +from .utils import ReactiveValue +from .view import ReactiveView + +__all__ = ["ReactiveButton", "ReactiveView", "ReactiveValue"] diff --git a/src/pycord_reactive_views/components.py b/src/pycord_reactive_views/components.py new file mode 100644 index 0000000..cdb9d30 --- /dev/null +++ b/src/pycord_reactive_views/components.py @@ -0,0 +1,53 @@ +from typing import Any + +import discord + +from .utils import MaybeReactiveValue, ReactiveValue, is_reactive + + +class Reactive: + """A class that can be used with reactive values.""" + + def __init__(self): + super().__init__() + self.reactives: dict[str, ReactiveValue[Any]] = {} + self.super_kwargs: dict[str, Any] = {} + + def add_reactive(self, key: str, value: MaybeReactiveValue[Any]) -> None: + """Add a reactive value to the view.""" + if is_reactive(value): + self.reactives[key] = value + if value.default: + setattr(self, key, value.default) + else: + setattr(self, key, value) + + async def refresh(self) -> None: + """Refresh the reactive values.""" + for key, value in self.reactives.items(): + setattr(self, key, await value()) + + +class ReactiveButton(discord.ui.Button, Reactive): # pyright: ignore[reportUnsafeMultipleInheritance,reportMissingTypeArgument] + """A button that can be used with reactive values.""" + + def __init__( + self, + *, + style: MaybeReactiveValue[discord.ButtonStyle] = discord.ButtonStyle.secondary, + label: MaybeReactiveValue[str | None] = None, + disabled: MaybeReactiveValue[bool] = False, + custom_id: str | None = None, + url: MaybeReactiveValue[str | None] = None, + emoji: MaybeReactiveValue[str | discord.Emoji | discord.PartialEmoji | None] = None, + sku_id: int | None = None, + row: MaybeReactiveValue[int | None] = None, + ): + discord.ui.Button.__init__(self) + Reactive.__init__(self) + self.add_reactive("style", style) + self.add_reactive("label", label) + self.add_reactive("disabled", disabled) + self.add_reactive("url", url) + self.add_reactive("emoji", emoji) + self.add_reactive("row", row) diff --git a/src/pycord_reactive_views/utils/__init__.py b/src/pycord_reactive_views/utils/__init__.py new file mode 100644 index 0000000..040803f --- /dev/null +++ b/src/pycord_reactive_views/utils/__init__.py @@ -0,0 +1,3 @@ +from .reactivity import MaybeReactiveValue, ReactiveValue, is_reactive + +__all__ = ["ReactiveValue", "MaybeReactiveValue", "is_reactive"] diff --git a/src/pycord_reactive_views/utils/reactivity.py b/src/pycord_reactive_views/utils/reactivity.py new file mode 100644 index 0000000..af542e9 --- /dev/null +++ b/src/pycord_reactive_views/utils/reactivity.py @@ -0,0 +1,44 @@ +from collections.abc import Awaitable, Callable +from inspect import isawaitable +from typing import TypeGuard, TypeVar + +T = TypeVar("T") + + +class Unset: + """A class to represent an unset value.""" + + def __bool__(self): + return False + + +UNSET = Unset() + + +class ReactiveValue[T]: + """A value that can be a constant, a callable, or an async callable.""" + + def __init__(self, func: Callable[[], T] | Callable[[], Awaitable[T]], default: T | Unset = UNSET): + """Create a new reactive value.""" + self._func: Callable[[], T] | Callable[[], Awaitable[T]] = func + self.default = default + + async def __call__(self) -> T: + """Call the function and return the value. + + :raises TypeError: If the value is not callable + """ + if callable(self._func): + ret = self._func() + if isawaitable(ret): + return await ret + return ret + raise TypeError("ReactiveValue must be a callable") + + +MaybeReactiveValue = T | ReactiveValue[T] + + +def is_reactive(value: MaybeReactiveValue[T]) -> TypeGuard[ReactiveValue[T]]: + """Check if a value is a reactive value.""" + return isinstance(value, ReactiveValue) diff --git a/src/pycord_reactive_views/view.py b/src/pycord_reactive_views/view.py new file mode 100644 index 0000000..22615c8 --- /dev/null +++ b/src/pycord_reactive_views/view.py @@ -0,0 +1,57 @@ +from typing import Self, override + +import discord + +from .components import ReactiveButton + + +class ReactiveView(discord.ui.View): + """A view that can be used with reactive components.""" + + def __init__( + self, + *, + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + super().__init__(timeout=timeout, disable_on_timeout=disable_on_timeout) + self._reactives: list[ReactiveButton] = [] + + @override + def add_item(self, item: discord.ui.Item[Self]) -> None: + if isinstance(item, ReactiveButton): + self._reactives.append(item) + super().add_item(item) + + async def _get_embed(self) -> discord.Embed | None: + """Get the discord embed to be displayed in the message.""" + return None + + async def _get_embeds(self) -> list[discord.Embed]: + """Get the discord embeds to be displayed in the message.""" + return [] + + async def _get_content(self) -> str | None: + """Get the content to be displayed in the message.""" + return None + + async def update(self) -> None: + """Update the view with new components. + + :raises ValueError: If the view has no message (not yet sent?), can't update + """ + for reactive in self._reactives: + await reactive.refresh() + if not self.message: + raise ValueError("View has no message (not yet sent?), can't refresh") + if embeds := await self._get_embeds(): + await self.message.edit(content=await self._get_content(), embeds=embeds, view=self) + else: + await self.message.edit(content=await self._get_content(), view=self) + + async def send(self, ctx: discord.ApplicationContext | discord.Interaction) -> None: + """Send the view to a context.""" + if embeds := await self._get_embeds(): + await ctx.respond(content=await self._get_content(), embeds=embeds, view=self) + else: + await ctx.respond(content=await self._get_content(), view=self)