173 Commits

Author SHA1 Message Date
55cfad90a9 use werkzeug safe_join to sanitize the requested path
no tests changed, so my implementation might have been good, but let's
use the provided check
2022-12-31 11:53:14 -06:00
b3dfab2611 simplify and better standardize the link underline 2022-12-31 11:33:36 -06:00
715bc38d78 serve per-instance static files at custom-static/ 2022-12-31 10:51:36 -06:00
e9af2de21e don't assume all styles are in the static directory
this is to make room for a second, instance-configured spot for them
2022-12-31 10:16:35 -06:00
83bc8b2c21 requirements bump, only affected dev tools 2022-12-31 10:13:20 -06:00
4a2f650a33 don't hardcode styles to present, use config
now that we can override the styles in practice, we also need to only
present what is possible in the HTML
2022-12-31 09:53:22 -06:00
fd0fb390ff allow for overriding PAGE_STYLES
moving this allows for per-instance customizations later, but that won't
be practical until serving styles from the instance dir is also allowed.
but, this sets the ground work and does allow for removing some styles
(e.g. if someone wanted to only allow 'plain').

also I still need to add the ability to present the themes list dynamically
2022-12-31 09:40:13 -06:00
be8a8dd35a test full path for stylesheets
I'm going to be screwing around with this code in some future commits so
it's better to be explicit
2022-12-31 09:02:57 -06:00
0f19fcb174 fix bad copy and paste job on link styles 2022-09-16 14:16:13 -05:00
f1684a57a9 requirements recompile 2022-09-16 13:49:57 -05:00
83eb464be9 style the potential for links in the footer 2022-09-16 13:40:23 -05:00
0f03ad6f38 allow pages to supply extra footer text 2022-09-16 13:35:40 -05:00
21ea24ffa1 header style tweaks, deemphasizing it a bit 2022-06-05 21:30:49 -05:00
724a2240b2 requirements bump for latest release 2022-05-25 07:24:03 -05:00
aa6a27dd8b make the header bigger, and align header and content padding better 2022-05-17 07:57:23 -05:00
c80172cffd go back to red headers and links as normal-colored text
the new way to do the links without adding links to images is probably
dumb and/or missing some stuff, but it works and does what I want, and I
think I like the old look of the colors better, so time to try it and
see if it sticks still
2022-05-17 07:57:23 -05:00
89ea2fb87e give the header nav links an underline on hover 2022-05-17 07:57:23 -05:00
8ac5b25208 get rid of the slight recoloring of bold text 2022-05-17 07:57:23 -05:00
54b953f5ed go back to the old, balanced width alignments 2022-05-17 07:57:23 -05:00
de0641b08f tweak the two-tone backgrounds and whitespace up the header 2022-05-17 07:57:23 -05:00
cc3e311738 clarify my DCO-like stance, and provide cloning info 2022-05-17 07:52:58 -05:00
985bb93839 do a requirements bump
notable changes:

* Flask 1.1 treats redirects to itself as relative URLs now, so a spate
  of tests were updated
2022-04-19 08:41:59 -05:00
3454de17fc style links with color, not underlines
this allows me to link to images (e.g. in figures) without having a
separate override class to remove their underline, which is ugly. on
account of making links red, it was looking like too much red
(especially on the index) with headers also being red, so this makes
headers the default text color.

still not 100% committed to this but I'm trying it out
2022-04-19 08:41:59 -05:00
1fe6623587 remove .img-frame class, unnecessary with figures 2022-04-19 08:41:59 -05:00
ec042e466c scoot the footer down a bit, and give it margins like the main content's 2022-04-19 08:41:59 -05:00
6daed848d0 remove duplicate link styles
all these subclasses or whatever are the same
2022-04-19 08:41:59 -05:00
502adac89c make the pydot syntax backwards compatible with code fences
without this, the code fence parser was getting thrown off if you didn't
have the pydot extension loaded, which was thwarting backwards
compatibility. this makes the pydot bits look like an attribute to the
vanilla parser, so at least then the vanilla markdown renders as
intended
2022-04-19 08:41:59 -05:00
08305e26db pydot: handle multiple dot images in one file 2022-04-19 08:41:59 -05:00
9a65dfffaf tweaks to the content responsiveness and header padding
more readability stuff for desktop without (hopefully) affecting the
mobile experience much
2022-04-19 08:41:58 -05:00
a42ce34aad add a bit of whitespace in content text
meant to help readability slightly, in part because of what I'm screwing
around with in changing link and header colors
2022-04-19 08:41:58 -05:00
9ce8e9a1cc restyle figures and captions for more flexibility 2022-04-19 08:41:58 -05:00
d07260331b remove unused figure classes
these are being reworked and I don't think I'm going to do responsive
stuff anymore
2022-04-19 08:41:58 -05:00
2c47501589 remove unused thumbnail class
used to appear on a couple images, the styling was replaced with some
more explicit image tags and soon will be replaced again with figure
stuff
2022-04-19 08:41:58 -05:00
f15d955067 initial crack at a block-level figure parser
I didn't like the other figure + figcaption parsers, they either assumed
a lot about usage (e.g. images only), or they were inline parsers that
either wrapped the figure in a paragraph tag (which is incorrect syntax)
or did span trickery (annoying)

so, this handles images and maybe other things, and does things properly
with figures as their own blocks. incomplete but it works with my
images, and should allow for looping (for multi-line content) in the
future?
2022-04-19 08:41:55 -05:00
b3c3c0de45 clean up the default markdown configuration a bit
this brings the config a bit closer to my default, and updates the
expectations of a test accordingly
2022-04-19 06:45:32 -05:00
853a58b78b make the breadcrumbs and style menu a bit nicer 2022-03-24 22:41:34 -05:00
3cade4fe73 add the image floats to the plain CSS
primarily it's the main page that just looks weird without it
2022-03-24 22:28:09 -05:00
63a764c6a0 don't use system tz, display timestamps as UTC
no particular reason, just one less dependency, and it goes further
towards my idyllic world where everyone is on UTC and there are no
timezones
2022-03-24 22:12:00 -05:00
d129d04e04 remove mdx_linkify and do requirement bumps 2022-03-24 22:00:37 -05:00
410f96ffb4 relicense under the AGPLv3+
after a lot of deliberation I think I'm starting to prefer GPLv3 over
GPLv2 for copyleft, and this is a case where my rationale benefits from
the additions of the Affero clause
2022-01-17 16:55:24 -06:00
e325831f70 some tweaks to the CONTRIBUTING section 2022-01-17 16:49:40 -06:00
56289ab75d remove warning for coverage feature not in use 2022-01-15 17:40:12 -06:00
d623c9c93d adding support for 3.10, dropping support for 3.7 2022-01-15 17:35:56 -06:00
9d87aab61b go back to lighter red in dark style
contrast is just too low for the "incorporeal red"
2022-01-03 12:40:30 -06:00
35ea94185b unify some of the colors in light vs. dark 2022-01-02 22:55:43 -06:00
69feb0c29c add a 75% max-width class, using it for GOTY? 2022-01-02 22:51:21 -06:00
788a9cbaba use a smaller font for the styled pages 2022-01-02 22:50:04 -06:00
be6d96273c eliminate warnings about how we register the pydot markdown extension 2021-11-03 14:16:18 -05:00
a700470067 document the customization options
more work towards #15
2021-11-01 23:36:10 -05:00
8a62167cea remove some self-specific stuff from settings
implements most, if not all, of #15
2021-11-01 23:27:00 -05:00
4ea824e86f provide some comments explaining the css files 2021-11-01 23:20:42 -05:00
28dbfd45b5 remove bss-specific image from the package
part of the work of #15
2021-11-01 23:20:16 -05:00
1de69dfc70 requirements bump 2021-10-08 07:30:46 -05:00
ccf8434f43 remove a bunch of unnecessary font size styling
in the end, a lot of this doesn't really matter, and right now, I
slightly prefer the site with a touch bigger font, so let's just let the
default do its thing
2021-10-08 07:16:31 -05:00
5aabb79199 call the pydot rendering support 'dot' in package extras 2021-10-08 07:09:21 -05:00
509072ab78 safety in tox: scan requirements for known bad packages 2021-06-24 11:46:56 -05:00
e61c55bed2 handle graphviz parsing errors more cleanly 2021-06-24 11:37:57 -05:00
41a53a2a13 add py39 environment to tox test envs 2021-06-24 11:23:36 -05:00
da055acda6 provide markdown extension to render graphviz
this is server side, and a more standard format, and thus I like it more
than mermaid, which I've been using at work. but, I really wanted a
server-side option (see my manifesto) for drawing relationship graphs,
for D&D stuff of all things.

this adds an optional 'graphviz' feature to package installation which
consequently depends on pydot
2021-06-24 09:46:26 -05:00
1583e3be99 more debugging and catch md misconfigured error 2021-06-24 09:43:00 -05:00
946a557177 correct the license declaration to match LICENSE and README 2021-06-24 08:34:26 -05:00
f0d4e7d3d9 have footer clear: both;
closes #14
2021-06-06 22:28:43 -05:00
954f7f4e80 allow markdown files to specify a redirect
closes #13
2021-06-06 22:24:35 -05:00
27bb139a2b add installation and usage information 2021-06-05 22:59:00 -05:00
c15862850f add a CONTRIBUTING file 2021-06-04 17:41:18 -05:00
afbfab338f properly apply site wrap classes to style wide version
fixes #12
2021-06-03 07:52:01 -05:00
cac6b40af5 relicense the project under GPLv2
prepping for an actual public release of a sort, this hopefully
clarifies the license and copyright

license from https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
2021-06-02 07:44:23 -05:00
65bc028524 implement base-wide.html as an extension of base.html
not quite sure why I didn't think of this initially... too much hacking
2021-05-19 22:30:20 -05:00
e8377adcf5 allow pages to specify different templates 2021-04-30 19:41:39 -05:00
f4beb15a3b make directory redirects absolute paths
I think this is always the right choice, since we're rewriting the full
input path
2021-04-17 15:06:39 -05:00
da447d2873 Merge branch 'master' of git.incorporeal.org:bss/incorporeal-cms 2021-04-17 14:57:20 -05:00
cde82ab918 don't route /media separately anymore 2021-04-17 11:16:34 -05:00
1ac13f3b9c add some 500 tests for test coverage 2021-04-17 11:08:01 -05:00
6705fa38eb requirements bumps 2021-04-17 10:58:06 -05:00
30b79e9dc1 add tests for subdir symlinks
this is automagically supported by the previous rewrite
2021-04-17 10:39:05 -05:00
60715a3a5c make request -> instance conversion support symlink dirs
I think this also clarifies the code, a bit
2021-04-17 10:31:05 -05:00
c90f0a3a42 treat symlinks as redirects
closes #7
2021-04-15 21:44:02 -05:00
71ead20f3f have file handler return render type rather than bool
for when we have further types to render
2021-04-15 20:36:30 -05:00
be88c3c1bc don't error on breadcrumbs if a dir doesn't have index.md
fixes #8
2021-04-14 21:35:14 -05:00
ced67bec8b allow for serving files directly inside pages/ 2021-04-14 20:45:50 -05:00
757b067e16 create a "plain" style with next to no CSS 2021-03-09 09:10:33 -06:00
06d948a709 have specific styles @import the base styles
this clarifies the value of what was formerly "style.css" a bit, and
also opens the door for potential styles that don't inherit the base
styling at all
2021-03-07 23:09:58 -06:00
d89fd151ca use just the page part of the path in breadcrumbs
rather than showing the full path (e.g. /foo/bar/baz) in breadcrumbs
when the page doesn't have a Title, show just the leaf (baz)

