Repository: sopel-irc/sopel

Issue:
docs: privileges now on another page and not linked
### Description

[On this page](https://web.archive.org/web/20231031205435/https://unstable.docs.sopel.chat/plugin/decorators.html), `require_privilege` and `require__bot_privilege` mention needing an `int` for permissions, but those are no longer on that page ([they were in stable](https://web.archive.org/web/20231031205300/https://sopel.chat/docs/plugin/decorators.html)); they are now on [this page](http://web.archive.org/web/20231031205517/https://unstable.docs.sopel.chat/plugin/privileges.html#module-sopel.privileges).

It would be nice if there was a link to the relevant docs or something.

### Reproduction steps

N/A

### Expected behavior

It would be nice if there was a link to the relevant docs or something.

### Relevant logs

```shell
N/A
```


### Notes

N/A

### Sopel version

N/A

### Installation method

`pip install`

### Python version

N/A

### Operating system

N/A

### IRCd

N/A

### Relevant plugins

N/A


Discussion:
I can open a separate issue for "this should be an ENUM or otherwise handled better anyway", too, if you want, @dgw. :P

Resolving patch:
diff --git a/sopel/plugin.py b/sopel/plugin.py
--- a/sopel/plugin.py
+++ b/sopel/plugin.py
@@ -24,7 +24,14 @@
 )
 
 # import and expose privileges as shortcut
-from sopel.privileges import ADMIN, HALFOP, OP, OPER, OWNER, VOICE
+from sopel.privileges import AccessLevel
+
+VOICE = AccessLevel.VOICE
+HALFOP = AccessLevel.HALFOP
+OP = AccessLevel.OP
+ADMIN = AccessLevel.ADMIN
+OWNER = AccessLevel.OWNER
+OPER = AccessLevel.OPER
 
 
 if TYPE_CHECKING:
@@ -1457,7 +1464,7 @@ def guarded(bot, trigger, *args, **kwargs):
 
 
 def require_privilege(
-    level: int,
+    level: AccessLevel,
     message: Optional[str] = None,
     reply: bool = False,
 ) -> Callable:
@@ -1581,7 +1588,7 @@ def guarded(bot, trigger, *args, **kwargs):
 
 
 def require_bot_privilege(
-    level: int,
+    level: AccessLevel,
     message: Optional[str] = None,
     reply: bool = False,
 ) -> Callable:
diff --git a/sopel/privileges.py b/sopel/privileges.py
--- a/sopel/privileges.py
+++ b/sopel/privileges.py
@@ -1,146 +1,132 @@
-"""Constants for user privileges in channels.
+"""Constants for user privileges in channels."""
+# Copyright 2023 dgw, technobabbl.es
+#
+# Licensed under the Eiffel Forum License 2.
+from __future__ import annotations
 
-Privilege levels
-================
+import enum
 
-Historically, there were two user privileges in channels:
 
-* :data:`OP`: channel operator, or chanop, set and unset by ``+o`` and ``-o``
-* :data:`VOICE`: the privilege to send messages to a channel with the
-  ``+m`` mode, set and unset by ``+v`` and ``-v``
+__all__ = [
+    'AccessLevel',
+]
 
-Since then, other privileges have been adopted by IRC servers and clients:
 
-* :data:`HALFOP`: intermediate level between Voiced and OP, set and unset by
-  ``+h`` and ``-h``
-* :data:`ADMIN`: channel admin, above OP and below OWNER, set and unset by
-  ``+a`` and ``-a``
-* :data:`OWNER`: channel owner, above ADMIN and OP, set and unset by ``+q`` and
-  ``-q``
+class AccessLevel(enum.IntFlag):
+    """Enumeration of available user privilege levels.
 
-.. important::
+    This class represents privileges as comparable, combinable flags. Lower
+    privilege levels compare as *less than* (``<``) higher ones::
 
