Merge branch 'search' of cmccabe/linkulator2 into master

This commit is contained in:
cmccabe 2019-12-22 20:28:43 -05:00 committed by Gitea
commit 2e05eb3408
4 changed files with 244 additions and 26 deletions

34
data.py
View File

@ -70,6 +70,16 @@ def parse_ignore_file() -> list:
return ignore_names
def get_parent_record(parent_id: str, link_data: list) -> list:
"""given a parent ID, return the ID for the parent record or -1"""
if parent_id == "":
raise ValueError("parent_id cannot be empty")
for record in link_data:
if record[0] == parent_id.partition("+")[2]:
return record
raise KeyError("there's no parent record for the specified parent_id")
class LinkData:
"""Class that contains link_data, categories and categories count tables,
plus methods to generate and update these items"""
@ -173,3 +183,27 @@ class LinkData:
cat_record["count"] += 1
if cat_record["last_updated"] < timestamp:
cat_record["last_updated"] = timestamp
def search(self, keyword: str) -> list:
"""returns a unique list of link_data records for posts that contain
the specified keyword"""
if keyword == "":
raise ValueError("a search keyword must be specified")
query = (record for record in self.link_data if keyword in record)
if query:
search_results: set = set()
for record in query:
post_id = record[0]
parent_id = record[3]
if post_id:
search_results.add(tuple(record))
else:
try:
parent_record = get_parent_record(parent_id, self.link_data)
except KeyError:
continue
search_results.add(tuple(parent_record))
return sorted(search_results, key=lambda x: x[0], reverse=True)

View File

