Skip to content

Commit 4367f10

Browse files
committed
Merge branch 'release/1.3.0' into main
2 parents d340ad7 + 8f19f40 commit 4367f10

File tree

18 files changed

+378
-66
lines changed

18 files changed

+378
-66
lines changed

CHANGELOG.md

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

3+
# V 1.3.0
4+
In this release, we're introducing some cool new features along with
5+
some bug fixes.
6+
The star of the show this time is the test suite class for improved
7+
testability of Pyttman applications.
8+
9+
### :star2: News
10+
* **Test suite class for developing tests**
11+
12+
A new Test suite class has been developed for use in Pyttman apps, for
13+
making it easier to write unit test for Pyttman applications. The test automatically
14+
loads the app context for the app in which the tests are created in.
15+
16+
```python
17+
from pyttman.testing import PyttmanTestCase
18+
19+
class TestUserSynchronizer(PyttmanTestCase):
20+
21+
def setUp(self) -> None:
22+
# This setup hook works as with any TestCase class.
23+
# You have access to your pyttman app just as if the app was running.
24+
app = self.app
25+
26+
def test_some_func(self):
27+
self.fail()
28+
```
29+
30+
* **New EntityField class for Decimal type**
31+
32+
For finance and other domains, the floating point precision
33+
`float` isn't high enough. An industry standard is the `Decimal` type
34+
and it's now supported in the `EntityField` ecosystem in Pyttman.
35+
36+
```python
37+
38+
class EnterIncome(Intent):
39+
income = DecimalEntityField()
40+
```
41+
42+
* **New mode in Pyttman CLI: `runfile` - Run single scripts with Pyttman**
43+
44+
Some times a single script does the job, or the app you've developed
45+
isn't designed to be conversational. For these situations, the new
46+
PyttmanCLI command `runfile` is perfect. It will invoke a Python file
47+
with the app loaded, providing you all the benefits of using Pyttman
48+
without having to develop your app with a conversational approach.
49+
50+
```python
51+
# someapp/script.py
52+
53+
from pyttman import app
54+
55+
if __name__ == "__main__":
56+
print(f"Hello! I'm using {app.name} as context.")
57+
58+
```
59+
Running the file above with PyttmanCLI:
60+
```python
61+
$ pyttman runfile pyttman_app_name script.py
62+
63+
> Hello! I'm using pyttman_app_name as context.
64+
```
65+
66+
* **New mode in Pyttman CLI: `-V` - See the version you're at**
67+
68+
This mode is quite simple. It returns the version of the installed
69+
the version of the installation of Pyttman.
70+
71+
```python
72+
pyttman -V
73+
1.3.0
74+
```
75+
76+
### **🐛 Splatted bugs and corrected issues**
77+
* **Fixes problem where words with special characters where ignored in entities**
78+
79+
80+
381

482
# V 1.2.1.1
583
This is a hotfix release, addressing an issue with the integration with discord.py 2.0 API

pyttman/clients/base.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ def __repr__(self):
2424
return f"{self.name}({vars(self)})"
2525

2626
@abc.abstractmethod
27-
def run_client(self):
27+
def run_client(self, *args, **kwargs) -> None:
2828
"""
2929
Starts the main method for the client, opening
3030
a session to the front end with which it is
3131
associated with.
32-
:return: None
3332
"""
3433
pass
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import sys
2+
3+
import pyttman
4+
from pyttman.clients.base import BaseClient
5+
from pyttman.core.containers import Message, Reply, \
6+
ReplyStream
7+
8+
9+
class ScriptClient(BaseClient):
10+
"""
11+
The ScriptClient runs a single file within a Pyttman app
12+
context.
13+
This client is suitable for applications which don't have
14+
a program loop, but are designed to be executed as a script
15+
to run once.
16+
"""
17+
def run_client(self, *args, **kwargs):
18+
print(f"\nPyttman v.{pyttman.__version__} - "
19+
f"Script client", end="\n")
20+
try:
21+
print(f"{pyttman.settings.APP_NAME} is online! Start chatting"
22+
f" with your app below."
23+
"\n(?) Use Ctrl-Z or Ctrl-C plus Return to exit",
24+
end="\n\n")
25+
while True:
26+
message = Message(input("[YOU]: "), client=self)
27+
reply: Reply | ReplyStream = self.\
28+
message_router.get_reply(message)
29+
30+
if isinstance(reply, ReplyStream):
31+
while reply.qsize():
32+
print(f"[{pyttman.settings.APP_NAME.upper()}]: ",
33+
reply.get().as_str())
34+
elif isinstance(reply, Reply):
35+
print(f"{pyttman.settings.APP_NAME}:", reply.as_str())
36+
print()
37+
except (KeyboardInterrupt, EOFError):
38+
sys.exit(0)

