Compare commits

...

11 Commits

Author SHA1 Message Date
Valentin Lorentz
9a4dca8054 Misc: update version fetching to the new branches
master is now used for main development, so PyPI has to be used instead to get
the latest release
2024-05-29 21:49:23 +02:00
Valentin Lorentz
dcd95d3a77 DDG: Fix regexp escape in test
9bcb21389adc62dd099afd0665990aa0128a7ad3 added it to the wrong string
2024-05-29 07:26:34 +02:00
Valentin Lorentz
5b2b38ab37 Add per-network 'vhost' and 'vhostv6' config variables 2024-05-21 21:19:14 +02:00
Valentin Lorentz
4898926f20 RSS: Fix error when re-creating a feed with a different name
Closes GH-1547
2024-05-12 16:34:36 +02:00
Valentin Lorentz
b1ba8ecb2a ci: Test on Python 3.13 alpha 2024-05-05 22:18:14 +02:00
Valentin Lorentz
9ae7690484 Unix: Disable 'crypt' command on Python >= 3.13
The module is not available anymore
2024-05-05 22:18:14 +02:00
Valentin Lorentz
e18332efde Internet: Use socket directly instead of telnetlib
We don't actually need telnetlib here; and it will be removed in
Python 3.11
2024-05-05 22:18:14 +02:00
Valentin Lorentz
0ad61f5791 httpserver: Rewrite without the cgi module
It is removed in Python 3.13
2024-05-05 22:18:14 +02:00
Valentin Lorentz
9bcb21389a Fix SyntaxWarning on Python 3.12 2024-05-05 22:18:14 +02:00
Valentin Lorentz
f65089af86 CONTRIBUTING.md: Remove the bit about the testing branch
We're going to commit directly to master from now one.

The 'testing' policy predates PyPI releases and Git master was the primary mean
of distributing Limnoria back then, but it does not make sense anymore.
2024-05-05 17:56:48 +02:00
Valentin Lorentz
07834620f3 CONTRIBUTING.md: Update documentation URLs 2024-05-05 17:56:00 +02:00
17 changed files with 239 additions and 78 deletions

View File

@ -15,7 +15,11 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- python-version: "3.12.0-alpha.7" - python-version: "3.13.0-alpha.6"
with-opt-deps: false # https://github.com/pyca/cryptography/issues/10806
runs-on: ubuntu-22.04
- python-version: "3.12.0"
with-opt-deps: true with-opt-deps: true
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -67,7 +71,7 @@ jobs:
- name: Upgrade pip - name: Upgrade pip
run: | run: |
python3 -m pip install --upgrade pip python3 -m pip install --upgrade pip setuptools
- name: Install optional dependencies - name: Install optional dependencies
if: ${{ matrix.with-opt-deps }} if: ${{ matrix.with-opt-deps }}

View File

@ -15,14 +15,10 @@ Last rule: you shouldn't add a mandatory dependency. Limnoria does not
come with any (besides Python), so please try to keep all dependencies come with any (besides Python), so please try to keep all dependencies
optional. optional.
[Style Guidelines]:https://limnoria.readthedocs.io/en/latest/develop/style.html [Style Guidelines]:https://docs.limnoria.net/develop/style.html
## Sending patches ## Sending patches
When you send a pull request, **send it to the testing branch**.
It will be merged to master when it's considered to be stable enough to be
supported.
Don't fear that you spam Limnoria by sending many pull requests. According Don't fear that you spam Limnoria by sending many pull requests. According
to @ProgVal, it's easier for them to accept pull requests than to to @ProgVal, it's easier for them to accept pull requests than to
cherry-pick everything manually. cherry-pick everything manually.
@ -32,6 +28,6 @@ is very appreciated.
See also [Contributing to Limnoria] at [Limnoria documentation]. See also [Contributing to Limnoria] at [Limnoria documentation].
[Contributing to Limnoria]:https://limnoria.readthedocs.io/en/latest/contribute/index.html [Contributing to Limnoria]:https://docs.limnoria.net/contribute/index.html
[Limnoria documentation]:https://limnoria.readthedocs.io/ [Limnoria documentation]:https://docs.limnoria.net/

