flask-nginx 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,19 @@
1
+ # pylint: disable=unused-import
2
+ from __future__ import annotations
3
+
4
+ from . import irds # noqa:
5
+ from . import mailer # noqa:
6
+ from . import mysql # noqa:
7
+ from . import remote # noqa:
8
+ from . import restartd # noqa:
9
+ from . import rsync # noqa:
10
+ from . import watch # noqa:
11
+ from .cli import cli
12
+ from .systemd import nginx # noqa:
13
+ from .systemd import supervisor # noqa:
14
+ from .systemd import systemd # noqa:
15
+
16
+ # from . import logo # noqa:
17
+
18
+ if __name__ == "__main__":
19
+ cli.main(prog_name="footprint")
flask_nginx/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ import click
6
+
7
+
8
+ @click.group(
9
+ # cls=DYMGroup,
10
+ epilog=click.style("Commands to manage websites\n", fg="magenta"),
11
+ )
12
+ @click.version_option()
13
+ def cli() -> None:
14
+ pass
15
+
16
+
17
+ @cli.command()
18
+ def update() -> None:
19
+ """Update this package"""
20
+ import sys
21
+
22
+ from .config import REPO
23
+
24
+ ret = subprocess.call([sys.executable, "-m", "pip", "install", "-U", REPO])
25
+ if ret:
26
+ click.secho(f"can't install {REPO}", fg="red")
27
+ raise click.Abort()
28
+
29
+
30
+ @cli.command()
31
+ def repo() -> None:
32
+ """show git repository"""
33
+
34
+ from .config import REPO
35
+
36
+ click.echo(REPO)
37
+
38
+
39
+ @cli.command()
40
+ def config_show() -> None:
41
+ """Show configuration"""
42
+ from dataclasses import fields
43
+ from .config import get_config
44
+
45
+ Config = get_config()
46
+
47
+ n = max(len(f.name) for f in fields(Config))
48
+
49
+ for f in fields(Config):
50
+ k = f.name
51
+ v = getattr(Config, f.name)
52
+ click.echo(f"{k:<{n}}: {v}")
53
+
54
+
55
+ @cli.command()
56
+ @click.option("-a", "--append", is_flag=True, help="append to file")
57
+ @click.argument("filename")
58
+ def config_dump(filename: str, append: bool) -> None:
59
+ """Dump configuration"""
60
+ from .utils import require_mod
61
+ from .config import dump_to_file
62
+
63
+ require_mod("toml")
64
+
65
+ if not dump_to_file(filename, append):
66
+ click.secho("can't dump configuration!", fg="red", err=True)
67
+ raise click.Abort()
68
+
69
+
70
+ # @cli.command()
71
+ # @click.option("-p", "--with-python", is_flag=True)
72
+ # @click.option("-c", "--compile", "use_pip_compile", is_flag=True)
73
+ # @click.argument("project_dir", required=False)
74
+ def poetry_to_reqs(
75
+ project_dir: str,
76
+ with_python: bool,
77
+ use_pip_compile: bool = True,
78
+ ) -> None:
79
+ """Generate a requirements.txt file from pyproject.toml [**may require toml**]"""
80
+ import os
81
+ from contextlib import suppress
82
+ from .utils import toml_load
83
+
84
+ pyproject = "pyproject.toml"
85
+ if project_dir:
86
+ pyproject = os.path.join(project_dir, pyproject)
87
+ if not os.path.isfile(pyproject):
88
+ raise click.BadArgumentUsage("no pyproject.toml file!")
89
+
90
+ def fix(req: str) -> str:
91
+ if req.startswith("^"):
92
+ return f">={req[1:]}"
93
+ return req
94
+
95
+ reqs = "\n".join(
96
+ f"{k}{fix(v)}"
97
+ for k, v in sorted(
98
+ toml_load(pyproject)["tool"]["poetry"]["dependencies"].items(),
99
+ )
100
+ if with_python or k != "python" and isinstance(v, str)
101
+ )
102
+ if use_pip_compile:
103
+ try:
104
+ with open("requirements.in", "w", encoding="utf-8") as fp:
105
+ click.echo(reqs, file=fp)
106
+ subprocess.check_call(["pip-compile"])
107
+ finally:
108
+ with suppress(OSError):
109
+ os.remove("requirements.in")
110
+ else:
111
+ click.echo(reqs)
flask_nginx/config.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import asdict
5
+ from dataclasses import dataclass
6
+ from dataclasses import field
7
+ from dataclasses import fields
8
+ from dataclasses import replace
9
+ from typing import IO
10
+
11
+ from .utils import toml_load
12
+
13
+
14
+ REPO = "git+https://github.com/arabidopsis/footprint.git"
15
+
16
+
17
+ @dataclass
18
+ class Config:
19
+ mailhost: str = "uwa-edu-au.mail.protection.outlook.com"
20
+ # mailhost: str = "antivirus.uwa.edu.au"
21
+ datastore: str = "//drive.irds.uwa.edu.au/sci-ms-001"
22
+ # directories that *might* be in the static directory
23
+ static_dir: str = (
24
+ r"img|images|js|css|media|docs|tutorials|notebooks|downloads|\.well-known"
25
+ )
26
+ # basic files that have urls such as /robots.txt /favicon.ico etc.
27
+ static_files: str = (
28
+ r"robots\.txt|crossdomain\.xml|favicon\.ico|browserconfig\.xml|humans\.txt"
29
+ )
30
+ # exclude these filenames/directories from static consideration
31
+ exclude: list[str] = field(default_factory=lambda: ["__pycache__"])
32
+ # directory to put config files: (Ubuntu, RHEL8)
33
+ nginx_dirs: list[str] = field(
34
+ default_factory=lambda: ["/etc/nginx/sites-enabled", "/etc/nginx/conf.d"],
35
+ )
36
+ arg_color: str = "yellow" # use "none" for no color
37
+
38
+
39
+ XConfig: Config | None = None
40
+
41
+
42
+ def get_config() -> Config:
43
+ global XConfig
44
+ if XConfig is None:
45
+ XConfig = _init_config(Config())
46
+ return XConfig
47
+
48
+
49
+ def _init_config(config: Config, application_dir: str = ".") -> Config:
50
+ project = os.path.join(application_dir, "pyproject.toml")
51
+ if os.path.isfile(project):
52
+ try:
53
+ d = toml_load(project)
54
+ if "tool" not in d:
55
+ return config
56
+ cfg = d["tool"].get("footprint")
57
+ if cfg is None:
58
+ return config
59
+ data = {}
60
+ for f in fields(config):
61
+ if f.name in cfg:
62
+ data[f.name] = cfg[f.name]
63
+
64
+ if data:
65
+ config = replace(config, **data)
66
+
67
+ except ImportError:
68
+ pass
69
+ except Exception:
70
+ import click
71
+
72
+ click.secho(f'can\'t load "{project}"', fg="red", bold=True, err=True)
73
+ return config
74
+
75
+
76
+ def dump_toml(config: Config, out: IO[str]) -> bool:
77
+ try:
78
+ import toml # type: ignore
79
+
80
+ d = dict(tool=dict(footprint=asdict(config)))
81
+ toml.dump(d, out)
82
+ return True
83
+ except Exception:
84
+ return False
85
+
86
+
87
+ def dump_to_file(filename: str, append: bool) -> bool:
88
+ config = get_config()
89
+ with open(filename, "a" if append else "w", encoding="utf-8") as fp:
90
+ return dump_toml(config, fp)
flask_nginx/core.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from contextlib import redirect_stderr
6
+ from io import StringIO
7
+ from os.path import abspath
8
+ from os.path import expanduser
9
+ from os.path import isdir
10
+ from os.path import isfile
11
+ from os.path import normpath
12
+ from typing import cast
13
+ from typing import Iterator
14
+ from typing import NamedTuple
15
+ from typing import TYPE_CHECKING
16
+
17
+ from click import BadParameter
18
+ from click import secho
19
+ from click import style
20
+ from werkzeug.routing import Rule
21
+
22
+ if TYPE_CHECKING:
23
+ from flask import Flask
24
+
25
+
26
+ # core ability
27
+
28
+
29
+ class StaticFolder(NamedTuple):
30
+ url: str | None
31
+ folder: str
32
+ rewrite: bool
33
+
34
+
35
+ STATIC_RULE = re.compile("^(.*)/<path:filename>$")
36
+
37
+
38
+ def topath(path: str) -> str:
39
+ return normpath(abspath(expanduser(path)))
40
+
41
+
42
+ def get_static_folders(app: Flask) -> list[StaticFolder]: # noqa: C901
43
+ def get_static_folder(rule: Rule) -> str | None:
44
+ bound_method = app.view_functions[rule.endpoint]
45
+ if hasattr(bound_method, "static_folder"):
46
+ return bound_method.static_folder # type: ignore
47
+ # __self__ is the blueprint of send_static_file method
48
+ if hasattr(bound_method, "__self__"):
49
+ bp = bound_method.__self__ # type: ignore
50
+ if bp.has_static_folder:
51
+ return bp.static_folder # type: ignore
52
+ # now just a lambda :(
53
+ return None
54
+
55
+ def find_static(app: Flask) -> Iterator[StaticFolder]:
56
+ if app.has_static_folder:
57
+ prefix, folder = app.static_url_path, app.static_folder
58
+ if folder is not None and isdir(folder):
59
+ yield StaticFolder(
60
+ prefix,
61
+ topath(folder),
62
+ (not folder.endswith(prefix) if prefix else False),
63
+ )
64
+ for r in app.url_map.iter_rules():
65
+ if not r.endpoint.endswith("static"):
66
+ continue
67
+ m = STATIC_RULE.match(r.rule)
68
+ if not m:
69
+ continue
70
+ rewrite = False
71
+ prefix = m.group(1)
72
+ folder = get_static_folder(r)
73
+ if folder is None:
74
+ if r.endpoint != "static":
75
+ # static view_func for app is now
76
+ # just a lambda.
77
+ secho(
78
+ f"location: can't find static folder for endpoint: {r.endpoint}",
79
+ fg="red",
80
+ err=True,
81
+ )
82
+ continue
83
+ if not folder.endswith(prefix):
84
+ rewrite = True
85
+
86
+ if not isdir(folder):
87
+ continue
88
+ yield StaticFolder(prefix, topath(folder), rewrite)
89
+
90
+ return list(set(find_static(app)))
91
+
92
+
93
+ def get_static_folders_for_app(
94
+ application_dir: str,
95
+ app: Flask | None = None,
96
+ prefix: str = "",
97
+ entrypoint: str = "app.app",
98
+ ) -> list[StaticFolder]:
99
+ def fixstatic(s: StaticFolder) -> StaticFolder:
100
+ url = prefix + (s.url or "")
101
+ if url and s.folder.endswith(url):
102
+ path = s.folder[: -len(url)]
103
+ return StaticFolder(url, path, False)
104
+ return StaticFolder(url, s.folder, s.rewrite if not prefix else True)
105
+
106
+ if app is None:
107
+ app = find_application(
108
+ application_dir,
109
+ get_app_entrypoint(application_dir, entrypoint),
110
+ )
111
+ return [fixstatic(s) for s in get_static_folders(app)]
112
+
113
+
114
+ def find_application(application_dir: str, module: str) -> Flask:
115
+ import sys
116
+ from importlib import import_module
117
+
118
+ remove = False
119
+ if application_dir not in sys.path:
120
+ sys.path.append(application_dir)
121
+ remove = True
122
+ try:
123
+ # FIXME: we really want to run this
124
+ # under the virtual environment that this pertains too
125
+ venv = sys.prefix
126
+ secho(
127
+ f"trying to load application ({module}) using {venv}: ",
128
+ fg="yellow",
129
+ nl=False,
130
+ err=True,
131
+ )
132
+ with redirect_stderr(StringIO()) as stderr:
133
+ m = import_module(module)
134
+ app = m.application # type: ignore
135
+ v = stderr.getvalue()
136
+ if v:
137
+ secho(f"got possible errors ...{style(v[-100:], fg='red')}", err=True)
138
+ else:
139
+ secho("ok", fg="green", err=True)
140
+ return cast("Flask", app)
141
+ except (ImportError, AttributeError) as e:
142
+ raise BadParameter(f"can't load application from {application_dir}: {e}") from e
143
+ finally:
144
+ if remove:
145
+ sys.path.remove(application_dir)
146
+
147
+
148
+ def get_dot_env(fname: str) -> dict[str, str | None] | None:
149
+ try:
150
+ from dotenv import dotenv_values # type: ignore
151
+
152
+ return dotenv_values(fname)
153
+ except ImportError:
154
+ import click
155
+
156
+ click.secho(
157
+ '".flaskenv" file detected but no python-dotenv module found',
158
+ fg="yellow",
159
+ bold=True,
160
+ err=True,
161
+ )
162
+ return None
163
+
164
+
165
+ def get_app_entrypoint(application_dir: str, default: str = "app.app") -> str:
166
+ app = os.environ.get("FLASK_APP")
167
+ if app is not None:
168
+ return app
169
+ dot = os.path.join(application_dir, ".flaskenv")
170
+ if isfile(dot):
171
+ cfg = get_dot_env(dot)
172
+ if cfg is None:
173
+ return default
174
+ app = cfg.get("FLASK_APP")
175
+ if app is not None:
176
+ return app
177
+ return default
flask_nginx/irds.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from getpass import getuser
5
+
6
+ import click
7
+
8
+ from .cli import cli
9
+ from .systemd.systemd import systemd
10
+ from .systemd.utils import make_args
11
+ from .utils import get_pass
12
+ from .utils import which
13
+
14
+
15
+ def mount_irds(path_str: str, user: str | None = None) -> int:
16
+ from .config import get_config
17
+ from pathlib import Path
18
+ import os
19
+
20
+ sudo = which("sudo")
21
+ mount = which("mount")
22
+
23
+ path = Path(path_str).expanduser().absolute()
24
+ if not path.exists():
25
+ path.mkdir(exist_ok=True, parents=True)
26
+
27
+ datastore = path / "datastore"
28
+ if datastore.exists():
29
+ return 0
30
+
31
+ if user is None:
32
+ user = getuser()
33
+ uid = os.getuid()
34
+ gid = os.getgid()
35
+ pheme = get_pass("PHEME", f"user {user} pheme")
36
+ cmd = [
37
+ sudo,
38
+ mount,
39
+ "-t",
40
+ "cifs",
41
+ "-o",
42
+ f"user={user}",
43
+ "-o",
44
+ f"pass={pheme}",
45
+ "-o",
46
+ f"uid={uid},gid={gid},forceuid,forcegid",
47
+ get_config().datastore,
48
+ str(path),
49
+ ]
50
+ pmount = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
51
+ returncode = pmount.wait()
52
+ return returncode
53
+
54
+
55
+ @cli.group(help=click.style("IRDS commands", fg="magenta"))
56
+ def irds() -> None:
57
+ pass
58
+
59
+
60
+ @irds.command(name="mount")
61
+ @click.argument("directory")
62
+ @click.argument("user", required=False)
63
+ def mount_irds_cmd(directory: str, user: str | None) -> None:
64
+ """Mount IRDS datastore."""
65
+
66
+ returncode = mount_irds(directory, user=user)
67
+ if returncode != 0:
68
+ click.secho("can't mound irds", fg="red")
69
+ raise click.Abort()
70
+
71
+
72
+ MOUNT_ARGS = {
73
+ "mount_dir": "locations of repo",
74
+ "user": "user to run as [default: current user]",
75
+ "version": "SMB version [default: 3.0]",
76
+ "credentials": "file containg PHEME password as a line: password={pw}"
77
+ " (no spaces)\nroot owned with permission 600",
78
+ "password": "PHEME password",
79
+ "drive": "IRDS drive to mount",
80
+ }
81
+
82
+ MOUNT_HELP = f"""
83
+ Generate a systemd mount file for a IRDS.
84
+
85
+ Use footprint irds systemd path/to/mount_dir ... etc.
86
+ with the following arguments:
87
+
88
+ \b
89
+ {make_args(MOUNT_ARGS)}
90
+ \b
91
+ example:
92
+ \b
93
+ footprint irds systemd ~/irds user=00033472
94
+ """
95
+
96
+
97
+ @irds.command(name="systemd", help=MOUNT_HELP)
98
+ @click.option("-i", "--ignore-unknowns", is_flag=True, help="ignore unknown variables")
99
+ @click.option(
100
+ "-c",
101
+ "--credentials",
102
+ type=click.Path(file_okay=True, dir_okay=False, exists=True),
103
+ help="credentials file for CIFS",
104
+ )
105
+ @click.option("-t", "--template", metavar="TEMPLATE_FILE", help="template file")
106
+ @click.option("-n", "--no-check", is_flag=True, help="don't check parameters")
107
+ @click.argument(
108
+ "mount_dir",
109
+ type=click.Path(exists=True, dir_okay=True, file_okay=False),
110
+ required=True,
111
+ )
112
+ @click.argument("params", nargs=-1)
113
+ def systemd_mount_cmd(
114
+ mount_dir: str,
115
+ params: list[str],
116
+ template: str | None,
117
+ no_check: bool,
118
+ ignore_unknowns: bool,
119
+ credentials: str | None,
120
+ ) -> None:
121
+ """Generate a systemd unit file to mount IRDS.
122
+
123
+ PARAMS are key=value arguments for the template.
124
+ """
125
+ import os
126
+ from getpass import getpass
127
+
128
+ from .config import get_config
129
+
130
+ params = list(params)
131
+
132
+ Config = get_config()
133
+
134
+ mount_dir = mount_dir or "."
135
+ mount_dir = os.path.abspath(os.path.expanduser(mount_dir))
136
+
137
+ def isadir(d: str) -> str | None:
138
+ return None if os.path.isdir(d) else f"{d}: not a directory"
139
+
140
+ def isafile(d: str) -> str | None:
141
+ return None if os.path.isfile(d) else f"{d}: not a file"
142
+
143
+ se = which("systemd-escape")
144
+ filename = subprocess.check_output(
145
+ [se, "-p", "--suffix=mount", mount_dir],
146
+ text=True,
147
+ ).strip()
148
+
149
+ if credentials is not None:
150
+ params.append(f"credentials={os.path.expanduser(credentials)}")
151
+
152
+ systemd(
153
+ template or "systemd.mount",
154
+ mount_dir,
155
+ params,
156
+ help_args=MOUNT_ARGS,
157
+ check=not no_check,
158
+ output=filename,
159
+ ignore_unknowns=ignore_unknowns,
160
+ checks=[
161
+ (
162
+ "mount_dir",
163
+ lambda _, v: isadir(v),
164
+ ),
165
+ ("credentials", lambda _, v: isafile(v)),
166
+ ],
167
+ default_values=[
168
+ ("uid", lambda _: str(os.getuid())),
169
+ ("gid", lambda _: str(os.getgid())),
170
+ ("drive", lambda _: Config.datastore),
171
+ (
172
+ "password",
173
+ lambda params: (
174
+ getpass(f"PHEME password for {params['user']}: ")
175
+ if "credentials" not in params
176
+ else None
177
+ ),
178
+ ),
179
+ ],
180
+ )
181
+ msg = click.style(f"footprint config systemd-install {filename}", fg="green")
182
+ click.echo(f'use: "{msg}" to install')
flask_nginx/logo.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import click
6
+
7
+ from .cli import cli
8
+
9
+
10
+ def img2ico(png: str, out: str) -> None:
11
+ from PIL import Image # type: ignore
12
+
13
+ with open(png, "rb") as fp:
14
+ im = Image.open(fp)
15
+
16
+ im.thumbnail((128, 128), Image.ANTIALIAS) # type: ignore
17
+
18
+ size_tuples = [ # (256, 256),
19
+ (128, 128),
20
+ (64, 64),
21
+ (48, 48),
22
+ (32, 32),
23
+ (24, 24),
24
+ (16, 16),
25
+ ]
26
+
27
+ im.save(out, sizes=size_tuples)
28
+
29
+
30
+ @cli.command()
31
+ @click.option("-o", "--output", help="output filename")
32
+ @click.argument(
33
+ "image",
34
+ nargs=1,
35
+ type=click.Path(exists=True, dir_okay=False, file_okay=True),
36
+ )
37
+ def img_to_ico(image: str, output: str | None) -> None:
38
+ "Convert a image file to an .ico file [**requires Pillow**]."
39
+ from .utils import require_mod
40
+
41
+ require_mod("PIL", "Pillow")
42
+
43
+ # see https://anaconda.org/conda-forge/svg2png
44
+ if output is None:
45
+ out, _ = os.path.splitext(image)
46
+ output = out + ".ico"
47
+ img2ico(image, output)
flask_nginx/mailer.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import smtplib
4
+ from email.mime.text import MIMEText
5
+
6
+ import click
7
+
8
+ from .cli import cli
9
+
10
+ # from email.mime.image import MIMEImage
11
+ # from email.mime.multipart import MIMEMultipart
12
+
13
+
14
+ def sendmail(
15
+ html: str,
16
+ you: str,
17
+ me: str = "footprint@uwa.edu.au",
18
+ mailhost: str | None = None,
19
+ subject: str = "footprint monitor",
20
+ ) -> None:
21
+ from .config import get_config
22
+
23
+ if mailhost is None:
24
+ mailhost = get_config().mailhost
25
+ msg = MIMEText(html, "html")
26
+
27
+ msg["Subject"] = subject
28
+ msg["From"] = me
29
+ msg["To"] = you
30
+
31
+ with smtplib.SMTP() as s:
32
+ s.connect(mailhost)
33
+ s.sendmail(me, [you], msg.as_string())
34
+
35
+
36
+ @cli.command()
37
+ @click.option("-m", "--mailhost", help="mail host to use [default from config]")
38
+ @click.argument("email")
39
+ @click.argument("message", nargs=-1)
40
+ def email_test(email: str, message: list[str], mailhost: str | None) -> None:
41
+ """Test email setup from this host"""
42
+ import platform
43
+
44
+ if not message:
45
+ raise click.BadArgumentUsage("no message")
46
+
47
+ sendmail(
48
+ " ".join(message),
49
+ you=email,
50
+ mailhost=mailhost,
51
+ subject=f"Message from footprint on {platform.node()}",
52
+ )
53
+ click.secho("message sent!", fg="green", bold=True)