Skip to content

Commit 3022718

Browse files
committed
Merge branch 'release/1.3.2'
2 parents 26bb610 + 30fd605 commit 3022718

File tree

10 files changed

+191
-12
lines changed

10 files changed

+191
-12
lines changed

CHANGELOG.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,68 @@
11
# Pyttman Changelog
22

3+
V 1.3.2
4+
5+
### :star2: News* **
6+
* **Removed clutter from log entries**
7+
8+
The log entries from Pyttman are now cleaner, without as much clutter for each log entry.
9+
* **New argment to `EntityField` classes available: `post_processor`**
10+
11+
The `post_processor` argument allows you to define a function which will be called on the value of the entity after it has been parsed. This is useful for scenarios where you want to clean up the value of the entity, or perform other operations on it before it is stored in `message.entities` in the `respond` method.
12+
13+
```python
14+
class SomeIntent(Intent):
15+
"""
16+
In this example, the name will be stripped of any leading or trailing whitespace.
17+
"""
18+
name = StringEntityField(default="", post_processor=lambda x: x.strip())
19+
```
20+
* **All `ability` classes are now available by exact name on the `app` instance**
21+
22+
The `app` instance in Pyttman apps now has all `Ability` classes available by their exact name, as defined in the `settings.py` file. This is useful for scenarios where you want to access the `storage` object of an ability, or other properties of the ability.
23+
24+
```python
25+
# ability.py
26+
class SomeAbility(Ability):
27+
pass
28+
29+
# settings.py
30+
ABILITIES = [
31+
"some_ability.SomeAbility"
32+
]
33+
34+
# any file in the project
35+
from pyttman import app
36+
37+
```
38+
39+
40+
### **🐛 Splatted bugs and corrected issues**
41+
42+
* **Fixed a bug where `LOG_TO_STDOUT` didn't work, and logs were not written to STDOUT:** [#86](https://github.com/dotchetter/Pyttman/issues/86)
43+
44+
# V 1.3.1
45+
46+
### :star2: News
47+
* **New setting variable: `STATIC_FILES_DIR`**
48+
49+
This new setting is set by default in all new apps, and offers a standard way to keep static files in a project.
50+
All new apps, even ones created with older versions of Pyttman, will have the `static_files` directory
51+
as part of the app catalog.
52+
53+
* **Simplified the use of the logger in Pyttman**
54+
The logger in pyttman offers a simple, ready-to-use logger for your app.
55+
It offers a decorator previously as `@pyttman.logger.logged_method` which is now simplified to `@pyttman.logger`.
56+
57+
58+
### **🐛 Splatted bugs and corrected issues**
59+
60+
* **Corrected an issue when using `pyttman runfile` to execute scripts**
61+
An issue with the relative path to the script file being exeucted has been
62+
corrected; now, an absolute path can be provided to the script file, and
63+
the script will be executed as expected.
64+
**
65+
366
# V 1.3.0.1
467
Hotfix release, addressing an issue with PyttmanCLI executing scripts,
568
where the directory of the app is included in the path for a script

devtools/upload_pypi.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import shutil
2+
import subprocess
3+
from pathlib import Path
4+
5+
from setuptools import setup
6+
from twine.commands.upload import upload
7+
8+
# Replace these with your package information
9+
package_name = "Pyttman"
10+
11+
# Get the package version dynamically
12+
# the file location is in a sibling directory, add it to the path
13+
14+
# Upload to PyPI using twine
15+
confirm = input("Deploying to PyPi.\n\n1: For pypi production, type 'production'"
16+
"\n2: For test.pypi.org, type 'test'\n\n")
17+
18+
if not Path("dist").exists():
19+
print("You need to build the package first. Run devtools/build.py.")
20+
exit(0)
21+
22+
print("Enter '__token__' for username, and the token for password")
23+
if confirm == "production":
24+
subprocess.run(["twine", "upload", "dist/*"])
25+
elif confirm == "test":
26+
subprocess.run(["twine", "upload", "--repository-url", "https://test.pypi.org/legacy/", "dist/*"])
27+