View File

@ -39,7 +39,7 @@ class DDGTestCase(PluginTestCase):
def testSearch(self): def testSearch(self):
self.assertRegexp( self.assertRegexp(
'ddg search wikipedia', 'Wikipedia.*? - .*?https?\:\/\/') 'ddg search wikipedia', r'Wikipedia.*? - .*?https?\:\/\/')
self.assertRegexp( self.assertRegexp(
'ddg search en.wikipedia.org', 'ddg search en.wikipedia.org',
'Wikipedia, the free encyclopedia\x02 - ' 'Wikipedia, the free encyclopedia\x02 - '
@ -47,6 +47,6 @@ class DDGTestCase(PluginTestCase):
with conf.supybot.plugins.DDG.region.context('fr-fr'): with conf.supybot.plugins.DDG.region.context('fr-fr'):
self.assertRegexp( self.assertRegexp(
'ddg search wikipedia', 'ddg search wikipedia',
'Wikipédia, l\'encyclopédie libre - .*?https?\:\/\/') r'Wikipédia, l\'encyclopédie libre - .*?https?\:\/\/')
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

View File

@ -194,7 +194,7 @@ class Connection:
if code != 151 or code is None: if code != 151 or code is None:
break break
resultword, resultdb = re.search('^"(.+)" (\S+)', text).groups() resultword, resultdb = re.search(r'^"(.+)" (\S+)', text).groups()
defstr = self.get100block() defstr = self.get100block()
retval.append(Definition(self, self.getdbobj(resultdb), retval.append(Definition(self, self.getdbobj(resultdb),
resultword, defstr)) resultword, defstr))

View File

@ -33,11 +33,11 @@ import datetime
# Credits for the regexp and function: https://stackoverflow.com/a/2765366/539465 # Credits for the regexp and function: https://stackoverflow.com/a/2765366/539465
_XSD_DURATION_RE = re.compile( _XSD_DURATION_RE = re.compile(
"(?P<sign>-?)P" r"(?P<sign>-?)P"
"(?:(?P<years>\d+)Y)?" r"(?:(?P<years>\d+)Y)?"
"(?:(?P<months>\d+)M)?" r"(?:(?P<months>\d+)M)?"
"(?:(?P<days>\d+)D)?" r"(?:(?P<days>\d+)D)?"
"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?" r"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?"
) )

View File

@ -31,7 +31,6 @@
import time import time
import socket import socket
import telnetlib
import supybot.conf as conf import supybot.conf as conf
import supybot.utils as utils import supybot.utils as utils
@ -158,14 +157,14 @@ class Internet(callbacks.Plugin):
if not status: if not status:
status = 'unknown' status = 'unknown'
try: try:
t = telnetlib.Telnet('whois.iana.org', 43) sock = socket.create_connection(('whois.iana.org', 43))
except socket.error as e: except socket.error as e:
irc.error(str(e)) irc.error(str(e))
return return
t.write(b'registrar ') sock.sendall(b'registrar ')
t.write(registrar.split('(')[0].strip().encode('ascii')) sock.sendall(registrar.split('(')[0].strip().encode('ascii'))
t.write(b'\n') sock.sendall(b'\n')
s = t.read_all() s = sock.recv(100000)
url = '' url = ''
for line in s.splitlines(): for line in s.splitlines():
line = line.decode('ascii').strip() line = line.decode('ascii').strip()

View File

@ -40,7 +40,6 @@ class InternetTestCase(PluginTestCase):
'Host not found.') 'Host not found.')
def testWhois(self): def testWhois(self):
self.assertNotError('internet whois ohio-state.edu')
self.assertNotError('internet whois microsoft.com') self.assertNotError('internet whois microsoft.com')
self.assertNotError('internet whois inria.fr') self.assertNotError('internet whois inria.fr')
self.assertNotError('internet whois slime.com.au') self.assertNotError('internet whois slime.com.au')

