mirror of
https://github.com/evennia/evennia.git
synced 2026-04-01 13:37:17 +02:00
Ran black on sources, add black config
This commit is contained in:
parent
c405c909f1
commit
a22ca9a925
32 changed files with 1031 additions and 232 deletions
|
|
@ -15,8 +15,16 @@ _IGNORE_FILES = []
|
|||
_SOURCEDIR_NAME = "source"
|
||||
_SOURCE_DIR = pathjoin(dirname(dirname(abspath(__file__))), _SOURCEDIR_NAME)
|
||||
_TOC_FILE = pathjoin(_SOURCE_DIR, "toc.md")
|
||||
_NO_REMAP_STARTSWITH = ["http://", "https://", "github:", "api:",
|
||||
"feature-request", "report-bug", "issue", "bug-report"]
|
||||
_NO_REMAP_STARTSWITH = [
|
||||
"http://",
|
||||
"https://",
|
||||
"github:",
|
||||
"api:",
|
||||
"feature-request",
|
||||
"report-bug",
|
||||
"issue",
|
||||
"bug-report",
|
||||
]
|
||||
|
||||
TXT_REMAPS = {
|
||||
"Developer Central": "Evennia Components overview",
|
||||
|
|
@ -52,6 +60,7 @@ _USED_REFS = {}
|
|||
|
||||
_CURRFILE = None
|
||||
|
||||
|
||||
def auto_link_remapper():
|
||||
"""
|
||||
- Auto-Remaps links to fit with the actual document file structure. Requires
|
||||
|
|
@ -69,7 +78,7 @@ def auto_link_remapper():
|
|||
# we allow a max of 4 levels of nesting in the source dir
|
||||
ind = pathparts[-5:].index(_SOURCEDIR_NAME)
|
||||
# get the part after source/
|
||||
pathparts = pathparts[-5 + 1 + ind:]
|
||||
pathparts = pathparts[-5 + 1 + ind :]
|
||||
url = "/".join(pathparts)
|
||||
# get the reference, without .md
|
||||
url = url.rsplit(".", 1)[0]
|
||||
|
|
@ -96,7 +105,8 @@ def auto_link_remapper():
|
|||
raise DocumentError(
|
||||
f" Tried to add {src_url}.md, but a file {duplicate_src_url}.md already exists.\n"
|
||||
" Evennia's auto-link-corrector does not accept doc-files with the same \n"
|
||||
" name, even in different folders. Rename one.\n")
|
||||
" name, even in different folders. Rename one.\n"
|
||||
)
|
||||
toc_map[fname] = src_url
|
||||
|
||||
# find relative links to all other files
|
||||
|
|
@ -111,17 +121,20 @@ def auto_link_remapper():
|
|||
url = "./" + url
|
||||
docref_map[sourcepath][targetname] = url.rsplit(".", 1)[0]
|
||||
|
||||
|
||||
# normal reference-links [txt](urls)
|
||||
ref_regex = re.compile(r"\[(?P<txt>[\w -\[\]\`]+?)\]\((?P<url>.+?)\)", re.I + re.S + re.U + re.M)
|
||||
ref_regex = re.compile(
|
||||
r"\[(?P<txt>[\w -\[\]\`]+?)\]\((?P<url>.+?)\)", re.I + re.S + re.U + re.M
|
||||
)
|
||||
# in document references
|
||||
ref_doc_regex = re.compile(r"\[(?P<txt>[\w -\`]+?)\]:\s+?(?P<url>.+?)(?=$|\n)", re.I + re.S + re.U + re.M)
|
||||
ref_doc_regex = re.compile(
|
||||
r"\[(?P<txt>[\w -\`]+?)\]:\s+?(?P<url>.+?)(?=$|\n)", re.I + re.S + re.U + re.M
|
||||
)
|
||||
|
||||
def _sub(match):
|
||||
# inline reference links
|
||||
global _USED_REFS
|
||||
grpdict = match.groupdict()
|
||||
txt, url = grpdict['txt'], grpdict['url']
|
||||
txt, url = grpdict["txt"], grpdict["url"]
|
||||
|
||||
txt = TXT_REMAPS.get(txt, txt)
|
||||
url = URL_REMAPS.get(url, url)
|
||||
|
|
@ -142,7 +155,7 @@ def auto_link_remapper():
|
|||
|
||||
if _CURRFILE in docref_map and fname in docref_map[_CURRFILE]:
|
||||
cfilename = _CURRFILE.rsplit("/", 1)[-1]
|
||||
urlout = docref_map[_CURRFILE][fname] + ('#' + anchor[0] if anchor else '')
|
||||
urlout = docref_map[_CURRFILE][fname] + ("#" + anchor[0] if anchor else "")
|
||||
if urlout != url:
|
||||
print(f" {cfilename}: [{txt}]({url}) -> [{txt}]({urlout})")
|
||||
else:
|
||||
|
|
@ -154,7 +167,7 @@ def auto_link_remapper():
|
|||
# reference links set at the bottom of the page
|
||||
global _USED_REFS
|
||||
grpdict = match.groupdict()
|
||||
txt, url = grpdict['txt'], grpdict['url']
|
||||
txt, url = grpdict["txt"], grpdict["url"]
|
||||
|
||||
txt = TXT_REMAPS.get(txt, txt)
|
||||
url = URL_REMAPS.get(url, url)
|
||||
|
|
@ -175,7 +188,7 @@ def auto_link_remapper():
|
|||
|
||||
if _CURRFILE in docref_map and fname in docref_map[_CURRFILE]:
|
||||
cfilename = _CURRFILE.rsplit("/", 1)[-1]
|
||||
urlout = docref_map[_CURRFILE][fname] + ('#' + anchor[0] if anchor else '')
|
||||
urlout = docref_map[_CURRFILE][fname] + ("#" + anchor[0] if anchor else "")
|
||||
if urlout != url:
|
||||
print(f" {cfilename}: [{txt}]: {url} -> [{txt}]: {urlout}")
|
||||
else:
|
||||
|
|
@ -190,12 +203,12 @@ def auto_link_remapper():
|
|||
# from pudb import debugger;debugger.Debugger().set_trace()
|
||||
_CURRFILE = path.as_posix()
|
||||
|
||||
with open(path, 'r') as fil:
|
||||
with open(path, "r") as fil:
|
||||
intxt = fil.read()
|
||||
outtxt = ref_regex.sub(_sub, intxt)
|
||||
outtxt = ref_doc_regex.sub(_sub_doc, outtxt)
|
||||
if intxt != outtxt:
|
||||
with open(path, 'w') as fil:
|
||||
with open(path, "w") as fil:
|
||||
fil.write(outtxt)
|
||||
count += 1
|
||||
print(f" -- Auto-relinked links in {path.name}")
|
||||
|
|
@ -232,5 +245,6 @@ def auto_link_remapper():
|
|||
|
||||
print(" -- Auto-Remapper finished.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
auto_link_remapper()
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def create_search_index(sourcedir, outfile):
|
|||
print(f"Building Search index from {len(filepaths)} files ... ", end="")
|
||||
|
||||
for filepath in filepaths:
|
||||
with open(filepath, 'r') as fil:
|
||||
with open(filepath, "r") as fil:
|
||||
filename = filepath.rsplit(sep, 1)[1].split(".", 1)[0]
|
||||
url = f"{URL_BASE}{sep}{filename}.html".strip()
|
||||
title = filename.replace("-", " ").strip()
|
||||
|
|
@ -61,16 +61,7 @@ def create_search_index(sourcedir, outfile):
|
|||
idx = lunr(
|
||||
ref="url",
|
||||
documents=outlist,
|
||||
fields=[
|
||||
{
|
||||
"field_name": "title",
|
||||
"boost": 10
|
||||
},
|
||||
{
|
||||
"field_name": "text",
|
||||
"boost": 1
|
||||
}
|
||||
],
|
||||
fields=[{"field_name": "title", "boost": 10}, {"field_name": "text", "boost": 1}],
|
||||
)
|
||||
|
||||
with open(outfile, "w") as fil:
|
||||
|
|
@ -83,10 +74,18 @@ if __name__ == "__main__":
|
|||
|
||||
parser = ArgumentParser(description="Build a static search index.")
|
||||
|
||||
parser.add_argument("-i", dest="sourcedir", default=DEFAULT_SOURCE_DIR,
|
||||
help="Absolute path to the documentation source dir")
|
||||
parser.add_argument("-o", dest="outfile", default=DEFAULT_OUTFILE,
|
||||
help="Absolute path to the index file to output.")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
dest="sourcedir",
|
||||
default=DEFAULT_SOURCE_DIR,
|
||||
help="Absolute path to the documentation source dir",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
dest="outfile",
|
||||
default=DEFAULT_OUTFILE,
|
||||
help="Absolute path to the index file to output.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,11 @@ _INDEX_PREFIX = f"""
|
|||
"""
|
||||
|
||||
_WIKI_DIR = "../../../evennia.wiki/"
|
||||
_INFILES = [path for path in sorted(glob.glob(_WIKI_DIR + "/*.md"))
|
||||
if path.rsplit('/', 1)[-1] not in _IGNORE_FILES]
|
||||
_INFILES = [
|
||||
path
|
||||
for path in sorted(glob.glob(_WIKI_DIR + "/*.md"))
|
||||
if path.rsplit("/", 1)[-1] not in _IGNORE_FILES
|
||||
]
|
||||
_FILENAMES = [path.rsplit("/", 1)[-1] for path in _INFILES]
|
||||
_FILENAMES = [path.split(".", 1)[0] for path in _FILENAMES]
|
||||
_FILENAMESLOW = [path.lower() for path in _FILENAMES]
|
||||
|
|
@ -95,8 +98,17 @@ _ABSOLUTE_LINK_SKIP = (
|
|||
# specific references tokens that should be ignored. Should be given
|
||||
# without any #anchor.
|
||||
_REF_SKIP = (
|
||||
"[5](Win)", "[6](Win)", "[7](Win)", "[10](Win)", "[11](Mac)", "[13](Win)",
|
||||
"[14](IOS)", "[15](IOS)", "[16](Andr)", "[17](Andr)", "[18](Unix)",
|
||||
"[5](Win)",
|
||||
"[6](Win)",
|
||||
"[7](Win)",
|
||||
"[10](Win)",
|
||||
"[11](Mac)",
|
||||
"[13](Win)",
|
||||
"[14](IOS)",
|
||||
"[15](IOS)",
|
||||
"[16](Andr)",
|
||||
"[17](Andr)",
|
||||
"[18](Unix)",
|
||||
"[21](Chrome)",
|
||||
# these should be checked
|
||||
"[EvTable](EvTable)",
|
||||
|
|
@ -126,20 +138,19 @@ def _sub_remap(match):
|
|||
def _sub_link(match):
|
||||
|
||||
mdict = match.groupdict()
|
||||
txt, url_orig = mdict['txt'], mdict['url']
|
||||
txt, url_orig = mdict["txt"], mdict["url"]
|
||||
url = url_orig
|
||||
# if not txt:
|
||||
# # the 'comment' is not supported by Mkdocs
|
||||
# return ""
|
||||
print(f" [{txt}]({url})")
|
||||
|
||||
|
||||
url = _CUSTOM_LINK_REMAP.get(url, url)
|
||||
|
||||
url, *anchor = url.rsplit("#", 1)
|
||||
|
||||
if url in _ABSOLUTE_LINK_SKIP:
|
||||
url += (("#" + anchor[0]) if anchor else "")
|
||||
url += ("#" + anchor[0]) if anchor else ""
|
||||
return f"[{txt}]({url})"
|
||||
|
||||
if url.startswith("evennia"):
|
||||
|
|
@ -166,11 +177,10 @@ def _sub_link(match):
|
|||
# this happens on same-file #labels in wiki
|
||||
url = _CURRENT_TITLE
|
||||
|
||||
if (url not in _FILENAMES and
|
||||
not url.startswith("http") and not url.startswith(_CODE_PREFIX)):
|
||||
if url not in _FILENAMES and not url.startswith("http") and not url.startswith(_CODE_PREFIX):
|
||||
|
||||
url_cap = url.capitalize()
|
||||
url_plur = url[:-3] + 's' + ".md"
|
||||
url_plur = url[:-3] + "s" + ".md"
|
||||
url_cap_plur = url_plur.capitalize()
|
||||
|
||||
link = f"[{txt}]({url})"
|
||||
|
|
@ -201,6 +211,7 @@ def _sub_link(match):
|
|||
|
||||
return f"[{txt}]({url})"
|
||||
|
||||
|
||||
def create_toctree(files):
|
||||
with open("../source/toc.md", "w") as fil:
|
||||
fil.write("# Toc\n")
|
||||
|
|
@ -215,13 +226,14 @@ def create_toctree(files):
|
|||
|
||||
fil.write(f"\n* [{linkname}]({ref}.md)")
|
||||
|
||||
|
||||
def convert_links(files, outdir):
|
||||
global _CURRENT_TITLE
|
||||
|
||||
for inpath in files:
|
||||
|
||||
is_index = False
|
||||
outfile = inpath.rsplit('/', 1)[-1]
|
||||
outfile = inpath.rsplit("/", 1)[-1]
|
||||
if outfile == "Home.md":
|
||||
outfile = "index.md"
|
||||
is_index = True
|
||||
|
|
@ -236,24 +248,31 @@ def convert_links(files, outdir):
|
|||
if is_index:
|
||||
text = _INDEX_PREFIX + text
|
||||
lines = text.split("\n")
|
||||
lines = (lines[:-11]
|
||||
+ [" - The [TOC](toc) lists all regular documentation pages.\n\n"]
|
||||
+ lines[-11:])
|
||||
lines = (
|
||||
lines[:-11]
|
||||
+ [" - The [TOC](toc) lists all regular documentation pages.\n\n"]
|
||||
+ lines[-11:]
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
|
||||
_CURRENT_TITLE = title.replace(" ", "-")
|
||||
text = _RE_CLEAN.sub("", text)
|
||||
text = _RE_REF_LINK.sub(_sub_remap, text)
|
||||
text = _RE_MD_LINK.sub(_sub_link, text)
|
||||
text = text.split('\n')[1:] if text.split('\n')[0].strip().startswith('[]') else text.split('\n')
|
||||
text = (
|
||||
text.split("\n")[1:]
|
||||
if text.split("\n")[0].strip().startswith("[]")
|
||||
else text.split("\n")
|
||||
)
|
||||
text = "\n".join(text)
|
||||
|
||||
if not is_index:
|
||||
text = f"# {title}\n\n{text}"
|
||||
|
||||
with open(outfile, 'w') as fil:
|
||||
with open(outfile, "w") as fil:
|
||||
fil.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("This should not be run on develop files, it would overwrite changes.")
|
||||
# create_toctree(_INFILES)
|
||||
|
|
|
|||
|
|
@ -19,30 +19,26 @@ if __name__ == "__main__":
|
|||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument("files")
|
||||
parser.add_argument("-w", '--width', dest="width", type=int, default=_DEFAULT_WIDTH)
|
||||
parser.add_argument("-w", "--width", dest="width", type=int, default=_DEFAULT_WIDTH)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
filepaths = glob.glob(args.files, recursive=True)
|
||||
width = args.width
|
||||
|
||||
wrapper = textwrap.TextWrapper(
|
||||
width=width,
|
||||
break_long_words=False,
|
||||
expand_tabs=True,
|
||||
)
|
||||
wrapper = textwrap.TextWrapper(width=width, break_long_words=False, expand_tabs=True,)
|
||||
|
||||
count = 0
|
||||
for filepath in filepaths:
|
||||
with open(filepath, 'r') as fil:
|
||||
with open(filepath, "r") as fil:
|
||||
lines = fil.readlines()
|
||||
|
||||
outlines = [
|
||||
"\n".join(wrapper.wrap(line)) if len(line) > width else line.strip('\n')
|
||||
"\n".join(wrapper.wrap(line)) if len(line) > width else line.strip("\n")
|
||||
for line in lines
|
||||
]
|
||||
txt = "\n".join(outlines)
|
||||
with open(filepath, 'w') as fil:
|
||||
with open(filepath, "w") as fil:
|
||||
fil.write(txt)
|
||||
count += 1
|
||||
|
||||
|
|
|
|||
729
docs/source/Howto/Starting/Part3/A-Sittable-Object.md
Normal file
729
docs/source/Howto/Starting/Part3/A-Sittable-Object.md
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
# Making a sittable object
|
||||
|
||||
In this lesson we will go through how to make a chair you can sit on. Sounds easy, right?
|
||||
Well it is. But in the process of making the chair we will need to consider the various ways
|
||||
to do it depending on how we want our game to work.
|
||||
|
||||
The goals of this lesson are as follows:
|
||||
|
||||
- We want a new 'sittable' object, a Chair in particular".
|
||||
- We want to be able to use a command to sit in the chair.
|
||||
- Once we are sitting in the chair it should affect us somehow. To demonstrate this we'll
|
||||
set a flag "Resting" on the Character sitting in the Chair.
|
||||
- When you sit down you should not be able to walk to another room without first standing up.
|
||||
- A character should be able to stand up and move away from the chair.
|
||||
|
||||
## Don't move us when resting
|
||||
|
||||
This requires a change to our Character typeclass. Open `mygame/characters.py`:
|
||||
|
||||
```python
|
||||
|
||||
# ...
|
||||
|
||||
class Character(DefaultCharacter):
|
||||
# ...
|
||||
|
||||
def at_before_move(self, destination):
|
||||
"""
|
||||
Called by self.move_to when trying to move somewhere. If this returns
|
||||
False, the move is immediately cancelled.
|
||||
"""
|
||||
if self.db.is_resting:
|
||||
self.msg("You can't go anywhere while resting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
```
|
||||
|
||||
When moving somewhere, [character.move_to](api.objects.objects#Object.move_to) is called. This in turn
|
||||
will call `character.at_before_move`. Here we look for an Attribute `is_resting` (which we will assign below)
|
||||
to determine if we are stuck on the chair or not.
|
||||
|
||||
## Making the Chair itself
|
||||
|
||||
First we need the Chair itself. Since we want this to do some extra coded actions when
|
||||
you sit in it, we can't just use a default Object. We need a new, custom Typeclass.
|
||||
Create a new module `mygame/typeclasses/sittables.py` with the following content:
|
||||
|
||||
```python
|
||||
|
||||
from evennia import DefaultObject
|
||||
|
||||
class Sittable(DefaultObject):
|
||||
|
||||
def at_object_creation(self):
|
||||
self.db.sitter = None
|
||||
|
||||
def do_sit(self, sitter):
|
||||
"""
|
||||
Called when trying to sit on/in this object.
|
||||
|
||||
Args:
|
||||
sitter (Object): The one trying to sit down.
|
||||
|
||||
"""
|
||||
current = self.db.sitter
|
||||
if current:
|
||||
if current == sitter:
|
||||
sitter.msg("You are already sitting on {self.key}.")
|
||||
else:
|
||||
sitter.msg(f"You can't sit on {self.key} "
|
||||
f"- {current.key} is already sitting there!")
|
||||
return
|
||||
self.db.sitting = sitter
|
||||
sitter.db.is_resting = True
|
||||
sitter.msg(f"You sit on {self.key}")
|
||||
|
||||
def do_stand(self, stander):
|
||||
"""
|
||||
Called when trying to stand from this object.
|
||||
|
||||
Args:
|
||||
stander (Object): The one trying to stand up.
|
||||
|
||||
"""
|
||||
current = self.db.sitter
|
||||
if not stander == current:
|
||||
stander.msg(f"You are not sitting on {self.key}.")
|
||||
else:
|
||||
self.db.sitting = None
|
||||
stander.db.is_resting = False
|
||||
stander.msg(f"You stand up from {self.key}")
|
||||
```
|
||||
|
||||
Here we have a small Typeclass that handles someone trying to sit on it. It has two methods that we can simply
|
||||
call from a Command later. We set the `is_resting` Attribute on the one sitting down.
|
||||
|
||||
One could imagine that one could have the future `sit` command check if someone is already sitting in the
|
||||
chair instead. This would work too, but letting the `Sittable` class handle the logic around who can sit on it makes
|
||||
logical sense.
|
||||
|
||||
We let the typeclass handle the logic, we also let it do all the return messaging. This makes it easy to churn out
|
||||
a bunch of chairs for people to sit on. But it's not perfect. The `Sittable` class is general. What if you want to
|
||||
make an armchair. You sit "in" an armchair rather than "on" it. We _could_ make a child class of `Sittable` named
|
||||
`SittableIn` that makes this change, but that feels excessive. Instead we will make it so that Sittables can
|
||||
modify this per-instance:
|
||||
|
||||
|
||||
```python
|
||||
|
||||
from evennia import DefaultObject
|
||||
|
||||
class Sittable(DefaultObject):
|
||||
|
||||
def at_object_creation(self):
|
||||
self.db.sitter = None
|
||||
# do you sit "on" or "in" this object?
|
||||
self.db.adjective = "on"
|
||||
|
||||
def do_sit(self, sitter):
|
||||
"""
|
||||
Called when trying to sit on/in this object.
|
||||
|
||||
Args:
|
||||
sitter (Object): The one trying to sit down.
|
||||
|
||||
"""
|
||||
adjective = self.db.adjective
|
||||
current = self.db.sitter
|
||||
if current:
|
||||
if current == sitter:
|
||||
sitter.msg(f"You are already sitting {adjective} {self.key}.")
|
||||
else:
|
||||
sitter.msg(
|
||||
f"You can't sit {adjective} {self.key} "
|
||||
f"- {current.key} is already sitting there!")
|
||||
return
|
||||
self.db.sitting = sitter
|
||||
sitter.db.is_resting = True
|
||||
sitter.msg(f"You sit {adjective} {self.key}")
|
||||
|
||||
def do_stand(self, stander):
|
||||
"""
|
||||
Called when trying to stand from this object.
|
||||
|
||||
Args:
|
||||
stander (Object): The one trying to stand up.
|
||||
|
||||
"""
|
||||
current = self.db.sitter
|
||||
if not stander == current:
|
||||
stander.msg(f"You are not sitting {self.db.adjective} {self.key}.")
|
||||
else:
|
||||
self.db.sitting = None
|
||||
stander.db.is_resting = False
|
||||
stander.msg(f"You stand up from {self.key}")
|
||||
```
|
||||
|
||||
We added a new Attribute `adjective` which will probably usually be `in` or `on` but could also be `at` if you
|
||||
want to be able to sit at a `desk` for example. A regular builder would use it like this:
|
||||
|
||||
> create/drop armchair : sittables.Sittable
|
||||
> set armchair/adjective = in
|
||||
|
||||
This is probably enough. But all those strings are hard-coded. What if we want some more dramatic flair when you
|
||||
sit down?
|
||||
|
||||
You sit down and a whoopie cushion makes a loud fart noise!
|
||||
|
||||
For this we needs to allow some further customization. Let's let the current strings be defaults that
|
||||
we can replace.
|
||||
|
||||
```python
|
||||
|
||||
from evennia import DefaultObject
|
||||
|
||||
class Sittable(DefaultObject):
|
||||
"""
|
||||
An object one can sit on
|
||||
|
||||
Customizable Attributes:
|
||||
adjective: How to sit (on, in, at etc)
|
||||
Return messages (set as Attributes):
|
||||
msg_already_sitting: Already sitting here
|
||||
format tokens {adjective} and {key}
|
||||
msg_other_sitting: Someone else is sitting here.
|
||||
format tokens {adjective}, {key} and {other}
|
||||
msg_sitting_down: Successfully sit down
|
||||
format tokens {adjective}, {key}
|
||||
msg_standing_fail: Fail to stand because not sitting.
|
||||
format tokens {adjective}, {key}
|
||||
msg_standing_up: Successfully stand up
|
||||
format tokens {adjective}, {key}
|
||||
|
||||
"""
|
||||
def at_object_creation(self):
|
||||
self.db.sitter = None
|
||||
# do you sit "on" or "in" this object?
|
||||
self.db.adjective = "on"
|
||||
|
||||
def do_sit(self, sitter):
|
||||
"""
|
||||
Called when trying to sit on/in this object.
|
||||
|
||||
Args:
|
||||
sitter (Object): The one trying to sit down.
|
||||
|
||||
"""
|
||||
adjective = self.db.adjective
|
||||
current = self.db.sitter
|
||||
if current:
|
||||
if current == sitter:
|
||||
if self.db.msg_already_sitting:
|
||||
sitter.msg(
|
||||
self.db.msg_already_sitting.format(
|
||||
adjective=self.db.adjective, key=self.key))
|
||||
else:
|
||||
sitter.msg(f"You are already sitting {adjective} {self.key}.")
|
||||
else:
|
||||
if self.db.msg_other_sitting:
|
||||
sitter.msg(self.db.msg_already_sitting.format(
|
||||
other=current.key, adjective=self.db.adjective, key=self.key))
|
||||
else:
|
||||
sitter.msg(f"You can't sit {adjective} {self.key} "
|
||||
f"- {current.key} is already sitting there!")
|
||||
return
|
||||
self.db.sitting = sitter
|
||||
sitter.db.is_resting = True
|
||||
if self.db.msg_sitting_down:
|
||||
sitter.msg(self.db.msg_sitting_down.format(adjective=adjective, key=self.key))
|
||||
else:
|
||||
sitter.msg(f"You sit {adjective} {self.key}")
|
||||
|
||||
def do_stand(self, stander):
|
||||
"""
|
||||
Called when trying to stand from this object.
|
||||
|
||||
Args:
|
||||
stander (Object): The one trying to stand up.
|
||||
|
||||
"""
|
||||
current = self.db.sitter
|
||||
if not stander == current:
|
||||
if self.db.msg_standing_fail:
|
||||
stander.msg(self.db.msg_standing_fail.format(
|
||||
adjective=self.db.adjective, key=self.key))
|
||||
else:
|
||||
stander.msg(f"You are not sitting {self.db.adjective} {self.key}")
|
||||
else:
|
||||
self.db.sitting = None
|
||||
stander.db.is_resting = False
|
||||
if self.db.msg_standing_up:
|
||||
stander.msg(self.db.msg_standing_up.format(
|
||||
adjective=self.db.adjective, key=self.key))
|
||||
else:
|
||||
stander.msg(f"You stand up from {self.key}")
|
||||
```
|
||||
|
||||
Here we really went all out with flexibility. We added a bunch of optional Attributes to
|
||||
hold alternative versions of all the messages. There are some things to note:
|
||||
|
||||
- We don't actually initiate those Attributes in `at_object_creation`. This is a simple
|
||||
optimization. The assumption is that _most_ chairs will probably not be this customized.
|
||||
So initiating to, say, empty strings would be a lot of useless database calls. The drawback
|
||||
is that the available Attributes become less visible when reading the code. So we add a long
|
||||
describing docstring to the end to explain all you can use.
|
||||
- We use `.format` to inject formatting-tokens in the text. The good thing about such formatting
|
||||
markers is that they are _optional_. They are there if you want them, but Python will not complain
|
||||
if you don't include some or any of them.
|
||||
|
||||
Let's actually create the chair now so we can test it later.
|
||||
|
||||
> reload # if you have new code
|
||||
> create/drop armchair : sittables.Sittable
|
||||
> set armchair/adjective = in
|
||||
> set armchair/msg_sitting_down = As you sit down {adjective} {key}, life feels easier.
|
||||
> set armchair/msg_standing_up = You stand up from {key}. Life resumes.
|
||||
|
||||
We could have skipped `{key}` and `{adjective}` if we wanted. Whenever the message is returned, the format-tokens
|
||||
within weill be replaced with `armchair` and `in` respectively. Should we rename the chair later, this will
|
||||
show in the messages automatically (since `{key}` will change).
|
||||
|
||||
We have no Command to use this chair yet. But we can try it out with `py`:
|
||||
|
||||
> py self.search("armchair").do_sit(self)
|
||||
As you sit down in armchair, life feels easier.
|
||||
> self.db.resting
|
||||
True
|
||||
> py self.search("armchair").do_stand(self)
|
||||
You stand up from armchair. Life resumes
|
||||
> self.db.resting
|
||||
False
|
||||
|
||||
If you follow along and get a result like this, all seems to be working well!
|
||||
|
||||
## Deciding on a sitting command
|
||||
|
||||
We already know what our `sit` command must do:
|
||||
|
||||
- It needs to somehow figure out what object to sit on
|
||||
- Once it finds a suitable sittable, it should call `sittable.do_sit(caller)`. The
|
||||
object does the rest from there.
|
||||
|
||||
There are a few ways to do a Command like this. We'll explore the two main ones.
|
||||
|
||||
### Command variant 1: Command on the Chair
|
||||
|
||||
We can put the `sit` command in a command-set _on_ the chair. As we've learned before, commands on
|
||||
objects are made available to others in the room. This makes the command easy. In combination with our
|
||||
new `armchair`:
|
||||
|
||||
> sit
|
||||
As you sit down in armchair, life feels easier.
|
||||
|
||||
What happens if there are also a sittable `sofa` and `barstool` in the room? Evennia will automatically
|
||||
handle this for us and allow us to specify which one we want:
|
||||
|
||||
> sit
|
||||
More than one match for 'sit' (please narrow target):
|
||||
sit-1 (armchair)
|
||||
sit-2 (sofa)
|
||||
sit-3 (barstool)
|
||||
> sit-1
|
||||
As you sit down in armchair, life feels easier.
|
||||
|
||||
This is how we'd implement this type of command. To keep things separate we'll make a new module
|
||||
`mygame/commands/sittables.py`
|
||||
|
||||
```sidebar:: Separate Commands and Typeclasses?
|
||||
|
||||
You can organize these things as you like. If you wanted you could also put the sit-command + cmdset
|
||||
together with the `Sittable` typeclass in `mygame/typeclasses/sittables.py`. That has the advantage of
|
||||
keeping everything related to sitting in one place.
|
||||
|
||||
```
|
||||
|
||||
```python
|
||||
from evennia import Command, CmdSet
|
||||
|
||||
class CmdSit(Command):
|
||||
"""
|
||||
Sit down.
|
||||
"""
|
||||
key = "sit"
|
||||
|
||||
def func(self):
|
||||
self.obj.do_sit(self.caller)
|
||||
|
||||
class CmdStand(Command):
|
||||
"""
|
||||
Stand up.
|
||||
"""
|
||||
key = "stand"
|
||||
def func(self):
|
||||
self.obj.do_stand(self.caller)
|
||||
|
||||
|
||||
class CmdSetSit(CmdSet):
|
||||
priority = 1
|
||||
def at_cmdset_creation(self):
|
||||
self.add(CmdSit)
|
||||
self.add(CmdStand)
|
||||
|
||||
```
|
||||
|
||||
As seen, the commands are nearly trivial. `self.obj` is the object to which we added the cmdset with this
|
||||
Command (so for example a chair). We just call the `do_sit/stand` on that object and the `Sittable` will
|
||||
do the rest.
|
||||
|
||||
Why that `priority = 1` on `CmdSetSit`? This makes same-named Commands from this cmdset merge with a bit higher
|
||||
priority than Commands from the Character-cmdset. Why this is a good idea will become clear shortly.
|
||||
|
||||
We also need to make a change to our `Sittable` typeclass. Open `mygame/typeclasses/sittables.py`:
|
||||
|
||||
```python
|
||||
from evennia import DefaultObject
|
||||
from commands.sittables import CmdSetSit # <- new
|
||||
|
||||
class Sittable(DefaultObject):
|
||||
"""
|
||||
(docstring)
|
||||
"""
|
||||
def at_object_creation(self):
|
||||
|
||||
self.db.sitter = None
|
||||
# do you sit "on" or "in" this object?
|
||||
self.db.adjective = "on"
|
||||
self.cmdset.add_default(CmdSetSit) # <- new
|
||||
```
|
||||
|
||||
Any _new_ Sittables will now have your `sit` Command. Your existing `armchair` will not,
|
||||
since `at_object_creation` will not re-run for already existing objects. We can update it manually:
|
||||
|
||||
> reload
|
||||
> update armchair
|
||||
|
||||
We could also update all existing sittables (all on one line):
|
||||
|
||||
> py from typeclasses.sittables import Sittable ;
|
||||
[sittable.at_object_creation() for sittable in Sittable.objects.all()]
|
||||
|
||||
We should now be able to use `sit` while in the room with the armchair.
|
||||
|
||||
One issue with placing the `sit` (or `stand`) Command "on" the chair is that it will not be available when in a
|
||||
room without a Sittable object:
|
||||
|
||||
> sit
|
||||
Command 'sit' is not available. ...
|
||||
|
||||
This is practical but not so good-looking; it makes it harder for the user to know a `sit` action is at all possible.
|
||||
Here is a trick for fixing this. Let's add another Command to the bottom of `mygame/commands/sittables.py`:
|
||||
|
||||
```python
|
||||
# ...
|
||||
|
||||
class CmdNoSitStand(Command):
|
||||
"""
|
||||
Sit down or Stand up
|
||||
"""
|
||||
key = "sit"
|
||||
aliases = ["stand"]
|
||||
|
||||
def func(self):
|
||||
if self.cmdname == "sit":
|
||||
self.msg("You have nothing to sit on.")
|
||||
else:
|
||||
self.msg("You are not sitting down.")
|
||||
|
||||
```
|
||||
|
||||
Here we have a Command that is actually two - it will answer to both `sit` and `stand` since we
|
||||
added `stand` to its `aliases`. In the command we look at `self.cmdname`, which is the string
|
||||
_actually used_ to call this command. We use this to return different messages.
|
||||
|
||||
We don't need a separate CmdSet for this, instead we will add this
|
||||
to the default Character cmdset. Open `mygame/commands/default_cmdsets`:
|
||||
|
||||
```python
|
||||
# ...
|
||||
from commands.sittables import CmdNoSitStand
|
||||
|
||||
class CharacterCmdSet(CmdSet):
|
||||
"""
|
||||
(docstring)
|
||||
"""
|
||||
def at_cmdset_creation(self):
|
||||
# ...
|
||||
self.add(CmdNoSitStand)
|
||||
|
||||
```
|
||||
|
||||
To test we'll build a new location without any comfy armchairs and go there:
|
||||
|
||||
> reload
|
||||
> tunnel n = kitchen
|
||||
north
|
||||
> sit
|
||||
You have nothing to sit on.
|
||||
> south
|
||||
sit
|
||||
As you sit down in armchair, life feels easier.
|
||||
|
||||
We now have a fully functioning `sit` action that is contained with the chair itself. When no chair is around, a
|
||||
default error message is shown.
|
||||
|
||||
How does this work? There are two cmdsets at play, both of which have a `sit` Command. As you may remember we
|
||||
set the chair's cmdset to `priority = 1`. This is where that matters. The default Character cmdset has a
|
||||
priority of 0. This means that whenever we enter a room with a Sittable thing, _its_ cmdset will take
|
||||
_precedence_ over the Character cmdset. So we are actually picking different `sit` commands
|
||||
depending on circumstance. The user will never be the wiser.
|
||||
|
||||
So this handles `sit`. What about `stand`? That will work just fine:
|
||||
|
||||
> stand
|
||||
You stand up from armchair.
|
||||
> north
|
||||
> stand
|
||||
You are not sitting down.
|
||||
|
||||
We have one remaining problem with `stand` though - what happens when you are sitting down and try to
|
||||
`stand` in a room with more than one chair:
|
||||
|
||||
> stand
|
||||
More than one match for 'stand' (please narrow target):
|
||||
stand-1 (armchair)
|
||||
stand-2 (sofa)
|
||||
stand-3 (barstool)
|
||||
|
||||
Since all the sittables have the `stand` Command on them, you'll get a multi-match error. This _works_ ... but
|
||||
you could pick _any_ of those sittables to "stand up from". That's really weird and non-intuitive. With `sit` it
|
||||
was okay to get a choice - Evennia can't know which chair we intended to sit on. But we know which chair we
|
||||
sit on so we should only get _its_ `stand` command.
|
||||
|
||||
We will fix this with a `lock` and a custom `lock function`. We want a lock on the `stand` Command that only
|
||||
makes it available when the caller is actually sitting on the chair the `stand` command is on.
|
||||
|
||||
First let's add the lock so we see what we want. Open `mygame/commands/sittables.py`:
|
||||
|
||||
```python
|
||||
# ...
|
||||
|
||||
class CmdStand(Command):
|
||||
"""
|
||||
Stand up.
|
||||
"""
|
||||
key = "stand"
|
||||
lock = "cmd:sitsonthis()" # < this is new
|
||||
|
||||
def func(self):
|
||||
self.obj.do_stand(self.caller)
|
||||
# ...
|
||||
```
|
||||
|
||||
We define a [Lock](../../../Components/Locks) on the command. The `cmd:` is in what situation Evennia will check the lock. The `cmd`
|
||||
means that it will check the lock when determining if a user has access to this command or not. What will be
|
||||
checked is the `sitsonthis` _lock function_ which doesn't exist yet.
|
||||
|
||||
Open `mygame/server/conf/lockfuncs.py` to add it!
|
||||
|
||||
```python
|
||||
"""
|
||||
(module lockstring)
|
||||
"""
|
||||
|
||||
def sitsonthis(accessing_obj, accessed_obj, *args, **kwargs):
|
||||
return accessed_obj.db.sitting == accessing_obj
|
||||
|
||||
```
|
||||
|
||||
Evennia knows that all functions in `mygame/server/conf/lockfuncs` should be possible to use in a lock definition.
|
||||
The arguments are required and Evennia will pass all relevant objects to them:
|
||||
|
||||
- `accessing_obj` is the one trying to access the lock. So us, in this case.
|
||||
- `accessed_obj` is the entity we are trying to gain a particular type of access to. So the chair.
|
||||
- `args` is a tuple holding any arguments passed to the lockfunc. Since we use `sitsondthis()` this will
|
||||
be empty (and if we add anything, it will be ignored).
|
||||
- `kwargs` is a tuple of keyword arguments passed to the lockfuncs. This will be empty as well in our example.
|
||||
|
||||
If you are superuser, it's important that you `quell` yourself before trying this out. This is because the superuser
|
||||
bypasses all locks - it can never get locked out, but it means it will also not see the effects of a lock like this.
|
||||
|
||||
> reload
|
||||
> quell
|
||||
> stand
|
||||
You stand up from armchair
|
||||
|
||||
None of the other sittables' `stand` commands passed the lock and only the one we are actually sitting on did.
|
||||
|
||||
Adding a Command to the chair object like this is powerful and a good technique to know. It does come with some
|
||||
caveats though that one needs to keep in mind.
|
||||
|
||||
We'll now try another way to add the `sit/stand` commands.
|
||||
|
||||
### Command variant 2: Command on Character
|
||||
|
||||
Before we start with this, delete the chairs you've created (`del armchair` etc) and then do the following
|
||||
changes:
|
||||
|
||||
- In `mygame/typeclasses/sittables.py`, comment out the line `self.cmdset.add_default(CmdSetSit)`.
|
||||
- In `mygame/commands/default_cmdsets.py`, comment out the line `self.add(CmdNoSitStand)`.
|
||||
|
||||
This disables the on-object command solution so we can try an alternative. Make sure to `reload` so the
|
||||
changes are known to Evennia.
|
||||
|
||||
In this variation we will put the `sit` and `stand` commands on the `Character` instead of on the chair. This
|
||||
makes some things easier, but makes the Commands themselves more complex because they will not know which
|
||||
chair to sit on. We can't just do `sit` anymore. We need this:
|
||||
|
||||
> sit <chair>
|
||||
You sit on chair.
|
||||
> stand
|
||||
You stand up from chair.
|
||||
|
||||
Open `mygame/commands.sittables.py` again. We'll add a new sit-command. We name the class `CmdSit2` since
|
||||
we already have `CmdSit` from the previous example. We put everything at the end of the module to
|
||||
keep it separate.
|
||||
|
||||
```python
|
||||
from evennia import Command, CmdSet
|
||||
from evennia import InterruptCommand # <- this is new
|
||||
|
||||
class CmdSit(Command):
|
||||
# ...
|
||||
|
||||
# ...
|
||||
|
||||
# new from here
|
||||
|
||||
class CmdSit2(Command):
|
||||
"""
|
||||
Sit down.
|
||||
|
||||
Usage:
|
||||
sit <sittable>
|
||||
|
||||
"""
|
||||
key = "sit"
|
||||
|
||||
def parse(self):
|
||||
self.args = self.args.strip()
|
||||
if not self.args:
|
||||
self.caller.msg("Sit on what?")
|
||||
raise InterruptCommand
|
||||
|
||||
def func(self):
|
||||
|
||||
# self.search handles all error messages etc.
|
||||
sittable = self.caller.search(self.args)
|
||||
if not sittable:
|
||||
return
|
||||
try:
|
||||
sittable.do_sit(self.caller)
|
||||
except AttributeError:
|
||||
self.caller.msg("You can't sit on that!")
|
||||
|
||||
```
|
||||
|
||||
With this Command-variation we need to search for the sittable. A series of methods on the Command
|
||||
are run in sequence:
|
||||
|
||||
1. `Command.at_pre_command` - this is not used by default
|
||||
2. `Command.parse` - this should parse the input
|
||||
3. `Command.func` - this should implement the actual Command functionality
|
||||
4. `Command.at_post_func` - this is not used by default
|
||||
|
||||
So if we just `return` in `.parse`, `.func` will still run, which is not what we want. To immediately
|
||||
abort this sequence we need to `raise InterruptCommand`.
|
||||
|
||||
```sidebar:: Raising exceptions
|
||||
|
||||
Raising an exception allows for immediately interrupting the current program flow. Python
|
||||
automatically raises error-exceptions when detecting problems with the code. It will be
|
||||
raised up through the sequence of called code (the 'stack') until it's either `caught` with
|
||||
a `try ... except` or reaches the outermost scope where it'll be logged or displayed.
|
||||
|
||||
```
|
||||
|
||||
`InterruptCommand` is an _exception_ that the Command-system catches with the understanding that we want
|
||||
to do a clean abort. In the `.parse` method we strip any whitespaces from the argument and
|
||||
sure there actuall _is_ an argument. We abort immediately if there isn't.
|
||||
|
||||
We we get to `.func` at all, we know that we have an argument. We search for this and abort if we there was
|
||||
a problem finding the target.
|
||||
|
||||
> We could have done `raise InterruptCommand` in `.func` as well, but `return` is a little shorter to write
|
||||
> and there is no harm done if `at_post_func` runs since it's empty.
|
||||
|
||||
Next we call the found sittable's `do_sit` method. Note that we wrap this call like this:
|
||||
|
||||
```python
|
||||
|
||||
try:
|
||||
# code
|
||||
except AttributeError:
|
||||
# stuff to do if AttributeError exception was raised
|
||||
```
|
||||
|
||||
The reason is that `caller.search` has no idea we are looking for a Sittable. The user could have tried
|
||||
`sit wall` or `sit sword`. These don't have a `do_sit` method _but we call it anyway and handle the error_.
|
||||
This is a very "Pythonic" thing to do. The concept is often called "leap before you look" or "it's easier to
|
||||
ask for forgiveness than for permission". If `sittable.do_sit` does not exist, Python will raise an `AttributeError`.
|
||||
We catch this with `try ... except AttributeError` and convert it to a proper error message.
|
||||
|
||||
While it's useful to learn about `try ... except`, there is also a way to leverage Evennia to do this without
|
||||
`try ... except`:
|
||||
|
||||
```python
|
||||
|
||||
# ...
|
||||
|
||||
def func(self):
|
||||
|
||||
# self.search handles all error messages etc.
|
||||
sittable = self.caller.search(
|
||||
self.args,
|
||||
typeclass="typeclasses.sittables.Sittable")
|
||||
if not sittable:
|
||||
return
|
||||
sittable.do_sit(self.caller)
|
||||
```
|
||||
|
||||
```sidebar:: Continuing across multiple lines
|
||||
|
||||
Note how the `.search()` method's arguments are spread out over multiple
|
||||
lines. This works for all lists, tuples and other listings and is
|
||||
a good way to avoid very long and hard-to-read lines.
|
||||
|
||||
```
|
||||
|
||||
The `caller.search` method has an keyword argument `typeclass` that can take either a python-path to a
|
||||
typeclass, the typeclass itself, or a list of either to widen the allowed options. In this case we know
|
||||
for sure that the `sittable` we get is actually a `Sittable` class and we can call `sittable.do_sit` without
|
||||
needing to worry about catching errors.
|
||||
|
||||
Let's do the `stand` command while we are at it. Again, since the Command is external to the chair we don't
|
||||
know which object we are sitting in and have to search for it.
|
||||
|
||||
```python
|
||||
|
||||
class CmdStand2(Command):
|
||||
"""
|
||||
Stand up.
|
||||
|
||||
Usage:
|
||||
stand
|
||||
|
||||
"""
|
||||
key = "stand"
|
||||
|
||||
def func(self):
|
||||
|
||||
caller = self.caller
|
||||
# find the thing we are sitting on/in, by finding the object
|
||||
# in the current location that as an Attribute "sitter" set
|
||||
# to the caller
|
||||
sittable = self.caller.search(
|
||||
caller,
|
||||
candidates=caller.location.contents,
|
||||
attribute_name="sitter",
|
||||
typeclass="typeclasses.sittables.Sittable")
|
||||
# if this is None, the error was already reported to user
|
||||
if not sittable:
|
||||
return
|
||||
|
||||
sittable.do_stand(caller)
|
||||
|
||||
```
|
||||
|
||||
This forced us to to use the full power of the ``
|
||||
|
|
@ -128,7 +128,7 @@ def url_resolver(url):
|
|||
if url.endswith(choose_issue):
|
||||
return _github_issue_choose
|
||||
elif githubstart in url:
|
||||
urlpath = url[url.index(githubstart) + len(githubstart):]
|
||||
urlpath = url[url.index(githubstart) + len(githubstart) :]
|
||||
if not (urlpath.startswith("develop/") or urlpath.startswith("master")):
|
||||
urlpath = "master/" + urlpath
|
||||
return _github_code_root + urlpath
|
||||
|
|
@ -137,14 +137,14 @@ def url_resolver(url):
|
|||
ind = url.index(apistart)
|
||||
depth = url[:ind].count("/") + 1
|
||||
path = "../".join("" for _ in range(depth))
|
||||
urlpath = path + "api/" + url[ind + len(apistart):] + ".html"
|
||||
urlpath = path + "api/" + url[ind + len(apistart) :] + ".html"
|
||||
return urlpath
|
||||
elif sourcestart in url:
|
||||
ind = url.index(sourcestart)
|
||||
depth = url[:ind].count("/") + 1
|
||||
path = "../".join("" for _ in range(depth))
|
||||
|
||||
modpath, *inmodule = url[ind + len(sourcestart):].rsplit("#", 1)
|
||||
modpath, *inmodule = url[ind + len(sourcestart) :].rsplit("#", 1)
|
||||
modpath = "/".join(modpath.split("."))
|
||||
inmodule = "#" + inmodule[0] if inmodule else ""
|
||||
modpath = modpath + ".html" + inmodule
|
||||
|
|
@ -252,13 +252,12 @@ def autodoc_post_process_docstring(app, what, name, obj, options, lines):
|
|||
|
||||
def _sub_codeblock(match):
|
||||
code = match.group(1)
|
||||
return "::\n\n {}".format(
|
||||
"\n ".join(lne for lne in code.split("\n")))
|
||||
return "::\n\n {}".format("\n ".join(lne for lne in code.split("\n")))
|
||||
|
||||
underline_map = {
|
||||
1: "-",
|
||||
2: "=",
|
||||
3: '^',
|
||||
3: "^",
|
||||
4: '"',
|
||||
}
|
||||
|
||||
|
|
@ -271,11 +270,14 @@ def autodoc_post_process_docstring(app, what, name, obj, options, lines):
|
|||
return f"{title}\n" + (underline_map[lvl] * len(title))
|
||||
|
||||
doc = "\n".join(lines)
|
||||
doc = re.sub(r"```python\s*\n+(.*?)```", _sub_codeblock, doc,
|
||||
flags=re.MULTILINE + re.DOTALL)
|
||||
doc = re.sub(
|
||||
r"```python\s*\n+(.*?)```", _sub_codeblock, doc, flags=re.MULTILINE + re.DOTALL
|
||||
)
|
||||
doc = re.sub(r"```", "", doc, flags=re.MULTILINE)
|
||||
doc = re.sub(r"`{1}", "**", doc, flags=re.MULTILINE)
|
||||
doc = re.sub(r"^(?P<hashes>#{1,2})\s*?(?P<title>.*?)$", _sub_header, doc, flags=re.MULTILINE)
|
||||
doc = re.sub(
|
||||
r"^(?P<hashes>#{1,2})\s*?(?P<title>.*?)$", _sub_header, doc, flags=re.MULTILINE
|
||||
)
|
||||
|
||||
newlines = doc.split("\n")
|
||||
# we must modify lines in-place
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue