Compare commits

..

6 Commits

12 changed files with 417 additions and 384 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
# Minecraft-specific
server.jar
world/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -7,13 +7,14 @@ WORKDIR /usr/src/app
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Prepare MC server # # Prepare MC server
COPY server.jar ./ # COPY server.jar ./
RUN java -jar server.jar --nogui # RUN java -jar server.jar --nogui
COPY . . COPY src/ ./src/
COPY main.py ./
# Buffer Python's stdout for debugging during runtime # Buffer Python's stdout for debugging during runtime
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
CMD ["python", "main.py", "java", "-Xmx1024M", "-Xms1024M", "-jar", "server.jar", "nogui"] CMD ["python", "main.py"]

View File

@ -6,25 +6,36 @@ This way, communication is enabled between a Minecraft server and a Matrix room.
## Setup ## Setup
To create a Docker image, go to this folder and run the following command: 1. Create a Docker image using the following command:
``` ```
docker build -t matrix-mc-server ./ docker build -t matrix-mc-server ./
``` ```
Then, once a Docker image has been created, you can run the following command to run a Minecraft server. In `config.py`, you can find additional environment variables you can insert to alter settings on the Minecraft server. 2. Download the [latest Minecraft server jar file](https://www.minecraft.net/en-us/download/server).
``` 3. Open `config.yaml` and adjust it to however you like.
docker run --name mc-server \
-p 25565:25565 \
-v "<folder in which your store your Minecraft world>":/usr/src/app/world \
-e EULA=true \
-e MATRIX_HOMESERVER='<your matrix homeserver>' \
-e MATRIX_USERNAME='<matrix bridge client username>' \
-e MATRIX_PASSWORD='<matrix bridge client password>' \
-e MC_CHANNEL='<channel in which you communicate>' \
-e SERVER_ADDRESS='<ip address where players can connect to the server>' \
mc-bridge
```
This should successfully launch your bridged Minecraft server. **NOTE:** Make sure to set EULA to `true` otherwise the server will refuse to start.
4. Start the Docker container. You will need a few volumes:
- `/usr/src/app/config.yaml` - Location of your custom `config.yaml`
- `/usr/src/app/whitelist.json` - Location of your whitelist, if any.
- `/usr/src/app/world/` - Folder of your custom Minecraft world to load
- `/usr/src/app/server.jar` - (Default) location of the server jar file. Can be changed in `config.yaml`.
For example, your Docker compose file could look like the following:
```docker-compose
version: '3'
services:
matrix-mc-server:
image: matrix-mc-server:latest
volumes:
- <your config>:/usr/src/app/config.yaml
- <your whitelist>:/usr/src/app/whitelist.json
- <your world folder>:/usr/src/app/world
- <your server jar file>:/usr/src/app/server.jar
```

View File

@ -1,48 +0,0 @@
import os
import config
def rewriter(file_name):
"""Create a decorator that rewrites a file based on given settings."""
def exec(func):
"""Rewrite a file"""
new_lines = []
info_to_remember = {}
line_no = 0
with open(file_name, 'r', encoding='utf-8') as open_file:
for line in open_file:
line_no += 1
new_line = func(line, line_no, data=info_to_remember)
new_lines.append(new_line)
with open(file_name, 'w', encoding='utf-8') as write_file:
for line in new_lines:
write_file.write(line)
return exec
@rewriter('eula.txt')
def confirm_eula(line : str, line_no : int, data):
"""Confirm the Minecraft EULA"""
if not config.EULA:
return line
else:
return line.replace('eula=false', 'eula=true')
@rewriter('server.properties')
def fill_in_server_settings(line : str, line_no : int, data):
"""Set up the server based on our chosen properties"""
if line.strip().startswith('#'):
return line
key = line.split('=')[0]
server_settings = config.at(['minecraft']) or {}
try:
value = server_settings[key]
except IndexError:
return line
else:
return key + '=' + str(value) + '\n'

View File

@ -1,84 +0,0 @@
import os
import yaml
from typing import Any, List, Optional
with open('config.yaml', 'r') as open_file:
SETTINGS = yaml.load(open_file)
def at(keys : List[str]) -> Optional[Any]:
"""
Potentially get a value. If it doesn't exist, return None.
"""
return at_value(keys, SETTINGS)
def at_value(keys : List[str], value : Any) -> Optional[Any]:
try:
head, tail = keys[0], keys[1:]
except IndexError:
return value
else:
try:
new_value = value[head]
except TypeError:
return None
except KeyError:
return None
else:
return at_value(tail, new_value)
# EULA
EULA = at(['minecraft', 'eula']) or False
# Minecraft bridge credentials
MATRIX_HOMESERVER = at(['matrix', 'homeserver']) or "https://matrix.example.org/"
MATRIX_USERNAME = at(['matrix', 'username']) or "@alice:example.org"
MATRIX_PASSWORD = at(['matrix', 'password']) or "bridge_password"
# Matrix bridge room
MATRIX_ROOM = at(['matrix', 'room_id']) or "!channel_id:example.org"
SERVER_IP = os.getenv('SERVER_ADDRESS') or 'unknown ip'
# Matrix users who are allowed to run OP commands in Minecraft through Matrix
MC_ADMINS = [
"@bramvdnheuvel:nltrix.net", # Bram on Matrix (example, feel free to remove)
"@_discord_625632515314548736:t2bot.io" # Bram on Discord (example, feel free to remove)
# Your username on Matrix
]
if os.getenv('MATRIX_ADMINS') is not None:
MC_ADMINS = os.getenv('MATRIX_ADMINS').split(',')
make_bool = lambda os_value, default_value : default_value if not os_value else (
False if os_value.lower() == 'false' else True
)
SERVER_SETTINGS = {
'level-name': os.getenv('WORLD') or 'world',
# Server settings
'port' : 25565 if os.getenv('PORT') == None else int(os.getenv('PORT')),
'query.port' : 25565 if os.getenv('PORT') == None else int(os.getenv('PORT')),
'max-players' : 7 if os.getenv('MAX_PLAYERS') == None else int(os.getenv('MAX_PLAYERS')),
# Server temperature >:3
'view-distance' : 10 if os.getenv('RENDER_DISTANCE') == None else int(os.getenv('RENDER_DISTANCE')),
'enable-command-block' : make_bool(os.getenv('COMMAND_BLOCKS'), True),
# Environment
'allow-nether' : make_bool(os.getenv('NETHER'), True),
'spawn-npcs' : make_bool(os.getenv('NPCS'), True),
'spawn-animals' : make_bool(os.getenv('ANIMALS'), True),
'spawn-monsters' : make_bool(os.getenv('MONSTERS'), True),
# Gamemode
'pvp' : make_bool(os.getenv('PVP'), True),
'gamemode' : os.getenv('GAMEMODE') or 'survival',
'difficulty': os.getenv('DIFFICULTY') or 'medium',
'hardcore' : make_bool(os.getenv('HARDCORE'), False),
# Grief protection
'online-mode' : make_bool(os.getenv('VERIFY_ACCOUNTS'), True),
'white-list' : make_bool(os.getenv('WHITELIST'), True),
'enforce-whitelist' : make_bool(os.getenv('WHITELIST'), True),
'spawn-protection' : 16 if os.getenv('SPAWN_PROTECTION') == None else os.getenv('SPAWN_PROTECTION'),
}

View File

@ -1,3 +1,16 @@
config:
# How much memory the Minecraft server is allowed to take up
# Number in megabytes. Defaults to 1024. (= 1 GB)
ram: 1024
# File location of the server jar.
# To be downloaded at: https://www.minecraft.net/en-us/download/server
server_jar: server.jar
# Confirm the Minecraft EULA. https://account.mojang.com/documents/minecraft_eula
# Defaults to false, but is required to run the server.
# eula: true
# Matrix bridge configurations # Matrix bridge configurations
matrix: matrix:
# Homeserver URL # Homeserver URL
@ -14,14 +27,16 @@ matrix:
server_address: unknown ip server_address: unknown ip
# List of Matrix users that can send commands to the bridge. # List of Matrix users that can send commands to the bridge.
# When a message starts with a slash, (/) the bridge will interpret it as a # When a message starts with an exclamation mark, (!) the bridge will
# Minecraft command and will put that as a command into the console. # interpret it as a Minecraft command and will put that as a command
# into the console.
mc-admins: mc-admins:
- "@bram:matrix.directory" - "@bram:matrix.directory"
# - "@alice:example.org" # - "@alice:example.org"
# - # -
# When users have bridged from other platforms, you can indicate accordingly. # When users have bridged from other platforms, you can indicate accordingly.
# When multiple RegEx strings apply, all are included.
alternative_platforms: alternative_platforms:
Discord: Discord:
match: "@_?discord_\d+:.+" match: "@_?discord_\d+:.+"
@ -34,8 +49,6 @@ matrix:
# Settings that directly affect running the Minecraft server. # Settings that directly affect running the Minecraft server.
minecraft: minecraft:
# Confirm the Minecraft EULA. Defaults to false.
# eula: true
# -------------------- # --------------------
# MINECRAFT SERVER # MINECRAFT SERVER

74
main.py
View File

@ -1,67 +1,7 @@
import asyncio import src.mc_wrapper as mc_wrapper
import time import src.mxclient as matrix_client
import re
import asyncio
from nio import AsyncClient, MatrixRoom, RoomMessageText
import mc_wrapper # Start the Minecraft process
asyncio.run(matrix_client.start())
import config
import build_server
STARTUP_TIME = time.time()
client = AsyncClient(config.MATRIX_HOMESERVER, config.MATRIX_USERNAME)
async def message_callback(room: MatrixRoom, event: RoomMessageText) -> None:
if room.machine_name != config.MATRIX_ROOM:
return
if event.sender == client.user_id:
return
if int(event.server_timestamp) < STARTUP_TIME:
return
# Determine platform
platform = 'Matrix'
if re.fullmatch(r"@_discord_\d+:t2bot\.io", event.sender):
platform = 'Discord'
# Determine how to display username
name = room.users[event.sender].display_name
for user in room.users:
if user == event.sender:
continue
if room.users[user].display_name == name:
name = room.users[event.sender].disambiguated_name
break
mc_wrapper.reply_to_mc(
event.body, name,
admin=(event.sender in config.MC_ADMINS),
platform=platform
)
client.add_event_callback(message_callback, RoomMessageText)
async def activate_client() -> None:
print(await client.login(config.MATRIX_PASSWORD))
await client.room_send(
room_id=config.MC_CHANNEL,
message_type="m.room.message",
content = {
"msgtype": "m.text",
"body": "Starting Minecraft-Matrix bridge...",
"format": "org.matrix.custom.html",
"formatted_body": "<strong>Starting Minecraft-Matrix bridge...</strong>"
}
)
await client.sync_forever(timeout=30000) # milliseconds
async def start():
await asyncio.gather(
activate_client(),
mc_wrapper.start(client, config.MC_CHANNEL)
)
asyncio.run(start())

25
src/build_server.py Normal file
View File

@ -0,0 +1,25 @@
"""
This module prepares the necessary files for running the server in the
correct configuration.
"""
import src.config as config
def write_eula():
"""
Write whether the user accepts to Minecraft's EULA.
The server refuses to run unless explicitly accepted.
"""
with open("eula.txt", 'w') as fp:
if config.EULA == True:
fp.write("eula=true")
else:
fp.write("eula=false")
def write_server_properties():
"""
Write the configuration for the Minecraft world.
"""
with open("server.properties", 'w') as fp:
for key, value in config.at(['minecraft']).items():
fp.write(f"{key}={value}\n")

62
src/config.py Normal file
View File

@ -0,0 +1,62 @@
"""
This module loads and parses the config.yaml file.
"""
from typing import Any, List, Optional
import yaml
with open('config.yaml', 'r') as open_file:
SETTINGS = yaml.load(open_file)
def at(keys : List[str]) -> Optional[Any]:
"""
Potentially get a value. If it doesn't exist, return None.
"""
return at_value(keys, SETTINGS)
def at_value(keys : List[str], value : Any) -> Optional[Any]:
try:
head, tail = keys[0], keys[1:]
except IndexError:
return value
else:
try:
new_value = value[head]
except TypeError:
return None
except KeyError:
return None
else:
return at_value(tail, new_value)
# EULA
EULA = at(['config', 'eula']) or False
# Minecraft bridge credentials
MATRIX_HOMESERVER = at(['matrix', 'homeserver']) or "https://matrix.example.org/"
MATRIX_USERNAME = at(['matrix', 'username']) or "@alice:example.org"
MATRIX_PASSWORD = at(['matrix', 'password']) or "bridge_password"
# Matrix bridge room
MATRIX_ROOM = at(['matrix', 'room_id']) or "!channel_id:example.org"
SERVER_IP = at(['matrix', 'server_address']) or 'unknown ip'
MATRIX_ADMINS = at(['matrix', 'mc-admins']) or []
try:
RAM_SIZE = int(at(['config', 'ram']))
except TypeError:
RAM_SIZE = 1024
except ValueError:
RAM_SIZE = 1024
SERVER_JAR_LOCATION = at(['config', 'server_jar']) or 'server.jar'
RUN_COMMAND = [
'java',
f'-Xmx{RAM_SIZE}M',
f'-Xms{RAM_SIZE}M',
'-jar', SERVER_JAR_LOCATION,
'nogui'
]

View File

@ -1,124 +1,160 @@
from subprocess import Popen, PIPE """
from typing import Union The mc_wrapper modules handles the process that runs the Minecraft server.
import asyncio
import json The server is run in a subprocess, and live communication between the
import sys game and the Python script is facilitated.
import re """
from nbsr import NonBlockingStreamReader as NBSR
import config from subprocess import Popen, PIPE
from typing import Union
# run the shell as a subprocess: import asyncio
p = Popen(sys.argv[1:], import json
stdin = PIPE, stdout = PIPE, stderr = PIPE, shell = False) import re
# wrap p.stdout with a NonBlockingStreamReader object: from src.nbsr import NonBlockingStreamReader as NBSR
nbsr = NBSR(p.stdout) import src.config as config
import src.build_server as build
async def start(client, mc_channel):
await asyncio.sleep(3) # Write the appropriate files
build.write_eula()
while True: build.write_server_properties()
output = nbsr.readline(0.1)
# 0.1 secs to let the shell output the result # run the shell as a subprocess:
if not output: p = Popen(config.RUN_COMMAND,
await asyncio.sleep(1) stdin = PIPE, stdout = PIPE, stderr = PIPE, shell = False)
else: # wrap p.stdout with a NonBlockingStreamReader object:
try: nbsr = NBSR(p.stdout)
sentence = output.decode("utf-8").strip()
except UnicodeDecodeError: async def start(client, mc_channel):
print("Could not decode sentence:") """
print(output) Start reading from the Minecraft subprocess.
else: """
print(sentence) await asyncio.sleep(3)
plain_text, sentence = process_message(sentence)
while True:
if sentence is not None: output = nbsr.readline(0.1)
print("[Matrix] " + plain_text) # 0.1 secs to let the shell output the result
if not output:
# Send terminal message to Matrix await asyncio.sleep(1)
await client.room_send( else:
room_id=mc_channel, try:
message_type="m.room.message", sentence = output.decode("utf-8").strip()
content = { except UnicodeDecodeError:
"msgtype": "m.text", print("Could not decode sentence:")
"body": plain_text, print(output)
"format": "org.matrix.custom.html", else:
"formatted_body": sentence print(sentence)
} plain_text, sentence = process_message(sentence)
)
if sentence is not None:
server_live = False print("[Matrix] " + plain_text)
def process_message(sentence : str) -> Union[str, None]: # Send terminal message to Matrix
global server_live await client.room_send(
room_id=mc_channel,
if (match := re.fullmatch( message_type="m.room.message",
r"\[[\d:]+\] \[Server thread\/INFO\]: Preparing level \"(.+)\"" content = {
, sentence)): "msgtype": "m.text",
level, = match.groups() "body": plain_text,
return f"Preparing level {level}...", "<strong>Preparing level \"" + level + "\"...</strong>" "format": "org.matrix.custom.html",
"formatted_body": sentence
if re.fullmatch( }
r"\[[\d:]+\] \[Server thread\/INFO\]: Done \(\d+.?\d*s\)! For help, type \"help\"", )
sentence):
server_live = True server_live = False
return f"The Minecraft server is live. The server is reacable at <code>{config.SERVER_IP}</code>.", f"The minecraft server is live. The server is reacable at <code>{config.SERVER_IP}</code>."
def process_message(sentence : str) -> Union[str, None]:
if re.fullmatch( """
r"\[[\d:]+\] \[Server thread\/INFO\]: Stopping server", Process a message that is sent to stdout in the Minecraft terminal.
sentence):
return "The server has stopped.", "<strong>The server has stopped.</strong>" If this function deems it relevant, it returns a string that can be
sent to Matrix.
if not server_live: """
return None, None global server_live
if (match := re.fullmatch( if (match := re.fullmatch(
r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) joined the game", r"\[[\d:]+\] \[Server thread\/INFO\]: Preparing level \"(.+)\""
sentence)): , sentence)):
username, = match.groups() level, = match.groups()
return username + " joined the Minecraft server", ( return f"Preparing level {level}...", "<strong>Preparing level \"" + level + "\"...</strong>"
"<strong>" + username + " joined the Minecraft server</strong>")
if re.fullmatch(
if (match := re.fullmatch( r"\[[\d:]+\] \[Server thread\/INFO\]: Done \(\d+.?\d*s\)! For help, type \"help\"",
r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) left the game", sentence):
sentence)): server_live = True
username, = match.groups() return f"The Minecraft server is live. The server is reacable at <code>{config.SERVER_IP}</code>.", f"The minecraft server is live. The server is reacable at <code>{config.SERVER_IP}</code>."
return username + " left the Minecraft server", (
"<strong>" + username + " left the Minecraft server</strong>") if re.fullmatch(
r"\[[\d:]+\] \[Server thread\/INFO\]: Stopping server",
if (match := re.fullmatch( sentence):
r"\[[\d:]+\] \[Server thread\/INFO\]: <([A-Za-z0-9_]{3,16})> (.+)", return "The server has stopped.", "<strong>The server has stopped.</strong>"
sentence)):
username, message = match.groups() if not server_live:
return username + ": " + message, ( return None, None
"<strong>" + username + "</strong>: " + message
) if (match := re.fullmatch(
r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) joined the game",
if (match := re.fullmatch( sentence)):
r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) ([\w\[\]\-\. !?,]+)", username, = match.groups()
sentence)): return username + " joined the Minecraft server", (
message = " ".join(match.groups()) "<strong>" + username + " joined the Minecraft server</strong>")
return message, "<strong>" + message + "</strong>"
if (match := re.fullmatch(
return None, None r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) left the game",
sentence)):
def reply_to_mc(message : str, author : str, username, = match.groups()
admin : bool = False, platform : str = 'Matrix'): return username + " left the Minecraft server", (
""" "<strong>" + username + " left the Minecraft server</strong>"
Send something back to the Minecraft terminal. )
"""
if admin and message.startswith('!'): if (match := re.fullmatch(
p.stdin.write((message[1:] + "\r\n").encode()) r"\[[\d:]+\] \[Server thread\/INFO\]: <([A-Za-z0-9_]{3,16})> (.+)",
else: sentence)):
msg = [ username, message = match.groups()
"", return username + ": " + message, (
dict(text="M", color="red"), "<strong>" + username + "</strong>: " + message
dict(text="D", color="aqua") if platform == 'Discord' else None, )
dict(text=f" <{author}> {message}")
] if (match := re.fullmatch(
p.stdin.write( r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) ([\w\[\]\-\. !?,]+)",
("execute as @a run tellraw @s " + json.dumps([m for m in msg if m is not None]) + "\r\n").encode() sentence)):
) message = " ".join(match.groups())
p.stdin.flush() return message, "<strong>" + message + "</strong>"
if __name__ == '__main__': return None, None
def reply_to_mc(message : str, display_name : str, sender : str):
"""
Send something back to the Minecraft terminal.
"""
if sender in config.MATRIX_ADMINS and message.startswith('!'):
p.stdin.write((message[1:] + "\r\n").encode())
else:
p.stdin.write(
("execute as @a run tellraw @s " + format(sender, display_name, message) + "\r\n").encode()
)
p.stdin.flush()
def format(sender : str, display_name : str, message : str) -> str:
"""
Create a string used to format the user's message.
"""
start = [ "", dict(text="M", color="red" ) ]
end = [ dict(text=f" <{display_name}> {message}") ]
options = config.at(['matrix', 'alternative_platforms']) or {}
for platform, details in options.items():
try:
regex = details['match']
text = details['text']
color = details['color']
except KeyError:
print("WARNING: Platform `" + platform + "` is missing some configurations.")
else:
if re.fullmatch(regex, sender):
start.append(dict(text=text, color=color))
return json.dumps(start + end)
if __name__ == '__main__':
asyncio.run(start()) asyncio.run(start())