View File

@ -849,7 +849,7 @@ class UnitGroup:
def updateCurrentUnit(self, text, cursorPos): def updateCurrentUnit(self, text, cursorPos):
"Set current unit number" "Set current unit number"
self.currentNum = len(re.findall('[\*/]', text[:cursorPos])) self.currentNum = len(re.findall(r'[\*/]', text[:cursorPos]))
def currentUnit(self): def currentUnit(self):
"Return current unit if its a full match, o/w None" "Return current unit if its a full match, o/w None"
@ -925,7 +925,7 @@ class UnitGroup:
def parseGroup(self, text): def parseGroup(self, text):
"Return list of units from text string" "Return list of units from text string"
unitList = [] unitList = []
parts = [part.strip() for part in re.split('([\*/])', text)] parts = [part.strip() for part in re.split(r'([\*/])', text)]
numerator = 1 numerator = 1
while parts: while parts:
unit = self.parseUnit(parts.pop(0)) unit = self.parseUnit(parts.pop(0))
@ -1180,7 +1180,7 @@ class Unit:
self.equiv = unitList[0].strip() self.equiv = unitList[0].strip()
if self.equiv[0] == '[': # used only for non-linear units if self.equiv[0] == '[': # used only for non-linear units
try: try:
self.equiv, self.fromEqn = re.match('\[(.*?)\](.*)', \ self.equiv, self.fromEqn = re.match(r'\[(.*?)\](.*)', \
self.equiv).groups() self.equiv).groups()
if ';' in self.fromEqn: if ';' in self.fromEqn:
self.fromEqn, self.toEqn = self.fromEqn.split(';', 1) self.fromEqn, self.toEqn = self.fromEqn.split(';', 1)
@ -1190,7 +1190,7 @@ class Unit:
raise UnitDataError('Bad equation for "%s"' % self.name) raise UnitDataError('Bad equation for "%s"' % self.name)
else: # split factor and equiv unit for linear else: # split factor and equiv unit for linear
parts = self.equiv.split(None, 1) parts = self.equiv.split(None, 1)
if len(parts) > 1 and re.search('[^\d\.eE\+\-\*/]', parts[0]) \ if len(parts) > 1 and re.search(r'[^\d\.eE\+\-\*/]', parts[0]) \
== None: # only allowed digits and operators == None: # only allowed digits and operators
try: try:
self.factor = float(eval(parts[0])) self.factor = float(eval(parts[0]))

View File