pyttman/core/entity_parsing/fields.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,34 @@ class EntityFieldBase(EntityFieldValueParser, ABC):
2323
2424
Not only to find the word(s) but also type-convert to a given
2525
datatype if a match is True.
26-
2726
"""
2827
default = None
28+
"""
29+
Specify a default return value, if no match is found.
30+
"""
2931
type_cls = None
32+
"""
33+
The type_cls is the class which the value is converted to.
34+
For example: int, str, float, etc.
35+
"""
3036
identifier_cls = None
37+
"""
38+
Identifier class is a class which specializes in finding
39+
patterns in text. You can provide a custom Identifier class
40+
to further increase the granularity of the value you're looking for.
41+
"""
42+
post_processor = None
43+
"""
44+
Pre-processor is a callable which is called before the
45+
value is converted to the type_cls of the EntityField.
46+
You can specify any callable here, and it will be called
47+
with the value as its only argument.
48+
"""
3149

3250
def __init__(self,
3351
identifier: Type[Identifier] | None = None,
3452
default: Any = None,
53+
post_processor: callable = None,
3554
**kwargs):
3655
"""
3756
:param as_list: If set to True combined with providing 'valid_strings',
@@ -48,6 +67,7 @@ def __init__(self,
4867
You can read more about Identifier classes in the Pyttman
4968
documentation.
5069
"""
70+
self.post_processor = post_processor
5171
if self.type_cls is None or inspect.isclass(self.type_cls) is False:
5272
raise InvalidPyttmanObjectException("All EntityField classes "
5373
"must define a 'type_cls', "
@@ -91,6 +111,15 @@ def convert_value(self, value: Any) -> Any:
91111
except Exception as e:
92112
raise TypeConversionFailed(from_type=type(value),
93113
to_type=self.type_cls) from e
114+
115+
try:
116+
if self.post_processor is not None and callable(self.post_processor):
117+
converted_value = self.post_processor(converted_value)
118+
except Exception as e:
119+
value_err = ValueError("The post_processor callable '"
120+
f"'{self.post_processor}' failed: {e}")
121+
raise TypeConversionFailed(from_type=type(value),
122+
to_type=self.type_cls) from value_err
94123
return converted_value
95124

96125
def before_conversion(self, value: Any) -> str:

pyttman/core/entity_parsing/parsers.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
import string
13
import typing
24
from itertools import zip_longest
35
from typing import Type, Dict, Union
@@ -19,6 +21,8 @@ class EntityFieldValueParser(PrettyReprMixin):
1921
EntityParser Api component: 'EntityField'.
2022
"""
2123
__repr_fields__ = ("identifier", "exclude", "prefixes", "suffixes")
24+
ignore_chars = True
25+
chars_to_ignore = ".,;:!?-"
2226

2327
def __init__(self,
2428
prefixes: tuple | typing.Callable = None,
@@ -122,14 +126,21 @@ def parse_message(self,
122126
output = []
123127
word_index = 0
124128

125-
casefolded_msg = message.lowered_content()
129+
message_lowered = message.lowered_content()
130+
if self.ignore_chars:
131+
# Strip away special chars
132+
message_lowered = [
133+
re.sub(rf"[{self.chars_to_ignore}]", "", word)
134+
for word in message_lowered
135+
]
136+
126137
common_occurrences = tuple(
127-
OrderedSet(casefolded_msg).intersection(self.valid_strings))
138+
OrderedSet(message_lowered).intersection(self.valid_strings))
128139

129140
for i, word in enumerate(common_occurrences):
130141
if i > self.span and not self.as_list:
131142
break
132-
word_index = casefolded_msg.index(word)
143+
word_index = message_lowered.index(word)
133144
output.append(message.content[word_index])
134145

135146
if len(output) > 1:
@@ -142,6 +153,13 @@ def parse_message(self,
142153
self._validate_prefixes_suffixes(message)
143154

144155
if self.value:
156+
if self.ignore_chars:
157+
if isinstance(self.value.value, list):
158+
for i, elem in enumerate(self.value.value):
159+
elem = re.sub(rf"[{self.chars_to_ignore}]", "", elem)
160+
self.value.value[i] = elem
161+
elif isinstance(self.value.value, str):
162+
self.value.value = re.sub(rf"[{self.chars_to_ignore}]", "", self.value.value)
145163
entity = self.value
146164
if entity.value == self.default:
147165
return

pyttman/core/internals.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ class PyttmanApp(PrettyReprMixin):
139139
client: Any
140140
name: str | None = field(default=None)
141141
settings: Settings | None = field(default=None)
142-
abilities: set = field(default_factory=set)
143142
hooks: LifecycleHookRepository = field(
144143
default_factory=lambda: LifecycleHookRepository())
144+
_abilities: set = field(default_factory=set)
145145

146146
def start(self):
147147
"""
@@ -152,3 +152,13 @@ def start(self):
152152
self.client.run_client()
153153
except Exception:
154154
warnings.warn(traceback.format_exc())
155+
156+
@property
157+
def abilities(self):
158+
return self._abilities
159+
160+
@abilities.setter
161+
def abilities(self, abilities):
162+
for ability in abilities:
163+
setattr(self, ability.__class__.__name__, ability)
164+
self._abilities.add(ability)

pyttman/tools/pyttmancli/ability.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ def run_application(self) -> None:
4040
# #(used for attribute access in completion)
4141
app: PyttmanApp = None
4242
if (app := self.storage.get("app")) is not None:
43+
if not app.client.message_router.abilities:
44+
print("There are no abilities loaded, the app will not "
45+
"respond to any messages. Create abilities by "
46+
"running 'pyttman new ability' in the terminal.\n"
47+
"Next up, add them to your app's settings.py file "
48+
"under the ABILITIES key.")
49+
exit(0)
4350
print(f"- Ability classes loaded: "
4451
f"{app.client.message_router.abilities}")
4552
app.start()

pyttman/tools/pyttmancli/intents.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import code
22
import os
3+
import re
34
import traceback
45
from pathlib import Path
56

@@ -28,7 +29,7 @@ class ShellMode(Intent, PyttmanCliComplainerMixin):
2829
example = "pyttman shell <app name>"
2930
help_string = "Opens a Python interactive shell with access to modules, " \
3031
"app settings and the Pyttman 'app' object."
31-
app_name = TextEntityField()
32+
app_name = TextEntityField(default="")
3233

3334
def respond(self, message: Message) -> Reply | ReplyStream:
3435
app_name = message.entities["app_name"]
@@ -48,7 +49,7 @@ class CreateNewApp(Intent, PyttmanCliComplainerMixin):
4849
Create a new Pyttman app. The directory is terraformed
4950
and prepared with a template project.
5051
"""
51-
app_name = TextEntityField()
52+
app_name = TextEntityField(default="")
5253
lead = ("new",)
5354
trail = ("app",)
5455
ordered = True
@@ -104,7 +105,7 @@ class RunAppInDevMode(Intent, PyttmanCliComplainerMixin):
104105
Run a Pyttman app in dev mode. This sets "DEV_MODE"
105106
to True and opens a chat interface in the terminal.
106107
"""
107-
app_name = TextEntityField()
108+
app_name = TextEntityField(default="")
108109
fail_gracefully = True
109110
lead = ("dev",)
110111
example = "pyttman dev <app name>"
@@ -147,7 +148,7 @@ class RunAppInClientMode(Intent, PyttmanCliComplainerMixin):
147148
"settings.py under 'CLIENT'.\n" \
148149
f"Example: {example}"
149150

150-
app_name = TextEntityField()
151+
app_name = TextEntityField(default="")
151152

152153
def respond(self, message: Message) -> Reply | ReplyStream:
153154
app_name = message.entities["app_name"]
@@ -177,7 +178,7 @@ class RunFile(Intent, PyttmanCliComplainerMixin):
177178
help_string = "Run a singe file within a Pyttman app context. " \
178179
f"Example: {example}"
179180

180-
app_name = TextEntityField()
181+
app_name = TextEntityField(default="")
181182
script_file_name = TextEntityField(prefixes=(app_name,))
182183

183184
def respond(self, message: Message) -> Reply | ReplyStream:

pyttman/tools/pyttmancli/terraforming.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,15 @@ def bootstrap_app(module: str = None, devmode: bool = False,
141141
logging_format = logging.BASIC_FORMAT
142142

143143
logging_handle.setFormatter(logging.Formatter(logging_format))
144-
logger = logging.getLogger(f"Pyttman logger on app {app_name}")
144+
logger = logging.getLogger(app_name)
145145
logger.setLevel(logging.DEBUG)
146146
logger.addHandler(logging_handle)
147147

148+
if settings.LOG_TO_STDOUT:
149+
stdout_handle = logging.StreamHandler(sys.stdout)
150+
stdout_handle.setFormatter(logging.Formatter(logging_format))
151+
logger.addHandler(stdout_handle)
152+
148153
# Set the configured instance of logger to the pyttman.PyttmanLogger object
149154
pyttman.logger.LOG_INSTANCE = logger
150155

pyttman/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11

2-
__version__ = "1.3.1"
2+
__version__ = "1.3.2"

tests/core/entity_parsing/test_entity_fields/test_entity_parsing.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,22 @@ class IntentClass(ImplementedTestIntent):
409409
cheese_type = StringEntityField(valid_strings=("blue", "yellow"),
410410
default="blue",
411411
as_list=True)
412+
413+
414+
class PyttmanInternalTestEntityPreProcessor(
415+
PyttmanInternalTestBaseCase
416+
):
417+
mock_message = Message("I would like some tea, please.")
418+
process_message = True
419+
expected_entities = {
420+
"beverage": "Tea",
421+
}
422+
423+
class IntentClass(ImplementedTestIntent):
424+
"""
425+
Tests that the 'post_processor' callable is executed and
426+
can process the return value before it's spat out.
427+
"""
428+
beverage = StringEntityField(valid_strings=("tea", "coffee"),
429+
post_processor=lambda x: x.capitalize())
430+

0 commit comments

Comments
 (0)