Repository: sopel-irc/sopel

Issue:
isupport.PREFIX is not ordered
We don't currently use isupport.PREFIX for anything interesting, but the IRC spec says it's in a specific order, #2197 will need it to preserve the order, and we currently shove it into a normal `dict`. See https://modern.ircdocs.horse/#prefix-parameter


Discussion:
In python3.7 normal dicts _[seem](https://mail.python.org/pipermail/python-dev/2017-December/151283.html)_ to require preserving insertion order, but oops, even in 8.0 we only require 3.6+, where preservation is only an implementation detail of cpython.
Either way, it needs to be tested and documented.

Resolving patch:
diff --git a/sopel/irc/isupport.py b/sopel/irc/isupport.py
--- a/sopel/irc/isupport.py
+++ b/sopel/irc/isupport.py
@@ -13,9 +13,11 @@
 # Licensed under the Eiffel Forum License 2.
 from __future__ import generator_stop
 
+from collections import OrderedDict
 import functools
 import itertools
 import re
+from typing import Dict
 
 
 def _optional(parser, default=None):
@@ -97,7 +99,7 @@ def _parse_prefix(value):
     if len(modes) != len(prefixes):
         raise ValueError('Mode list does not match for PREFIX: %r' % value)
 
-    return tuple(sorted(zip(modes, prefixes)))
+    return tuple(zip(modes, prefixes))
 
 
 ISUPPORT_PARSERS = {
@@ -328,7 +330,7 @@ def MAXLIST(self):
         return dict(self['MAXLIST'])
 
     @property
-    def PREFIX(self):
+    def PREFIX(self) -> Dict[str, str]:
         """Expose ``PREFIX`` as a dict, if advertised by the server.
 
         This exposes information about the modes and nick prefixes used for
@@ -343,6 +345,8 @@ def PREFIX(self):
                 'v': '+',
             }
 
+        Entries are in order of descending privilege.
+
         This attribute is not available if the server does not provide the
         right information, and accessing it will raise an
         :exc:`AttributeError`.
@@ -355,7 +359,10 @@ def PREFIX(self):
         if 'PREFIX' not in self:
             raise AttributeError('PREFIX')
 
-        return dict(self['PREFIX'])
+        # This can use a normal dict once we drop python 3.6, as 3.7 promises
+        # `dict` maintains insertion order. Since `OrderedDict` subclasses
+        # `dict`, we'll not promise to always return the former.
+        return OrderedDict(self['PREFIX'])
 
     @property
     def TARGMAX(self):


Test patch:
diff --git a/test/irc/test_irc_isupport.py b/test/irc/test_irc_isupport.py
--- a/test/irc/test_irc_isupport.py
+++ b/test/irc/test_irc_isupport.py
@@ -1,6 +1,8 @@
 """Tests for core ``sopel.irc.isupport``"""
 from __future__ import generator_stop
 
+from collections import OrderedDict
+
 import pytest
 
 from sopel.irc import isupport
@@ -500,6 +502,29 @@ def test_parse_parameter_prefix_invalid_format():
         isupport.parse_parameter('PREFIX=(o)@+')
 
 
+def test_parse_parameter_prefix_order_parser():
+    """Ensure PREFIX order is maintained through parser.
+
+    https://modern.ircdocs.horse/#prefix-parameter
+    """
+    key, value = isupport.parse_parameter('PREFIX=(qov)~@+')
+
+    assert value == (('q', '~'), ('o', '@'), ('v', '+'))
+
+
+def test_parse_parameter_prefix_order_property():
+    """Ensure PREFIX order is maintained in property."""
+    instance = isupport.ISupport()
+
+    key, value = isupport.parse_parameter('PREFIX=(qov)~@+')
+    new = instance.apply(
+        prefix=value,
+    )
+
+    assert new.PREFIX == OrderedDict((('q', '~'), ('o', '@'), ('v', '+')))
+    assert tuple(new.PREFIX.keys()) == ('q', 'o', 'v')
+
+
 def test_parse_parameter_targmax():
     key, value = isupport.parse_parameter('TARGMAX=PRIVMSG:3')