@ -120,9 +120,59 @@ def print_thread_details(post_id) -> tuple:
return parent_id, post_url
def print_search_results(keyword: str, search_results: list):
"""a view for the search results - prints results to screen"""
print(
"\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format(
keyword, "ID#", "DATE", "AUTHOR", "DESC"
)
)
for display_index, record in enumerate(search_results, start=1):
date = datetime.fromtimestamp(float(record[2])).strftime("%Y-%m-%d")
author = record[1]
desc = record[6]
print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc))
## CONTROLS
def search():
"""Control for the search function"""
while True:
keyword = input("\nEnter your search (or leave empty to cancel):\n")
if keyword == "":
print("Search cancelled\n")
return
search_results = LinkData.search(keyword)
if not search_results:
print("No results found\n")
return
while True:
print_search_results(keyword, search_results)
option = input(
"\nEnter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: \n".format(
style_text("s", "underline"),
style_text("m", "underline"),
style_text("q", "underline"),
)
).lower()
if option == "q":
graceful_exit()
if option == "m":
return
if option == "s":
break
try:
if 1 <= int(option) <= len(search_results):
menu_view_thread_details(search_results[int(option) - 1][0])
else:
raise IndexError("Invalid post ID")
except (KeyError, ValueError, IndexError):
# Catch a Post ID that is not in the thread list or is not a number
print("\n{}\n".format(style_text("Invalid entry", "bold")))
def view_link_in_browser(url):
"""Attempts to view the specified URL in the configured browser"""
if which(config.USER.browser) is None:
@ -241,28 +291,6 @@ def post_link() -> int:
return LinkData.add(record)
def search(keyword):
"""Search function - not yet complete"""
raise NotImplementedError
## PSEUDOCODE:
## results_found = ""
## for line in link_data:
## if keyword in link_data[title] or keyword in link_data[category] or
## keyword in link_data[comment]: ## results_found = "yes"
## if line is parent post:
## print line
## elif line is reply:
## get parentID
## print(line[parentID])
## if results_found == "":
## print("No results found")
## else:
## next_step = input("Enter ID to view thread, "M" for main menu, or [Enter] to quit: ")
## if next_step...
def menu_view_categories():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
@ -270,8 +298,10 @@ def menu_view_categories():
print_categories()
option = input(
"\nEnter a category ID, {} to post a link, or {} to quit: ".format(
style_text("p", "underline"), style_text("q", "underline")
"\nEnter a category ID, {} to post a link, {} to search, or {} to quit: ".format(
style_text("p", "underline"),
style_text("s", "underline"),
style_text("q", "underline"),
)
).lower()
@ -282,6 +312,9 @@ def menu_view_categories():
if post_id >= 0:
menu_view_thread_details(post_id)
continue
if option == "s":
search()
continue
try:
cat_index = categories[int(option) - 1]
menu_view_category_details(cat_index)
@ -298,8 +331,9 @@ def menu_view_category_details(cat_index):
option = input(
"Enter a post ID to see its thread, {} to go back, {} to "
"post a link, or {} to quit: ".format(
"search, {} to post a link, or {} to quit: ".format(
style_text("m", "underline"),
style_text("s", "underline"),
style_text("p", "underline"),
style_text("q", "underline"),
)
@ -309,6 +343,9 @@ def menu_view_category_details(cat_index):
graceful_exit()
if option == "m":
return
if option == "s":
search()
continue
if option == "p":
post_id = post_link()
if post_id >= 0:
@ -324,10 +361,11 @@ def menu_view_category_details(cat_index):
def menu_view_thread_details(post_id):
"""Displays thread details, handles related navigation menu"""
option_text = "\nType {} to reply, {} to view in {}, {} to post a new link, {} to go back, or {} to quit: ".format(
option_text = "\nType {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format(
style_text("r", "underline"),
style_text("b", "underline"),
config.USER.browser,
style_text("s", "underline"),
style_text("p", "underline"),
style_text("m", "underline"),
style_text("q", "underline"),
@ -344,6 +382,9 @@ def menu_view_thread_details(post_id):
if option == "r":
reply(parent_id)
continue
if option == "s":
search()
continue
if option == "p":
post_id = post_link()
if post_id >= 0:

View File

@ -1,11 +1,13 @@
"""unit tests for the data module"""
import unittest
import unittest.mock
from time import time
import data
class TestDataHelperFunctions(unittest.TestCase):
"""Tests that cover helper functions, mostly handling string validation"""
def test_wash_line(self):
"""tests the data.wash_line function"""
teststrings = [
@ -104,6 +106,110 @@ class TestDataHelperFunctions(unittest.TestCase):
data.process(item, "username{}".format(i)), teststrings_output[i]
)
def test_get_parent_record(self):
test_link_data = []
with self.assertRaises(ValueError):
data.get_parent_record("", test_link_data)
class TestLinkDataSearch(unittest.TestCase):
@unittest.mock.patch.object(data.LinkData, "get")
def test_search_exceptions(self, mock_get):
"""ensures exceptions are raised"""
link_data = data.LinkData()
mock_get.assert_called()
with self.assertRaises(ValueError):
link_data.search("")
@unittest.mock.patch.object(data.LinkData, "get")
def test_search(self, mock_get):
"""tests search function"""
link_data = data.LinkData()
mock_get.assert_called()
link_data.link_data = [
[
"",
"user1",
"1576486443.8539028",
"poster1+1576289662.7914467",
"",
"",
"this comment contains a keyword regarding the keyword website",
],
[
"",
"user2",
"1576486440.65404",
"poster1+1576289662.7914467",
"",
"",
"this is a reply to the site but does not contain the word",
],
[
70,
"poster1",
"1576289662.7914467",
"",
"keyword",
"http://keyword.com",
"the keyword website",
],
[
69,
"poster2",
"1576462584.2307518",
"",
"keyword",
"gemini://keyword",
"keyword site with no replies",
],
[68, "poster3", "1576462007.9509487", "", "a", "a", "key word"],
[67, "poster4", "1576461366.5580268", "", "b", "b", "key.word"],
[
"",
"user3",
"1576376868.284987",
"poster3+1576376644.2783155",
"",
"",
"this is an orphaned reply but it contains the keyword",
],
[66, "keyword", "1576461366.5580268", "", "c", "c", "c"],
[65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"],
[64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"],
[63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"],
[63, "poster9", "1576461370.5580268", "", "c", "c", "z8keyworddui3"],
]
test_results = [
(
70,
"poster1",
"1576289662.7914467",
"",
"keyword",
"http://keyword.com",
"the keyword website",
),
(
69,
"poster2",
"1576462584.2307518",
"",
"keyword",
"gemini://keyword",
"keyword site with no replies",
),
(66, "keyword", "1576461366.5580268", "", "c", "c", "c"),
(65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"),
(64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"),
(63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"),
]
self.assertEqual(link_data.search("keyword"), test_results)
if __name__ == "__main__":
unittest.main()

37
tests/view_tests.py Normal file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Tests for Linkulator views"""
import unittest
from unittest.mock import patch, call
import linkulator
class TestPrintSearchResults(unittest.TestCase):
"""Tests covering the print_search_results function"""
@patch("builtins.print")
def test_print_search_results(self, mock_print):
test_keyword = "keyword"
test_search_results = [
(66, "keyword", "1576461366.5580268", "", "c", "c", "c"),
(65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"),
(64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"),
(63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"),
]
test_print_calls = [
call(
"\nShowing results for keyword\n\n ID# DATE AUTHOR DESC "
),
call(" 1 2019-12-16 keyword c \n"),
call(" 2 2019-12-16 poster6 c \n"),
call(" 3 2019-12-16 poster7 c \n"),
call(" 4 2019-12-16 poster8 keyword \n"),
]
linkulator.print_search_results(test_keyword, test_search_results)
self.assertEqual(
mock_print.call_count, 5
) # one count for title, 4 for the items)
self.assertListEqual(test_print_calls, mock_print.call_args_list)