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)