thinhost 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thinhost
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Thin HTTP hosts for Decelium microsite workflows
5
- Home-page: https://github.com/Decelium/decelium_simple_host
5
+ Home-page: https://github.com/Decelium/thinhost
6
6
  Author: Justin Girard
7
7
  Author-email: justingirard@decelium.com
8
8
  License: MIT
9
9
  Classifier: Development Status :: 2 - Pre-Alpha
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Programming Language :: Python :: 3
13
12
  Requires-Python: >=3.7
14
13
  Description-Content-Type: text/markdown
@@ -5,18 +5,17 @@ long_description = Path("README.md").read_text(encoding="utf-8")
5
5
 
6
6
  setup(
7
7
  name="thinhost",
8
- version="0.1.0",
8
+ version="0.2.0",
9
9
  description="Thin HTTP hosts for Decelium microsite workflows",
10
10
  long_description=long_description,
11
11
  long_description_content_type="text/markdown",
12
12
  author="Justin Girard",
13
13
  author_email="justingirard@decelium.com",
14
- url="https://github.com/Decelium/decelium_simple_host",
14
+ url="https://github.com/Decelium/thinhost",
15
15
  license="MIT",
16
16
  classifiers=[
17
17
  "Development Status :: 2 - Pre-Alpha",
18
18
  "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
19
  "Programming Language :: Python :: 3",
21
20
  ],
22
21
  packages=find_packages(),
@@ -561,14 +561,25 @@ class UploadService(BaseService):
561
561
  )
562
562
  return mapped
563
563
 
564
+ @staticmethod
565
+ def _format_size_compact(num_bytes) -> str:
566
+ try:
567
+ size = float(num_bytes or 0)
568
+ except Exception:
569
+ size = 0.0
570
+ units = ["B", "KB", "MB", "GB", "TB"]
571
+ idx = 0
572
+ while size >= 1024.0 and idx < len(units) - 1:
573
+ size /= 1024.0
574
+ idx += 1
575
+ if idx == 0:
576
+ return f"{int(size)} {units[idx]}"
577
+ return f"{size:.1f} {units[idx]}"
578
+
564
579
  @classmethod
565
580
  def run_remote(cls, hostAddress, cmd):
566
- # headers = {"Upload-Token": d[d.f_token]}
567
581
  headers = {}
568
- print(type(hostAddress))
569
- print(hostAddress)
570
582
  s = UploadService.tHostAddress(hostAddress)
571
- # print(jsondateencode_local.dumps(cmd,indent =1))
572
583
 
