Compare commits

...

18 Commits

Author SHA1 Message Date
Brian S. Stephan 05fb402029
add some quick contribution info and point at an IRC channel
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-02-25 00:29:08 -06:00
Brian S. Stephan ada8691f85
version bumps
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-02-24 23:55:01 -06:00
Brian S. Stephan d4ce04cd34
update feed test directory for the new structure
I guess this proves that the old structure still works with the new
code, though it creates IDs that have the file path in them

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 22:35:58 -06:00
Brian S. Stephan 63a263724c
simplify the expected feed structure
I don't think there's any need for a million directories, on second
thought, so just put YYMMDD prefixed files in the feed/ directory

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 22:17:28 -06:00
Brian S. Stephan 02c2176c4f
link to the Atom and RSS feeds in the template
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 22:09:58 -06:00
Brian S. Stephan 30d6f99c9b
return the proper atom and rss content types for the feeds
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:31:18 -06:00
Brian S. Stephan 575e2ad387
provide author information for the feed and entries
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:25:46 -06:00
Brian S. Stephan b26975421c
make the feed ID be a valid URL for compliance
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:19:57 -06:00
Brian S. Stephan 6dc443e59f
implement a rudimentary Atom/RSS feed module
this provides a somewhat unconfigurable (at the moment) feed module
which provides Atom and RSS feeds. entries are determined by symlinks to
content pages, because my core CMS usage is still more general and not
blog-like. the symlinks allow for arbitrarily adding entries as I see
fit.

this also moves core Markdown parser stuff to the library module, since
that's used by the feed as well as normal pages

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 14:55:02 -06:00
Brian S. Stephan 5a9a36f463
deduplicate TITLE_SUFFIX from new DOMAIN_NAME
I will need the domain name for feed stuff, and I'm already crudely
using the title suffix in the nav as if it was a domain name, so let's just be
explicit in the case I ever change my mind on domain-in-title styling

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:55:01 -06:00
Brian S. Stephan 680a2bc764
add python 3.12 to tox environments
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:13:08 -06:00
Brian S. Stephan 713632fe7a
unpin tox in requirements
for some reason bandit wasn't earlier catching the SubElement usage but
now it is, but it's harmless anyway so we'll just suppress it.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:12:59 -06:00
Brian S. Stephan bf646db1e8
convert tooling to pyproject.toml based
still has dynamic versioning and etc.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 10:33:28 -06:00
Brian S. Stephan 2871e5a000
version bumps
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-28 15:40:56 -06:00
Brian S. Stephan 7b225a6de3
adding a compliant copyright line to all code
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-28 15:33:41 -06:00
Brian S. Stephan e1dc2afc7b
add SPDX-License-Identifier and DCO information
this includes my personal signoff on the MAINTAINERS.md for DCO purposes

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-18 11:15:56 -06:00
Brian S. Stephan 0fef13c71a
version bumps, especially flask and werkzeug 3 2023-10-07 14:02:23 -05:00
Brian S. Stephan 6b5cdb7f7e
add python 3.11 to tox tests 2023-10-07 13:07:05 -05:00
35 changed files with 609 additions and 2660 deletions

3
.gitignore vendored
View File

@ -117,3 +117,6 @@ dmypy.json
# vim stuff
*.swp
# autogenerated versioning
_version.py

View File