pyttman/core/entity_parsing/fields.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
from decimal import Decimal
23
from abc import ABC
34
from typing import Any, Sequence, Type
45

@@ -169,6 +170,16 @@ def parse_message(self,
169170
self.value = Entity(value=True)
170171

171172

173+
class DecimalEntityField(FloatEntityField):
174+
"""
175+
Converts floating point numbers to Decimal. Useful
176+
when dealing with currencies or in other areas where
177+
decimal precision is not trivial.
178+
"""
179+
type_cls = Decimal
180+
default = Decimal(0)
181+
182+
172183
if __name__ != "__main__":
173184
StringEntityField = TextEntityField
174185
StrEntityField = TextEntityField

pyttman/core/entity_parsing/parsers.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,11 @@ def _identify_value(self, message: MessageMixin,
250250
suffix_entity = None
251251
prefixes = []
252252
suffixes = []
253-
prefix_indexes = []
254-
suffix_indexes = []
253+
prefix_indices = []
254+
suffix_incides = []
255255
last_prefix_index, earliest_suffix_index = 0, 0
256256
parsed_entity: Union[Entity, None] = None
257-
sanitized_msg_content = message.sanitized_content(preserve_case=False)
257+
lowered_msg_content = message.lowered_content()
258258

259259
# First - traverse over the pre- and suffixes and
260260
# collect them in separate lists
@@ -276,19 +276,19 @@ def _identify_value(self, message: MessageMixin,
276276
try:
277277
# Save the index of this prefix in the message
278278
if prefix is not None:
279-
prefix_indexes.append(sanitized_msg_content.index(prefix))
279+
prefix_indices.append(lowered_msg_content.index(prefix))
280280
if suffix is not None:
281-
suffix_indexes.append(sanitized_msg_content.index(suffix))
281+
suffix_incides.append(lowered_msg_content.index(suffix))
282282
except ValueError:
283283
# The prefix was not in the message
284284
continue
285285

286286
# Let's extract the last occurring prefix,
287287
# and the earliest suffix of the ones present
288288
if len(prefixes):
289-
if not len(prefix_indexes):
289+
if not len(prefix_indices):
290290
return None
291-
last_prefix_index = max(prefix_indexes)
291+
last_prefix_index = max(prefix_indices)
292292
try:
293293
index_for_value = last_prefix_index + 1
294294
value_at_index = message.content[index_for_value]
@@ -299,10 +299,10 @@ def _identify_value(self, message: MessageMixin,
299299
start_index = last_prefix_index + 1
300300

301301
if len(suffixes):
302-
if not len(suffix_indexes):
302+
if not len(suffix_incides):
303303
return None
304304

305-
earliest_suffix_index = min(suffix_indexes)
305+
earliest_suffix_index = min(suffix_incides)
306306
try:
307307
index_for_value = earliest_suffix_index - 1
308308
value_at_index = message.content[index_for_value]

pyttman/core/middleware/routing.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
import random
33
import warnings
44
from copy import copy
5-
from typing import List, Any
5+
from typing import List, Any, Iterable
66

77
import pyttman
8+
from pyttman.core.exceptions import PyttmanProjectInvalidException
89
from pyttman.core.entity_parsing.parsers import parse_entities
910
from pyttman.core.ability import Ability
1011
from pyttman.core.intent import Intent
11-
from pyttman.core.containers import MessageMixin, \
12-
Reply, ReplyStream, Message
12+
from pyttman.core.containers import MessageMixin, Reply, ReplyStream, Message
1313
from pyttman.core.internals import _generate_error_entry
1414

1515

@@ -112,6 +112,19 @@ def process(message: Message,
112112
if keep_alive_on_exc is False:
113113
raise e
114114

115+
original_reply = copy(reply)
116+
try:
117+
if not any((isinstance(reply, Reply), isinstance(reply, ReplyStream))):
118+
if isinstance(reply, Iterable) and not isinstance(reply, str):
119+
reply = Reply(reply)
120+
else:
121+
reply = ReplyStream(reply)
122+
except Exception:
123+
raise PyttmanProjectInvalidException(
124+
f"Could not return reply from intent '{intent}' due to "
125+
f"a misconfiguration of the response type. '{intent.respond}' "
126+
f"returned: '{original_reply}'")
127+
115128
constraints = {
116129
bool(reply is not None),
117130
bool(isinstance(reply, Reply) or isinstance(reply, ReplyStream))

pyttman/testing/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
3+
from pyttman.testing.pyttman_test_case import PyttmanTestCase
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import traceback
2+
from pathlib import Path
3+
from unittest import TestCase
4+
5+
from pyttman.core.decorators import LifeCycleHookType
6+
from pyttman.core.exceptions import PyttmanProjectInvalidException
7+
from pyttman.tools.pyttmancli import bootstrap_app
8+
9+
10+
class PyttmanTestCase(TestCase):
11+
devmode = False
12+
application_abspath = None
13+
app_name = None
14+
override_devmode_warning = False
15+
16+
def __init__(self, *args, **kwargs):
17+
if self.application_abspath is None:
18+
self.application_abspath = self.find_app_path()
19+
if self.app_name is None:
20+
self.app_name = self.application_abspath.name
21+
22+
try:
23+
self.app = bootstrap_app(
24+
devmode=self.devmode,
25+
module=self.app_name,
26+
application_abspath=self.application_abspath.parent)
27+
except Exception as e:
28+
print(traceback.format_exc())
29+
raise PyttmanProjectInvalidException(
30+
"\n\nPyttman could not boostrap the application for "
31+
"testing. Full traceback above"
32+
) from e
33+
34+
super().__init__(*args, **kwargs)
35+
if not any((self.devmode, self.app.settings.DEV_MODE, self.override_devmode_warning)):
36+
raise Warning("Warning! This test class does not declare 'devmode' as a "
37+
"True, and 'DEV_MODE' in settings.py for "
38+
"this app is False. This could potentially lead to "
39+
"a test executing to production environment. "
40+
"To override this warning, set 'override_devmode_warning = True' "
41+
"as a class variable in this unit test.")
42+
self.app.settings.DEV_MODE = self.devmode
43+
self.app.hooks.trigger(LifeCycleHookType.before_start)
44+
45+
@staticmethod
46+
def find_app_path(start_dir: Path = None) -> Path:
47+
"""
48+
Look for signature catalog which looks like a
49+
Pyttman app root directory.
50+
51+
Walk up the file structure until the directory is found.
52+
:raise ModuleNotFoundError: The file hierarchy was exhausted
53+
and no pyttman app directory wasn't found
54+
"""
55+
sought_file = "settings.py"
56+
start_dir = start_dir or Path.cwd()
57+
next_dir = start_dir
58+
while not next_dir.joinpath(sought_file).exists():
59+
if next_dir.parent == start_dir:
60+
raise ModuleNotFoundError(
61+
"Could not find a Pyttman app in the "
62+
"same directory as the test, or in any "
63+
"parent folder above it. Is the test "
64+
"module placed in a Pyttman application?")
65+
next_dir = next_dir.parent
66+
return next_dir

pyttman/tools/pyttmancli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def run(argv=None, dev_args: typing.List = None):
5252
pyttman_cli = PyttmanCli()
5353
dot = "\u2022"
5454
default_response = [pyttman_cli.description]
55-
default_response.extend([f"\n{dot} {i.example}"
55+
default_response.extend([f"\n{dot} {i.example}{i.__doc__}"
5656
for i in pyttman_cli.intents])
5757
default_response = str(" ").join(default_response) + "\n"
5858

pyttman/tools/pyttmancli/ability.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
import pyttman
2+
import pyttman.tools.pyttmancli.intents as intents
23
from pyttman.core.internals import PyttmanApp
34
from pyttman.core.ability import Ability
4-
from pyttman.tools.pyttmancli.intents import (
5-
CreateNewApp,
6-
RunAppInDevMode,
7-
RunAppInClientMode,
8-
CreateNewAbilityIntent,
9-
ShellMode
10-
)
115

126

137
class PyttmanCli(Ability):
148
"""
159
Encapsulates the Pyttman CLI tool 'pyttman'
1610
used in the terminal by framework users.
1711
"""
18-
intents = (CreateNewApp,
19-
RunAppInDevMode,
20-
RunAppInClientMode,
21-
CreateNewAbilityIntent,
22-
ShellMode)
12+
intents = (
13+
intents.CreateNewApp,
14+
intents.RunAppInDevMode,
15+
intents.RunAppInClientMode,
16+
intents.CreateNewAbilityIntent,
17+
intents.ShellMode,
18+
intents.VersionInfo,
19+
intents.RunFile,
20+
)
2321

2422
description = f"\nPyttman v{pyttman.__version__}\n\n" \
2523
"For help about a commend, type pyttman help [command]" \

0 commit comments

Comments
 (0)