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:
matrix:
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
runs-on: ubuntu-22.04
@ -67,7 +71,7 @@ jobs:
- name: Upgrade pip
run: |
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pip setuptools
- name: Install optional dependencies
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
optional.
[Style Guidelines]:https://limnoria.readthedocs.io/en/latest/develop/style.html
[Style Guidelines]:https://docs.limnoria.net/develop/style.html
## 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
to @ProgVal, it's easier for them to accept pull requests than to
cherry-pick everything manually.
@ -32,6 +28,6 @@ is very appreciated.
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):
self.assertRegexp(
'ddg search wikipedia', 'Wikipedia.*? - .*?https?\:\/\/')
'ddg search wikipedia', r'Wikipedia.*? - .*?https?\:\/\/')
self.assertRegexp(
'ddg search en.wikipedia.org',
'Wikipedia, the free encyclopedia\x02 - '
@ -47,6 +47,6 @@ class DDGTestCase(PluginTestCase):
with conf.supybot.plugins.DDG.region.context('fr-fr'):
self.assertRegexp(
'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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -280,7 +280,7 @@ class RSS(callbacks.Plugin):
raise callbacks.Error(s)
if 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.'),
feed.name)
raise callbacks.Error(s)

View File

@ -84,7 +84,7 @@ def mock_urllib(f):
url = 'http://www.advogato.org/rss/articles.xml'
class RSSTestCase(ChannelPluginTestCase):
plugins = ('RSS','Plugin')
plugins = ('RSS', 'Plugin')
timeout = 1
@ -121,6 +121,27 @@ class RSSTestCase(ChannelPluginTestCase):
self.assertEqual(self.irc.getCallback('RSS').feed_names, {})
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
def testInitialAnnounceNewest(self, mock):
mock._data = xkcd_new

View File

@ -33,7 +33,6 @@ import os
import re
import pwd
import sys
import crypt
import errno
import random
import select
@ -41,6 +40,12 @@ import struct
import subprocess
import shlex
try:
import crypt
except ImportError:
# Python >= 3.13
crypt = None
import supybot.conf as conf
import supybot.utils as utils
from supybot.commands import *
@ -119,25 +124,26 @@ class Unix(callbacks.Plugin):
irc.reply(format('%i', os.getpid()), private=True)
pid = wrap(pid, [('checkCapability', 'owner')])
_cryptre = re.compile(b'[./0-9A-Za-z]')
@internationalizeDocstring
def crypt(self, irc, msg, args, password, salt):
"""<password> [<salt>]
if crypt is not None: # Python < 3.13
_cryptre = re.compile(b'[./0-9A-Za-z]')
@internationalizeDocstring
def crypt(self, irc, msg, args, password, salt):
"""<password> [<salt>]
Returns the resulting of doing a crypt() on <password>. If <salt> is
not given, uses a random salt. If running on a glibc2 system,
prepending '$1$' to your salt will cause crypt to return an MD5sum
based crypt rather than the standard DES based crypt.
"""
def makeSalt():
s = b'\x00'
while self._cryptre.sub(b'', s) != b'':
s = struct.pack('<h', random.randrange(-(2**15), 2**15))
return s
if not salt:
salt = makeSalt().decode()
irc.reply(crypt.crypt(password, salt))
crypt = wrap(crypt, ['something', additional('something')])
Returns the resulting of doing a crypt() on <password>. If <salt> is
not given, uses a random salt. If running on a glibc2 system,
prepending '$1$' to your salt will cause crypt to return an MD5sum
based crypt rather than the standard DES based crypt.
"""
def makeSalt():
s = b'\x00'
while self._cryptre.sub(b'', s) != b'':
s = struct.pack('<h', random.randrange(-(2**15), 2**15))
return s
if not salt:
salt = makeSalt().decode()
irc.reply(crypt.crypt(password, salt))
crypt = wrap(crypt, ['something', additional('something')])
@internationalizeDocstring
def spell(self, irc, msg, args, word):

View File

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

View File

@ -49,10 +49,12 @@ except ImportError:
install. This package is pretty standard, and often installed alongside
Python, but it is missing on your system.
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
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(textwrap.fill(s))
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
from the server when it first connects. If empty, defaults to
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')
registerGlobalValue(sasl, 'username', registry.String(sasl_username,
_("""Determines what SASL username will be used on %s. This should

View File

@ -312,8 +312,10 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin):
address,
port=self.currentServer.port,
socks_proxy=socks_proxy,
vhost=conf.supybot.protocols.irc.vhost(),
vhostv6=conf.supybot.protocols.irc.vhostv6(),
vhost=self.networkGroup.get('vhost')()
or conf.supybot.protocols.irc.vhost(),
vhostv6=self.networkGroup.get('vhostv6')()
or conf.supybot.protocols.irc.vhostv6(),
)
except socket.error as 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.
#
# 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 cgi
import socket
import urllib.parse
from threading import Thread
import supybot.log as log
@ -164,6 +164,114 @@ def get_template(filename):
with open(path + '.example', 'r') as fd:
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):
def do_X(self, callbackMethod, *args, **kwargs):
if self.path == '/':
@ -199,12 +307,11 @@ class SupyHTTPRequestHandler(BaseHTTPRequestHandler):
if 'Content-Type' not in self.headers:
self.headers['Content-Type'] = 'application/x-www-form-urlencoded'
if self.headers['Content-Type'] == 'application/x-www-form-urlencoded':
form = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={'REQUEST_METHOD':'POST',
'CONTENT_TYPE':self.headers['Content-Type'],
})
length = min(100000, int(self.headers.get('Content-Length', '100000')))
qs = self.rfile.read(length).decode()
form = HttpHeaders([
HttpHeader(k, v) for (k, v) in urllib.parse.parse_qsl(qs)
])
else:
content_length = int(self.headers.get('Content-Length', '0'))
form = self.rfile.read(content_length)