Closes #4
2021-02-27 00:30:32 -06:00
ce1ed60dd2 allow for configuration to override the favicon
Closes #5
2021-02-27 00:10:03 -06:00
f46bff6ec6 tweak language around the email 2021-02-23 13:16:58 -06:00
70a8d4f06a add configurable contact email for error pages 2021-02-23 13:11:52 -06:00
085571e58f requiremnets bump 2021-02-22 21:20:49 -06:00
0bfea79a8f log the OSError before returning 500 2021-02-21 19:37:59 -06:00
39d78af524 add error handler pages for 400, 404, 500 2021-02-21 19:35:52 -06:00
e570ee26b5 clean up unused imports 2021-02-21 18:41:09 -06:00
411ecebbc6 fold some nav/style menu styles into header
unnecessarily duplicated now that I have div.header, and also useful for
when I start the error handling pages
2021-02-21 10:14:42 -06:00
c1801b0086 move render() into shared spot
going to be used by error page handling code, once it exists
2021-02-20 23:36:03 -06:00
f08c1117d8 move site suffix into title always
this tweaks the behavior of the title to always append ' - suffix' to
any title (from the meta tag, or generated via request path), unless the
page explicitly specifies an empty Title meta tag
2021-02-20 23:21:29 -06:00
dd7687884a remove resolve_page_file, been refactored away 2021-02-20 22:50:56 -06:00
15c88d920b use request path as an alternative to Title metadata 2021-02-20 22:43:42 -06:00
1cef3b8196 rewrite generate_parent_navs to work on resource paths
the old code was kind of impossible to understand by reading it, so this
is hopefully considerably clearer
2021-02-20 21:47:39 -06:00
faf4a7f166 minor style cleanup 2021-02-20 19:22:23 -06:00
4dcc1c91c2 add method to from resource path to request path 2021-02-20 19:19:36 -06:00
1c40f45ffd clarify name of request_path_to_instance_resource_path 2021-02-20 17:53:32 -06:00
6026c51490 add some functional tests for our sanity checks 2021-02-20 17:47:36 -06:00
2e0e87fe95 begin rewriting path to resource resolver
this code was getting too messy and scattered, and I realized that Flask
wasn't doing as much as I thought it was here, so now we need more
safety and sanity checks
2021-02-20 17:42:58 -06:00
b6aa125b8d add sane_lists to markdown extensions
this fixes stuff like

* foo
* bar

1. hax
2021-02-13 11:07:00 -06:00
15142054da tweak the appearance of footnotes 2021-02-12 19:37:25 -06:00
dc81ef35de float image left/right for inlining in an article 2021-02-12 12:51:43 -06:00
c292f33334 CSS for framing an image inline the article 2021-02-12 12:51:12 -06:00
1c052b8409 pin bandit in requirements-dev since 1.7.0 is weird in tox 2021-02-12 09:28:12 -06:00
7cf8a427ce add an .img-25 for 25% wide images 2021-02-12 09:26:10 -06:00
e8a749d9ba Revert "tweak the base text line height, again"
This reverts commit 1878d5951b.

the more I look at this, the more I like the old text spacing
2021-02-12 09:21:21 -06:00
ae72fe87b5 class to center an image as a block element
this is effectively a replacement for div.splash means of getting a
centered header image, and can be used anywhere
2021-02-12 09:19:35 -06:00
bb0e71e9e4 give *all* images max-width of the inner column
this was done for the giant splash logo but I should really just
restrain this everywhere
2021-02-12 09:18:54 -06:00
3bfdacdb6d add attr_list to markdown extensions
this will lead to me putting less HTML in the .md files, which is a good
thing
2021-02-12 09:15:41 -06:00
e6d2015de5 use smarty markdown extension for dashes, ellipses 2021-02-11 19:05:01 -06:00
56eb767e33 don't let sub/superscripts affect line height 2021-02-11 18:53:57 -06:00
07031fe667 enable footnotes extra for markdown 2021-02-11 18:36:48 -06:00
48c6e8495a provide some styling of footnotes 2021-02-11 18:20:42 -06:00
4f45943775 initialize markdown on a per-page basis
the footnote extra expects to only parse one document over the Markup's
lifetime, and writes the footnotes to the bottom of every page that is
rendered (again assuming only one) with links back to the reference

having one parser for the entire app, naturally, introduced
ever-increasing footnote links and every footnote on the site showing up
on every page. this was not intended

in some light testing, doing this per-request has a nominal effect on
performance
2021-02-11 18:17:26 -06:00
b26ea6a661 add html tag in order to specify lang="en" 2021-02-11 09:36:24 -06:00
1878d5951b tweak the base text line height, again 2021-02-11 09:35:21 -06:00
829165ad8c style link underline same color as the hover 2021-02-11 09:35:21 -06:00
7d982b96c9 tweak text colors; less normal, more bold 2021-02-11 09:35:21 -06:00
5e41cde52e use a flexbox for the header sections
this is better than a float because I have always kind of hated how
floating divs work, and this also orders and displays the navs better in
elinks
2021-02-11 00:23:19 -06:00
ad33cf2e83 replace section tags with div tags
syntactically incorrect usage, as picked up by a W3C validator
2021-02-11 00:08:19 -06:00
87ad48d8d2 add mdx-linkify to markdown extensions 2021-01-22 09:51:53 -06:00
8a6f4d6b45 test multi-line metadata entries 2021-01-17 23:58:57 -06:00
c25fefa9e3 add opengraph metadata to pages, via Markdown meta 2021-01-17 23:02:14 -06:00
b0795999fe make splash images look better on small devices 2020-12-14 16:26:08 -06:00
aaced9d0e1 add polycephaly-style figure support
this is really pushing my patience for CSS, but I've always thought this
looked nice, so I'm going to try to retain it
2020-12-14 16:25:35 -06:00
4042932240 tone down the line-height a bit
I think the color changes and using viewport magic has helped
readability a bit
2020-12-08 18:56:49 -06:00
49ab2befb6 disable browser resize magic, do viewport magic instead 2020-12-08 18:47:02 -06:00
fbf6a81e0b use my old "square" logo as favicon
also provide a backgroundless version as a possible splash page image
2020-12-08 18:46:04 -06:00
dabf9f7544 more corrections of the link colors 2020-12-08 18:27:55 -06:00
dcf173ab61 add a test to ensure style selection works 2020-12-08 17:12:35 -06:00
d2c1c2e3ce why did I make user styles a config setting???
this moves it into the code, where it's sensible, and leaves the default
to the config
2020-12-08 16:43:20 -06:00
3fcf916317 requirements bump 2020-12-08 16:33:19 -06:00
67e1890629 increase the line height for readability(?) 2020-12-07 21:55:56 -06:00
e1cb541ea5 highlight links in light theme as in dark theme 2020-12-07 21:55:32 -06:00
93e9c8dc24 tweaks to the dark theme 2020-12-07 21:54:43 -06:00
7cf11986c5 user-selectable light and dark themes
cookies, template rendering with different CSS files via default or
request param or cookie, etc.
2020-10-30 00:19:19 -05:00
5ca483a904 configurable markdown extensions
meta is always loaded, because the code expects it
2020-10-29 23:51:58 -05:00
fe7d61e1f7 actually style the white bg beyond the viewport scroll 2020-10-25 18:05:48 -05:00
1398cfe3db put some sidebars on the site for readability 2020-10-25 17:48:19 -05:00
f63de031f6 tox updates: run py38, combine coverage, dist-as-dir 2020-10-20 16:07:49 -05:00
46bce5a0a5 recompile all requirements, add flake8-mutable 2020-10-20 16:05:17 -05:00
0af0f4e8aa tox.ini updates, use requirements-dev.txt, fix pathing 2020-06-23 13:33:15 -05:00
08896a18c1 reorganize requirements-dev.in, add dlint and flake8-fixme, bandit 2020-06-23 13:30:49 -05:00
ea7c9a1e07 let TODOs through linting, but warn about them 2020-06-22 19:09:39 -05:00
63da59efd5 enable flake8-logging-format violations 2020-06-22 18:50:13 -05:00
c7d4a1c930 add any suppressed flake8-fixme messages in the fail-open run 2020-06-22 18:49:34 -05:00
421d0e6f8e properly create the symlink to dist/ across multiple runs of tox 2020-06-22 18:48:22 -05:00
5c1fc93ff9 combine tox deps in order to unconfuse flake8-isort
with pytest not being included in the lint environment, flake8-isort
didn't know how to treat it vs. incorporealcms imports, leading to false
positives only inside tox. this makes it so that certain packages
(defined in base deps) can be imported in any/all envs, because they
show up in analyzed/imported/etc code rather than being merely tools
2020-06-22 18:48:18 -05:00
7b5f7ff00b add dlint and flake8-fixme 2020-06-20 10:48:46 -05:00
9db5189c65 add flake8-isort, with a caveat 2020-06-19 20:23:23 -05:00
ab2d754e43 reorganize tox.ini a bit and use pytest-cov rather than coverage directly 2020-06-19 20:01:06 -05:00
0f7495bf2b add the ability to redirect a file-looking request to a dir
if the client has requested /foo, and foo is actually a directory,
this redirects the client to /foo/
2020-06-19 19:58:12 -05:00
cf8f0325a2 fix /most/ isort problems, but conftest.py is being weird 2020-06-19 19:54:01 -05:00
718b217868 add flake8 and many plugins to requirements-dev, for vim's sake 2020-06-19 19:40:01 -05:00
ebaccbd0ad organize tests a bit better between unit and functional tests 2020-06-18 23:36:51 -05:00
63f13398e0 versioneer.py doesn't need to be included in the package 2020-06-18 23:29:37 -05:00
605a82680d add bandit and flake8 plugins to tox, remove redundant deps 2020-06-18 17:39:34 -05:00
14f6125f4e use new-style tox.ini, add flake8-docstrings, add docstrings 2020-06-17 20:18:43 -05:00
21f65813fb properly run pytest + cov in the tox env 2020-06-17 16:34:50 -05:00
f77aebb097 replace CI tools with tox invocation 2020-06-16 23:00:49 -05:00
5994b73b2e give tables a lighter border 2020-06-14 10:56:57 -05:00
dadc902c49 put a bit of a background behind blockquote
closes #2
2020-06-14 10:55:24 -05:00
5c8251d01a explicitly set the footer margin-top 2020-06-14 10:01:07 -05:00
29498504cc get the actual pinned requirements in setup.py 2020-05-28 17:00:58 -05:00
ce06de78a8 tests misleadingly had a leading /, need to append it ourselves 2020-05-28 16:52:43 -05:00
beea0c80bf CSS: slightly tweak/specify the text size/height 2020-05-28 12:18:28 -05:00
ab977f7e81 header CSS tweaks 2020-05-28 12:18:04 -05:00
05f879ab80 display untitled-page paths as /path rather than path.md 2020-05-28 12:17:27 -05:00
059108c37b rewrite generate_parent_navs
* works on a path now, not a file location
* as such is sliiiiiightly easier to understand
* now also puts the current page in the nav
* fixed failing test where this caused an error (rather than 404) on
  non-existent paths