61
src/mxclient.py Normal file
View File

@ -0,0 +1,61 @@
import asyncio
import time
import re
from nio import AsyncClient, MatrixRoom, RoomMessageText
import src.mc_wrapper as mc_wrapper
import src.config as config
STARTUP_TIME = time.time()
client = AsyncClient(config.MATRIX_HOMESERVER, config.MATRIX_USERNAME)
async def message_callback(room: MatrixRoom, event: RoomMessageText) -> None:
if room.machine_name != config.MATRIX_ROOM:
return
if event.sender == client.user_id:
return
if int(event.server_timestamp) < STARTUP_TIME:
return
# Determine how to display username
name = room.users[event.sender].display_name
for user in room.users:
if user == event.sender:
continue
if room.users[user].display_name == name:
name = room.users[event.sender].disambiguated_name
break
mc_wrapper.reply_to_mc(event.body, name, event.sender)
client.add_event_callback(message_callback, RoomMessageText)
async def activate_client() -> None:
print(await client.login(config.MATRIX_PASSWORD))
await client.room_send(
room_id=config.MATRIX_ROOM,
message_type="m.room.message",
content = {
"msgtype": "m.text",
"body": "Starting Minecraft-Matrix bridge...",
"format": "org.matrix.custom.html",
"formatted_body": "<strong>Starting Minecraft-Matrix bridge...</strong>"
}
)
await client.sync_forever(timeout=30000) # milliseconds
async def start():
await asyncio.gather(
# Start the Matrix client
activate_client(),
# Start the Minecraft subprocess
mc_wrapper.start(client, config.MATRIX_ROOM)
)
if __name__ == '__main__':
asyncio.run(start())

