From: Drew Fisher <drew.m.fisher@gmail.com>
Date: Sun, 2 Oct 2022 03:28:51 +0000 (-0700)
Subject: Rewrite in python3 without web.py
X-Git-Url: http://git.zarvox.org/shortlog/week?a=commitdiff_plain;h=af35de78d1c7db97c00fb16560888082779550a5;p=shortlog.git

Rewrite in python3 without web.py
---

diff --git a/shortlog.py b/shortlog.py
index f7b62b3..0a41f3f 100755
--- a/shortlog.py
+++ b/shortlog.py
@@ -1,11 +1,9 @@
-#!/usr/bin/env python2
-
-import web
-import os
+#!/usr/bin/python3
+from collections import namedtuple
 import datetime
+import mimetypes
+import os
 import re
-import smtplib
-import hashlib
 import sys
 from wsgiref.handlers import CGIHandler
 
@@ -16,8 +14,7 @@ import markdoku
 entriesdir = os.path.join(pwd, "entries")
 commentsdir = os.path.join(pwd, "comments")
 templatesdir = os.path.join(pwd, "templates")
-
-web.config.debug = True
+staticsdir = os.path.join(pwd, "static")
 
 monthnames = {"01": "January",
 		"02": "February",
@@ -32,29 +29,52 @@ monthnames = {"01": "January",
 		"11": "November",
 		"12": "December"}
 
-urls = (
-	'/', 'pastweek',
-	'/week', 'pastweek' ,
-	'/month', 'pastmonth' ,
-	'/year', 'pastyear' ,
-	'/feed', 'feed' ,
-	'/(\d\d\d\d)/(\d\d)/(\d\d)', 'day' ,
-	'/(\d\d\d\d)-(\d\d)-(\d\d)', 'day' ,
-	'/(\d\d\d\d)/(\d\d)', 'month' ,
-	'/(\d\d\d\d)', 'year',
-	'', 'redir',
-	)
-
-## This is the way to do it in web.py 0.3+
-app = web.application(urls, globals())
-
-## Workaround for autoreloading and sessions when using web.config.debug = True
-## Note: I don't actually use sessions
-#if web.config.get("_session") is None:
-#	session = web.session.Session(app, web.session.DiskStore('sessions'), initializer={'count': 0})
-#	web.config._session = session
-#else:
-#	session = web.config._session
+STATUS_CODES = {
+	200: "OK",
+	302: "Found",
+	404: "Not Found",
+	500: "Internal Server Error",
+}
+
+class Response:
+	def __init__(self, status, headers, body):
+		self.status = status
+		self.headers = headers
+		self.body = body
+
+	def send(self, start_response):
+		status_str = STATUS_CODES[self.status]
+		resp_line = f"{self.status} {status_str}"
+		start_response(resp_line, self.headers)
+		return self.body
+
+class Route:
+	def __init__(self, path_re, fn, methods=["GET"], prefix_match=False):
+		self.path_re = re.compile(path_re)
+		self.fn = fn
+		self.methods = methods
+		self.prefix_match = prefix_match
+
+	def handle(self, match, env):
+		return self.fn(match, env)
+
+class Router:
+	def __init__(self, routes):
+		self.routes = routes
+
+	def match(self, env):
+		for route in self.routes:
+			if env["REQUEST_METHOD"] not in route.methods:
+				continue
+			if route.prefix_match:
+				m = route.path_re.match(env["PATH_INFO"])
+			else:
+				m = route.path_re.fullmatch(env["PATH_INFO"])
+			if m:
+				return (route, m)
+		return (None, None)
+
+# Helper functions
 
 # read the paragraphs from a file
 def readFile(filepath):
@@ -76,6 +96,10 @@ def loadComment(filepath):
 	#timestamp = datetime.datetime.fromtimestamp(os.stat(filepath).st_mtime).isoformat()
 	return (nameline, emailline, websiteline, commentbody, timestamp, gravatar)
 
+def most_recent_entries_filelist(count):
+	files = sorted(os.listdir(entriesdir))
+	return files[-count:]
+
 def getCommentFiles(date): # Returns a list of paths to comment files associated with date
 	filelist = [x for x in os.listdir(commentsdir) if re.match(date, x)]
 	return filelist
@@ -97,7 +121,6 @@ def makeParas(lines):
 	#return map( lambda x: x if "<ol>" in x or "<ul>" in x or "<pre>" in x or "<table>" in x or "<video>" in x else "<p>" + x + "</p>", paragraphs)
 	return [x if "<ol>" in x or "<ul>" in x or "<pre>" in x or "<table>" in x or "<video>" in x else "<p>" + x + "</p>" for x in paragraphs]
 
-
 def getFileList(numdays=7):
 	files = sorted(os.listdir(entriesdir))
 	today = datetime.date.today()
@@ -105,143 +128,194 @@ def getFileList(numdays=7):
 	retval = [ x for x in sorted(files) if x > then.isoformat() ]
 	return retval
 
-class year:
-	def GET(self, theyear):
-		files = sorted(os.listdir(entriesdir))
-		filelist = [ os.path.join(entriesdir, x) for x in files if re.match(theyear,x) ]
-		# It makes more sense to sort fixed chunks of time in chronological order, rather than feed-style
-		#filelist.reverse()
-		dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
-		entries = [markdoku.markdown(readFile(x)) for x in filelist]
-		commentcounts = map(len, map( getCommentFiles, dates))
-		render = web.template.render(templatesdir)
-		web.header("Content-Type","text/html; charset=utf-8")
-		return render.multiday(zip(dates, entries, commentcounts), "Entries from %s" % theyear)
-
-class month:
-	def GET(self, theyear, themonth):
-		files = sorted(os.listdir(entriesdir))
-		filelist = [ os.path.join(entriesdir, x) for x in files if re.match("%s-%s" % (theyear, themonth), x) ]
-		#filelist.reverse()
-		dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
-		entries = [markdoku.markdown(readFile(x)) for x in filelist]
-		commentcounts = map(len, map( getCommentFiles, dates))
-		render = web.template.render(templatesdir)
-		web.header("Content-Type","text/html; charset=utf-8")
-		return render.multiday(zip(dates, entries, commentcounts), "Entries from %s, %s" % (monthnames[themonth], theyear))
-
-class day:
-	def GET(self, year, month, day):
-		filename = os.path.join(entriesdir, "%s-%s-%s.txt" % (year, month, day))
-		web.header("Content-Type","text/html; charset=utf-8")
-		if os.path.isfile(filename):
-			entry = markdoku.markdown(readFile(filename))
-			commentfilelist = os.listdir(commentsdir)
-			files = sorted([os.path.join(commentsdir, filename) for filename in commentfilelist if re.match("(.*)%s-%s-%s_(\d\d\d\d\d\d).txt$" % (year, month, day), filename) ])
-			comments = []
-			for f in files:
-				comments.append(loadComment(f))
-			render = web.template.render(templatesdir)
-			return render.day("%s-%s-%s" % (year, month, day), entry, comments)
-		else:
-			render = web.template.render(templatesdir)
-			return render.noentry("%s-%s-%s" % (year, month, day))
-
-	def verify(self, test):
-		if len(test) != 4:
-			return False
-		try:
-			x = int(test,10)
-		except ValueError:
-			return False
-		return True
-
-	def POST(self, year, month, day):
-		i = web.input()
-		bottest = i.bottest.strip()
-		if not self.verify(bottest):
-			web.redirect("http://127.0.0.1/go_away_bot.html")
-			return
-		name = i.name.strip()
-		email = i.email.strip()
-		wantsreply = True
-		if 'wantsreply' in i:
-			wantsreply = False
-		website = i.website.strip()
-		if website != "" and not website.startswith("http"):
-			website = "http://" + website
-		comment = i.comment.strip()
-		timestamp = datetime.datetime.today().isoformat()
-		# find a file name that isn't taken
-		fname = ""
-		for i in xrange(0, 1000000):
-			fname = os.path.join(pwd, "pending", "%s-%s-%s_%06d.txt" % (year, month, day, i))
-			if not os.path.exists(fname):
-				break
-		f = open(fname, "a+")
-		f.write(name + "\n")
-		if wantsreply:
-			f.write(email + "\n")
-		else:
-			f.write(email + " noreply\n")
-		f.write(website + "\n")
-		f.write(timestamp + "\n")
-		f.write(comment + "\n")
-		f.close()
-
-		s = smtplib.SMTP("127.0.0.1")
-		s.sendmail("shortlog@zarvox.org", ["drew.m.fisher@gmail.com"], "To: Drew Fisher <drew.m.fisher@gmail.com>\nFrom: Shortlog <shortlog@zarvox.org>\nSubject: [shortlog] new comment on %s-%s-%s\n\nName: %s\nEmail: %s\nWebsite: %s\nComment: %s" % (year, month, day, name, email, website, comment ))
-		s.quit()
-		web.header('Content-Type', 'text/html')
-		return "<html><body>Thanks! Your comment has been submitted and will be posted pending review.</body></html>"
-
-class pastyear:
-	def GET(self):
-		filelist = [ os.path.join(entriesdir, x) for x in getFileList(365)]
-		filelist.reverse()
-		dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
-		entries = [markdoku.markdown(readFile(x)) for x in filelist]
-		commentcounts = map(len, map( getCommentFiles, dates))
-		render = web.template.render(templatesdir)
-		web.header("Content-Type","text/html; charset=utf-8")
-		return render.multiday(zip(dates, entries, commentcounts))
-
-class pastmonth:
-	def GET(self):
-		filelist = [ os.path.join(entriesdir, x) for x in getFileList(30)]
-		filelist.reverse()
-		dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
-		entries = [markdoku.markdown(readFile(x)) for x in filelist]
-		commentcounts = map(len, map( getCommentFiles, dates))
-		render = web.template.render(templatesdir)
-		web.header("Content-Type","text/html; charset=utf-8")
-		return render.multiday(zip(dates, entries, commentcounts))
-
-class pastweek:
-	def GET(self):
-		filelist = [ os.path.join(entriesdir, x) for x in getFileList(7)]
-		filelist.reverse()
-		dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
-		entries = [markdoku.markdown(readFile(x)) for x in filelist]
-		commentcounts = map(len, map( getCommentFiles, dates))
-		render = web.template.render(templatesdir)
-		web.header("Content-Type","text/html; charset=utf-8")
-		return render.multiday(zip(dates, entries, commentcounts))
-
-class redir:
-	def GET(self): 
-		web.redirect("/")
-
-class feed:
-	def GET(self):
-		web.header('Content-Type', 'application/atom+xml')
-		filelist = os.listdir(entriesdir)
-		files = [os.path.join(entriesdir, f) for f in filelist if re.match("(.*)(\d\d\d\d-\d\d-\d\d).txt$", f)]
-		stats = [os.stat(f) for f in files]
-		decorated = [(stat.st_mtime, filename) for (filename, stat) in zip(files, stats)]
-		decorated.sort(reverse=True)
-		if len(decorated) == 0:
-			return render.feed(datetime.datetime.today(), [] )
+# Templates
+
+def render_no_entry(date):
+	return f"""<!DOCTYPE HTML>
+<html>
+<head>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8" >
+<title>Shortlog - a log of everyday things</title>
+</head>
+<body>
+<h1>Shortlog - a log of everyday things</h1>
+<h3><a href="/shortlog/">Home</a></h3>
+<p>There's no entry for {date}.  Perhaps you reached this page by mistake?</p>
+</body>
+</html>
+"""
+
+def render_comments(comments):
+	rendered_comments = []
+	for comment in comments:
+		name = comment[0]
+		email = comment[1]
+		website = comment[2]
+		body = comment[3]
+		timestamp = comment[4]
+		gravatar = comment[5]
+		writer_line = None
+		name_block = f'<a href="{website}">{name}</a>' if website else name
+		rendered_paras = "\n".join(body)
+		rendered_comment = "\n".join([
+			'<div class="comment">',
+			f'<p><span style="float: right;"><img src="{gravatar}" alt="avatar from Gravatar"></span>',
+			f'<p>{name_block} | {timestamp}</p',
+			rendered_paras
+		])
+		rendered_comments.append(rendered_comment)
+	else:
+		return ""
+
+	return "\n".join([
+		"<hr><h3>Comments:</h3>",
+		"\n".join(rendered_comments),
+	])
+
+def render_page(content):
+	return f"""<!DOCTYPE HTML>
+<html>
+<head>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8" >
+<title>Shortlog - a log of everyday things</title>
+<link rel="stylesheet" type="text/css" href="/static/style.css">
+<link rel="alternate" type="application/atom+xml" href="/shortlog/feed">
+</head>
+<body>
+{content}
+</body>
+</html>
+"""
+
+def render_multiday(entry_tuples, note):
+	entries = []
+	for entry in entry_tuples:
+		(date, entrytext, commentcount) = entry
+		commentcount_suffix = "s" if commentcount != 1 else ""
+		commentcount_label = f"{commentcount} comment{commentcount_suffix}"
+		entries.append("\n".join([
+			"<div>",
+			f'<h3><a href="/shortlog/{date}">{date}</a> | <a href="/shortlog/{date}#comments">{commentcount_label}</a></h3>',
+			entrytext,
+		]))
+	all_entry_text = "\n".join(entries)
+	content = f"""<h1>Shortlog - a log of everyday things</h1>
+<h3>View the past: <a href="/shortlog/2013">2013</a> <a href="/shortlog/2012">2012</a> <a href="/shortlog/2011">2011</a> <a href="/shortlog/2010">2010</a> </h3>
+<h4><a href="/shortlog/feed">Atom Feed</a></h4>
+{note}
+{all_entry_text}
+"""
+	return render_page(content)
+
+def render_day(date, entry, comments):
+	comment_markup = render_comments(comments)
+	content = f"""<h1>Shortlog - a log of everyday things</h1>
+<h3><a href="/shortlog/">Home</a></h3>
+<div>
+  <h3><a href="/shortlog/{date}">{date}</a></h3>
+  {entry}
+</div>
+<div id="comments">
+{comment_markup}
+</div>"""
+	return render_page(content)
+
+def render_feed(timestamp, entries):
+	rendered_entries = []
+	for entry in entries:
+		rendered_entries.append(f"""	<entry>
+		<title>{entry["date"]}</title>
+		<link href="https://zarvox.org/shortlog/{entry["date"]}" rel="alternate"></link>
+		<id>https://zarvox.org/shortlog/{entry["date"]}</id>
+		<updated>{entry["timestamp"]}</updated>
+		<summary type="html">{entry["content"]}</summary>
+	</entry>""")
+	entries_text = "\n".join(rendered_entries)
+	content = f"""<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
+	<title>Shortlog</title>
+	<subtitle>A log of everyday things</subtitle>
+	<link href="https://zarvox.org/shortlog/feed rel="self"></link>
+	<link href="https://zarvox.org/shortlog/" rel="alternate"></link>
+	<id>https://zarvox.org/shortlog</id>
+	<updated>{timestamp}</updated>
+	<author>
+		<name>Drew Fisher</name>
+		<email>zarvox@zarvox.org</email>
+	</author>
+{entries_text}
+</feed>
+"""
+	return content
+
+# Route definitions
+
+def serve_static(match, env):
+	requested_filename = env["PATH_INFO"][len("/static/"):]
+	path = os.path.normpath(os.path.join(staticsdir, requested_filename))
+	if os.path.commonprefix([path, staticsdir]) == staticsdir:
+		content_type = mimetypes.guess_type(path)
+		with open(path, "rb") as f:
+			content = f.read()
+		return Response(200, [("Content-Type", content_type[0])], [content])
+	return Response(404, [("Content-Type", "text/html")],
+	                [b"<h1>404 Asset " + requested_filename.encode('utf-8') + b" Not Found :(</h1>"])
+
+def serve_test_page(match, env):
+	return Response(200, [("Content-Type", "text/html")],
+	                [b"<h1>It works!</h1>"])
+
+def serve_year(match, env):
+	(year,) = match.groups()
+	files = sorted(os.listdir(entriesdir))
+	filelist = [ os.path.join(entriesdir, x) for x in files if re.match(year,x) ]
+	# It makes more sense to sort fixed chunks of time in chronological order, rather than feed-style
+	#filelist.reverse()
+	dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
+	entries = [markdoku.markdown(readFile(x)) for x in filelist]
+	commentcounts = map(len, map( getCommentFiles, dates))
+	note = f"Entries from {year}"
+	body = render_multiday(zip(dates, entries, commentcounts), note)
+	return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
+
+def serve_month(match, env):
+	(year, month) = match.groups()
+	files = sorted(os.listdir(entriesdir))
+	filelist = [ os.path.join(entriesdir, x) for x in files if re.match(f"{year}-{month}", x) ]
+	#filelist.reverse()
+	dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
+	entries = [markdoku.markdown(readFile(x)) for x in filelist]
+	commentcounts = map(len, map( getCommentFiles, dates))
+	note = f"Entries from {monthnames[month]}, {year}"
+	body = render_multiday(zip(dates, entries, commentcounts), note)
+	return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
+
+def serve_day(match, env):
+	(year, month, day) = match.groups()
+	datestr = f"{year}-{month}-{day}"
+	filename = os.path.join(entriesdir, f"{datestr}.txt")
+	if os.path.isfile(filename):
+		entry = markdoku.markdown(readFile(filename))
+		commentfilelist = os.listdir(commentsdir)
+		files = sorted([os.path.join(commentsdir, filename) for filename in commentfilelist if re.match("(.*)%s-%s-%s_(\d\d\d\d\d\d).txt$" % (year, month, day), filename) ])
+		comments = []
+		for f in files:
+			comments.append(loadComment(f))
+		body = render_day(datestr, entry, comments)
+		return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
+	else:
+		body = render_no_entry(datestr)
+		return Response(404, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
+
+def serve_feed(match, env):
+	filelist = os.listdir(entriesdir)
+	files = [os.path.join(entriesdir, f) for f in filelist if re.match("(.*)(\d\d\d\d-\d\d-\d\d).txt$", f)]
+	stats = [os.stat(f) for f in files]
+	decorated = [(stat.st_mtime, filename) for (filename, stat) in zip(files, stats)]
+	decorated.sort(reverse=True)
+	if len(decorated) == 0:
+		body = render_feed(datetime.datetime.today(), [])
+	else:
 		group_timestamp = datetime.datetime.fromtimestamp(decorated[0][0]).isoformat() + "-06:00"
 		entries = []
 		for d in decorated[:10]:
@@ -250,20 +324,80 @@ class feed:
 					"content": markdoku.markdown(readFile(d[1])),
 					"timestamp": (mtime.isoformat() + "-06:00") }
 			entries.append(second)
-		render = web.template.render(templatesdir)
-		return render.feed(group_timestamp, entries)
+		body = render_feed(group_timestamp, entries)
+	return Response(200, [('Content-Type', 'application/atom+xml')],
+	                [body.encode('utf-8')])
+
+def serve_most_recent(match, env):
+	filelist = [ os.path.join(entriesdir, x) for x in most_recent_entries_filelist(20) ]
+	filelist.reverse()
+	dates = [ re.sub("(.*)(\d\d\d\d-\d\d-\d\d).txt", "\g<2>", filename) for filename in filelist ]
+	print(dates)
+	entries = [markdoku.markdown(readFile(x)) for x in filelist]
+	commentcounts = map(len, map( getCommentFiles, dates))
+	note = ""
+	body = render_multiday(zip(dates, entries, commentcounts), note)
+	return Response(200, [('Content-Type', 'text/html; charset=utf-8')], [body.encode('utf-8')])
+
+router = Router([
+	Route("/static/", serve_static, prefix_match=True),
+	Route("/(\d\d\d\d)-(\d\d)-(\d\d)", serve_day),
+	Route("/(\d\d\d\d)/(\d\d)/(\d\d)", serve_day),
+	Route("/(\d\d\d\d)-(\d\d)", serve_month),
+	Route("/(\d\d\d\d)/(\d\d)", serve_month),
+	Route("/(\d\d\d\d)", serve_year),
+	Route("/feed", serve_feed),
+	Route("/", serve_most_recent),
+])
+
+#def redir(match, env):
+#	return Response(302, [("Location", "/")], [])
+
+#urls = (
+#	'', 'redir',
+#	'/week', 'pastweek' ,
+#	'/month', 'pastmonth' ,
+#	'/year', 'pastyear' ,
+#	'/feed', 'feed' ,
+#	'/', 'pastweek',
+#	)
+
+def application(env, start_response):
+    (route, match) = router.match(env)
+
+    response = None
+    if route:
+        try:
+            response = route.handle(match, env)
+        except e:
+            print("EXCEPTION:", e)
+            pass
+    else:
+        print("No route matched", repr(env["PATH_INFO"]))
+        response = Response(404, [("Content-Type", "text/html")],
+                            [b"<h1>404 Not Found :(</h1>"])
+
+
+    if response is None:
+        response = Response(500, [("Content-Type", "text/html")],
+                            [b"<h1>Internal Server Error</h1>"])
 
-# web.py 0.3+ way
-application = web.application(urls, globals()).wsgifunc()
+    print(env["REQUEST_METHOD"], env["PATH_INFO"], response.status)
+    return response.send(start_response)
 
-# web.py 0.2 way
-#application = web.wsgifunc(web.webpyfunc(urls, globals()))
 
 if __name__ == "__main__":
     # CGI interface
     CGIHandler().run(application)
-    ## The web.py 0.3+ way
-    #app.run()
-    ## The web.py 0.2 way
-    #web.run(urls, globals())
 
+    # cherrypy WSGI container
+    #import cherrypy
+    #cherrypy.tree.graft(application, "/")
+    #cherrypy.server.unsubscribe()
+    #server = cherrypy._cpserver.Server()
+    #server.socket_host = "127.0.0.1"
+    #server.socket_port= 5000
+    #server.thread_pool = 4
+    #server.subscribe()
+    #cherrypy.engine.start()
+    #cherrypy.engine.block()