2020-05-28 12:09:59 -05:00
0993147dea give tables a bottom margin
otherwise they look bad, for instance, at the very end of the page, too
close to the "Last modified" text.
2020-05-28 08:20:24 -05:00
9e97cb097e requirements bump; tests pass 2020-05-28 08:13:55 -05:00
59 changed files with 2639 additions and 886 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ __pycache__/
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
dist
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/

76
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,76 @@
# How to Contribute
incorporeal-cms is a personal project seeking to implement a simpler, cleaner form of what would
commonly be called a "CMS". I appreciate any help in making it better.
incorporeal-cms is made available under the GNU Affero General Public License version 3, or any
later version.
## Opening Issues
Issues should be posted to my Gitea instance at
<https://git.incorporeal.org/bss/incorporeal-cms/issues>. I'm not too picky about format, but I
recommend starting the title with "Improvement:", "Bug:", or similar, so I can do a high level of
prioritization.
## 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
contributing often are also welcome to request access to the project directly.
Otherwise, contact me via any means you know to reach me at, or <bss@incorporeal.org>, to discuss
your change and to tell me how to pull your changes.
### Guidelines for Patches, etc.
* Cloning
* Clone the project. I would advise using a pull-based workflow where I have access to the hosted
repository --- using my Gitea, cloning to a public GitHub, etc. --- rather than doing this over
email, but that works too if we must.
* Make your contributions in a new branch, generally off of `master`.
* Send me a pull request when you're ready, and we'll go through a code review.
* Code:
* Keep in mind that I strive for simplicity in the software. It serves files and renders
Markdown, that's pretty much it. Features around that function are good; otherwise, I need
convincing.
* Follow the style precedent set in the code. Do **not** use Black, or otherwise reformat existing
code. I like it the way it is and don't need a militant tool making bad decisions about what is
readable.
* `tox` should run cleanly, of course.
* Almost any change should include unit tests, and also functional tests if they provide a feature
to the CMS functionality. For defects, include unit tests that fail on the unfixed codebase, so I
know exactly what's happening.
* Commits:
* Squash tiny commits if you'd like. I prefer commits that make one atomic conceptual change
that doesn't affect the rest of the code, assembling multiple of those commits into larger
changes.
* Follow something like [Chris Beams's post](https://chris.beams.io/posts/git-commit/) on
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.

1279
LICENSE

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
graft incorporealcms/static graft incorporealcms/static
graft incorporealcms/templates graft incorporealcms/templates
include versioneer.py
global-exclude *.pyc global-exclude *.pyc
global-exclude *.swp global-exclude *.swp

View File

@@ -1,3 +1,66 @@
# incorporeal-cms # incorporeal-cms
Software that makes incorporeal.org go. Software that makes simple Markdown content go.
## Installation and Usage
I recommend getting a release from <https://git.incorporeal.org/bss/incorporeal-cms/releases> and
installing the Python package in a virtualenv. Something like the following should suffice:
```
% cd ~/site
% virtualenv --python=python3.8 env-py3.8
% source env-py3.8/bin/activate
% pip install -U pip
% pip install ~/incorporeal_cms-1.3.0-py3-none-any.whl
% pip install -U gunicorn
% gunicorn -w 5 -t 60 -b 127.0.0.1:10000 --reload 'incorporealcms:create_app()'
```
This will get the CMS up and running, and listening on the specified port. The application is
further configured within `env-py3.8/var/incorporealcms-instance/config.py`, and content is served
out of `env-py3.8/var/incorporealcms-instance/pages/`.
## Serving a Site
Put content inside `env-py3.8/var/incorporealcms-instance/pages/` and go.
* Markdown files (ending in `.md`) are rendered via Python-Markdown if they are accessed without the
suffix (i.e., `post.md` should be referred to as `/post` to get it to render as Markdown.
* Directory paths (e.g. `/dir/`) can be rendered with a `/dir/index.md` file.
* Symlinks to files are treated as redirects to the destination content.
* Request paths with file suffixes are not rendered and served directly, so images, etc., can be
referenced naturally, and even the unrendered Markdown can be served as a text file via e.g.
`/post.md`.
Care is taken to not serve content above the `pages/` dir, even via symlink.
## Configuration
I've tried to keep the software agnostic to my personal domains, logos, etc. There are some settings
you are probably interested in tweaking, by specifying new values in
`incorporealcms-instance/config.py`:
* `TITLE_SUFFIX` is appended to the title of every page, separated from other title content by a
dash.
* `CONTACT_EMAIL` is referred to in error templates.
* `FAVICON` supplies the image used in browser tabs and that kind of thing.
If I missed anything, please let me know.
## Author and Licensing
Written by and copyright Brian S. Stephan (bss@incorporeal.org).
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

View File

@@ -1,9 +1,9 @@
"""create_app application factory function and similar things.""" """An application for running my Markdown-based sites."""
import logging import logging
import os import os
from logging.config import dictConfig from logging.config import dictConfig
from flask import Flask, request, send_from_directory from flask import Flask, request
from ._version import get_versions from ._version import get_versions
@@ -12,6 +12,7 @@ del get_versions
def create_app(instance_path=None, test_config=None): def create_app(instance_path=None, test_config=None):
"""Create the Flask app, with allowances for customizing path and test settings."""
app = Flask(__name__, instance_relative_config=True, instance_path=instance_path) app = Flask(__name__, instance_relative_config=True, instance_path=instance_path)
# if it doesn't already exist, create the instance folder # if it doesn't already exist, create the instance folder
@@ -39,12 +40,11 @@ def create_app(instance_path=None, test_config=None):
logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status) logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status)
return response return response
@app.route(f'/{app.config["MEDIA_DIR"]}/<path:filename>') from . import error_pages, pages, static
def media_files(filename):
return send_from_directory(os.path.join(app.instance_path, app.config['MEDIA_DIR']),
filename)
from . import pages
app.register_blueprint(pages.bp) app.register_blueprint(pages.bp)
app.register_blueprint(static.bp)
app.register_error_handler(400, error_pages.bad_request)
app.register_error_handler(404, error_pages.page_not_found)
app.register_error_handler(500, error_pages.internal_server_error)
return app return app

View File

@@ -32,5 +32,32 @@ class Config(object):
}, },
} }
TITLE_SUFFIX = 'incorporeal.org' MARKDOWN_EXTENSIONS = ['extra', 'incorporealcms.mdx.figures', 'sane_lists', 'smarty', 'toc']
MARKDOWN_EXTENSION_CONFIGS = {
'extra': {
'footnotes': {
'UNIQUE_IDS': True,
},
},
'smarty': {
'smart_dashes': True,
'smart_quotes': False,
'smart_angled_quotes': False,
'smart_ellipses': True,
},
}
MEDIA_DIR = 'media' MEDIA_DIR = 'media'
# customizations
PAGE_STYLES = {
'dark': '/static/css/dark.css',
'light': '/static/css/light.css',
'plain': '/static/css/plain.css',
}
DEFAULT_PAGE_STYLE = 'light'
TITLE_SUFFIX = 'example.com'
CONTACT_EMAIL = 'admin@example.com'
# specify FAVICON in your instance config.py to override the provided icon

View File

@@ -0,0 +1,17 @@
"""Error page views for 400, 404, etc."""
from incorporealcms.lib import render
def bad_request(error):
"""Display 400 error messaging."""
return render('400.html'), 400
def internal_server_error(error):
"""Display 500 error messaging."""
return render('500.html'), 500
def page_not_found(error):
"""Display 404 error messaging."""
return render('404.html'), 404

47
incorporealcms/lib.py Normal file
View File

@@ -0,0 +1,47 @@
"""Miscellaneous helper functions and whatnot."""
import logging
import markdown
from flask import current_app as app
from flask import make_response, render_template, request
logger = logging.getLogger(__name__)
def get_meta_str(md, key):
"""Provide the page's (parsed in Markup obj md) metadata for the specified key, or '' if unset."""
return " ".join(md.Meta.get(key)) if md.Meta.get(key) else ""
def init_md():
"""Initialize the Markdown parser.
This used to done at the app level in __init__, but extensions like footnotes apparently
assume the parser to only live for the length of parsing one document, and create double
footnote ref links if the one parser sees the same document multiple times.
"""
# initialize markdown parser from config, but include
# extensions our app depends on, like the meta extension
return markdown.Markdown(extensions=app.config['MARKDOWN_EXTENSIONS'] + ['meta'],
extension_configs=app.config['MARKDOWN_EXTENSION_CONFIGS'])
def render(template_name_or_list, **context):
"""Wrap Flask's render_template.
* Determine the proper site theme to use in the template and provide it.
"""
page_styles = app.config['PAGE_STYLES']
selected_style = request.args.get('style', None)
if selected_style:
user_style = selected_style
else:
user_style = request.cookies.get('user-style')
logger.debug("user style cookie: %s", user_style)
context['user_style'] = page_styles.get(user_style, page_styles.get(app.config['DEFAULT_PAGE_STYLE']))
context['page_styles'] = page_styles
resp = make_response(render_template(template_name_or_list, **context))
if selected_style:
resp.set_cookie('user-style', selected_style)
return resp

View File

@@ -0,0 +1 @@
"""Markdown extensions."""

View File