View File

@ -1,39 +1,51 @@
from threading import Thread """
from queue import Queue, Empty The NBSR module defines a Non-blocking stream reader (NBSR class).
class NonBlockingStreamReader: In short, the Minecraft stdout is a stream of data that doesn't end until
the server shuts down. Traditionally, Python would not run any code until
def __init__(self, stream): the server has shut down and returns its entire output.
'''
stream: the stream to read from. The NBSR class allows us to read from the stream without blocking the entire
Usually a process' stdout or stderr. Python script. We will occasionally ask the NBSR for any updates, and it
''' will give us the latest output, if it exists.
"""
self._s = stream
self._q = Queue() from threading import Thread
from queue import Queue, Empty
def _populateQueue(stream, queue):
''' class NonBlockingStreamReader:
Collect lines from 'stream' and put them in 'quque'.
''' def __init__(self, stream):
'''
while True: stream: the stream to read from.
line = stream.readline() Usually a process' stdout or stderr.
if line: '''
queue.put(line)
else: self._s = stream
raise UnexpectedEndOfStream self._q = Queue()
self._t = Thread(target = _populateQueue, def _populateQueue(stream, queue):
args = (self._s, self._q)) '''
self._t.daemon = True Collect lines from 'stream' and put them in 'quque'.
self._t.start() #start collecting lines from the stream '''
def readline(self, timeout = None): while True:
try: line = stream.readline()
return self._q.get(block = timeout is not None, if line:
timeout = timeout) queue.put(line)
except Empty: else:
return None raise UnexpectedEndOfStream
self._t = Thread(target = _populateQueue,
args = (self._s, self._q))
self._t.daemon = True
self._t.start() #start collecting lines from the stream
def readline(self, timeout = None):
try:
return self._q.get(block = timeout is not None,
timeout = timeout)
except Empty:
return None
class UnexpectedEndOfStream(Exception): pass class UnexpectedEndOfStream(Exception): pass