andreped commited on
Commit
99252aa
·
unverified ·
2 Parent(s): 43f458c cf83c7a

Merge pull request #1 from andreped:demo

Browse files

Implemented plotly backend; added docs and unit tests

.gitignore ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # PyBuilder
72
+ .pybuilder/
73
+ target/
74
+
75
+ # Jupyter Notebook
76
+ .ipynb_checkpoints
77
+
78
+ # IPython
79
+ profile_default/
80
+ ipython_config.py
81
+
82
+ # pyenv
83
+ # For a library or package, you might want to ignore these files since the code is
84
+ # intended to run in multiple environments; otherwise, check them in:
85
+ # .python-version
86
+
87
+ # pipenv
88
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
90
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
91
+ # install all needed dependencies.
92
+ #Pipfile.lock
93
+
94
+ # poetry
95
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
96
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
97
+ # commonly ignored for libraries.
98
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
99
+ #poetry.lock
100
+
101
+ # pdm
102
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
103
+ #pdm.lock
104
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
105
+ # in version control.
106
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
107
+ .pdm.toml
108
+ .pdm-python
109
+ .pdm-build/
110
+
111
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
112
+ __pypackages__/
113
+
114
+ # Celery stuff
115
+ celerybeat-schedule
116
+ celerybeat.pid
117
+
118
+ # SageMath parsed files
119
+ *.sage.py
120
+
121
+ # Environments
122
+ .env
123
+ .venv
124
+ env/
125
+ venv/
126
+ ENV/
127
+ env.bak/
128
+ venv.bak/
129
+
130
+ # Spyder project settings
131
+ .spyderproject
132
+ .spyproject
133
+
134
+ # Rope project settings
135
+ .ropeproject
136
+
137
+ # mkdocs documentation
138
+ /site
139
+
140
+ # mypy
141
+ .mypy_cache/
142
+ .dmypy.json
143
+ dmypy.json
144
+
145
+ # Pyre type checker
146
+ .pyre/
147
+
148
+ # pytype static type analyzer
149
+ .pytype/
150
+
151
+ # Cython debug symbols
152
+ cython_debug/
153
+
154
+ # PyCharm
155
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
156
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
157
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
158
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
159
+ #.idea/
160
+
161
+ # macOS stuff
162
+ .DS_Store
README.md CHANGED
@@ -1 +1,44 @@
1
- # postly
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Postly
2
+
3
+ This repository contain the Postly client, which serves as a micro-message communication platform, similar to Twitter.
4
+
5
+ ## Getting started
6
+
7
+ Implemented client requires only Python 3.x, no additional requirements.
8
+
9
+ To use client, simply do something like this:
10
+ ```
11
+ from postly.clients import PostlyClient
12
+
13
+ postly_instance = PostlyClient()
14
+ ```
15
+
16
+ ## Testing
17
+
18
+ For this project, we perform continuous integration to make sure that code is tested and formatted appropriately:
19
+
20
+ | Build Type | Status |
21
+ | - | - |
22
+ | **Unit tests** | [![CI](https://github.com/andreped/postly/workflows/Tests/badge.svg)](https://github.com/andreped/postly/actions) |
23
+
24
+ To perform unit tests, you need to install `pytest`. For running formatting checks you also need `flake8`, `isort`, and `black`. We also depend on `pydantic` for type validation. To do so, lets configure a virtual environment:
25
+ ```
26
+ python -m venv venv/
27
+ source venv/bin/activate
28
+
29
+ pip install -r requirements.txt
30
+ ```
31
+
32
+ Then run this command to perform unit tests:
33
+ ```
34
+ pytest -v tests/
35
+ ```
36
+
37
+ To perform formatting checks, run the following:
38
+ ```
39
+ sh shell/lint.sh
40
+ ```
41
+
42
+ ## License
43
+
44
+ This project has MIT license.
postly/__init__.py ADDED
File without changes
postly/clients/__init__.py ADDED
File without changes
postly/clients/postly_client.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from collections import Counter
3
+ from typing import List
4
+
5
+ from ..common.models import Post
6
+
7
+
8
+ class PostlyClient:
9
+ """
10
+ The Postly service interface.
11
+
12
+ This allows adding and deleting users, adding, and retrieving posts
13
+ and getting trending topics.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ self.userPosts = {}
18
+ self.post_max_length = 140
19
+ self.timestamp_iter = 0
20
+
21
+ @staticmethod
22
+ def get_topics_from_post(post: str) -> List[str]:
23
+ """
24
+ Get topics from post.
25
+
26
+ Args:
27
+ post: The post to extract topics from.
28
+ Returns:
29
+ A list of topics.
30
+ """
31
+ return re.findall(pattern=r"#(\w+)", string=post)
32
+
33
+ def add_user(self, user_name: str) -> None:
34
+ """
35
+ Add new user to system.
36
+
37
+ Args:
38
+ user_name: The name of the user to add.
39
+ Returns:
40
+ None
41
+ """
42
+ self.userPosts[user_name] = []
43
+
44
+ def add_post(self, user_name: str, post_text: str, timestamp: int) -> None:
45
+ """
46
+ Add new post to the user's post history.
47
+
48
+ Args:
49
+ user_name: The name of the user to add the post to.
50
+ post_text: The text of the post.
51
+ timestamp: The timestamp of the post.
52
+ Returns:
53
+ None
54
+ """
55
+ if user_name not in self.userPosts:
56
+ raise KeyError(f"User {user_name} not found.")
57
+
58
+ if len(post_text) > self.post_max_length:
59
+ raise RuntimeError("Post is too long")
60
+
61
+ if not post_text.strip():
62
+ raise ValueError("Post cannot be empty.")
63
+
64
+ if not isinstance(timestamp, int) or timestamp < 0:
65
+ raise ValueError("Timestamp must be a non-negative integer.")
66
+
67
+ self.timestamp_iter += 1
68
+ curr_topics = self.get_topics_from_post(post_text)
69
+
70
+ self.userPosts[user_name].append(Post(content=post_text, timestamp=self.timestamp_iter, topics=curr_topics))
71
+
72
+ def delete_user(self, user_name: str) -> None:
73
+ """
74
+ Delete user from system.
75
+
76
+ Args:
77
+ user_name: The name of the user to delete.
78
+ Returns:
79
+ None
80
+ """
81
+ if user_name not in self.userPosts:
82
+ raise KeyError(f"User '{user_name}' not found.")
83
+
84
+ self.userPosts.pop(user_name, None)
85
+
86
+ def get_posts_for_user(self, user_name: str) -> List[str]:
87
+ """
88
+ Get all posts for user, sorted by timestamp in descending order.
89
+
90
+ Args:
91
+ user_name: The name of the user to retrieve posts for.
92
+ Returns:
93
+ A list of posts.
94
+ """
95
+ if user_name not in self.userPosts:
96
+ raise KeyError(f"User '{user_name} not found.")
97
+
98
+ return [post_data.content for post_data in self.userPosts[user_name][::-1]]
99
+
100
+ def get_posts_for_topic(self, topic: str) -> List[str]:
101
+ """
102
+ Get all posts for topic.
103
+
104
+ Args:
105
+ topic: The topic to retrieve posts for.
106
+ Returns:
107
+ A list of posts.
108
+ """
109
+ matched_posts = []
110
+ for user in self.userPosts:
111
+ for post_data in self.userPosts[user]:
112
+ if topic in post_data.topics:
113
+ matched_posts.append(post_data.content)
114
+
115
+ return matched_posts
116
+
117
+ def get_trending_topics(self, from_timestamp: int, to_timestamp: int) -> List[str]:
118
+ """
119
+ Get all trending topics within a time interval.
120
+
121
+ Args:
122
+ from_timestamp: The start of the time interval.
123
+ to_timestamp: The end of the time interval.
124
+ Returns:
125
+ A list of topics.
126
+ """
127
+ if not isinstance(from_timestamp, int) or from_timestamp < 0:
128
+ raise ValueError("from_timestamp must be a non-negative integer.")
129
+
130
+ if not isinstance(to_timestamp, int) or to_timestamp < 0:
131
+ raise ValueError("to_timestamp must be a non-negative integer.")
132
+
133
+ if from_timestamp > to_timestamp:
134
+ raise ValueError("from_timestamp cannot be greater than to_timestamp.")
135
+
136
+ # construct topic histogram
137
+ topics_frequency = Counter()
138
+ for user in self.userPosts:
139
+ for post_data in self.userPosts[user]:
140
+ if from_timestamp <= post_data.timestamp <= to_timestamp:
141
+ topics_frequency.update(post_data.topics)
142
+
143
+ # retriev top topics in descending order
144
+ return [topic for topic, _ in topics_frequency.most_common()]
postly/common/__init__.py ADDED
File without changes
postly/common/models.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from pydantic import BaseModel
3
+ from typing import List
4
+
5
+
6
+ class StrictPost(BaseModel):
7
+ content: str
8
+ timestamp: int
9
+ topics: List[str]
10
+
11
+
12
+ @dataclass
13
+ class Post:
14
+ content: str
15
+ timestamp: int
16
+ topics: List[str]
17
+
18
+
19
+ if __name__ == "__main__":
20
+ # this should be OK, as not strictly typed
21
+ Post(content=1, timestamp=1, topics=["1"])
22
+
23
+ # this should result in a validation error, as pydantic enforces strict typing on runtime
24
+ StrictPost(content=1, timestamp=1, topics=["1"])
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flake8
2
+ isort
3
+ black
4
+ pydantic
5
+ pytest
tests/__init__.py ADDED
File without changes
tests/test_plotly.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from postly.clients.postly_client import PostlyClient
2
+
3
+
4
+ class TestPlotlyClient:
5
+ def setup_method(self):
6
+ self.postly_instance = PostlyClient()
7
+
8
+ # define reference data for testing
9
+ self.gt_posts = [
10
+ "just #chilling today",
11
+ "eating #steak for dinner",
12
+ "ugh! this #steak tasted like dog food"
13
+ ]
14
+ self.gt_topics = [["chilling"], ["steak"], ["steak"]]
15
+
16
+ # add toy data for testing
17
+ self.postly_instance.add_user("john")
18
+ for i, post in enumerate(self.gt_posts):
19
+ self.postly_instance.add_post("john", post, i)
20
+
21
+ def test_add_user(self):
22
+ assert "john" in self.postly_instance.userPosts
23
+
24
+ def test_add_post(self):
25
+ assert len(self.postly_instance.userPosts["john"]) == 3
26
+
27
+ def test_get_posts_for_user(self):
28
+ retrieved_posts = self.postly_instance.get_posts_for_user("john")
29
+
30
+ assert len(retrieved_posts) == 3
31
+ for post, gt_post in zip(retrieved_posts, self.gt_posts[::-1]):
32
+ assert post == gt_post
33
+
34
+ def test_get_posts_for_topic(self):
35
+ retrieved_posts = self.postly_instance.get_posts_for_topic("steak")
36
+
37
+ assert len(retrieved_posts) == 2
38
+ for post in retrieved_posts:
39
+ assert "#steak" in post
40
+
41
+ def test_get_trending_topics(self):
42
+ trending_topics = self.postly_instance.get_trending_topics(1, 3)
43
+
44
+ assert len(trending_topics) == 2
45
+ assert trending_topics == ["steak", "chilling"]
46
+
47
+ trending_topics = self.postly_instance.get_trending_topics(2, 3)
48
+
49
+ assert len(trending_topics) == 1
50
+ assert trending_topics == ["steak"]
51
+
52
+ def test_delete_user(self):
53
+ temporary_postly_instance = PostlyClient()
54
+ temporary_postly_instance.add_user("simon")
55
+ temporary_postly_instance.add_post("simon", "just #coding today", 1)
56
+
57
+ assert "simon" in temporary_postly_instance.userPosts
58
+
59
+ temporary_postly_instance.delete_user("simon")
60
+
61
+ assert "simon" not in temporary_postly_instance.userPosts