@ -342,21 +342,31 @@ class Misc(callbacks.Plugin):
Returns the version of the current bot. Returns the version of the current bot.
""" """
try: try:
newestUrl = 'https://api.github.com/repos/progval/Limnoria/' + \ versions = []
'commits/%s'
versions = {} # fetch from PyPI
for branch in ('master', 'testing'): data = json.loads(utils.web.getUrl(
data = json.loads(utils.web.getUrl(newestUrl % branch) 'https://pypi.org/pypi/limnoria/json'
.decode('utf8')) ).decode('utf8'))
version = data['commit']['committer']['date'] release_version = data['info']['version']
# zero-left-pad months and days
release_version = re.sub(
r'\.([0-9])\b', lambda m: '.0' + m.group(1), release_version
)
# fetch from Git
data = json.loads(utils.web.getUrl(
'https://api.github.com/repos/progval/Limnoria/'
'commits/master'
).decode('utf8'))
git_version = data['commit']['committer']['date']
# Strip the last 'Z': # Strip the last 'Z':
version = version.rsplit('T', 1)[0].replace('-', '.') git_version = git_version.rsplit('T', 1)[0].replace('-', '.')
if minisix.PY2 and isinstance(version, unicode):
version = version.encode('utf8') newest = _(
versions[branch] = version 'The newest version available online is %(release_version)s, '
newest = _('The newest versions available online are %s.') % \ 'or %(git_version)s in Git'
', '.join([_('%s (in %s)') % (y,x) ) % {'release_version': release_version, 'git_version': git_version}
for x,y in versions.items()])
except utils.web.Error as e: except utils.web.Error as e:
self.log.info('Couldn\'t get website version: %s', e) self.log.info('Couldn\'t get website version: %s', e)
newest = _('I couldn\'t fetch the newest version ' newest = _('I couldn\'t fetch the newest version '

View File

@ -280,7 +280,7 @@ class RSS(callbacks.Plugin):
raise callbacks.Error(s) raise callbacks.Error(s)
if url: if url:
feed = self.feeds.get(url) feed = self.feeds.get(url)
if feed and feed.name != feed.url: if feed and feed.name != feed.url and feed.name in self.feed_names:
s = format(_('I already have a feed with that URL named %s.'), s = format(_('I already have a feed with that URL named %s.'),
feed.name) feed.name)
raise callbacks.Error(s) raise callbacks.Error(s)

View File

@ -121,6 +121,27 @@ class RSSTestCase(ChannelPluginTestCase):
self.assertEqual(self.irc.getCallback('RSS').feed_names, {}) self.assertEqual(self.irc.getCallback('RSS').feed_names, {})
self.assertTrue(self.irc.getCallback('RSS').get_feed('http://xkcd.com/rss.xml')) self.assertTrue(self.irc.getCallback('RSS').get_feed('http://xkcd.com/rss.xml'))
@mock_urllib
def testChangeUrl(self, mock):
try:
self.assertNotError('rss add xkcd http://xkcd.com/rss.xml')
self.assertNotError('rss remove xkcd')
self.assertNotError('rss add xkcd https://xkcd.com/rss.xml')
self.assertRegexp('help xkcd', 'https://')
finally:
self._feedMsg('rss remove xkcd')
@mock_urllib
def testChangeName(self, mock):
try:
self.assertNotError('rss add xkcd http://xkcd.com/rss.xml')
self.assertNotError('rss remove xkcd')
self.assertNotError('rss add xkcd2 http://xkcd.com/rss.xml')
self.assertRegexp('help xkcd2', 'http://xkcd.com')
finally:
self._feedMsg('rss remove xkcd')
self._feedMsg('rss remove xkcd2')
@mock_urllib @mock_urllib
def testInitialAnnounceNewest(self, mock): def testInitialAnnounceNewest(self, mock):
mock._data = xkcd_new mock._data = xkcd_new

View File

@ -33,7 +33,6 @@ import os
import re import re
import pwd import pwd
import sys import sys
import crypt
import errno import errno
import random import random
import select import select
@ -41,6 +40,12 @@ import struct
import subprocess import subprocess
import shlex import shlex
try:
import crypt
except ImportError:
# Python >= 3.13
crypt = None
import supybot.conf as conf import supybot.conf as conf
import supybot.utils as utils import supybot.utils as utils
from supybot.commands import * from supybot.commands import *
@ -119,6 +124,7 @@ class Unix(callbacks.Plugin):
irc.reply(format('%i', os.getpid()), private=True) irc.reply(format('%i', os.getpid()), private=True)
pid = wrap(pid, [('checkCapability', 'owner')]) pid = wrap(pid, [('checkCapability', 'owner')])
if crypt is not None: # Python < 3.13
_cryptre = re.compile(b'[./0-9A-Za-z]') _cryptre = re.compile(b'[./0-9A-Za-z]')
@internationalizeDocstring @internationalizeDocstring
def crypt(self, irc, msg, args, password, salt): def crypt(self, irc, msg, args, password, salt):

View File

@ -31,6 +31,11 @@
import os import os
import socket import socket
try:
import crypt
except ImportError:
crypt = None
from supybot.test import * from supybot.test import *
try: try:
@ -106,6 +111,7 @@ if os.name == 'posix':
def testProgstats(self): def testProgstats(self):
self.assertNotError('progstats') self.assertNotError('progstats')
if crypt is not None: # Python < 3.13
def testCrypt(self): def testCrypt(self):
self.assertNotError('crypt jemfinch') self.assertNotError('crypt jemfinch')

View File

@ -49,10 +49,12 @@ except ImportError:
install. This package is pretty standard, and often installed alongside install. This package is pretty standard, and often installed alongside
Python, but it is missing on your system. Python, but it is missing on your system.
Try installing it with your package manager, it is usually called Try installing it with your package manager, it is usually called
'python3-setuptools'. If that does not work, try installing python3-pip 'python3-setuptools'; or with '%s -m pip install setuptools'.
If that does not work, try installing python3-pip
instead, either with your package manager or by following these instead, either with your package manager or by following these
instructions: https://pip.pypa.io/en/stable/installation/ (replace instructions: https://pip.pypa.io/en/stable/installation/ (replace
'python' with 'python3' in all the commands)""") 'python' with 'python3' in all the commands)"""
% sys.executable)
sys.stderr.write(os.linesep*2) sys.stderr.write(os.linesep*2)
sys.stderr.write(textwrap.fill(s)) sys.stderr.write(textwrap.fill(s))
sys.stderr.write(os.linesep*2) sys.stderr.write(os.linesep*2)