@@ -0,0 +1,60 @@
"""Create generic figures with captions."""
import re
from xml.etree.ElementTree import SubElement
import markdown
class FigureExtension(markdown.Extension):
"""Wrap the markdown prepcoressor."""
def extendMarkdown(self, md):
"""Add FigureBlockProcessor to the Markdown instance."""
md.parser.blockprocessors.register(FigureBlockProcessor(md.parser), 'figure', 100)
class FigureBlockProcessor(markdown.blockprocessors.BlockProcessor):
"""Process figures."""
# |> thing to put in the figure
# |: optional caption for the figure
# optional whatever else, like maybe an attr_list
figure_regex = re.compile(r'^[ ]{0,3}\|>[ ]{0,3}(?P<content>[^\n]*)')
caption_regex = re.compile(r'^[ ]{0,3}\|:[ ]{0,3}(?P<caption>[^\n]*)')
def test(self, parent, block):
"""Determine if we should process this block."""
lines = block.split('\n')
return bool(self.figure_regex.search(lines[0]))
def run(self, parent, blocks):
"""Replace the top block with HTML."""
block = blocks.pop(0)
lines = block.split('\n')
# consume line and create a figure
figure_match = self.figure_regex.search(lines[0])
lines.pop(0)
content = figure_match.group('content')
figure = SubElement(parent, 'figure')
figure.text = content
if lines:
if caption_match := self.caption_regex.search(lines[0]):
# consume line and add the caption as a child of the figure
lines.pop(0)
caption = caption_match.group('caption')
figcaption = SubElement(figure, 'figcaption')
figcaption.text = caption
if lines:
# other lines are mysteries, might be attr_list, so re-append
# make sure there's a child to hang the rest (which is maybe an attr_list?) off of
# this is probably a bad hack
if not len(list(figure)):
SubElement(figure, 'span')
rest = '\n'.join(lines)
figure[-1].tail = f'\n{rest}'
def makeExtension(*args, **kwargs):
"""Provide the extension to the markdown extension loader."""
return FigureExtension(*args, **kwargs)

View File

@@ -0,0 +1,54 @@
"""Serve dot diagrams inline."""
import base64
import logging
import re
import markdown
import pydot
logger = logging.getLogger(__name__)
class InlinePydot(markdown.Extension):
"""Wrap the markdown prepcoressor."""
def extendMarkdown(self, md):
"""Add InlinePydotPreprocessor to the Markdown instance."""
md.preprocessors.register(InlinePydotPreprocessor(md), 'dot_block', 100)
class InlinePydotPreprocessor(markdown.preprocessors.Preprocessor):
"""Identify dot codeblocks and run them through pydot."""
BLOCK_RE = re.compile(r'~~~{\s+pydot:(?P<filename>[^\s]+)\s+}\n(?P<content>.*?)~~~', re.DOTALL)
def run(self, lines):
"""Match and generate diagrams from dot code blocks."""
text = '\n'.join(lines)
out = text
for block_match in self.BLOCK_RE.finditer(text):
filename = block_match.group(1)
dot_string = block_match.group(2)
logger.debug("matched markdown block: %s", dot_string)
logger.debug("match start/end: %s/%s", block_match.start(), block_match.end())
# use pydot to turn the text into pydot
graphs = pydot.graph_from_dot_data(dot_string)
if not graphs:
logger.debug("some kind of issue with parsed 'dot' %s", dot_string)
raise ValueError("error parsing dot text!")
# encode the image and provide as an inline image in markdown
encoded_image = base64.b64encode(graphs[0].create_png()).decode('ascii')
data_path = f'data:image/png;base64,{encoded_image}'
inline_image = f'![{filename}]({data_path})'
# replace the image in the output markdown
out = out.replace(block_match.group(0), inline_image)
return out.split('\n')
def makeExtension(*args, **kwargs):
"""Provide the extension to the markdown extension loader."""
return InlinePydot(*args, **kwargs)

View File

@@ -2,67 +2,202 @@
import datetime import datetime
import logging import logging
import os import os
import re
import markdown from flask import Blueprint, Markup, abort
from flask import Blueprint, Markup, abort, current_app as app, render_template from flask import current_app as app
from tzlocal import get_localzone from flask import redirect, request, send_from_directory
from werkzeug.security import safe_join
from incorporealcms.lib import get_meta_str, init_md, render
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('pages', __name__, url_prefix='/') bp = Blueprint('pages', __name__, url_prefix='/')
md = markdown.Markdown(extensions=['meta', 'tables'])
@bp.route('/', defaults={'path': 'index'}) @bp.route('/', defaults={'path': 'index'})
@bp.route('/<path:path>') @bp.route('/<path:path>')
def display_page(path): def display_page(path):
"""Get the file contents of the requested path and render the file.""" """Get the file contents of the requested path and render the file."""
resolved_path = resolve_page_file(path)
parent_navs = generate_parent_navs(resolved_path)
logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path)
try: try:
with app.open_instance_resource(resolved_path, 'r') as entry_file: resolved_path, render_type = request_path_to_instance_resource_path(path)
logger.debug("file '%s' found", resolved_path) logger.debug("received request for path '%s', resolved to '%s', type '%s'",
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone()) path, resolved_path, render_type)
entry = entry_file.read() except PermissionError:
abort(400)
except IsADirectoryError:
return redirect(f'/{path}/', code=301)
except FileNotFoundError: except FileNotFoundError:
logger.warning("requested path '%s' (resolved path '%s') not found!", path, resolved_path)
abort(404) abort(404)
if render_type == 'file':
return send_from_directory(app.instance_path, resolved_path)
elif render_type == 'symlink':
logger.debug("attempting to redirect path '%s' to reverse of resource '%s'", path, resolved_path)
redirect_path = f'/{instance_resource_path_to_request_path(resolved_path)}'
logger.debug("redirect path: '%s'", redirect_path)
return redirect(redirect_path, code=301)
elif render_type == 'markdown':
logger.debug("treating path '%s' as markdown '%s'", path, resolved_path)
return handle_markdown_file_path(resolved_path)
else: else:
content = Markup(md.convert(entry)) logger.exception("unsupported render_type '%s'!?", render_type)
abort(500)
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)
except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path)
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) logger.debug("file metadata: %s", md.Meta)
title = " ".join(md.Meta.get('title')) if md.Meta.get('title') else ""
return render_template('base.html', title=title, content=content, navs=parent_navs, parent_navs = generate_parent_navs(resolved_path)
mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'))
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
redirect_url = get_meta_str(md, 'redirect') if md.Meta.get('redirect') else None
if redirect_url:
logger.debug("redirecting via meta tag to '%s'", redirect_url)
return redirect(redirect_url, code=301)
return render(template, title=page_title, description=get_meta_str(md, 'description'),
image=get_meta_str(md, 'image'), base_url=request.base_url, content=content,
navs=parent_navs, mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'),
extra_footer=extra_footer)
def resolve_page_file(path): def request_path_to_instance_resource_path(path):
"""Manipulate the request path to find appropriate page file. """Turn a request URL path to the full page path.
* convert dir requests to index files flask.Flask.open_instance_resource will open a file like /etc/hosts if you tell it to,
which sucks, so we do a lot of work here to make sure we have a valid request to
Worth noting, Flask already does stuff like convert '/foo/../../../bar' to something inside the pages dir.
'/bar', so we don't need to take care around file access here.
""" """
if path.endswith('/'): # check if the path is allowed
path = f'{path}index' base_dir = os.path.realpath(f'{app.instance_path}/pages/')
path = f'pages/{path}.md' safe_path = safe_join(base_dir, path)
return path # bail if the requested real path isn't inside the base directory
if not safe_path:
logger.warning("client tried to request a path '%s' outside of the base_dir!", path)
raise PermissionError
verbatim_path = os.path.abspath(safe_path)
resolved_path = os.path.realpath(verbatim_path)
logger.debug("base_dir '%s', constructed resolved_path '%s' for path '%s'", base_dir, resolved_path, path)
# see if we have a real file or if we should infer markdown rendering
if os.path.exists(resolved_path):
# if this is a file-like request but actually a directory, redirect the user
if os.path.isdir(resolved_path) and not path.endswith('/'):
logger.info("client requested a path '%s' that is actually a directory", path)
raise IsADirectoryError
# if the requested path contains a symlink, redirect the user
if verbatim_path != resolved_path:
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
# derive the proper markdown or actual file depending on if this is a dir or file
if os.path.isdir(resolved_path):
resolved_path = os.path.join(resolved_path, 'index.md')
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file'
# if we're here, this isn't direct file access, so try markdown inference
verbatim_path = f'{safe_path}.md'
resolved_path = os.path.realpath(verbatim_path)
# does the final file actually exist?
if not os.path.exists(resolved_path):
logger.warning("requested final path '%s' does not exist!", resolved_path)
raise FileNotFoundError
# check for symlinks
if verbatim_path != resolved_path:
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
logger.info("final path = '%s' for request '%s'", resolved_path, path)
# we checked that the file exists via absolute path, but now we need to give the path relative to instance dir
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): def generate_parent_navs(path):
"""Create a series of paths/links to navigate up from the given path.""" """Create a series of paths/links to navigate up from the given resource path."""
parent_dir = os.path.dirname(path) if path == 'pages/index.md':
if parent_dir == 'pages': # bail and return the title suffix (generally the domain name) as a terminal case
return [(app.config['TITLE_SUFFIX'], '/')] return [(app.config['TITLE_SUFFIX'], '/')]
elif path.endswith('index.md'):
# if we're on an index.md, don't link to ourselves as we're our own parent
return generate_parent_navs(parent_dir)
else: else:
parent_path = f'{parent_dir}/'.replace('pages/', '/', 1) if path.endswith('index.md'):
resolved_parent_path = resolve_page_file(parent_path) # index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo
with app.open_instance_resource(resolved_parent_path, 'r') as entry_file: parent_resource_dir = os.path.dirname(os.path.dirname(path))
entry = entry_file.read() else:
_ = Markup(md.convert(entry)) # usual case: foo/buh.md -> foo
parent_name = " ".join(md.Meta.get('title')) if md.Meta.get('title') else os.path.basename(parent_dir) parent_resource_dir = os.path.dirname(path)
return generate_parent_navs(parent_dir) + [(parent_name, parent_path)]
# generate the request path (i.e. what the link will be) for this path, and
# also the resource path of this parent (which is always a dir, so always index.md)
request_path = f'/{instance_resource_path_to_request_path(path)}'
parent_resource_path = os.path.join(parent_resource_dir, 'index.md')
logger.debug("resource path: '%s'; request path: '%s'; parent resource path: '%s'", path,
request_path, parent_resource_path)
# for issues regarding parser reuse (see lib.init_md) we reinitialize the parser here
md = init_md()
# read the resource
try:
with app.open_instance_resource(path, 'r') as entry_file:
entry = entry_file.read()
_ = Markup(md.convert(entry))
page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title')
else request_path_to_breadcrumb_display(request_path))
return generate_parent_navs(parent_resource_path) + [(page_name, request_path)]
except FileNotFoundError:
return generate_parent_navs(parent_resource_path) + [(request_path, request_path)]
def request_path_to_breadcrumb_display(path):
"""Given a request path, e.g. "/foo/bar/baz/", turn it into breadcrumby text "baz"."""
undired = path.rstrip('/')
leaf = undired[undired.rfind('/'):]
return leaf.strip('/')