-    Not all IRC networks support these added privilege modes. If you are
-    writing a plugin for public distribution, ensure your code behaves sensibly
-    if only +v (voice) and +o (op) modes exist.
+        >>> from sopel.privileges import AccessLevel
+        >>> AccessLevel.VOICE < AccessLevel.HALFOP < AccessLevel.OP \\
+        ... < AccessLevel.ADMIN < AccessLevel.OWNER
+        True
 
-Compare privileges
-==================
+    A user's privileges are represented as a combination of privilege levels::
 
-This module represents privileges as powers of two, with higher values assigned
-to higher-level privileges::
+        >>> priv = AccessLevel.VOICE | AccessLevel.OP
 
-    >>> from sopel.privileges import VOICE, HALFOP, OP, ADMIN, OWNER
-    >>> VOICE < HALFOP < OP < ADMIN < OWNER
-    True
+    This allows using comparators and bitwise operators to compare privileges.
+    Here, ``priv`` contains both VOICE and OP privileges, but not HALFOP::
 
-Then a user's privileges are represented as a sum of privilege levels::
+        >>> priv >= AccessLevel.OP
+        True
+        >>> bool(priv & AccessLevel.HALFOP)
+        False
 
-    >>> VOICE
-    1
-    >>> OP
-    4
-    >>> priv = VOICE | OP
-    >>> priv
-    5
+    .. important::
 
-This allows to use comparators and bitwise operators to compare privileges::
+        Do not hard-code the value of a privilege level in your code; the values
+        may change. Always reference or compare to the appropriate member of
+        this class directly.
 
-    >>> priv >= OP
-    True
-    >>> bool(priv & HALFOP)
-    False
+    """
+    # values should behave as ints, but their string representations should
+    # still look like Enum
+    __str__ = enum.Enum.__str__
 
-In that case, ``priv`` contains both VOICE and OP privileges, but not HALFOP.
-"""
-from __future__ import annotations
+    VOICE = enum.auto()
+    """Privilege level for the +v channel permission
 
+    .. versionadded:: 4.1
+    .. versionchanged:: 8.0
 
-__all__ = [
-    'VOICE',
-    'HALFOP',
-    'OP',
-    'ADMIN',
-    'OWNER',
-    'OPER',
-]
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
+
+    """
+
+    HALFOP = enum.auto()
+    """Privilege level for the +h channel permission
+
+    .. versionadded:: 4.1
+    .. versionchanged:: 8.0
+
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
 
+    .. important::
 
-VOICE = 1
-"""Privilege level for the +v channel permission
+        Beware: This is one of the `nonstandard privilege levels`_.
 
-.. versionadded:: 4.1
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
-"""
+    """
 
-HALFOP = 2
-"""Privilege level for the +h channel permission
+    OP = enum.auto()
+    """Privilege level for the +o channel permission
 
-.. versionadded:: 4.1
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
+    .. versionadded:: 4.1
+    .. versionchanged:: 8.0
 
-.. important::
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
 
-    Not all IRC networks support this privilege mode. If you are writing a
-    plugin for public distribution, ensure your code behaves sensibly if only
-    ``+v`` (voice) and ``+o`` (op) modes exist.
+    """
 
-"""
+    ADMIN = enum.auto()
+    """Privilege level for the +a channel permission
 
-OP = 4
-"""Privilege level for the +o channel permission
+    .. versionadded:: 4.1
+    .. versionchanged:: 8.0
 
-.. versionadded:: 4.1
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
-"""
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
 
-ADMIN = 8
-"""Privilege level for the +a channel permission
+    .. important::
 
-.. versionadded:: 4.1
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
+        Beware: This is one of the `nonstandard privilege levels`_.
 
-.. important::
+    """
 
-    Not all IRC networks support this privilege mode. If you are writing a
-    plugin for public distribution, ensure your code behaves sensibly if only
-    ``+v`` (voice) and ``+o`` (op) modes exist.
+    OWNER = enum.auto()
+    """Privilege level for the +q channel permission
 
-"""
+    .. versionadded:: 4.1
+    .. versionchanged:: 8.0
 
-OWNER = 16
-"""Privilege level for the +q channel permission
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
 