View File

@ -419,6 +419,15 @@ def registerNetwork(name, password='', ssl=True, sasl_username='',
registry.String('', _("""Determines what user modes the bot will request registry.String('', _("""Determines what user modes the bot will request
from the server when it first connects. If empty, defaults to from the server when it first connects. If empty, defaults to
supybot.protocols.irc.umodes"""))) supybot.protocols.irc.umodes""")))
registerGlobalValue(network, 'vhost',
registry.String('', _("""Determines what vhost the bot will bind to before
connecting a server (IRC, HTTP, ...) via IPv4. If empty, defaults to
supybot.protocols.irc.vhost""")))
registerGlobalValue(network, 'vhostv6',
registry.String('', _("""Determines what vhost the bot will bind to before
connecting a server (IRC, HTTP, ...) via IPv6. If empty, defaults to
supybot.protocols.irc.vhostv6""")))
sasl = registerGroup(network, 'sasl') sasl = registerGroup(network, 'sasl')
registerGlobalValue(sasl, 'username', registry.String(sasl_username, registerGlobalValue(sasl, 'username', registry.String(sasl_username,
_("""Determines what SASL username will be used on %s. This should _("""Determines what SASL username will be used on %s. This should

View File

@ -312,8 +312,10 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin):
address, address,
port=self.currentServer.port, port=self.currentServer.port,
socks_proxy=socks_proxy, socks_proxy=socks_proxy,
vhost=conf.supybot.protocols.irc.vhost(), vhost=self.networkGroup.get('vhost')()
vhostv6=conf.supybot.protocols.irc.vhostv6(), or conf.supybot.protocols.irc.vhost(),
vhostv6=self.networkGroup.get('vhostv6')()
or conf.supybot.protocols.irc.vhostv6(),
) )
except socket.error as e: except socket.error as e:
drivers.log.connectError(self.currentServer, e) drivers.log.connectError(self.currentServer, e)

View File

@ -1,5 +1,5 @@
### ###
# Copyright (c) 2011-2021, Valentin Lorentz # Copyright (c) 2011-2024, Valentin Lorentz
# All rights reserved. # All rights reserved.
# #
# Redistribution and use in source and binary forms, with or without # Redistribution and use in source and binary forms, with or without
@ -32,8 +32,8 @@ An embedded and centralized HTTP server for Supybot's plugins.
""" """
import os import os
import cgi
import socket import socket
import urllib.parse
from threading import Thread from threading import Thread
import supybot.log as log import supybot.log as log
@ -164,6 +164,114 @@ def get_template(filename):
with open(path + '.example', 'r') as fd: with open(path + '.example', 'r') as fd:
return fd.read() return fd.read()
class HttpHeader:
__slots__ = ('name', 'value')
def __init__(self, name, value):
self.name = name
self.value = value
def __repr__(self):
"""Return printable representation."""
return "HttpHeader(%r, %r)" % (self.name, self.value)
class HttpHeaders:
"""Copy of `cgi.FieldStorage
<https://github.com/python/cpython/blob/v3.12.3/Lib/cgi.py#L512-L594>`
before it was removed from the stdlib.
"""
__slots__ = ('list',)
def __init__(self, headers):
self.list = headers
def __repr__(self):
return 'HttpHeaders(%r)' % self.list
def __iter__(self):
return iter(self.keys())
def __getattr__(self, name):
if name != 'value':
raise AttributeError(name)
if self.file:
self.file.seek(0)
value = self.file.read()
self.file.seek(0)
elif self.list is not None:
value = self.list
else:
value = None
return value
def __getitem__(self, key):
"""Dictionary style indexing."""
if self.list is None:
raise TypeError("not indexable")
found = []
for item in self.list:
if item.name == key: found.append(item)
if not found:
raise KeyError(key)
if len(found) == 1:
return found[0]
else:
return found
def getvalue(self, key, default=None):
"""Dictionary style get() method, including 'value' lookup."""
if key in self:
value = self[key]
if isinstance(value, list):
return [x.value for x in value]
else:
return value.value
else:
return default
def getfirst(self, key, default=None):
""" Return the first value received."""
if key in self:
value = self[key]
if isinstance(value, list):
return value[0].value
else:
return value.value
else:
return default
def getlist(self, key):
""" Return list of received values."""
if key in self:
value = self[key]
if isinstance(value, list):
return [x.value for x in value]
else:
return [value.value]
else:
return []
def keys(self):
"""Dictionary style keys() method."""
if self.list is None:
raise TypeError("not indexable")
return list(set(item.name for item in self.list))
def __contains__(self, key):
"""Dictionary style __contains__ method."""
if self.list is None:
raise TypeError("not indexable")
return any(item.name == key for item in self.list)
def __len__(self):
"""Dictionary style len(x) support."""
return len(self.keys())
def __bool__(self):
if self.list is None:
raise TypeError("Cannot be converted to bool.")
return bool(self.list)
class SupyHTTPRequestHandler(BaseHTTPRequestHandler): class SupyHTTPRequestHandler(BaseHTTPRequestHandler):
def do_X(self, callbackMethod, *args, **kwargs): def do_X(self, callbackMethod, *args, **kwargs):
if self.path == '/': if self.path == '/':
@ -199,12 +307,11 @@ class SupyHTTPRequestHandler(BaseHTTPRequestHandler):
if 'Content-Type' not in self.headers: if 'Content-Type' not in self.headers:
self.headers['Content-Type'] = 'application/x-www-form-urlencoded' self.headers['Content-Type'] = 'application/x-www-form-urlencoded'
if self.headers['Content-Type'] == 'application/x-www-form-urlencoded': if self.headers['Content-Type'] == 'application/x-www-form-urlencoded':
form = cgi.FieldStorage( length = min(100000, int(self.headers.get('Content-Length', '100000')))
fp=self.rfile, qs = self.rfile.read(length).decode()
headers=self.headers, form = HttpHeaders([
environ={'REQUEST_METHOD':'POST', HttpHeader(k, v) for (k, v) in urllib.parse.parse_qsl(qs)
'CONTENT_TYPE':self.headers['Content-Type'], ])
})
else: else:
content_length = int(self.headers.get('Content-Length', '0')) content_length = int(self.headers.get('Content-Length', '0'))
form = self.rfile.read(content_length) form = self.rfile.read(content_length)