@ -15,6 +15,39 @@ prioritization.
## Contributions
### Sign Offs/Custody of Contributions
I do not request the copyright of contributions be assigned to me or to the project, and I require no provision that I
be allowed to relicense your contributions. My personal oath is to maintain inbound=outbound in my open source projects,
and the expectation is authors are responsible for their contributions.
I am following the the [Developer Certificate of Origin (DCO)](https://developercertificate.org/), also available at
`DCO.txt`. The DCO is a way for contributors to certify that they wrote or otherwise have the right to license their
code contributions to the project. Contributors must sign-off that they adhere to these requirements by adding a
`Signed-off-by` line to their commit message, and/or, for frequent contributors, by signing off on their entry in
`MAINTAINERS.md`.
This process is followed by a number of open source projects, most notably the Linux kernel. Here's the gist of it:
```
[Your normal Git commit message here.]
Signed-off-by: Random J Developer <random@developer.example.org>
```
`git help commit` has more info on adding this:
```
-s, --signoff
Add Signed-off-by line by the committer at the end of the commit log
message. The meaning of a signoff depends on the project, but it typically
certifies that committer has the rights to submit this work under the same
license and agrees to a Developer Certificate of Origin (see
http://developercertificate.org/ for more information).
```
### Submitting Contributions
I don't expect contributors to sign up for my personal Gitea in order to send contributions, but it
of course makes it easier. If you wish to go this route, please sign up at
<https://git.incorporeal.org/bss/incorporeal-cms> and fork the project. People planning on
@ -50,27 +83,3 @@ your change and to tell me how to pull your changes.
formatting a good commit message.
* Please make sure your Author contact information is stable, in case I need to reach you.
* Consider cryptographically signing (`git commit -S`) your commits.
### Custody of Contributions
I do not request the copyright of contributions be assigned to me or to the project, and I require no
provision that I be allowed to relicense your contributions. My personal oath is to maintain
inbound=outbound in my open source projects, and the expectation is authors are responsible for their
contributions.
I am following the *spirit* of the [Developer Certificate of Origin](https://developercertificate.org/),
but in a simplified fashion:
By making a contribution to this project, you certify that:
1. The contribution was created by you and you have the right to submit it under the open source license
indicated in the LICENSE file; or
2. The contribution is based upon previous work that is covered under an appropriate open source license
compatible with the license indicated in the LICENSE file, and you have the right to contribute that
work with or without modifications, under the terms of that same open source license; or
3. The contribution was provided directly to you by some other person who certified points 1, 2, or 3, and
you have not modified it.
In the event of point 3, your commit **must** include the Signed-off-by line(s) as a chain of custody,
via `git commit -s`. For points 1 and 2, your commit with accurate Author information doubles as direct
custody.

34
DCO.txt Normal file
View File

@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

10
MAINTAINERS.md Normal file
View File

@ -0,0 +1,10 @@
# Maintainers
This file contains information about people permitted to make major decisions and direction on the project.
## Contributing Under the DCO
By adding your name and email address to this section, you certify that all of your subsequent contributions to
incorporeal-cms are made under the terms of the Developer's Certificate of Origin 1.1, available at `DCO.txt`.
* Brian S. Stephan (<bss@incorporeal.org>)

View File

@ -48,6 +48,14 @@ you are probably interested in tweaking, by specifying new values in
If I missed anything, please let me know.
## Development and Contributing
Improvements, new plugins, and etc. are all welcome.
I'm reachable on the fediverse, over email, or on Discord, but if you're looking for an option I prefer, I maintain an
IRC channel, `#incorporeal-cms`, on [my IRC network, Randomus](https://randomus.net/) if you would like a place to hang
out and discuss issues and features and whatnot.
## Author and Licensing
Written by and copyright Brian S. Stephan (bss@incorporeal.org).

View File

@ -1,15 +1,14 @@
"""An application for running my Markdown-based sites."""
"""An application for running my Markdown-based sites.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging
import os
from logging.config import dictConfig
from flask import Flask, request
from ._version import get_versions
__version__ = get_versions()['version']
del get_versions
def create_app(instance_path=None, test_config=None):
"""Create the Flask app, with allowances for customizing path and test settings."""
@ -40,7 +39,8 @@ def create_app(instance_path=None, test_config=None):
logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status)
return response
from . import error_pages, pages, static
from . import error_pages, feed, pages, static
app.register_blueprint(feed.bp)
app.register_blueprint(pages.bp)
app.register_blueprint(static.bp)
app.register_error_handler(400, error_pages.bad_request)

View File

@ -1,520 +0,0 @@
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
# This file is released into the public domain. Generated by
# versioneer-0.18 (https://github.com/warner/python-versioneer)
"""Git implementation of _version.py."""
import errno
import os
import re
import subprocess
import sys
def get_keywords():
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
git_refnames = "$Format:%d$"
git_full = "$Format:%H$"
git_date = "$Format:%ci$"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
def get_config():
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
cfg.style = "pep440-post"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "incorporealcms/_version.py"
cfg.verbose = False
return cfg
class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
LONG_VERSION_PY = {}
HANDLERS = {}
def register_vcs_handler(vcs, method): # decorator
"""Decorator to mark a method as the handler for a particular VCS."""
def decorate(f):
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
return decorate
def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
env=None):
"""Call the given command(s)."""
assert isinstance(commands, list)
p = None
for c in commands:
try:
dispcmd = str([c] + args)
# remember shell=False, so use git.cmd on windows, not just git
p = subprocess.Popen([c] + args, cwd=cwd, env=env,
stdout=subprocess.PIPE,
stderr=(subprocess.PIPE if hide_stderr
else None))
break
except EnvironmentError:
e = sys.exc_info()[1]
if e.errno == errno.ENOENT:
continue
if verbose:
print("unable to run %s" % dispcmd)
print(e)
return None, None
else:
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
return None, p.returncode
return stdout, p.returncode
def versions_from_parentdir(parentdir_prefix, root, verbose):
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
the project name and a version string. We will also support searching up
two directory levels for an appropriately named parent directory
"""
rootdirs = []
for i in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
return {"version": dirname[len(parentdir_prefix):],
"full-revisionid": None,
"dirty": False, "error": None, "date": None}
else:
rootdirs.append(root)
root = os.path.dirname(root) # up a level
if verbose:
print("Tried directories %s but none started with prefix %s" %
(str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
def git_get_keywords(versionfile_abs):
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
keywords = {}
try:
f = open(versionfile_abs, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["refnames"] = mo.group(1)
if line.strip().startswith("git_full ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["full"] = mo.group(1)
if line.strip().startswith("git_date ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["date"] = mo.group(1)
f.close()
except EnvironmentError:
pass
return keywords
@register_vcs_handler("git", "keywords")
def git_versions_from_keywords(keywords, tag_prefix, verbose):
"""Get version information from git keywords."""
if not keywords:
raise NotThisMethod("no keywords at all, weird")
date = keywords.get("date")
if date is not None:
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
# it's been around since git-1.5.3, and it's too difficult to
# discover which version we're using, or to work around using an
# older one.
date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
refnames = keywords["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
# expansion behaves like git log --decorate=short and strips out the
# refs/heads/ and refs/tags/ prefixes that would let us distinguish
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
tags = set([r for r in refs if re.search(r'\d', r)])
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
print("likely tags: %s" % ",".join(sorted(tags)))
for ref in sorted(tags):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
return {"version": r,
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": None,
"date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
return {"version": "0+unknown",
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
expanded, and _version.py hasn't already been rewritten with a short
version string, meaning we're inside a checked out source tree.
"""
GITS = ["git"]
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
hide_stderr=True)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
raise NotThisMethod("'git rev-parse --git-dir' returned error")
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
"--always", "--long",
"--match", "%s*" % tag_prefix],
cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
pieces = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
# look for -dirty suffix
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
# unparseable. Maybe git-describe is misbehaving?
pieces["error"] = ("unable to parse git-describe output: '%s'"
% describe_out)
return pieces
# tag
full_tag = mo.group(1)
if not full_tag.startswith(tag_prefix):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
% (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
# distance: number of commits since tag
pieces["distance"] = int(mo.group(2))
# commit: short hex revision ID
pieces["short"] = mo.group(3)
else:
# HEX: no tags
pieces["closest-tag"] = None
count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
cwd=root)
pieces["distance"] = int(count_out) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
cwd=root)[0].strip()
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
def plus_or_dot(pieces):
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
def render_pep440(pieces):
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
Exceptions:
1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += plus_or_dot(pieces)
rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
else:
# exception #1
rendered = "0+untagged.%d.g%s" % (pieces["distance"],
pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
def render_pep440_pre(pieces):
"""TAG[.post.devDISTANCE] -- No -dirty.
Exceptions:
1: no tags. 0.post.devDISTANCE
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += ".post.dev%d" % pieces["distance"]
else:
# exception #1
rendered = "0.post.dev%d" % pieces["distance"]
return rendered
def render_pep440_post(pieces):
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
(a dirty tree will appear "older" than the corresponding clean one),
but you shouldn't be releasing software with -dirty anyways.
Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += plus_or_dot(pieces)
rendered += "g%s" % pieces["short"]
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += "+g%s" % pieces["short"]
return rendered
def render_pep440_old(pieces):
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
Eexceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
return rendered
def render_git_describe(pieces):
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render_git_describe_long(pieces):
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
The distance/hash is unconditional.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render(pieces, style):
"""Render the given version pieces into the requested style."""
if pieces["error"]:
return {"version": "unknown",
"full-revisionid": pieces.get("long"),
"dirty": None,
"error": pieces["error"],
"date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
rendered = render_git_describe(pieces)
elif style == "git-describe-long":
rendered = render_git_describe_long(pieces)
else:
raise ValueError("unknown style '%s'" % style)
return {"version": rendered, "full-revisionid": pieces["long"],
"dirty": pieces["dirty"], "error": None,
"date": pieces.get("date")}
def get_versions():
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
# py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
# case we can only use expanded keywords.
cfg = get_config()
verbose = cfg.verbose
try:
return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
verbose)
except NotThisMethod:
pass
try:
root = os.path.realpath(__file__)
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
for i in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to find root of source tree",
"date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
return render(pieces, cfg.style)
except NotThisMethod:
pass
try:
if cfg.parentdir_prefix:
return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
except NotThisMethod:
pass
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to compute version", "date": None}

View File

@ -1,4 +1,8 @@
"""Default configuration."""
"""Default configuration.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
class Config(object):
@ -57,7 +61,11 @@ class Config(object):
}
DEFAULT_PAGE_STYLE = 'light'
TITLE_SUFFIX = 'example.com'
DOMAIN_NAME = 'example.com'
TITLE_SUFFIX = DOMAIN_NAME
CONTACT_EMAIL = 'admin@example.com'
# feed settings
AUTHOR = {'name': 'Test Name', 'email': 'admin@example.com'}
# specify FAVICON in your instance config.py to override the provided icon

View File

@ -1,4 +1,8 @@
"""Error page views for 400, 404, etc."""
"""Error page views for 400, 404, etc.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
from incorporealcms.lib import render

73
incorporealcms/feed.py Normal file
View File

@ -0,0 +1,73 @@
"""Generate Atom and RSS feeds based on content in a blog-ish location.
This parses a special root directory, feed/, for YYYYMMDD-foo.md files,
and combines them into an Atom or RSS feed. These files *should* be symlinks
to the real pages, which may mirror the same YYYYMMDD-foo.md file naming scheme
under pages/ (which may make sense for a blog) if they want, but could just
as well be pages/foo content.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging
import os
import re
from feedgen.feed import FeedGenerator
from flask import Blueprint, Response, abort
from flask import current_app as app
from incorporealcms.lib import instance_resource_path_to_request_path, parse_md
logger = logging.getLogger(__name__)
bp = Blueprint('feed', __name__, url_prefix='/feed')
@bp.route('/<feed_type>')
def serve_feed(feed_type):
"""Serve the Atom or RSS feed as requested."""
logger.warning("wat")
if feed_type not in ('atom', 'rss'):
abort(404)
fg = FeedGenerator()
fg.id(f'https://{app.config["DOMAIN_NAME"]}/')
fg.title(f'{app.config["TITLE_SUFFIX"]}')
fg.author(app.config["AUTHOR"])
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}/feed/{feed_type}', rel='self')
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}', rel='alternate')
fg.subtitle(f"Blog posts and other dated materials from {app.config['TITLE_SUFFIX']}")
# get recent feeds
feed_path = os.path.join(app.instance_path, 'feed')
feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path)
for filename in filenames if os.path.islink(os.path.join(dirpath, filename))]
for feed_entry_path in sorted(feed_entry_paths):
# get the actual file to parse it
resolved_path = os.path.realpath(feed_entry_path).replace(f'{app.instance_path}/', '')
try:
content, md, page_name, page_title, mtime = parse_md(resolved_path)
link = f'https://{app.config["DOMAIN_NAME"]}/{instance_resource_path_to_request_path(resolved_path)}'
except (OSError, ValueError, TypeError):
logger.exception("error loading/rendering markdown!")
abort(500)
fe = fg.add_entry()
fe.id(_generate_feed_id(feed_entry_path))
fe.title(page_name if page_name else page_title)
fe.author(app.config["AUTHOR"])
fe.link(href=link)
fe.content(content, type='html')
if feed_type == 'atom':
return Response(fg.atom_str(pretty=True), mimetype='application/atom+xml')
else:
return Response(fg.rss_str(pretty=True), mimetype='application/rss+xml')
def _generate_feed_id(feed_entry_path):
"""For a relative file path, generate the Atom/RSS feed ID for it."""
date = re.sub(r'.*(\d{4})(\d{2})(\d{2}).*', r'\1-\2-\3', feed_entry_path)
cleaned = feed_entry_path.replace('#', '/').replace('feed/', '', 1).replace(app.instance_path, '')
return f'tag:{app.config["DOMAIN_NAME"]},{date}:{cleaned}'

View File

@ -1,9 +1,17 @@
"""Miscellaneous helper functions and whatnot."""
"""Miscellaneous helper functions and whatnot.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import datetime
import logging
import os
import re
import markdown
from flask import current_app as app
from flask import make_response, render_template, request
from markupsafe import Markup
logger = logging.getLogger(__name__)
@ -26,6 +34,45 @@ def init_md():
extension_configs=app.config['MARKDOWN_EXTENSION_CONFIGS'])
def instance_resource_path_to_request_path(path):
"""Reverse a (presumed to exist) RELATIVE disk path to the canonical path that would show up in a Flask route.
This does not include the leading /, so aside from the root index case, this should be
bidirectional.
"""
return re.sub(r'^pages/', '', re.sub(r'.md$', '', re.sub(r'index.md$', '', path)))
def parse_md(resolved_path):
"""Given a file to parse, return file content and other derived data along with the md object."""
try:
logger.debug("opening resolved path '%s'", resolved_path)
with app.open_instance_resource(resolved_path, 'r') as entry_file:
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), tz=datetime.timezone.utc)
entry = entry_file.read()
logger.debug("resolved path '%s' read", resolved_path)
md = init_md()
content = Markup(md.convert(entry))
except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path)
raise
except ValueError:
logger.exception("error parsing/rendering markdown!")
raise
except TypeError:
logger.exception("error loading/rendering markdown!")
raise
logger.debug("file metadata: %s", md.Meta)
page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else
f'/{instance_resource_path_to_request_path(resolved_path)}')
page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX']
logger.debug("title (potentially derived): %s", page_title)
return content, md, page_name, page_title, mtime
def render(template_name_or_list, **context):
"""Wrap Flask's render_template.

View File

@ -1 +1,5 @@
"""Markdown extensions."""
"""Markdown extensions.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""

View File

@ -1,6 +1,10 @@
"""Create generic figures with captions."""
"""Create generic figures with captions.
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import re
from xml.etree.ElementTree import SubElement
from xml.etree.ElementTree import SubElement # nosec B405 - not parsing untrusted XML here
import markdown

View File

@ -1,4 +1,8 @@
"""Serve dot diagrams inline."""
"""Serve dot diagrams inline.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import base64
import logging
import re

View File

@ -1,15 +1,18 @@
"""General page functionality."""
import datetime
"""General page functionality.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging
import os
import re
from flask import Blueprint, Markup, abort
from flask import Blueprint, abort
from flask import current_app as app
from flask import redirect, request, send_from_directory
from markupsafe import Markup
from werkzeug.security import safe_join
from incorporealcms.lib import get_meta_str, init_md, render
from incorporealcms.lib import get_meta_str, init_md, instance_resource_path_to_request_path, parse_md, render
logger = logging.getLogger(__name__)
@ -49,36 +52,19 @@ def display_page(path):
def handle_markdown_file_path(resolved_path):
"""Given a location on disk, attempt to open it and render the markdown within."""
try:
logger.debug("opening resolved path '%s'", resolved_path)
with app.open_instance_resource(resolved_path, 'r') as entry_file:
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), tz=datetime.timezone.utc)
entry = entry_file.read()
logger.debug("resolved path '%s' read", resolved_path)
content, md, page_name, page_title, mtime = parse_md(resolved_path)
except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path)
abort(500)
except ValueError:
logger.exception("error parsing/rendering markdown!")
abort(500)
except TypeError:
logger.exception("error loading/rendering markdown!")
abort(500)
else:
try:
md = init_md()
content = Markup(md.convert(entry))
except ValueError:
logger.exception("error parsing/rendering markdown!")
abort(500)
except TypeError:
logger.exception("error loading/rendering markdown!")
abort(500)
logger.debug("file metadata: %s", md.Meta)
parent_navs = generate_parent_navs(resolved_path)
page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else
f'/{instance_resource_path_to_request_path(resolved_path)}')
page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX']
logger.debug("title (potentially derived): %s", page_title)
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None
template = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html'
# check if this has a HTTP redirect
@ -151,20 +137,11 @@ def request_path_to_instance_resource_path(path):
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
def instance_resource_path_to_request_path(path):
"""Reverse a (presumed to exist) disk path to the canonical path that would show up in a Flask route.
This does not include the leading /, so aside from the root index case, this should be
bidirectional.
"""
return re.sub(r'^pages/', '', re.sub(r'.md$', '', re.sub(r'index.md$', '', path)))
def generate_parent_navs(path):
"""Create a series of paths/links to navigate up from the given resource path."""
if path == 'pages/index.md':
# bail and return the title suffix (generally the domain name) as a terminal case
return [(app.config['TITLE_SUFFIX'], '/')]
# bail and return the domain name as a terminal case
return [(app.config['DOMAIN_NAME'], '/')]
else:
if path.endswith('index.md'):
# index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo

View File

@ -1,4 +1,8 @@
"""Serve static files from the instance directory."""
"""Serve static files from the instance directory.
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
from flask import Blueprint

View File

@ -9,6 +9,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{ user_style }}">
<link rel="icon" href="{% if config.FAVICON %}{{ config.FAVICON }}{% else %}{{ url_for('static', filename='img/favicon.png') }}{% endif %}">
<link rel="alternate" type="application/atom+xml" href="/feed/atom" />
<link rel="alternate" type="application/rss+xml" href="/feed/rss" />
<div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}>
{% block header %}

54
pyproject.toml Normal file
View File

@ -0,0 +1,54 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "incorporeal-cms"
description = "A CMS for serving Markdown files with a bit of dynamicism."
readme = "README.md"
license = {text = "AGPL-3.0-or-later"}
authors = [
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
]
requires-python = ">=3.8"
dependencies = ["feedgen", "Flask", "Markdown"]
dynamic = ["version"]
classifiers = [
"Framework :: Flask",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Operating System :: OS Independent",
"Topic :: Text Processing :: Markup :: Markdown",
]
[project.urls]
"Homepage" = "https://git.incorporeal.org/bss/incorporeal-cms"
"Changelog" = "https://git.incorporeal.org/bss/incorporeal-cms/releases"
"Bug Tracker" = "https://git.incorporeal.org/bss/incorporeal-cms/issues"
[project.optional-dependencies]
dev = ["bandit", "dlint", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings",
"flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable",
"flake8-pyproject", "mypy", "pip-tools", "pydot", "pytest", "pytest-cov", "safety",
"setuptools-scm", "tox"]
dot = ["pydot"]
[tool.flake8]
enable-extensions = "G,M"
exclude = [".tox/", "venv/", "_version.py"]
extend-ignore = "T101"
max-complexity = 10
max-line-length = 120
[tool.isort]
line_length = 120
[tool.mypy]
ignore_missing_imports = true
[tool.pytest.ini_options]
python_files = ["*_tests.py", "tests.py", "test_*.py"]
[tool.setuptools_scm]
write_to = "incorporealcms/_version.py"

View File

@ -1,26 +0,0 @@
-r requirements.in
# testing runner, test reporting, packages used during testing (e.g. requests-mock), etc.
pydot
pytest
pytest-cov
# linting and other static code analysis
bandit
dlint
flake8
flake8-blind-except
flake8-builtins
flake8-docstrings
flake8-executable
flake8-fixme
flake8-isort
flake8-logging-format
flake8-mutable
safety # check requirements file for issues
# maintenance utilities and tox
pip-tools # pip-compile
tox<4 # CI stuff, pinned for now to avoid packaging conflict w/safety
tox-wheel # build wheels in tox
versioneer # automatic version numbering

View File

@ -2,170 +2,232 @@
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
# pip-compile --extra=dev --output-file=requirements/requirements-dev.txt pyproject.toml
#
attrs==22.2.0
# via pytest
bandit==1.7.4
# via -r requirements/requirements-dev.in
build==0.9.0
authlib==1.3.0
# via safety
bandit==1.7.7
# via incorporeal-cms (pyproject.toml)
blinker==1.7.0
# via flask
build==1.0.3
# via pip-tools
certifi==2022.12.7
cachetools==5.3.2
# via tox
certifi==2024.2.2
# via requests
charset-normalizer==2.1.1
cffi==1.16.0
# via cryptography
chardet==5.2.0
# via tox
charset-normalizer==3.3.2
# via requests
click==8.1.3
click==8.1.7
# via
# flask
# pip-tools
# safety
coverage[toml]==7.0.1
# typer
colorama==0.4.6
# via tox
coverage[toml]==7.4.3
# via pytest-cov
distlib==0.3.6
cryptography==42.0.5
# via authlib
distlib==0.3.8
# via virtualenv
dlint==0.13.0
# via -r requirements/requirements-dev.in
dparse==0.6.2
# via safety
exceptiongroup==1.1.0
dlint==0.14.1
# via incorporeal-cms (pyproject.toml)
dparse==0.6.4b0
# via
# safety
# safety-schemas
exceptiongroup==1.2.0
# via pytest
filelock==3.9.0
feedgen==1.0.0
# via incorporeal-cms (pyproject.toml)
filelock==3.13.1
# via
# tox
# virtualenv
flake8==5.0.4
flake8==7.0.0
# via
# -r requirements/requirements-dev.in
# dlint
# flake8-builtins
# flake8-docstrings
# flake8-executable
# flake8-isort
# flake8-mutable
# flake8-pyproject
# incorporeal-cms (pyproject.toml)
flake8-blind-except==0.2.1
# via -r requirements/requirements-dev.in
flake8-builtins==2.1.0
# via -r requirements/requirements-dev.in
flake8-docstrings==1.6.0
# via -r requirements/requirements-dev.in
flake8-executable==2.1.2
# via -r requirements/requirements-dev.in
# via incorporeal-cms (pyproject.toml)
flake8-builtins==2.2.0
# via incorporeal-cms (pyproject.toml)
flake8-docstrings==1.7.0
# via incorporeal-cms (pyproject.toml)
flake8-executable==2.1.3
# via incorporeal-cms (pyproject.toml)
flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in
flake8-isort==6.0.0
# via -r requirements/requirements-dev.in
# via incorporeal-cms (pyproject.toml)
flake8-isort==6.1.1
# via incorporeal-cms (pyproject.toml)
flake8-logging-format==0.9.0
# via -r requirements/requirements-dev.in
# via incorporeal-cms (pyproject.toml)
flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in
flask==2.2.2
# via -r requirements/requirements.in
gitdb==4.0.10
# via gitpython
gitpython==3.1.30
# via bandit
idna==3.4
# via incorporeal-cms (pyproject.toml)
flake8-pyproject==1.2.3
# via incorporeal-cms (pyproject.toml)
flask==3.0.2
# via incorporeal-cms (pyproject.toml)
idna==3.6
# via requests
iniconfig==1.1.1
iniconfig==2.0.0
# via pytest
isort==5.11.4
isort==5.13.2
# via flake8-isort
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
# via flask
markdown==3.4.1
# via -r requirements/requirements.in
markupsafe==2.1.1
jinja2==3.1.3
# via
# flask
# safety
lxml==5.1.0
# via feedgen
markdown==3.5.2
# via incorporeal-cms (pyproject.toml)
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.5
# via
# jinja2
# werkzeug
marshmallow==3.20.2
# via safety
mccabe==0.7.0
# via flake8
packaging==21.3
mdurl==0.1.2
# via markdown-it-py
mypy==1.8.0
# via incorporeal-cms (pyproject.toml)
mypy-extensions==1.0.0
# via mypy
packaging==23.2
# via
# build
# dparse
# marshmallow
# pyproject-api
# pytest
# safety
# safety-schemas
# setuptools-scm
# tox
pbr==5.11.0
pbr==6.0.0
# via stevedore
pep517==0.13.0
# via build
pip-tools==6.12.1
# via -r requirements/requirements-dev.in
platformdirs==2.6.2
# via virtualenv
pluggy==1.0.0
pip-tools==7.4.0
# via incorporeal-cms (pyproject.toml)
platformdirs==4.2.0
# via
# tox
# virtualenv
pluggy==1.4.0
# via
# pytest
# tox
py==1.11.0
# via tox
pycodestyle==2.9.1
pycodestyle==2.11.1
# via flake8
pydocstyle==6.1.1
pycparser==2.21
# via cffi
pydantic==1.10.14
# via
# safety
# safety-schemas
pydocstyle==6.3.0
# via flake8-docstrings
pydot==1.4.2
# via -r requirements/requirements-dev.in
pyflakes==2.5.0
pydot==2.0.0
# via incorporeal-cms (pyproject.toml)
pyflakes==3.2.0
# via flake8
pyparsing==3.0.9
# via
# packaging
# pydot
pytest==7.2.0
# via
# -r requirements/requirements-dev.in
# pytest-cov
pytest-cov==4.0.0
# via -r requirements/requirements-dev.in
pyyaml==6.0
# via bandit
requests==2.28.1
# via safety
ruamel-yaml==0.17.21
# via safety
ruamel-yaml-clib==0.2.7
# via ruamel-yaml
safety==2.3.5
# via -r requirements/requirements-dev.in
six==1.16.0
pygments==2.17.2
# via rich
pyparsing==3.1.1
# via pydot
pyproject-api==1.6.1
# via tox
smmap==5.0.0
# via gitdb
pyproject-hooks==1.0.0
# via
# build
# pip-tools
pytest==8.0.2
# via
# incorporeal-cms (pyproject.toml)
# pytest-cov
pytest-cov==4.1.0
# via incorporeal-cms (pyproject.toml)
python-dateutil==2.8.2
# via feedgen
pyyaml==6.0.1
# via bandit
requests==2.31.0
# via safety
rich==13.7.0
# via
# bandit
# safety
ruamel-yaml==0.18.6
# via
# safety
# safety-schemas
ruamel-yaml-clib==0.2.8
# via ruamel-yaml
safety==3.0.1
# via incorporeal-cms (pyproject.toml)
safety-schemas==0.0.2
# via safety
setuptools-scm==8.0.4
# via incorporeal-cms (pyproject.toml)
six==1.16.0
# via python-dateutil
snowballstemmer==2.2.0
# via pydocstyle
stevedore==4.1.1
stevedore==5.2.0
# via bandit
toml==0.10.2
# via dparse
tomli==2.0.1
# via
# build
# coverage
# pep517
# pytest
# tox
tox==3.28.0
# via
# -r requirements/requirements-dev.in
# tox-wheel
tox-wheel==1.0.0
# via -r requirements/requirements-dev.in
urllib3==1.26.13
# via requests
versioneer==0.28
# via -r requirements/requirements-dev.in
virtualenv==20.17.1
# via tox
werkzeug==2.2.2
# via flask
wheel==0.38.4
# via
# dparse
# flake8-pyproject
# mypy
# pip-tools
# tox-wheel
# pyproject-api
# pyproject-hooks
# pytest
# setuptools-scm
# tox
tox==4.13.0
# via incorporeal-cms (pyproject.toml)
typer==0.9.0
# via safety
typing-extensions==4.9.0
# via
# mypy
# pydantic
# safety
# safety-schemas
# setuptools-scm
# typer
urllib3==2.2.1
# via
# requests
# safety
virtualenv==20.25.1
# via tox
werkzeug==3.0.1
# via flask
wheel==0.42.0
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
# pip

View File

@ -1,2 +0,0 @@
Flask # general purpose web service and web server stuff
Markdown # markdown rendering in templates

View File

@ -1,22 +1,32 @@
#
# This file is autogenerated by pip-compile with python 3.10
# To update, run:
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
# pip-compile --output-file=requirements/requirements.txt pyproject.toml
#
click==8.1.3
blinker==1.7.0
# via flask
flask==2.2.2
# via -r requirements/requirements.in
click==8.1.7
# via flask
feedgen==1.0.0
# via incorporeal-cms (pyproject.toml)
flask==3.0.2
# via incorporeal-cms (pyproject.toml)
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
jinja2==3.1.3
# via flask
markdown==3.4.1
# via -r requirements/requirements.in
markupsafe==2.1.1
lxml==5.1.0
# via feedgen
markdown==3.5.2
# via incorporeal-cms (pyproject.toml)
markupsafe==2.1.5
# via
# jinja2
# werkzeug
werkzeug==2.2.2
python-dateutil==2.8.2
# via feedgen
six==1.16.0
# via python-dateutil
werkzeug==3.0.1
# via flask

View File

@ -1,6 +0,0 @@
[versioneer]
VCS = git
style = pep440-post
versionfile_source = incorporealcms/_version.py
versionfile_build = incorporealcms/_version.py
tag_prefix = v

View File

@ -1,36 +0,0 @@
"""Setuptools configuration."""
import os
from setuptools import find_packages, setup
import versioneer
HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires():
"""Get pinned requirements from requirements.txt."""
with open(os.path.join(HERE, 'requirements/requirements.txt'), 'r') as reqs:
return [line.split(' ')[0] for line in reqs if not line[0] in ('-', '#')]
setup(
name='incorporeal-cms',
description='Flask project for running https://suou.net (and eventually others).',
url='https://git.incorporeal.org/bss/incorporeal-cms',
author='Brian S. Stephan',
author_email='bss@incorporeal.org',
license='AGPLv3+',
classifiers=[
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
],
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=extract_requires(),
extras_require={
'dot': ['pydot'],
},
)

View File

@ -1,4 +1,8 @@
"""Create the test app and other fixtures."""
"""Create the test app and other fixtures.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
import pytest

View File

@ -1,4 +1,8 @@
"""Test graphviz functionality."""
"""Test graphviz functionality.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
from incorporealcms import create_app

View File

@ -1,4 +1,8 @@
"""Test page requests."""
"""Test page requests.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import re
from unittest.mock import patch
@ -184,18 +188,18 @@ def test_that_dir_request_does_not_redirect(client):
def test_setting_selected_style_includes_cookie(client):
"""Test that a request with style=foo sets the cookie and renders appropriately."""
response = client.get('/')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
style_cookie = client.get_cookie('user-style')
assert style_cookie is None
response = client.get('/?style=light')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
style_cookie = client.get_cookie('user-style')
assert response.status_code == 200
assert b'/static/css/light.css' in response.data
assert b'/static/css/dark.css' not in response.data
assert style_cookie.value == 'light'
response = client.get('/?style=dark')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
style_cookie = client.get_cookie('user-style')
assert response.status_code == 200
assert b'/static/css/dark.css' in response.data
assert b'/static/css/light.css' not in response.data

View File

@ -1,4 +1,8 @@
"""Configure the test application."""
"""Configure the test application.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
LOGGING = {
'version': 1,

View File

@ -0,0 +1 @@
../pages/forced-no-title.md

View File

@ -0,0 +1 @@
../pages/subdir-with-title/page.md

View File

@ -1,4 +1,8 @@
"""Test basic configuration stuff."""
"""Test basic configuration stuff.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
from incorporealcms import create_app

44
tests/test_feed.py Normal file
View File

@ -0,0 +1,44 @@
"""Test the feed methods.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
from incorporealcms.feed import serve_feed
def test_unknown_type_is_404(client):
"""Test that requesting a feed type that doesn't exist is a 404."""
response = client.get('/feed/wat')
assert response.status_code == 404
def test_atom_type_is_200(client):
"""Test that requesting an ATOM feed is found."""
response = client.get('/feed/atom')
assert response.status_code == 200
assert 'application/atom+xml' in response.content_type
print(response.text)
def test_rss_type_is_200(client):
"""Test that requesting an RSS feed is found."""
response = client.get('/feed/rss')
assert response.status_code == 200
assert 'application/rss+xml' in response.content_type
print(response.text)
def test_feed_generator_atom(app):
"""Test the root feed generator."""
with app.test_request_context():
content = serve_feed('atom')
assert b'<id>https://example.com/</id>' in content.data
assert b'<email>admin@example.com</email>' in content.data
assert b'<name>Test Name</name>' in content.data
def test_feed_generator_rss(app):
"""Test the root feed generator."""
with app.test_request_context():
content = serve_feed('rss')
assert b'<author>admin@example.com (Test Name)</author>' in content.data

View File

@ -1,4 +1,8 @@
"""Unit test helper methods."""
"""Unit test helper methods.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
import pytest

56
tox.ini
View File

@ -4,21 +4,11 @@
# and then run "tox" from this directory.
[tox]
envlist = begin,py38,py39,py310,coverage,security,lint,bundle
isolated_build = true
envlist = begin,py38,py39,py310,py311,py312,coverage,security,lint
[testenv]
# build a wheel and test it
wheel = true
wheel_build_env = build
# whitelist commands we need
whitelist_externals = cp
# install everything via requirements-dev.txt, so that developer environment
# is the same as the tox environment (for ease of use/no weird gotchas in
# local dev results vs. tox results) and also to avoid ticky-tacky maintenance
# of "oh this particular env has weird results unless I install foo" --- just
# shotgun blast install everything everywhere
allow_externals = pytest, coverage
deps =
-rrequirements/requirements-dev.txt
@ -46,6 +36,16 @@ commands =
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py311]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py312]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:coverage]
# report on coverage runs from above
skip_install = true
@ -56,23 +56,17 @@ commands =
# run security checks
#
# again it seems the most valuable here to run against the packaged code
# 51457 is nearly a red herring that I'm stuck with because tox is pinned, try removing occasionally
commands =
bandit {envsitepackagesdir}/incorporealcms/ -r
safety check -r requirements/requirements-dev.txt -i 51457
safety check -r requirements/requirements-dev.txt
[testenv:lint]
# run style checks
# TODO: mypy incorporealcms
commands =
flake8
- flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO
[testenv:bundle]
# take extra actions (build sdist, sphinx, whatever) to completely package the app
commands =
cp -r {distdir} .
python setup.py sdist
[coverage:paths]
source =
./
@ -83,23 +77,3 @@ branch = True
omit =
**/_version.py
[flake8]
enable-extensions = G,M
exclude =
.tox/
versioneer.py
_version.py
instance/
extend-ignore = T101
max-complexity = 10
max-line-length = 120
[isort]
line_length = 120
[pytest]
python_files =
*_tests.py
tests.py
test_*.py

File diff suppressed because it is too large Load Diff