Skip to content

Commit a462a64

Browse files
authored
Python 3.7 package resources support (#98)
add support for reading webdeps and jinja-templates using either importlib_resources or pkg_resources, which removes the need for extracting these to a temporary folder on the filesystem * util: add helper functions to abstract embedded resource access * http*: serve embedded resources through resource abstraction * main: check webdeps through resource abstraction * httpconn: remove unused method `respath(name)` * use __package__ to find package resources * util: use importlib_resources backport if available * pass E.pkg as module object for importlib_resources compatibility * util: add pkg_resources compatibility to resource abstraction
1 parent 678675a commit a462a64

File tree

6 files changed

+342
-27
lines changed

6 files changed

+342
-27
lines changed

copyparty/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454

5555
class EnvParams(object):
5656
def __init__(self) -> None:
57+
self.pkg = None
5758
self.t0 = time.time()
5859
self.mod = ""
5960
self.cfg = ""

copyparty/__main__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
ansi_re,
5858
b64enc,
5959
dedent,
60+
has_resource,
6061
min_ex,
6162
pybin,
6263
termsize,
@@ -216,6 +217,7 @@ def get_unixdir() -> str:
216217

217218
raise Exception("could not find a writable path for config")
218219

220+
E.pkg = sys.modules[__package__]
219221
E.mod = os.path.dirname(os.path.realpath(__file__))
220222
if E.mod.endswith("__init__"):
221223
E.mod = os.path.dirname(E.mod)
@@ -325,8 +327,7 @@ def ensure_locale() -> None:
325327

326328

327329
def ensure_webdeps() -> None:
328-
ap = os.path.join(E.mod, "web/deps/mini-fa.woff")
329-
if os.path.exists(ap):
330+
if has_resource(E, "web/deps/mini-fa.woff"):
330331
return
331332

332333
warn(

copyparty/httpcli.py

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,15 @@
6868
get_spd,
6969
guess_mime,
7070
gzip_orig_sz,
71+
gzip_file_orig_sz,
72+
has_resource,
7173
hashcopy,
7274
hidedir,
7375
html_bescape,
7476
html_escape,
7577
humansize,
7678
ipnorm,
79+
load_resource,
7780
loadpy,
7881
log_reloc,
7982
min_ex,
@@ -93,6 +96,7 @@
9396
sanitize_vpath,
9497
sendfile_kern,
9598
sendfile_py,
99+
stat_resource,
96100
ub64dec,
97101
ub64enc,
98102
ujoin,
@@ -1093,12 +1097,11 @@ def handle_get(self) -> bool:
10931097
if self.vpath == ".cpr/metrics":
10941098
return self.conn.hsrv.metrics.tx(self)
10951099

1096-
path_base = os.path.join(self.E.mod, "web")
1097-
static_path = absreal(os.path.join(path_base, self.vpath[5:]))
1100+
static_path = os.path.join("web", self.vpath[5:])
10981101
if static_path in self.conn.hsrv.statics:
1099-
return self.tx_file(static_path)
1102+
return self.tx_res(static_path)
11001103

1101-
if not static_path.startswith(path_base):
1104+
if not undot(static_path).startswith("web"):
11021105
t = "malicious user; attempted path traversal [{}] => [{}]"
11031106
self.log(t.format(self.vpath, static_path), 1)
11041107
self.cbonk(self.conn.hsrv.gmal, self.req, "trav", "path traversal")
@@ -3300,6 +3303,129 @@ def _expand(self, txt: str, phs: list[str]) -> str:
33003303

33013304
return txt
33023305

3306+
def tx_res(self, req_path: str) -> bool:
3307+
status = 200
3308+
logmsg = "{:4} {} ".format("", self.req)
3309+
logtail = ""
3310+
3311+
editions = {}
3312+
file_ts = 0
3313+
3314+
if has_resource(self.E, req_path):
3315+
st = stat_resource(self.E, req_path)
3316+
if st:
3317+
file_ts = max(file_ts, st.st_mtime)
3318+
editions["plain"] = req_path
3319+
3320+
if has_resource(self.E, req_path + ".gz"):
3321+
st = stat_resource(self.E, req_path + ".gz")
3322+
if st:
3323+
file_ts = max(file_ts, st.st_mtime)
3324+
if not st or st.st_mtime > file_ts:
3325+
editions[".gz"] = req_path + ".gz"
3326+
3327+
if not editions:
3328+
return self.tx_404()
3329+
3330+
#
3331+
# if-modified
3332+
3333+
if file_ts > 0:
3334+
file_lastmod, do_send = self._chk_lastmod(int(file_ts))
3335+
self.out_headers["Last-Modified"] = file_lastmod
3336+
if not do_send:
3337+
status = 304
3338+
3339+
if self.can_write:
3340+
self.out_headers["X-Lastmod3"] = str(int(file_ts * 1000))
3341+
else:
3342+
do_send = True
3343+
3344+
#
3345+
# Accept-Encoding and UA decides which edition to send
3346+
3347+
decompress = False
3348+
supported_editions = [
3349+
x.strip()
3350+
for x in self.headers.get("accept-encoding", "").lower().split(",")
3351+
]
3352+
if ".gz" in editions:
3353+
is_compressed = True
3354+
selected_edition = ".gz"
3355+
3356+
if "gzip" not in supported_editions:
3357+
decompress = True
3358+
else:
3359+
if re.match(r"MSIE [4-6]\.", self.ua) and " SV1" not in self.ua:
3360+
decompress = True
3361+
3362+
if not decompress:
3363+
self.out_headers["Content-Encoding"] = "gzip"
3364+
else:
3365+
is_compressed = False
3366+
selected_edition = "plain"
3367+
3368+
res_path = editions[selected_edition]
3369+
logmsg += "{} ".format(selected_edition.lstrip("."))
3370+
3371+
res = load_resource(self.E, res_path)
3372+
3373+
if decompress:
3374+
file_sz = gzip_file_orig_sz(res)
3375+
res = gzip.open(res)
3376+
else:
3377+
res.seek(0, os.SEEK_END)
3378+
file_sz = res.tell()
3379+
res.seek(0, os.SEEK_SET)
3380+
3381+
#
3382+
# send reply
3383+
3384+
if is_compressed:
3385+
self.out_headers["Cache-Control"] = "max-age=604869"
3386+
else:
3387+
self.permit_caching()
3388+
3389+
if "txt" in self.uparam:
3390+
mime = "text/plain; charset={}".format(self.uparam["txt"] or "utf-8")
3391+
elif "mime" in self.uparam:
3392+
mime = str(self.uparam.get("mime"))
3393+
else:
3394+
mime = guess_mime(req_path)
3395+
3396+
logmsg += unicode(status) + logtail
3397+
3398+
if self.mode == "HEAD" or not do_send:
3399+
res.close()
3400+
if self.do_log:
3401+
self.log(logmsg)
3402+
3403+
self.send_headers(length=file_sz, status=status, mime=mime)
3404+
return True
3405+
3406+
ret = True
3407+
self.send_headers(length=file_sz, status=status, mime=mime)
3408+
remains = sendfile_py(
3409+
self.log,
3410+
0,
3411+
file_sz,
3412+
res,
3413+
self.s,
3414+
self.args.s_wr_sz,
3415+
self.args.s_wr_slp,
3416+
not self.args.no_poll,
3417+
)
3418+
3419+
if remains > 0:
3420+
logmsg += " \033[31m" + unicode(file_sz - remains) + "\033[0m"
3421+
ret = False
3422+
3423+
spd = self._spd(file_sz - remains)
3424+
if self.do_log:
3425+
self.log("{}, {}".format(logmsg, spd))
3426+
3427+
return ret
3428+
33033429
def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool:
33043430
status = 200
33053431
logmsg = "{:4} {} ".format("", self.req)
@@ -3815,15 +3941,11 @@ def tx_md(self, vn: VFS, fs_path: str) -> bool:
38153941
return self.tx_404(True)
38163942

38173943
tpl = "mde" if "edit2" in self.uparam else "md"
3818-
html_path = os.path.join(self.E.mod, "web", "{}.html".format(tpl))
38193944
template = self.j2j(tpl)
38203945

38213946
st = bos.stat(fs_path)
38223947
ts_md = st.st_mtime
38233948

3824-
st = bos.stat(html_path)
3825-
ts_html = st.st_mtime
3826-
38273949
max_sz = 1024 * self.args.txt_max
38283950
sz_md = 0
38293951
lead = b""
@@ -3857,7 +3979,7 @@ def tx_md(self, vn: VFS, fs_path: str) -> bool:
38573979
fullfile = html_bescape(fullfile)
38583980
sz_md = len(lead) + len(fullfile)
38593981

3860-
file_ts = int(max(ts_md, ts_html, self.E.t0))
3982+
file_ts = int(max(ts_md, self.E.t0))
38613983
file_lastmod, do_send = self._chk_lastmod(file_ts)
38623984
self.out_headers["Last-Modified"] = file_lastmod
38633985
self.out_headers.update(NO_CACHE)
@@ -3896,7 +4018,7 @@ def tx_md(self, vn: VFS, fs_path: str) -> bool:
38964018
zs = template.render(**targs).encode("utf-8", "replace")
38974019
html = zs.split(boundary.encode("utf-8"))
38984020
if len(html) != 2:
3899-
raise Exception("boundary appears in " + html_path)
4021+
raise Exception("boundary appears in " + tpl)
39004022

39014023
self.send_headers(sz_md + len(html[0]) + len(html[1]), status)
39024024

copyparty/httpconn.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,6 @@ def set_rproxy(self, ip: Optional[str] = None) -> str:
103103
self.log_src = ("%s \033[%dm%d" % (ip, color, self.addr[1])).ljust(26)
104104
return self.log_src
105105

106-
def respath(self, res_name: str) -> str:
107-
return os.path.join(self.E.mod, "web", res_name)
108-
109106
def log(self, msg: str, c: Union[int, str] = 0) -> None:
110107
self.log_func(self.log_src, msg, c)
111108

copyparty/httpsrv.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,17 @@
6868
NetMap,
6969
absreal,
7070
build_netmap,
71+
has_resource,
7172
ipnorm,
73+
load_resource,
7274
min_ex,
7375
shut_socket,
7476
spack,
7577
start_log_thrs,
7678
start_stackmon,
79+
stat_resource,
7780
ub64enc,
81+
walk_resources,
7882
)
7983

8084
if TYPE_CHECKING:
@@ -91,6 +95,10 @@
9195
setattr(socket, "AF_UNIX", -9001)
9296

9397

98+
def load_jinja2_resource(E: EnvParams, name: str):
99+
return load_resource(E, os.path.join("web", name), "r").read()
100+
101+
94102
class HttpSrv(object):
95103
"""
96104
handles incoming connections using HttpConn to process http,
@@ -153,7 +161,7 @@ def __init__(self, broker: "BrokerCli", nid: Optional[int]) -> None:
153161
self.u2idx_n = 0
154162

155163
env = jinja2.Environment()
156-
env.loader = jinja2.FileSystemLoader(os.path.join(self.E.mod, "web"))
164+
env.loader = jinja2.FunctionLoader(lambda f: load_jinja2_resource(self.E, f))
157165
jn = [
158166
"splash",
159167
"shares",
@@ -166,8 +174,7 @@ def __init__(self, broker: "BrokerCli", nid: Optional[int]) -> None:
166174
"cf",
167175
]
168176
self.j2 = {x: env.get_template(x + ".html") for x in jn}
169-
zs = os.path.join(self.E.mod, "web", "deps", "prism.js.gz")
170-
self.prism = os.path.exists(zs)
177+
self.prism = has_resource(self.E, os.path.join("web", "deps", "prism.js.gz"))
171178

172179
self.ipa_nm = build_netmap(self.args.ipa)
173180
self.xff_nm = build_netmap(self.args.xff_src)
@@ -210,9 +217,9 @@ def post_init(self) -> None:
210217
pass
211218

212219
def _build_statics(self) -> None:
213-
for dp, _, df in os.walk(os.path.join(self.E.mod, "web")):
220+
for dp, _, df in walk_resources(self.E, "web"):
214221
for fn in df:
215-
ap = absreal(os.path.join(dp, fn))
222+
ap = os.path.join(dp, fn)
216223
self.statics.add(ap)
217224
if ap.endswith(".gz"):
218225
self.statics.add(ap[:-3])
@@ -536,10 +543,20 @@ def cachebuster(self) -> str:
536543

537544
v = self.E.t0
538545
try:
539-
with os.scandir(os.path.join(self.E.mod, "web")) as dh:
540-
for fh in dh:
541-
inf = fh.stat()
546+
for (base, dirs, files) in walk_resources(self.E, "web"):
547+
inf = stat_resource(self.E, base)
548+
if inf:
542549
v = max(v, inf.st_mtime)
550+
for d in dirs:
551+
inf = stat_resource(self.E, os.path.join(base, d))
552+
if inf:
553+
v = max(v, inf.st_mtime)
554+
for f in files:
555+
inf = stat_resource(self.E, os.path.join(base, e))
556+
if inf:
557+
v = max(v, inf.st_mtime)
558+
# only do top-level
559+
break
543560
except:
544561
pass
545562

0 commit comments

Comments
 (0)