-.. versionadded:: 4.1
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
+    .. important::
 
-.. important::
+        Beware: This is one of the `nonstandard privilege levels`_.
 
-    Not all IRC networks support this privilege mode. If you are writing a
-    plugin for public distribution, ensure your code behaves sensibly if only
-    ``+v`` (voice) and ``+o`` (op) modes exist.
+    """
 
-"""
+    OPER = enum.auto()
+    """Privilege level for the +y/+Y channel permission
 
-OPER = 32
-"""Privilege level for the +y/+Y channel permissions
+    Note: Except for these (non-standard) channel modes, Sopel does not monitor
+    or store any user's OPER status.
 
-Note: Except for these (non-standard) channel modes, Sopel does not monitor or
-store any user's OPER status.
+    .. versionadded:: 7.0
+    .. versionchanged:: 8.0
 
-.. versionadded:: 7.0
-.. versionchanged:: 8.0
-   Moved into :mod:`sopel.privileges`.
+        Constant moved from :mod:`sopel.plugin` to
+        :class:`sopel.privileges.AccessLevel`.
 
-.. important::
+    .. important::
 
-    Not all IRC networks support this privilege mode. If you are writing a
-    plugin for public distribution, ensure your code behaves sensibly if only
-    ``+v`` (voice) and ``+o`` (op) modes exist.
+        Beware: This is one of the `nonstandard privilege levels`_.
 
-"""
+    """
diff --git a/sopel/tools/target.py b/sopel/tools/target.py
--- a/sopel/tools/target.py
+++ b/sopel/tools/target.py
@@ -4,7 +4,7 @@
 import functools
 from typing import Any, Optional, TYPE_CHECKING, Union
 
-from sopel import privileges
+from sopel.privileges import AccessLevel
 from sopel.tools import memories
 from sopel.tools.identifiers import Identifier, IdentifierFactory
 
@@ -158,7 +158,7 @@ def __init__(
 
         This maps nickname :class:`~sopel.tools.identifiers.Identifier`\\s to
         bitwise integer values. This can be compared to appropriate constants
-        from :mod:`sopel.privileges`.
+        from :class:`sopel.privileges.AccessLevel`.
         """
         self.topic: str = ''
         """The topic of the channel."""
@@ -203,7 +203,7 @@ def add_user(self, user: User, privs: int = 0) -> None:
 
         :param user: the new user to add
         :param privs: privilege bitmask (see constants in
-                      :mod:`sopel.privileges`)
+                      :class:`sopel.privileges.AccessLevel`)
 
         Called when a new user JOINs the channel.
         """
@@ -274,7 +274,7 @@ def is_oper(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.OPER)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.OPER)
 
     def is_owner(self, nick: str) -> bool:
         """Tell if a user has the OWNER privilege level.
@@ -306,7 +306,7 @@ def is_owner(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.OWNER)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.OWNER)
 
     def is_admin(self, nick: str) -> bool:
         """Tell if a user has the ADMIN privilege level.
@@ -338,7 +338,7 @@ def is_admin(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.ADMIN)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.ADMIN)
 
     def is_op(self, nick: str) -> bool:
         """Tell if a user has the OP privilege level.
@@ -364,7 +364,7 @@ def is_op(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.OP)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.OP)
 
     def is_halfop(self, nick: str) -> bool:
         """Tell if a user has the HALFOP privilege level.
@@ -396,7 +396,7 @@ def is_halfop(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.HALFOP)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.HALFOP)
 
     def is_voiced(self, nick: str) -> bool:
         """Tell if a user has the VOICE privilege level.
@@ -423,7 +423,7 @@ def is_voiced(self, nick: str) -> bool:
 
         """
         identifier = self.make_identifier(nick)
-        return bool(self.privileges.get(identifier, 0) & privileges.VOICE)
+        return bool(self.privileges.get(identifier, 0) & AccessLevel.VOICE)
 
     def rename_user(self, old: Identifier, new: Identifier) -> None:
         """Rename a user.


Test patch: