diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 50329bf0e..5515cfd92 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -30,8 +30,6 @@ things you want from here into your game folder and change them there. multiple descriptions for time and season as well as details. * GenderSub (Griatch 2015) - Simple example (only) of storing gender on a character and access it in an emote with a custom marker. -* In-game Python (Vincent Le Geoff 2017) - Allow trusted builders to script - objects and events using Python from in-game. * Mail (grungies1138 2016) - An in-game mail system for communication. * Menu login (Griatch 2011) - A login system using menus asking for name/password rather than giving them as one command @@ -40,6 +38,8 @@ things you want from here into your game folder and change them there. * Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu. * Multidescer (Griatch 2016) - Advanced descriptions combined from many separate description components, inspired by MUSH. +* Random_string_generator (Vincent Le Goff 2017) - Simple pseudo-random + gereator of strings with rules, avoiding repetitions. * RPLanguage (Griatch 2015) - Dynamic obfuscation of emotes when speaking unfamiliar languages. Also obfuscates whispers. * RPSystem (Griatch 2015) - Full director-style emoting system @@ -59,6 +59,8 @@ things you want from here into your game folder and change them there. * EGI_Client (gtaylor 2016) - Client for reporting game status to the Evennia game index (games.evennia.com) +* In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script + objects and events using Python from in-game. * Tutorial examples (Griatch 2011, 2015) - A folder of basic example objects, commands and scripts. * Tutorial world (Griatch 2011, 2015) - A folder containing the diff --git a/evennia/contrib/random_string_generator.py b/evennia/contrib/random_string_generator.py new file mode 100644 index 000000000..cdf38d683 --- /dev/null +++ b/evennia/contrib/random_string_generator.py @@ -0,0 +1,345 @@ +""" +Pseudo-random generator and registry + +Evennia contribution - Vincent Le Goff 2017 + +This contrib can be used to generate pseudo-random strings of information +with specific criteria. You could, for instance, use it to generate +phone numbers, license plate numbers, validation codes, non-sensivite +passwords and so on. The strings generated by the generator will be +stored and won't be available again in order to avoid repetition. +Here's a very simple example: + +```python +from evennia.contrib.random_string_generator import RandomStringGenerator +# Create a generator for phone numbers +phone_generator = RandomStringGenerator("phone number", r"555-[0-9]{3}-[0-9]{4}") +# Generate a phone number (555-XXX-XXXX with X as numbers) +number = phone_generator.get() +# `number` will contain something like: "555-981-2207" +# If you call `phone_generator.get`, it won't give the same anymore.phone_generator.all() +# Will return a list of all currently-used phone numbers +phone_generator.remove("555-981-2207") +# The number can be generated again +``` + +To use it, you will need to: + +1. Import the `RandomStringGenerator` class from the contrib. +2. Create an instance of this class taking two arguments: + - The name of the gemerator (like "phone number", "license plate"...). + - The regular expression representing the expected results. +3. Use the generator's `all`, `get` and `remove` methods as shown above. + +To understand how to read and create regular expressions, you can refer to +[the documentation on the re module](https://docs.python.org/2/library/re.html). +Some examples of regular expressions you could use: + +- `r"555-\d{3}-\d{4}"`: 555, a dash, 3 digits, another dash, 4 digits. +- `r"[0-9]{3}[A-Z][0-9]{3}"`: 3 digits, a capital letter, 3 digits. +- `r"[A-Za-z0-9]{8,15}"`: between 8 and 15 letters and digits. +- ... + +Behind the scenes, a script is created to store the generated information +for a single generator. The `RandomStringGenerator` object will also +read the regular expression you give to it to see what information is +required (letters, digits, a more restricted class, simple characters...)... +More complex regular expressions (with branches for instance) might not be +available. + +""" + +from random import choice, randint, seed +import re +import string +import time + +from evennia import DefaultScript, ScriptDB +from evennia.utils.create import create_script + +class RejectedRegex(RuntimeError): + + """The provided regular expression has been rejected. + + More details regarding why this error occurred will be provided in + the message. The usual reason is the provided regular expression is + not specific enough and could lead to inconsistent generating. + + """ + + pass + + +class ExhaustedGenerator(RuntimeError): + + """The generator hasn't any available strings to generate anymore.""" + + pass + + +class RandomStringGeneratorScript(DefaultScript): + + """ + The global script to hold all generators. + + It will be automatically created the first time `generate` is called + on a RandomStringGenerator object. + + """ + + def at_script_creation(self): + """Hook called when the script is created.""" + self.key = "generator_script" + self.desc = "Global generator script" + self.persistent = True + + # Permanent data to be stored + self.db.generated = {} + + +class RandomStringGenerator(object): + + """ + A generator class to generate pseudo-random strings with a rule. + + The "rule" defining what the generator should provide in terms of + string is given as a regular expression when creating instances of + this class. You can use the `all` method to get all generated strings, + the `get` method to generate a new string, the `remove` method + to remove a generated string, or the `clear` method to remove all + generated strings. + + Bear in mind, however, that while the generated strings will be + stored to avoid repetition, the generator will not concern itself + with how the string is stored on the object you use. You probably + want to create a tag to mark this object. This is outside of the scope + of this class. + + """ + + # We keep the script as a class variable to optimize querying + # with multiple instandces + script = None + + def __init__(self, name, regex): + """ + Create a new generator. + + Args: + name (str): name of the generator to create. + regex (str): regular expression describing the generator. + + Notes: + `name` should be an explicit name. If you use more than one + generator in your game, be sure to give them different names. + This name will be used to store the generated information + in the global script, and in case of errors. + + The regular expression should describe the generator, what + it should generate: a phone number, a license plate, a password + or something else. Regular expressions allow you to use + pretty advanced criteria, but be aware that some regular + expressions will be rejected if not specific enough. + + Raises: + RejectedRegex: the provided regular expression couldn't be + accepted as a valid generator description. + + """ + self.name = name + self.elements = [] + self.total = 1 + + # Analyze the regex if any + if regex: + self._find_elements(regex) + + def __repr__(self): + return "".format(self.name) + + def _get_script(self): + """Get or create the script.""" + if type(self).script: + return type(self).script + + try: + script = ScriptDB.objects.get(db_key="generator_script") + except ScriptDB.DoesNotExist: + script = create_script("contrib.random_string_generator.RandomStringGeneratorScript") + + type(self).script = script + return script + + def _find_elements(self, regex): + """ + Find the elements described in the regular expression. This will + analyze the provided regular expression and try to find elements. + + Args: + regex (str): the regular expression. + + """ + self.total = 1 + self.elements = [] + tree = re.sre_parse.parse(regex).data + # `tree` contains a list of elements in the regular expression + for element in tree: + # `eleemnt` is also a list, the first element is a string + name = element[0] + desc = {"min": 1, "max": 1} + + # If `.`, break here + if name == "any": + raise RejectedRegex("the . definition is too broad, specify what you need more precisely") + elif name == "at": + # Either the beginning or end, we ignore it + continue + elif name == "min_repeat": + raise RejectedRegex("you have to provide a maximum number of this character class") + elif name == "max_repeat": + desc["min"] = element[1][0] + desc["max"] = element[1][1] + desc["chars"] = self._find_literal(element[1][2][0]) + elif name == "in": + desc["chars"] = self._find_literal(element) + elif name == "literal": + desc["chars"] = self._find_literal(element) + else: + raise RejectedRegex("unhandled regex syntax:: {}".format(repr(name))) + + self.elements.append(desc) + self.total *= len(desc["chars"]) ** desc["max"] + + def _find_literal(self, element): + """Find the literal corresponding to a piece of regular expression.""" + chars = [] + if element[0] == "literal": + chars.append(chr(element[1])) + elif element[0] == "in": + negate = False + if element[1][0][0] == "negate": + negate = True + chars = list(string.ascii_letters + string.digits) + + for part in element[1]: + if part[0] == "negate": + continue + + sublist = self._find_literal(part) + for char in sublist: + if negate: + if char in chars: + chars.remove(char) + else: + chars.append(char) + elif element[0] == "range": + chars = [chr(i) for i in range(element[1][0], element[1][1] + 1)] + elif element[0] == "category": + category = element[1] + if category == "category_digit": + chars = list(string.digits) + elif category == "category_word": + chars = list(string.letters) + else: + raise RejectedRegex("unknown category: {}".format(category)) + else: + raise RejectedRegex("cannot find the literal: {}".format(element[0])) + + return chars + + def all(self): + """ + Return all generated strings for this generator. + + Returns: + strings (list of strr): the list of strings that are already + used. The strings that were generated first come first in the list. + + """ + script = self._get_script() + generated = list(script.db.generated.get(self.name, [])) + return generated + + def get(self, store=True, unique=True): + """ + Generate a pseudo-random string according to the regular expression. + + Args: + store (bool, optional): store the generated string in the script. + unique (bool, optional): keep on trying if the string is already used. + + Returns: + The newly-generated string. + + Raises: + ExhaustedGenerator: if there's no available string in this generator. + + Note: + Unless asked explicitly, the returned string can't repeat itself. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name) + if generated is None: + script.db.generated[self.name] = [] + generated = script.db.generated[self.name] + + if len(generated) >= self.total: + raise ExhaustedGenerator + + # Generate a pseudo-random string that might be used already + result = "" + for element in self.elements: + number = randint(element["min"], element["max"]) + chars = element["chars"] + for index in range(number): + char = choice(chars) + result += char + + # If the string has already been generated, try again + if result in generated and unique: + # Change the random seed, incrementing it slowly + epoch = time.time() + while result in generated: + epoch += 1 + seed(epoch) + result = self.get(store=False, unique=False) + + if store: + generated.append(result) + + return result + + def remove(self, element): + """ + Remove a generated string from the list of stored strings. + + Args: + element (str): the string to remove from the list of generated strings. + + Raises: + ValueError: the specified value hasn't been generated and is not present. + + Note: + The specified string has to be present in the script (so + has to have been generated). It will remove this entry + from the script, so this string could be generated again by + calling the `get` method. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name, []) + if element not in generated: + raise ValueError("the string {} isn't stored as generated by the generator {}".format( + element, self.name)) + + generated.remove(element) + + def clear(self): + """ + Clear the generator of all generated strings. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name, []) + generated[:] = [] diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index bbf6d29d4..7bc07ff22 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -985,3 +985,24 @@ class TestUnixCommand(CommandTest): lines = ret.splitlines() self.assertTrue(any(l.startswith("usage:") for l in lines)) self.assertTrue(any(l.startswith("dummy: error:") for l in lines)) + + +from evennia.contrib import random_string_generator + +SIMPLE_GENERATOR = random_string_generator.RandomStringGenerator("simple", "[01]{2}") + +class TestRandomStringGenerator(EvenniaTest): + + def test_generate(self): + """Generate and fail when exhausted.""" + generated = [] + for i in range(4): + generated.append(SIMPLE_GENERATOR.get()) + + generated.sort() + self.assertEqual(generated, ["00", "01", "10", "11"]) + + # At this point, we have generated 4 strings. + # We can't generate one more + with self.assertRaises(random_string_generator.ExhaustedGenerator): + SIMPLE_GENERATOR.get()