14
incorporealcms/static.py Normal file
View File

@@ -0,0 +1,14 @@
"""Serve static files from the instance directory."""
import os
from flask import Blueprint
from flask import current_app as app
from flask import send_from_directory
bp = Blueprint('static', __name__, url_prefix='/custom-static')
@bp.route('/<path:name>')
def serve_instance_static_file(name):
"""Serve a static file from the instance directory, used for customization."""
return send_from_directory(os.path.join(app.instance_path, 'custom-static'), name)

View File

@@ -0,0 +1,171 @@
html {
font-family: sans-serif;
padding: 0;
}
body {
margin: 0;
text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
.site-wrap-normal-width {
max-width: 65pc;
}
.site-wrap-double-width {
max-width: 130pc;
}
.site-wrap {
min-height: 100vh;
margin: 0;
margin-left: auto;
margin-right: auto;
}
a {
font-weight: bold;
text-decoration-line: underline;
text-decoration-thickness: 1px;
}
div.header {
display: flex;
justify-content: space-between;
font-size: 0.8em;
padding: 1rem 1rem;
padding-bottom: 0;
}
div.content {
font-size: 11pt;
padding: 0 1rem;
line-height: 1.6em;
}
div.content p {
margin: 1.25em 0;
}
sup, sub {
vertical-align: baseline;
position: relative;
top: -0.4em;
}
sub {
top: 0.4em;
}
footer {
clear: both;
display: block;
font-size: 75%;
color: #999;
padding: 0 1em;
padding-bottom: 16px;
margin-top: 30px;
}
.extra-footer {
margin-bottom: 5px;
}
table {
border-collapse: collapse;
}
table, th, td {
padding: 5px;
border: 1px solid #ccc;
margin-bottom: 15px;
}
blockquote {
background-color: rgba(120, 120, 120, 0.3);
padding: 1px 10px;
}
.splash {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
img {
max-width: 100%;
}
.img-25 {
max-width: 25% !important;
}
.img-50 {
max-width: 50% !important;
}
.img-75 {
max-width: 75% !important;
}
.img-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.img-left {
float: left;
margin-right: 1em;
}
.img-right {
float: right;
margin-left: 1em;
}
figure {
padding: 5px;
margin: 0;
margin-bottom: 5px;
text-align: center;
max-width: 30%;
min-width: 10em;
display: inline-block;
}
figure.right {
float: right;
margin-left: 10px;
display: block;
}
figure.left {
float: left;
margin-right: 10px;
display: block;
}
figure img {
max-width: 100%;
height: auto;
}
figcaption {
text-align: center;
font-size: 0.9em;
}
.footnote {
font-size: 0.8em;
}
.footnote p {
margin: 0;
}
.footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active {
font-weight: normal;
}

View File

@@ -0,0 +1,57 @@
/* common styling via the base.css, used in light and dark */
@import '/static/css/base.css';
html {
color: #DDD;
}
body {
background: #111;
}
h1, h2, h3, h4, h5, h6 {
color: #B31D15;
}
p a, ul a, ol a {
color: #DDD;
}
footer a {
color: #999;
}
p a:hover, ul a:hover, ol a:hover, footer a:hover {
color: #B31D15;
}
div.site-wrap {
background: black;
}
div.header, div.header a {
color: #555;
text-decoration: none;
}
table, th, td {
border: 1px solid #333;
}
th {
background: #333;
}
blockquote {
background-color: rgba(120, 120, 120, 0.3);
border: 1px solid #222;
}
figure {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid #333;
}
figcaption {
color: #AAAAAA;
}

View File

@@ -0,0 +1,57 @@
/* common styling via the base.css, used in light and dark */
@import '/static/css/base.css';
html {
color: #222;
}
body {
background: #EEE;
}
h1, h2, h3, h4, h5, h6 {
color: #811610;
}
p a, ul a, ol a {
color: #222;
}
footer a {
color: #999;
}
p a:hover, ul a:hover, ol a:hover, footer a:hover {
color: #811610;
}
div.site-wrap {
background: white;
}
div.header, div.header a {
color: #AAA;
text-decoration: none;
}
table, th, td {
border: 1px solid #CCC;
}
th {
background: #CCC;
}
blockquote {
background-color: rgba(120, 120, 120, 0.1);
border: 1px solid #DDD;
}
figure {
background-color: rgba(0, 0, 0, 0.1);
border: 1px solid #CCC;
}
figcaption {
color: #666666;
}

View File

@@ -0,0 +1,33 @@
/* specify almost no styling, just fix some image and nav rendering */
div.header {
display: flex;
justify-content: space-between;
}
.img-25 {
max-width: 25% !important;
}
.img-50 {
max-width: 50% !important;
}
.img-75 {
max-width: 75% !important;
}
.img-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.img-left {
float: left;
margin-right: 1em;
}
.img-right {
float: right;
margin-left: 1em;
}

View File

@@ -1,79 +0,0 @@
html {
font-family: sans-serif;
padding: 0;
padding-bottom: 16px;
color: #222;
}
body {
margin: 0;
}
h1,h2,h3,h4,h5,h6 {
color: #811610;
}
a:link {
color: #222;
font-weight: bold;
text-decoration: none;
border-bottom: 1px dotted #222;
}
a:visited {
color: #222;
font-weight: bold;
text-decoration: none;
border-bottom: 1px dotted #222;
}
a:hover {
color: #811610;
font-weight: bold;
text-decoration: none;
border-bottom: 1px dotted #222;
}
a:active {
color: #811610;
font-weight: bold;
text-decoration: none;
border-bottom: 1px dotted #222;
}
section.nav {
color: #666;
background: #eee;
font-size: 0.75em;
border-bottom: 1px solid #ccc;
padding: 0.25em 0.5em;
}
section.nav a {
color: #666;
border-bottom: none;
}
section.content {
padding: 0 1em;
}
footer {
display: block;
font-size: 75%;
color: #999;
padding: 0 1em;
}
table {
border-collapse: collapse;
}
table, th, td {
padding: 5px;
border: 1px solid #222;
}
th {
background: #eee;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block header %}
<div class="header">
<a href="/">{{ config.TITLE_SUFFIX }}</a>
</div>
{% endblock %}
{% block body %}
<div class="content">
<h1>BAD REQUEST</h1>
<p>You're doing something you're not supposed to. Stop it?</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block header %}
<div class="header">
<a href="/">{{ config.TITLE_SUFFIX }}</a>
</div>
{% endblock %}
{% block body %}
<div class="content">
<h1>NOT FOUND</h1>
<p>Sorry, <b><tt>{{ request.path }}</tt></b> does not seem to exist, at least not anymore.</p>
<p>It's possible you followed a dead link on this site, in which case I would appreciate it if you could email me at
{{ config.CONTACT_EMAIL }} and I can take a look. I make an effort to symlink old content to its new location,
so old links and URLs should, generally speaking, work.</p>
<p>Otherwise, I suggest you go <a href="/">to the index</a> and navigate your way (hopefully) to what
you're looking for.</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block header %}
<div class="header">
<a href="/">{{ config.TITLE_SUFFIX }}</a>
</div>
{% endblock %}
{% block body %}
<div class="content">
<h1>INTERNAL SERVER ERROR</h1>
<p>Something bad happened! Please email me at {{ config.CONTACT_EMAIL }} and tell me what happened.</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,2 @@
{% extends "base.html" %}
{% block site_class %}class="site-wrap site-wrap-double-width"{% endblock %}

View File

@@ -1,16 +1,39 @@
<!doctype html> <!doctype html>
<title>{{ title }}{% if title %} - {% endif %}{{ config.TITLE_SUFFIX }}</title> <html lang="en">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <title>{{ title }}</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}"> {% if title %}<meta property="og:title" content="{{ title }}">{% endif %}
<section class="nav"> {% if description %}<meta property="og:description" content="{{ description }}">{% endif %}
{% for nav in navs %} {% if image %}<meta property="og:image" content="{{ image }}">{% endif %}
<a href="{{ nav.1 }}">{{ nav.0 }}</a> <meta property="og:url" content="{{ base_url }}">
{% if not loop.last %} &raquo; {% endif %} <meta name="twitter:card" content="summary_large_image">
{% endfor %} <meta name="viewport" content="width=device-width, initial-scale=1">
</section> <link rel="stylesheet" href="{{ user_style }}">
<section class="content"> <link rel="icon" href="{% if config.FAVICON %}{{ config.FAVICON }}{% else %}{{ url_for('static', filename='img/favicon.png') }}{% endif %}">
{{ content }}
</section> <div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}>
<footer> {% block header %}
<i>Last modified: {{ mtime }}</i> <div class="header">
</footer> <div class="nav">
{% for nav in navs %}
<a href="{{ nav.1 }}">{{ nav.0 }}</a>
{% if not loop.last %} &raquo; {% endif %}
{% endfor %}
</div>
<div class="styles">
{% for style in page_styles %}
<a href="?style={{ style }}">[{{ style }}]</a>
{% endfor %}
</div>
</div>
{% endblock %}
{% block body %}
<div class="content">
{{ content }}
</div>
<footer>
{% if extra_footer %}<div class="extra-footer"><i>{{ extra_footer|safe }}</i></div>{% endif %}
<div class="footer"><i>Last modified: {{ mtime }}</i></div>
</footer>
{% endblock %}
</div>
</html>

View File

@@ -1,7 +1,26 @@
-r requirements.in -r requirements.in
flake8 # python code quality stuff # 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 pip-tools # pip-compile
pytest # unit tests tox<4 # CI stuff, pinned for now to avoid packaging conflict w/safety
pytest-cov # coverage in unit tests tox-wheel # build wheels in tox
versioneer # automatic version numbering versioneer # automatic version numbering

View File