573
584
  resp = requests.post(
574
585
  s.getCommandUrl(),
@@ -599,6 +610,16 @@ class UploadService(BaseService):
599
610
  for idx, transfer in mapped.items():
600
611
  transfer = cls.tTransferProgress(transfer)
601
612
  uploadRecord = cls.tClientUploadData(transfer[ts.f_data])
613
+ try:
614
+ source_path = str(uploadRecord[uploadRecord.f_stream_source])
615
+ except Exception:
616
+ source_path = ""
617
+ try:
618
+ size_bytes = int(transfer[ts.f_size])
619
+ except Exception:
620
+ size_bytes = 0
621
+ size_text = cls._format_size_compact(size_bytes)
622
+ print(f'uploading ({size_text}) "{source_path}" ...', flush=True)
602
623
  uploadEID = cls.EntityID(transfer[ts.f_data])
603
624
  args = {
604
625
  "__command": ["upload_file"],
@@ -622,12 +643,14 @@ class UploadService(BaseService):
622
643
  results[idx][ts.f_status] = "uploaded"
623
644
  results[idx][ts.f_attrib]["resp.upload_file"] = str(rec)
624
645
  results[idx][ts.f_attrib]["resp.has_file"] = str(has_file)
646
+ print("... upload finished", flush=True)
625
647
  else:
626
648
  results[idx][ts.f_progress] = 0
627
649
  results[idx][ts.f_status] = "failed"
628
650
  results[idx][ts.f_attrib]["resp.upload_file"] = str(rec)
629
651
  results[idx][ts.f_attrib]["resp.has_file"] = str(has_file)
630
652
  # return results
653
+ print("... upload failed", flush=True)
631
654
  try:
632
655
  results[idx][td.f_stream].close()
633
656
  except:
@@ -0,0 +1,224 @@
1
+ from nodejobs.dependencies.BaseService import BaseService
2
+ from nodejobs.dependencies.BaseData import BaseData
3
+ from thinhost.simple_upload_host import jsondateencode_local
4
+ from flask import Flask, send_from_directory, send_file, Response, request, has_request_context
5
+ from flask_cors import CORS
6
+ import os
7
+ import json
8
+ import base64
9
+
10
+
11
+ class SimpleWebServer(BaseService):
12
+ class Command(BaseData):
13
+ f_command_id = "command_id"
14
+ f_data = "data"
15
+
16
+ def get_keys(self):
17
+ return {
18
+ self.f_command_id: str,
19
+ self.f_data: dict,
20
+ }, {}
21
+
22
+ class tHostSpec(BaseData):
23
+ f_host = "host"
24
+ f_port = "port"
25
+ f_command_uri = "command_uri"
26
+ f_public_root = "public_root"
27
+ f_cwd = "cwd"
28
+
29
+ def get_keys(self):
30
+ required = {
31
+ self.f_host: str,
32
+ self.f_port: str,
33
+ self.f_command_uri: str,
34
+ self.f_public_root: str,
35
+ }
36
+ optional = { self.f_cwd: str }
37
+ return required, optional
38
+
39
+ c_index_file = "index.html"
40
+ c_cors_path = r"/*"
41
+ c_cors_origins_key = "origins"
42
+ c_cors_any = "*"
43
+ c_http_mode_key = "_mode"
44
+ c_http_mode_rpc = "rpc"
45
+ c_http_mode_html = "html"
46
+
47
+ @classmethod
48
+ def get_command_map(cls):
49
+ return {
50
+ "launch": { "required_args": [], "method": cls._cmd_launch },
51
+ }
52
+
53
+ @classmethod
54
+ def _after_launch_endpoints(cls, app, hostSpec):
55
+ pass
56
+
57
+ @staticmethod
58
+ def encode_http_object_base64url(payload: dict) -> str:
59
+ if not isinstance(payload, dict):
60
+ raise Exception("invalid_payload")
61
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
62
+ encoded = base64.urlsafe_b64encode(raw).decode("ascii")
63
+ return encoded.rstrip("=")
64
+
65
+ @staticmethod
66
+ def decode_http_object_base64url(encoded: str) -> dict:
67
+ if not isinstance(encoded, str) or not encoded.strip():
68
+ raise Exception("invalid_encoded_payload")
69
+ text = encoded.strip()
70
+ text = text + ("=" * ((4 - (len(text) % 4)) % 4))
71
+ raw = base64.urlsafe_b64decode(text.encode("ascii"))
72
+ payload = json.loads(raw.decode("utf-8"))
73
+ if not isinstance(payload, dict):
74
+ raise Exception("invalid_encoded_payload")
75
+ return payload
76
+
77
+ @classmethod
78
+ def parse_http_request_object(cls, request_obj):
79
+ method = str(getattr(request_obj, "method", "") or "").upper()
80
+ if method == "GET":
81
+ encoded = request_obj.args.get("__encoded")
82
+ if isinstance(encoded, str) and encoded.strip():
83
+ return cls.decode_http_object_base64url(encoded)
84
+ return dict(request_obj.args)
85
+ raw = request_obj.get_json()
86
+ if not isinstance(raw, dict):
87
+ raise Exception("invalid_request_payload")
88
+ return raw
89
+
90
+ @classmethod
91
+ def get_request(cls):
92
+ if not has_request_context():
93
+ return {}
94
+ return cls.parse_http_request_object(request)
95
+
96
+ @classmethod
97
+ def get_request_data(cls, request_payload: dict = None):
98
+ payload = request_payload if isinstance(request_payload, dict) else cls.get_request()
99
+ if not isinstance(payload, dict):
100
+ return {}
101
+ data = payload.get(cls.Command.f_data)
102
+ return data if isinstance(data, dict) else payload
103
+
104
+ @classmethod
105
+ def resolve_http_mode(cls, payload: dict):
106
+ if not isinstance(payload, dict):
107
+ return cls.c_http_mode_rpc
108
+ mode = str(payload.get(cls.c_http_mode_key) or "").strip().lower()
109
+ if mode == cls.c_http_mode_html:
110
+ return cls.c_http_mode_html
111
+ return cls.c_http_mode_rpc
112
+
113
+ @classmethod
114
+ def respond_mode_aware(
115
+ cls,
116
+ *,
117
+ request_payload: dict,
118
+ rpc_result,
119
+ rpc_status: int = 200,
120
+ html_file_spec: dict = None,
121
+ html_file_spec_builder=None,
122
+ ):
123
+ mode = cls.resolve_http_mode(request_payload)
124
+ if mode == cls.c_http_mode_html:
125
+ spec = html_file_spec
126
+ if spec is None and callable(html_file_spec_builder):
127
+ spec = html_file_spec_builder(request_payload, rpc_result)
128
+ if not isinstance(spec, dict):
129
+ raise Exception("html_mode_requires_file_spec")
130
+ return cls.serve_file_from(
131
+ root_path=str(spec.get("root_path") or ""),
132
+ cache_path=str(spec.get("cache_path") or ""),
133
+ rel_path=str(spec.get("rel_path") or ""),
134
+ as_attachment=bool(spec.get("as_attachment")),
135
+ download_name=str(spec.get("download_name") or ""),
136
+ )
137
+ return (jsondateencode_local.dumps(rpc_result), int(rpc_status))
138
+
139
+ @classmethod
140
+ def serve_file_from(
141
+ cls,
142
+ *,
143
+ root_path: str,
144
+ rel_path: str,
145
+ cache_path: str = "",
146
+ as_attachment: bool = False,
147
+ download_name: str = "",
148
+ ):
149
+ if not isinstance(root_path, str) or not root_path.strip():
150
+ raise Exception("invalid_root_path")
151
+ base_abs = os.path.abspath(root_path.strip())
152
+ if isinstance(cache_path, str) and cache_path.strip():
153
+ base_abs = os.path.abspath(os.path.join(base_abs, cache_path.strip()))
154
+ if not os.path.isdir(base_abs):
155
+ raise Exception("invalid_base_path")
156
+
157
+ rel = str(rel_path or "").strip().lstrip("/\\")
158
+ if not rel:
159
+ raise Exception("invalid_rel_path")
160
+ full_abs = os.path.abspath(os.path.join(base_abs, rel))
161
+ if os.path.commonpath([base_abs, full_abs]) != base_abs:
162
+ raise Exception("path_escapes_base")
163
+ if not os.path.isfile(full_abs):
164
+ raise FileNotFoundError("file_not_found")
165
+
166
+ name = str(download_name or "").strip() or None
167
+ return send_file(
168
+ full_abs,
169
+ as_attachment=bool(as_attachment),
170
+ download_name=name,
171
+ )
172
+
173
+ @classmethod
174
+ def _cmd_launch(cls, **kwargs):
175
+ if has_request_context():
176
+ raise Exception(
177
+ "launch is CLI-only and cannot be invoked via /cmd on a running server"
178
+ )
179
+ cls.currentHa = cls.tHostSpec(kwargs)
180
+ cwd_start = os.getcwd()
181
+ root_dir_raw = cls.currentHa[cls.currentHa.f_public_root]
182
+ cmd_uri = cls.currentHa[cls.currentHa.f_command_uri]
183
+ if not os.path.isdir(root_dir_raw): raise Exception(f"public_root does not exist: {root_dir_raw}")
184
+ if not cmd_uri.startswith("/"): raise Exception("command_uri must start with '/'")
185
+ os.chdir(root_dir_raw)
186
+ root_dir = os.getcwd()
187
+
188
+ app = Flask(__name__, static_folder=None, static_url_path=None)
189
+ app.config.from_object(__name__)
190
+ CORS(app, resources={cls.c_cors_path: {cls.c_cors_origins_key: cls.c_cors_any}})
191
+
192
+ @app.route("/", methods=["GET", "POST"])
193
+ @app.route("/<path:u_path>", methods=["GET", "POST"])
194
+ def index(u_path="/"):
195
+ rel_path = u_path.lstrip("/")
196
+ full = os.path.join(root_dir, rel_path)
197
+ if os.path.isfile(full): return send_from_directory(root_dir, rel_path)
198
+ if os.path.isdir(full):
199
+ index_path = os.path.join(full, cls.c_index_file)
200
+ if os.path.isfile(index_path): return send_from_directory(full, cls.c_index_file)
201
+ root_index = os.path.join(root_dir, cls.c_index_file)
202
+ if os.path.isfile(root_index): return send_from_directory(root_dir, cls.c_index_file)
203
+ msg = (
204
+ f"File not found: {rel_path}\n"
205
+ f"cwd_start: {cwd_start}\n"
206
+ f"public_root: {root_dir_raw}\n"
207
+ f"root_dir: {root_dir}\n"
208
+ f"full: {full}"
209
+ )
210
+ return Response(msg, status=404)
211
+
212
+ @app.route(cmd_uri, methods=["GET", "POST"])
213
+ def process_command():
214
+ cmd = cls.Command(cls.get_request())
215
+ resp = cls.run(__command=[cmd[cmd.f_command_id]], **cmd[cmd.f_data])
216
+ return (jsondateencode_local.dumps(resp), 201)
217
+
218
+ cls._after_launch_endpoints(app, cls.currentHa)
219
+ app.run(host=cls.currentHa[cls.currentHa.f_host], port=int(cls.currentHa[cls.currentHa.f_port]))
220
+ return "ok"
221
+
222
+
223
+ if __name__ == "__main__":
224
+ SimpleWebServer.run_cli()
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thinhost
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Thin HTTP hosts for Decelium microsite workflows
5
- Home-page: https://github.com/Decelium/decelium_simple_host
5
+ Home-page: https://github.com/Decelium/thinhost
6
6
  Author: Justin Girard
7
7
  Author-email: justingirard@decelium.com
8
8
  License: MIT
9
9
  Classifier: Development Status :: 2 - Pre-Alpha
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Programming Language :: Python :: 3
13
12
  Requires-Python: >=3.7
14
13
  Description-Content-Type: text/markdown
@@ -1,99 +0,0 @@
1
- from nodejobs.dependencies.BaseService import BaseService
2
- from nodejobs.dependencies.BaseData import BaseData
3
- from thinhost.simple_upload_host import jsondateencode_local
4
- from flask import Flask, send_from_directory, Response, request
5
- from flask_cors import CORS
6
- import os
7
-
8
-
9
- class SimpleWebServer(BaseService):
10
- class Command(BaseData):
11
- f_command_id = "command_id"
12
- f_data = "data"
13
-
14
- def get_keys(self):
15
- return {
16
- self.f_command_id: str,
17
- self.f_data: dict,
18
- }, {}
19
-
20
- class tHostSpec(BaseData):
21
- f_host = "host"
22
- f_port = "port"
23
- f_command_uri = "command_uri"
24
- f_public_root = "public_root"
25
- f_cwd = "cwd"
26
-
27
- def get_keys(self):
28
- required = {
29
- self.f_host: str,
30
- self.f_port: str,
31
- self.f_command_uri: str,
32
- self.f_public_root: str,
33
- }
34
- optional = { self.f_cwd: str }
35
- return required, optional
36
-
37
- c_index_file = "index.html"
38
- c_cors_path = r"/*"
39
- c_cors_origins_key = "origins"
40
- c_cors_any = "*"
41
-
42
- @classmethod
43
- def get_command_map(cls):
44
- return {
45
- "launch": { "required_args": [], "method": cls._cmd_launch },
46
- }
47
-
48
- @classmethod
49
- def _after_launch_endpoints(cls, app, hostSpec):
50
- pass
51
-
52
- @classmethod
53
- def _cmd_launch(cls, **kwargs):
54
- cls.currentHa = cls.tHostSpec(kwargs)
55
- cwd_start = os.getcwd()
56
- root_dir_raw = cls.currentHa[cls.currentHa.f_public_root]
57
- cmd_uri = cls.currentHa[cls.currentHa.f_command_uri]
58
- if not os.path.isdir(root_dir_raw): raise Exception(f"public_root does not exist: {root_dir_raw}")
59
- if not cmd_uri.startswith("/"): raise Exception("command_uri must start with '/'")
60
- os.chdir(root_dir_raw)
61
- root_dir = os.getcwd()
62
-
63
- app = Flask(__name__, static_folder=None, static_url_path=None)
64
- app.config.from_object(__name__)
65
- CORS(app, resources={cls.c_cors_path: {cls.c_cors_origins_key: cls.c_cors_any}})
66
-
67
- @app.route("/", methods=["GET", "POST"])
68
- @app.route("/<path:u_path>", methods=["GET", "POST"])
69
- def index(u_path="/"):
70
- rel_path = u_path.lstrip("/")
71
- full = os.path.join(root_dir, rel_path)
72
- if os.path.isfile(full): return send_from_directory(root_dir, rel_path)
73
- if os.path.isdir(full):
74
- index_path = os.path.join(full, cls.c_index_file)
75
- if os.path.isfile(index_path): return send_from_directory(full, cls.c_index_file)
76
- root_index = os.path.join(root_dir, cls.c_index_file)
77
- if os.path.isfile(root_index): return send_from_directory(root_dir, cls.c_index_file)
78
- msg = (
79
- f"File not found: {rel_path}\n"
80
- f"cwd_start: {cwd_start}\n"
81
- f"public_root: {root_dir_raw}\n"
82
- f"root_dir: {root_dir}\n"
83
- f"full: {full}"
84
- )
85
- return Response(msg, status=404)
86
-
87
- @app.route(cmd_uri, methods=["POST"])
88
- def process_command():
89
- cmd = cls.Command(request.get_json())
90
- resp = cls.run(__command=[cmd[cmd.f_command_id]], **cmd[cmd.f_data])
91
- return (jsondateencode_local.dumps(resp), 201)
92
-
93
- cls._after_launch_endpoints(app, cls.currentHa)
94
- app.run(host=cls.currentHa[cls.currentHa.f_host], port=int(cls.currentHa[cls.currentHa.f_port]))
95
- return "ok"
96
-
97
-
98
- if __name__ == "__main__":
99
- SimpleWebServer.run_cli()
File without changes
File without changes
File without changes
File without changes