@@ -1,38 +1,172 @@
# #
# This file is autogenerated by pip-compile # This file is autogenerated by pip-compile with Python 3.10
# To update, run: # by the following command:
# #
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in # pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
# #
attrs==19.3.0 # via pytest attrs==22.2.0
click==7.0 # via flask, pip-tools # via pytest
coverage==5.0.3 # via pytest-cov bandit==1.7.4
entrypoints==0.3 # via flake8 # via -r requirements/requirements-dev.in
flake8==3.7.9 # via -r requirements/requirements-dev.in build==0.9.0
flask==1.1.1 # via -r requirements/requirements.in # via pip-tools
importlib-metadata==1.5.0 # via pluggy, pytest certifi==2022.12.7
itsdangerous==1.1.0 # via flask # via requests
jinja2==2.11.1 # via flask charset-normalizer==2.1.1
markdown==3.2.1 # via -r requirements/requirements.in # via requests
markupsafe==1.1.1 # via jinja2 click==8.1.3
mccabe==0.6.1 # via flake8 # via
more-itertools==8.2.0 # via pytest # flask
packaging==20.3 # via pytest # pip-tools
pip-tools==4.5.1 # via -r requirements/requirements-dev.in # safety
pluggy==0.13.1 # via pytest coverage[toml]==7.0.1
py==1.8.1 # via pytest # via pytest-cov
pycodestyle==2.5.0 # via flake8 distlib==0.3.6
pyflakes==2.1.1 # via flake8 # via virtualenv
pyparsing==2.4.6 # via packaging dlint==0.13.0
pytest-cov==2.8.1 # via -r requirements/requirements-dev.in # via -r requirements/requirements-dev.in
pytest==5.3.5 # via -r requirements/requirements-dev.in, pytest-cov dparse==0.6.2
pytz==2019.3 # via tzlocal # via safety
six==1.14.0 # via packaging, pip-tools exceptiongroup==1.1.0
tzlocal==2.0.0 # via -r requirements/requirements.in # via pytest
versioneer==0.18 # via -r requirements/requirements-dev.in filelock==3.9.0
wcwidth==0.1.8 # via pytest # via
werkzeug==1.0.0 # via flask # tox
zipp==3.1.0 # via importlib-metadata # virtualenv
flake8==5.0.4
# via
# -r requirements/requirements-dev.in
# dlint
# flake8-builtins
# flake8-docstrings
# flake8-executable
# flake8-isort
# flake8-mutable
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
flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in
flake8-isort==6.0.0
# via -r requirements/requirements-dev.in
flake8-logging-format==0.9.0
# via -r requirements/requirements-dev.in
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 requests
iniconfig==1.1.1
# via pytest
isort==5.11.4
# 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
# via
# jinja2
# werkzeug
mccabe==0.7.0
# via flake8
packaging==21.3
# via
# build
# dparse
# pytest
# safety
# tox
pbr==5.11.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
# via
# pytest
# tox
py==1.11.0
# via tox
pycodestyle==2.9.1
# via flake8
pydocstyle==6.1.1
# via flake8-docstrings
pydot==1.4.2
# via -r requirements/requirements-dev.in
pyflakes==2.5.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
# via tox
smmap==5.0.0
# via gitdb
snowballstemmer==2.2.0
# via pydocstyle
stevedore==4.1.1
# 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
# pip-tools
# tox-wheel
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools # setuptools

View File

@@ -1,3 +1,2 @@
Flask # general purpose web service and web server stuff Flask # general purpose web service and web server stuff
Markdown # markdown rendering in templates Markdown # markdown rendering in templates
tzlocal # identifying system's local timezone

View File

@@ -1,18 +1,22 @@
# #
# This file is autogenerated by pip-compile # This file is autogenerated by pip-compile with python 3.10
# To update, run: # To update, run:
# #
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in # pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
# #
click==7.0 # via flask click==8.1.3
flask==1.1.1 # via -r requirements/requirements.in # via flask
itsdangerous==1.1.0 # via flask flask==2.2.2
jinja2==2.11.1 # via flask # via -r requirements/requirements.in
markdown==3.2.1 # via -r requirements/requirements.in itsdangerous==2.1.2
markupsafe==1.1.1 # via jinja2 # via flask
pytz==2019.3 # via tzlocal jinja2==3.1.2
tzlocal==2.0.0 # via -r requirements/requirements.in # via flask
werkzeug==1.0.0 # via flask markdown==3.4.1
# via -r requirements/requirements.in
# The following packages are considered to be unsafe in a requirements file: markupsafe==2.1.1
# setuptools # via
# jinja2
# werkzeug
werkzeug==2.2.2
# via flask

View File

@@ -1,23 +1,3 @@
[coverage:run]
branch = True
omit =
.venv/*
incorporealcms/_version.py
setup.py
tests/*
versioneer.py
source =
incorporealcms
[flake8]
exclude = .git,.venv,__pycache__,versioneer.py,incorporealcms/_version.py
max-line-length = 120
[tool:pytest]
addopts = --cov=. --cov-report=term --cov-report=term-missing
log_cli = 1
log_cli_level = DEBUG
[versioneer] [versioneer]
VCS = git VCS = git
style = pep440-post style = pep440-post

View File

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

View File

@@ -10,6 +10,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture @pytest.fixture
def app(): def app():
"""Create the Flask application, with test settings."""
app = create_app(instance_path=os.path.join(HERE, 'instance')) app = create_app(instance_path=os.path.join(HERE, 'instance'))
yield app yield app
@@ -17,4 +18,5 @@ def app():
@pytest.fixture @pytest.fixture
def client(app): def client(app):
"""Create a test client based on the test app."""
return app.test_client() return app.test_client()

View File

@@ -0,0 +1,63 @@
"""Test graphviz functionality."""
import os
from incorporealcms import create_app
HERE = os.path.dirname(os.path.abspath(__file__))
def app_with_pydot():
"""Create the test app, including the pydot extension."""
return create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'MARKDOWN_EXTENSIONS': ['incorporealcms.mdx.pydot']})
def test_functional_initialization():
"""Test initialization with the graphviz config."""
app = app_with_pydot()
assert app is not None
def test_graphviz_is_rendered():
"""Initialize the app with the graphviz extension and ensure it does something."""
app = app_with_pydot()
client = app.test_client()
response = client.get('/test-graphviz')
assert response.status_code == 200
assert b'~~~pydot' not in response.data
assert b'data:image/png;base64' in response.data
def test_two_graphviz_are_rendered():
"""Test two images are rendered."""
app = app_with_pydot()
client = app.test_client()
response = client.get('/test-two-graphviz')
assert response.status_code == 200
assert b'~~~pydot' not in response.data
assert b'data:image/png;base64' in response.data
def test_invalid_graphviz_is_not_rendered():
"""Check that invalid graphviz doesn't blow things up."""
app = app_with_pydot()
client = app.test_client()
response = client.get('/test-invalid-graphviz')
assert response.status_code == 500
assert b'INTERNAL SERVER ERROR' in response.data
def test_figures_are_rendered(client):
"""Test that a page with my figure syntax renders as expected."""
response = client.get('/figures')
assert response.status_code == 200
assert (b'<figure class="right"><img alt="fancy captioned logo" src="bss-square-no-bg.png" />'
b'<figcaption>this is my cool logo!</figcaption></figure>') in response.data
assert (b'<figure><img alt="vanilla captioned logo" src="bss-square-no-bg.png" />'
b'<figcaption>this is my cool logo without an attr!</figcaption>\n</figure>') in response.data
assert (b'<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />'
b'<span></span></figure>') in response.data
assert b'<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in response.data

240
tests/functional_tests.py Normal file
View File

@@ -0,0 +1,240 @@
"""Test page requests."""
import re
from unittest.mock import patch
def test_page_that_exists(client):
"""Test that the app can serve a basic file at the index."""
response = client.get('/')
assert response.status_code == 200
assert b'<h1 id="test-index">test index</h1>' in response.data
def test_direct_file_that_exists(client):
"""Test that the app can serve a basic file at the index."""
response = client.get('/foo.txt')
assert response.status_code == 200
assert b'test file' in response.data
def test_page_that_doesnt_exist(client):
"""Test that the app returns 404 for nonsense requests and they use my error page."""
response = client.get('/ohuesthaoeusth')
assert response.status_code == 404
assert b'<b><tt>/ohuesthaoeusth</tt></b> does not seem to exist' in response.data
# test the contact email config
assert b'admin@example.com' in response.data
def test_files_outside_pages_do_not_get_served(client):
"""Test that page pathing doesn't break out of the instance/pages/ dir, and the error uses my error page."""
response = client.get('/../unreachable')
assert response.status_code == 400
assert b'You\'re doing something you\'re not supposed to. Stop it?' in response.data
def test_internal_server_error_serves_error_page(client):
"""Test that various exceptions serve up the 500 page."""
response = client.get('/actually-a-png')
assert response.status_code == 500
assert b'INTERNAL SERVER ERROR' in response.data
# test the contact email config
assert b'admin@example.com' in response.data
def test_oserror_is_500(client, app):
"""Test that an OSError raises as a 500."""
with app.test_request_context():
with patch('flask.current_app.open_instance_resource', side_effect=OSError):
response = client.get('/')
assert response.status_code == 500
assert b'INTERNAL SERVER ERROR' in response.data
def test_unsupported_file_type_is_500(client, app):
"""Test a coding condition mishap raises as a 500."""
with app.test_request_context():
with patch('incorporealcms.pages.request_path_to_instance_resource_path', return_value=('foo', 'bar')):
response = client.get('/')
assert response.status_code == 500
assert b'INTERNAL SERVER ERROR' in response.data
def test_weird_paths_do_not_get_served(client):
"""Test that we clean up requests as desired."""
response = client.get('/../../')
assert response.status_code == 400
def test_page_with_title_metadata(client):
"""Test that a page with title metadata has its title written."""
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - example.com</title>' in response.data
def test_page_without_title_metadata(client):
"""Test that a page without title metadata gets the default title."""
response = client.get('/no-title')
assert response.status_code == 200
assert b'<title>/no-title - example.com</title>' in response.data
def test_page_in_subdir_without_title_metadata(client):
"""Test that the title-less page display is as expected."""
response = client.get('/subdir//page-no-title')
assert response.status_code == 200
assert b'<title>/subdir/page-no-title - example.com</title>' in response.data
def test_page_with_card_metadata(client):
"""Test that a page with opengraph metadata."""
response = client.get('/more-metadata')
assert response.status_code == 200
assert b'<meta property="og:title" content="title for the page - example.com">' in response.data
assert b'<meta property="og:description" content="description of this page made even longer">' in response.data
assert b'<meta property="og:image" content="http://buh.com/test.img">' in response.data
def test_page_with_card_title_even_when_no_metadata(client):
"""Test that a page without metadata still has a card with the derived title."""
response = client.get('/no-title')
assert response.status_code == 200
assert b'<meta property="og:title" content="/no-title - example.com">' in response.data
assert b'<meta property="og:description"' not in response.data
assert b'<meta property="og:image"' not in response.data
def test_page_with_forced_empty_title_just_shows_suffix(client):
"""Test that if a page specifies a blank Title meta tag explicitly, only the suffix is used in the title."""
response = client.get('/forced-no-title')
assert response.status_code == 200
assert b'<title>example.com</title>' in response.data
def test_page_with_redirect_meta_url_redirects(client):
"""Test that if a page specifies a URL to redirect to, that the site serves up a 301."""
response = client.get('/redirect')
assert response.status_code == 301
assert response.location == 'http://www.google.com/'
def test_page_has_modified_timestamp(client):
"""Test that pages have modified timestamps in them."""
response = client.get('/')
assert response.status_code == 200
assert re.search(r'Last modified: ....-..-.. ..:..:.. ...', response.data.decode()) is not None
def test_that_page_request_redirects_to_directory(client):
"""Test that a request to /foo reirects to /foo/, if foo is a directory.
This might be useful in cases where a formerly page-only page has been
converted to a directory with subpages.
"""
response = client.get('/subdir')
assert response.status_code == 301
assert response.location == '/subdir/'
def test_that_request_to_symlink_redirects_markdown(client):
"""Test that a request to /foo redirects to /what-foo-points-at."""
response = client.get('/symlink-to-no-title')
assert response.status_code == 301
assert response.location == '/no-title'
def test_that_request_to_symlink_redirects_file(client):
"""Test that a request to /foo.txt redirects to /what-foo-points-at.txt."""
response = client.get('/symlink-to-foo.txt')
assert response.status_code == 301
assert response.location == '/foo.txt'
def test_that_request_to_symlink_redirects_directory(client):
"""Test that a request to /foo/ redirects to /what-foo-points-at/."""
response = client.get('/symlink-to-subdir/')
assert response.status_code == 301
assert response.location == '/subdir'
# sadly, this location also redirects
response = client.get('/subdir')
assert response.status_code == 301
assert response.location == '/subdir/'
# but we do get there
response = client.get('/subdir/')
assert response.status_code == 200
def test_that_request_to_symlink_redirects_subdirectory(client):
"""Test that a request to /foo/bar redirects to /what-foo-points-at/bar."""
response = client.get('/symlink-to-subdir/page-no-title')
assert response.status_code == 301
assert response.location == '/subdir/page-no-title'
response = client.get('/subdir/page-no-title')
assert response.status_code == 200
def test_that_dir_request_does_not_redirect(client):
"""Test that a request to /foo/ serves the index page, if foo is a directory."""
response = client.get('/subdir/')
assert response.status_code == 200
assert b'another page' in response.data
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)
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)
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)
assert response.status_code == 200
assert b'/static/css/dark.css' in response.data
assert b'/static/css/light.css' not in response.data
assert style_cookie.value == 'dark'
def test_pages_can_supply_alternate_templates(client):
"""Test that pages can supply templates other than the default."""
response = client.get('/')
assert b'class="site-wrap site-wrap-normal-width"' in response.data
assert b'class="site-wrap site-wrap-double-width"' not in response.data
response = client.get('/custom-template')
assert b'class="site-wrap site-wrap-normal-width"' not in response.data
assert b'class="site-wrap site-wrap-double-width"' in response.data
def test_extra_footer_per_page(client):
"""Test that we don't include the extra-footer if there isn't one (or do if there is)."""
response = client.get('/')
assert b'<div class="extra-footer">' not in response.data
response = client.get('/index-but-with-footer')
assert b'<div class="extra-footer"><i>ooo <a href="a">a</a></i>' in response.data
def test_serving_static_files(client):
"""Test the usage of send_from_directory to serve extra static files."""
response = client.get('/custom-static/css/warm.css')
assert response.status_code == 200
# can't serve directories, just files
response = client.get('/custom-static/')
assert response.status_code == 404
response = client.get('/custom-static/css/')
assert response.status_code == 404
response = client.get('/custom-static/css')
assert response.status_code == 404
# can't serve files that don't exist or bad paths
response = client.get('/custom-static/css/cold.css')
assert response.status_code == 404
response = client.get('/custom-static/css/../../unreachable.md')
assert response.status_code == 404

View File

@@ -1,3 +1,5 @@
"""Configure the test application."""
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'formatters': { 'formatters': {
@@ -13,6 +15,10 @@ LOGGING = {
}, },
}, },
'loggers': { 'loggers': {
'incorporealcms.mdx': {
'level': 'DEBUG',
'handlers': ['console'],
},
'incorporealcms.pages': { 'incorporealcms.pages': {
'level': 'DEBUG', 'level': 'DEBUG',
'handlers': ['console'], 'handlers': ['console'],

View File

@@ -0,0 +1,3 @@
* {
color: red;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,3 @@
Template: base-wide.html
testttttttttt

View File

@@ -0,0 +1,13 @@
# test of figures
|> ![fancy captioned logo](bss-square-no-bg.png)
|: this is my cool logo!
{: .right }
|> ![vanilla captioned logo](bss-square-no-bg.png)
|: this is my cool logo without an attr!
|> ![fancy logo](bss-square-no-bg.png)
{: .left }
|> ![just a logo](bss-square-no-bg.png)

View File

@@ -0,0 +1 @@
test file

View File

@@ -0,0 +1,3 @@
Title:
some words are here

View File

@@ -0,0 +1,6 @@
Title: Index
Footer: ooo <a href="a">a</a>
# test index
this is some test content

View File

@@ -0,0 +1,3 @@
Test page
word --- word

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,6 @@
Title: title for the page
Description: description of this page
made even longer
Image: http://buh.com/test.img
hello

View File

@@ -0,0 +1 @@
this is a test page

View File

@@ -0,0 +1 @@
Redirect: http://www.google.com/

View File

@@ -0,0 +1 @@
this is just a page

View File

@@ -0,0 +1 @@
foo.txt

View File

@@ -0,0 +1 @@
no-title.md

View File

@@ -0,0 +1 @@
subdir

View File

@@ -0,0 +1,12 @@
# test
test
~~~{ pydot:attack-plan }
digraph G {
rankdir=LR
Earth
Mars
Earth -> Mars
}
~~~
more test

View File

@@ -0,0 +1,11 @@
# test
test
~~~{ pydot:attack-plan }
rankdir=LR
Earth
Mars
Earth -> Mars
}
~~~
more test

View File

@@ -0,0 +1,25 @@
# test
test
~~~{ pydot:attack-plan }
digraph G {
rankdir=LR
Earth
Mars
Earth -> Mars
}
~~~
more test
~~~{ pydot:new-attack-plan }
digraph H {
rankdir=LR
Venus
Mars
Venus -> Mars
}
~~~
done

View File

@@ -0,0 +1 @@
this file exists but the app should not serve it.

View File

@@ -13,6 +13,36 @@ def test_config():
assert create_app(instance_path=instance_path, test_config={"TESTING": True}).testing assert create_app(instance_path=instance_path, test_config={"TESTING": True}).testing
def test_markdown_meta_extension_always():
"""Test that the markdown meta extension is always loaded, even if not specified."""
app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'MARKDOWN_EXTENSIONS': []})
client = app.test_client()
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - example.com</title>' in response.data
def test_custom_markdown_extensions_work():
"""Test we can change extensions via config, and that they work.
This used to test smarty, but that's added by default now, so we test
that it can be removed by overriding the option.
"""
app = create_app(instance_path=os.path.join(HERE, 'instance'))
client = app.test_client()
response = client.get('/mdash-or-triple-dash')
assert response.status_code == 200
assert b'word &mdash; word' in response.data
app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'MARKDOWN_EXTENSIONS': []})
client = app.test_client()
response = client.get('/mdash-or-triple-dash')
assert response.status_code == 200
assert b'word --- word' in response.data
def test_title_override(): def test_title_override():
"""Test that a configuration with a specific title overrides the default.""" """Test that a configuration with a specific title overrides the default."""
instance_path = os.path.join(HERE, 'instance') instance_path = os.path.join(HERE, 'instance')
@@ -20,10 +50,30 @@ def test_title_override():
client = app.test_client() client = app.test_client()
response = client.get('/no-title') response = client.get('/no-title')
assert response.status_code == 200 assert response.status_code == 200
assert b'<title>suou.net</title>' in response.data assert b'<title>/no-title - suou.net</title>' in response.data
def test_media_file_access(client): def test_media_file_access(client):
"""Test that media files are served, and properly."""
response = client.get('/media/favicon.png') response = client.get('/media/favicon.png')
assert response.status_code == 200 assert response.status_code == 200
assert response.headers['content-type'] == 'image/png' assert response.headers['content-type'] == 'image/png'
def test_favicon_override():
"""Test that a configuration with a specific favicon overrides the default."""
instance_path = os.path.join(HERE, 'instance')
app = create_app(instance_path=instance_path, test_config={'FAVICON': '/media/foo.png'})
client = app.test_client()
response = client.get('/no-title')
assert response.status_code == 200
assert b'<link rel="icon" href="/media/foo.png">' in response.data
def test_misconfigured_markdown_extensions():
"""Test that a misconfigured markdown extensions leads to a 500 at render time."""
instance_path = os.path.join(HERE, 'instance')
app = create_app(instance_path=instance_path, test_config={'MARKDOWN_EXTENSIONS': 'WRONG'})
client = app.test_client()
response = client.get('/no-title')
assert response.status_code == 500

View File

@@ -1,67 +1,278 @@
"""Test page views and helper methods.""" """Unit test helper methods."""
import re import os
from incorporealcms.pages import generate_parent_navs, resolve_page_file import pytest
from werkzeug.http import dump_cookie
from incorporealcms import create_app
from incorporealcms.pages import (generate_parent_navs, instance_resource_path_to_request_path, render,
request_path_to_breadcrumb_display, request_path_to_instance_resource_path)
def test_resolve_page_file_dir_to_index(): HERE = os.path.dirname(os.path.abspath(__file__))
assert resolve_page_file('foo/') == 'pages/foo/index.md'
def test_resolve_page_file_subdir_to_index():
assert resolve_page_file('foo/bar/') == 'pages/foo/bar/index.md'
def test_resolve_page_file_other_requests_fine():
assert resolve_page_file('foo/baz') == 'pages/foo/baz.md'
def test_page_that_exists(client):
response = client.get('/')
assert response.status_code == 200
assert b'<h1>test index</h1>' in response.data
def test_page_that_doesnt_exist(client):
response = client.get('/ohuesthaoeusth')
assert response.status_code == 404
def test_page_with_title_metadata(client):
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - incorporeal.org</title>' in response.data
def test_page_without_title_metadata(client):
response = client.get('/no-title')
assert response.status_code == 200
assert b'<title>incorporeal.org</title>' in response.data
assert b'<h1>this page doesn\'t have a title!</h1>' in response.data
def test_page_has_modified_timestamp(client):
response = client.get('/')
assert response.status_code == 200
assert re.search(r'Last modified: ....-..-.. ..:..:.. ...', response.data.decode()) is not None
def test_generate_page_navs_index(app): def test_generate_page_navs_index(app):
"""Test that the index page has navs to the root (itself)."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/index.md') == [('incorporeal.org', '/')] assert generate_parent_navs('pages/index.md') == [('example.com', '/')]
def test_generate_page_navs_subdir_index(app): def test_generate_page_navs_subdir_index(app):
"""Test that dir pages have navs to the root and themselves."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir/index.md') == [('incorporeal.org', '/')] assert generate_parent_navs('pages/subdir/index.md') == [('example.com', '/'), ('subdir', '/subdir/')]
def test_generate_page_navs_subdir_real_page(app): def test_generate_page_navs_subdir_real_page(app):
"""Test that real pages have navs to the root, their parent, and themselves."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir/page.md') == [('incorporeal.org', '/'), ('subdir', '/subdir/')] assert generate_parent_navs('pages/subdir/page.md') == [('example.com', '/'), ('subdir', '/subdir/'),
('Page', '/subdir/page')]
def test_generate_page_navs_subdir_with_title_parsing_real_page(app): def test_generate_page_navs_subdir_with_title_parsing_real_page(app):
"""Test that title metadata is used in the nav text."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir-with-title/page.md') == [('incorporeal.org', '/'), assert generate_parent_navs('pages/subdir-with-title/page.md') == [
('SUB!', '/subdir-with-title/')] ('example.com', '/'),
('SUB!', '/subdir-with-title/'),
('page', '/subdir-with-title/page')
]
def test_generate_page_navs_subdir_with_no_index(app):
"""Test that breadcrumbs still generate even if a subdir doesn't have an index.md."""
with app.app_context():
assert generate_parent_navs('pages/no-index-dir/page.md') == [
('example.com', '/'),
('/no-index-dir/', '/no-index-dir/'),
('page', '/no-index-dir/page')
]
def test_render_with_user_dark_theme(app):
"""Test that a request with the dark theme selected renders the dark theme."""
cookie = dump_cookie("user-style", 'dark')
with app.test_request_context(headers={'COOKIE': cookie}):
assert b'/static/css/dark.css' in render('base.html').data
assert b'/static/css/light.css' not in render('base.html').data
def test_render_with_user_light_theme(app):
"""Test that a request with the light theme selected renders the light theme."""
with app.test_request_context():
assert b'/static/css/light.css' in render('base.html').data
assert b'/static/css/dark.css' not in render('base.html').data
def test_render_with_no_user_theme(app):
"""Test that a request with no theme set renders the light theme."""
with app.test_request_context():
assert b'/static/css/light.css' in render('base.html').data
assert b'/static/css/dark.css' not in render('base.html').data
def test_render_with_theme_defaults_affects_html(app):
"""Test that the base themes are all that's presented in the HTML."""
# test we can remove stuff from the default
with app.test_request_context():
assert b'?style=light' in render('base.html').data
assert b'?style=dark' in render('base.html').data
assert b'?style=plain' in render('base.html').data
def test_render_with_theme_overrides_affects_html(app):
"""Test that the overridden themes are presented in the HTML."""
# test we can remove stuff from the default
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'light': '/static/css/light.css'}})
with restyled_app.test_request_context():
assert b'?style=light' in render('base.html').data
assert b'?style=dark' not in render('base.html').data
assert b'?style=plain' not in render('base.html').data
# test that we can add new stuff too/instead
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'},
'DEFAULT_PAGE_STYLE': 'warm'})
with restyled_app.test_request_context():
assert b'?style=cool' in render('base.html').data
assert b'?style=warm' in render('base.html').data
def test_render_with_theme_overrides(app):
"""Test that the loaded themes can be overridden from the default."""
cookie = dump_cookie("user-style", 'cool')
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'}})
with restyled_app.test_request_context(headers={'COOKIE': cookie}):
assert b'/static/css/cool.css' in render('base.html').data
assert b'/static/css/warm.css' not in render('base.html').data
def test_render_with_theme_overrides_not_found_is_default(app):
"""Test that theme overrides work, and if a requested theme doesn't exist, the default is loaded."""
cookie = dump_cookie("user-style", 'nonexistent')
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'},
'DEFAULT_PAGE_STYLE': 'warm'})
with restyled_app.test_request_context(headers={'COOKIE': cookie}):
assert b'/static/css/warm.css' in render('base.html').data
assert b'/static/css/nonexistent.css' not in render('base.html').data
def test_request_path_to_instance_resource_path(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert request_path_to_instance_resource_path('index') == ('pages/index.md', 'markdown')
def test_request_path_to_instance_resource_path_direct_file(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert request_path_to_instance_resource_path('no-title') == ('pages/no-title.md', 'markdown')
def test_request_path_to_instance_resource_path_in_subdir(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert request_path_to_instance_resource_path('subdir/page') == ('pages/subdir/page.md', 'markdown')
def test_request_path_to_instance_resource_path_subdir_index(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert request_path_to_instance_resource_path('subdir/') == ('pages/subdir/index.md', 'markdown')
def test_request_path_to_instance_resource_path_relatives_walked(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('subdir/more-subdir/../../more-metadata') ==
('pages/more-metadata.md', 'markdown'))
def test_request_path_to_instance_resource_path_relatives_walked_indexes_work_too(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert request_path_to_instance_resource_path('subdir/more-subdir/../../') == ('pages/index.md', 'markdown')
def test_request_path_to_instance_resource_path_relatives_walked_into_subdirs_also_fine(app):
"""Test a normal URL request is transformed into the file path."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('subdir/more-subdir/../../subdir/page') ==
('pages/subdir/page.md', 'markdown'))
def test_request_path_to_instance_resource_path_permission_error_on_ref_above_pages(app):
"""Test that attempts to get above the base dir ("/../../foo") fail."""
with app.test_request_context():
with pytest.raises(PermissionError):
assert request_path_to_instance_resource_path('../unreachable')
def test_request_path_to_instance_resource_path_isadirectory_on_file_like_req_for_dir(app):
"""Test that a request for e.g. '/foo' when foo is a dir indicate to redirect."""
with app.test_request_context():
with pytest.raises(IsADirectoryError):
assert request_path_to_instance_resource_path('subdir')
def test_request_path_to_instance_resource_path_actual_file(app):
"""Test that a request for e.g. '/foo.png' when foo.png is a real file works."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('bss-square-no-bg.png') ==
('pages/bss-square-no-bg.png', 'file'))
def test_request_path_to_instance_resource_path_markdown_symlink(app):
"""Test that a request for e.g. '/foo' when foo.md is a symlink to another .md file redirects."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('symlink-to-no-title') ==
('pages/no-title.md', 'symlink'))
def test_request_path_to_instance_resource_path_file_symlink(app):
"""Test that a request for e.g. '/foo' when foo.txt is a symlink to another .txt file redirects."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('symlink-to-foo.txt') ==
('pages/foo.txt', 'symlink'))
def test_request_path_to_instance_resource_path_dir_symlink(app):
"""Test that a request for e.g. '/foo' when /foo is a symlink to /bar redirects."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('symlink-to-subdir/') ==
('pages/subdir', 'symlink'))
def test_request_path_to_instance_resource_path_subdir_symlink(app):
"""Test that a request for e.g. '/foo/baz' when /foo is a symlink to /bar redirects."""
with app.test_request_context():
assert (request_path_to_instance_resource_path('symlink-to-subdir/page-no-title') ==
('pages/subdir/page-no-title.md', 'symlink'))
def test_request_path_to_instance_resource_path_nonexistant_file_errors(app):
"""Test that a request for something not on disk errors."""
with app.test_request_context():
with pytest.raises(FileNotFoundError):
assert request_path_to_instance_resource_path('nthanpthpnh')
def test_request_path_to_instance_resource_path_absolute_file_errors(app):
"""Test that a request for something not on disk errors."""
with app.test_request_context():
with pytest.raises(PermissionError):
assert request_path_to_instance_resource_path('/etc/hosts')
def test_instance_resource_path_to_request_path_on_index(app):
"""Test index.md -> /."""
with app.test_request_context():
assert instance_resource_path_to_request_path('index.md') == ''
def test_instance_resource_path_to_request_path_on_page(app):
"""Test no-title.md -> no-title."""
with app.test_request_context():
assert instance_resource_path_to_request_path('no-title.md') == 'no-title'
def test_instance_resource_path_to_request_path_on_subdir(app):
"""Test subdir/index.md -> subdir/."""
with app.test_request_context():
assert instance_resource_path_to_request_path('subdir/index.md') == 'subdir/'
def test_instance_resource_path_to_request_path_on_subdir_and_page(app):
"""Test subdir/page.md -> subdir/page."""
with app.test_request_context():
assert instance_resource_path_to_request_path('subdir/page.md') == 'subdir/page'
def test_request_resource_request_root(app):
"""Test that a request can resolve to a resource and back to a request."""
with app.test_request_context():
instance_path, _ = request_path_to_instance_resource_path('index')
instance_resource_path_to_request_path(instance_path) == ''
def test_request_resource_request_page(app):
"""Test that a request can resolve to a resource and back to a request."""
with app.test_request_context():
instance_path, _ = request_path_to_instance_resource_path('no-title')
instance_resource_path_to_request_path(instance_path) == 'no-title'
def test_request_path_to_breadcrumb_display_patterns():
"""Test various conversions from request path to leaf nodes for display in the breadcrumbs."""
assert request_path_to_breadcrumb_display('/foo') == 'foo'
assert request_path_to_breadcrumb_display('/foo/') == 'foo'
assert request_path_to_breadcrumb_display('/foo/bar') == 'bar'
assert request_path_to_breadcrumb_display('/foo/bar/') == 'bar'
assert request_path_to_breadcrumb_display('/') == ''

105
tox.ini Normal file
View File

@@ -0,0 +1,105 @@
# tox (https://tox.readthedocs.io/) is a tool for running tests
# in multiple virtualenvs. This configuration file will run the
# test suite on all supported python versions. To use it, "pip install tox"
# and then run "tox" from this directory.
[tox]
envlist = begin,py38,py39,py310,coverage,security,lint,bundle
[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
deps =
-rrequirements/requirements-dev.txt
[testenv:build]
# require setuptools when building
deps = setuptools
[testenv:begin]
# clean up potential previous coverage runs
skip_install = true
commands = coverage erase
[testenv:py38]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py39]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py310]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:coverage]
# report on coverage runs from above
skip_install = true
commands =
coverage report --fail-under=95 --show-missing
[testenv:security]
# 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
[testenv:lint]
# run style checks
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 =
./
.tox/**/site-packages/
[coverage:run]
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