pam-python 0.2.0__py3-none-any.whl → 0.2.2__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.
- pam/__init__.py +1 -1
- pam/cli.py +353 -150
- pam/models/request_command.py +4 -0
- pam/request_file_format.py +11 -2
- pam/result_batch_uploader.py +99 -51
- pam/server.py +22 -2
- pam/service.py +38 -25
- pam/templates/init/AGENT.md +48 -68
- pam/templates/init/REPORTS.md +2 -2
- pam/templates/init/dockerignore.tmpl +3 -1
- pam/templates/project/pip/Dockerfile +16 -0
- pam/templates/{init → project/pip}/requirements.txt +0 -2
- pam/templates/project/uv/Dockerfile +20 -0
- pam/templates/project/uv/pyproject.toml +12 -0
- pam/templates/project/uv/python-version +1 -0
- pam/templates/service/pandas/functions.tmpl +13 -0
- pam/templates/service/{service.test.tmpl → pandas/service.test.tmpl} +31 -46
- pam/templates/service/pandas/service_class.tmpl +65 -0
- pam/templates/service/polars/functions.tmpl +11 -0
- pam/templates/service/polars/service.test.tmpl +122 -0
- pam/templates/service/polars/service_class.tmpl +59 -0
- pam/tester_task.py +22 -7
- pam/utils.py +2 -4
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/METADATA +56 -60
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/RECORD +30 -24
- pam/templates/docker/Dockerfile +0 -21
- pam/templates/service/functions.tmpl +0 -22
- pam/templates/service/service_class.tmpl +0 -81
- pam/templates/service/{service.yaml → common/service.yaml} +1 -1
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/WHEEL +0 -0
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/entry_points.txt +0 -0
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/licenses/LICENSE.txt +0 -0
- {pam_python-0.2.0.dist-info → pam_python-0.2.2.dist-info}/top_level.txt +0 -0
pam/__init__.py
CHANGED
pam/cli.py
CHANGED
|
@@ -1,168 +1,310 @@
|
|
|
1
|
+
import importlib.resources as resources
|
|
2
|
+
from importlib import metadata
|
|
3
|
+
import json
|
|
1
4
|
import os
|
|
5
|
+
from pathlib import Path
|
|
2
6
|
import re
|
|
3
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
4
9
|
import sys
|
|
5
|
-
import
|
|
6
|
-
|
|
10
|
+
import tempfile
|
|
11
|
+
import tomllib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PROJECT_MARKER = ".pam-project"
|
|
15
|
+
PACKAGE_MANAGERS = {"1": "uv", "2": "pip"}
|
|
16
|
+
DATAFRAME_ENGINES = {"1": "polars", "2": "pandas"}
|
|
17
|
+
|
|
18
|
+
COMMON_PROJECT_TEMPLATES = {
|
|
19
|
+
"init/main.tmpl": "main.py",
|
|
20
|
+
"buildcmd/pamb": "pamb",
|
|
21
|
+
"buildcmd/pamb-base.sh": "pamb-base.sh",
|
|
22
|
+
"init/pylintrc.tmpl": ".pylintrc",
|
|
23
|
+
"init/REPORTS.md": "REPORTS.md",
|
|
24
|
+
"init/AGENT.md": "AGENT.md",
|
|
25
|
+
"init/gitignore.tmpl": ".gitignore",
|
|
26
|
+
"init/dockerignore.tmpl": ".dockerignore",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
PACKAGE_MANAGER_TEMPLATES = {
|
|
30
|
+
"pip": {
|
|
31
|
+
"project/pip/Dockerfile": "Dockerfile",
|
|
32
|
+
"project/pip/requirements.txt": "requirements.txt",
|
|
33
|
+
"init/run_unit_test.sh": "run_unit_test.sh",
|
|
34
|
+
"init/run_unit_test.bat": "run_unit_test.bat",
|
|
35
|
+
"init/run_unit_test.ps1": "run_unit_test.ps1",
|
|
36
|
+
},
|
|
37
|
+
"uv": {
|
|
38
|
+
"project/uv/Dockerfile": "Dockerfile",
|
|
39
|
+
"project/uv/pyproject.toml": "pyproject.toml",
|
|
40
|
+
"project/uv/python-version": ".python-version",
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
SERVICE_TEMPLATES = {
|
|
45
|
+
"polars": {
|
|
46
|
+
"service/polars/service_class.tmpl": "#CLASS_NAME#.py",
|
|
47
|
+
"service/polars/functions.tmpl": "functions.py",
|
|
48
|
+
"service/polars/service.test.tmpl": "test_#MODULE_NAME#.py",
|
|
49
|
+
},
|
|
50
|
+
"pandas": {
|
|
51
|
+
"service/pandas/service_class.tmpl": "#CLASS_NAME#.py",
|
|
52
|
+
"service/pandas/functions.tmpl": "functions.py",
|
|
53
|
+
"service/pandas/service.test.tmpl": "test_#MODULE_NAME#.py",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ENGINE_DEPENDENCIES = {
|
|
58
|
+
"polars": ["polars>=1.0.0"],
|
|
59
|
+
"pandas": ["pandas>=2.2.3", "pyarrow>=19.0.1"],
|
|
60
|
+
}
|
|
7
61
|
|
|
8
62
|
|
|
9
63
|
def main(argv=None):
|
|
10
64
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
11
|
-
if
|
|
65
|
+
if not args:
|
|
12
66
|
print("Usage: pam <command> [args]")
|
|
13
67
|
return
|
|
14
68
|
|
|
15
69
|
cmd = args[0]
|
|
16
70
|
|
|
17
71
|
if cmd == "init":
|
|
72
|
+
if len(args) != 1:
|
|
73
|
+
print("Usage: pam init")
|
|
74
|
+
return
|
|
18
75
|
init_project()
|
|
19
76
|
elif cmd == "new":
|
|
20
|
-
if len(args)
|
|
77
|
+
if len(args) != 3 or args[1] != "service":
|
|
21
78
|
print("Usage: pam new service <service_name>")
|
|
22
79
|
return
|
|
23
|
-
|
|
24
|
-
if create_type == "service":
|
|
25
|
-
if len(args) < 3:
|
|
26
|
-
print("Usage: pam new service <service_name>")
|
|
27
|
-
return
|
|
28
|
-
name = args[2]
|
|
29
|
-
create_service(name)
|
|
30
|
-
else:
|
|
31
|
-
print(f"Unknown new target: {create_type}")
|
|
80
|
+
create_service(args[2])
|
|
32
81
|
else:
|
|
33
82
|
print(f"Unknown command: {cmd}")
|
|
34
83
|
|
|
35
84
|
|
|
36
85
|
def to_pascal_case(input_string: str) -> str:
|
|
37
|
-
""
|
|
38
|
-
|
|
86
|
+
words = re.split(r"[^A-Za-z0-9]+", input_string)
|
|
87
|
+
return "".join(word[:1].upper() + word[1:] for word in words if word)
|
|
39
88
|
|
|
40
|
-
:param input_string: The string to convert.
|
|
41
|
-
:return: The string in PascalCase.
|
|
42
|
-
"""
|
|
43
|
-
# Split the string into words using non-alphanumeric characters as delimiters
|
|
44
|
-
words = re.split(r'[^A-Za-z0-9]+', input_string)
|
|
45
89
|
|
|
46
|
-
|
|
47
|
-
|
|
90
|
+
def _template_path(src: str):
|
|
91
|
+
return resources.files("pam") / "templates" / src
|
|
48
92
|
|
|
49
|
-
return pascal_case
|
|
50
93
|
|
|
94
|
+
def read_template_text(src: str) -> str:
|
|
95
|
+
with open(_template_path(src), "r", encoding="utf-8") as file:
|
|
96
|
+
return file.read()
|
|
51
97
|
|
|
52
|
-
def cpy(src, dest):
|
|
53
|
-
template_dir = resources.files("pam") / "templates"
|
|
54
|
-
src_file = os.path.join(template_dir, src)
|
|
55
|
-
shutil.copy(src_file, dest)
|
|
56
98
|
|
|
99
|
+
def _render_template(src: str, replacements: dict[str, str]) -> str:
|
|
100
|
+
content = read_template_text(src)
|
|
101
|
+
for placeholder, value in replacements.items():
|
|
102
|
+
content = content.replace(placeholder, value)
|
|
103
|
+
return content
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_rendered_template(src: str, dest: Path, replacements: dict[str, str]):
|
|
107
|
+
dest.write_text(_render_template(src, replacements), encoding="utf-8")
|
|
108
|
+
source_mode = os.stat(_template_path(src)).st_mode
|
|
109
|
+
os.chmod(dest, source_mode)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _select(prompt: str, options: dict[str, str], default: str) -> str:
|
|
113
|
+
if not sys.stdin.isatty():
|
|
114
|
+
raise RuntimeError(f"{prompt} requires an interactive terminal")
|
|
115
|
+
while True:
|
|
116
|
+
selection = input(f"Select [1-{len(options)}] (default: {default}): ").strip()
|
|
117
|
+
if not selection:
|
|
118
|
+
selection = default
|
|
119
|
+
if selection in options:
|
|
120
|
+
return options[selection]
|
|
121
|
+
print(f"Invalid selection: {selection}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _select_package_manager() -> str:
|
|
125
|
+
print("Select package manager:")
|
|
126
|
+
print("1) uv (recommended)")
|
|
127
|
+
print("2) pip")
|
|
128
|
+
return _select("Package manager selection", PACKAGE_MANAGERS, "1")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _select_dataframe_engine() -> str:
|
|
132
|
+
print("Select dataframe engine:")
|
|
133
|
+
print("1) Polars (recommended)")
|
|
134
|
+
print("2) Pandas")
|
|
135
|
+
return _select("Dataframe engine selection", DATAFRAME_ENGINES, "1")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _project_name(directory: Path) -> str:
|
|
139
|
+
name = re.sub(r"[^a-z0-9]+", "-", directory.name.lower()).strip("-")
|
|
140
|
+
if not name:
|
|
141
|
+
name = "pam-data-plugin"
|
|
142
|
+
if name[0].isdigit():
|
|
143
|
+
name = f"pam-{name}"
|
|
144
|
+
return name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _assert_new_project(directory: Path):
|
|
148
|
+
known_project_files = set(COMMON_PROJECT_TEMPLATES.values())
|
|
149
|
+
for templates in PACKAGE_MANAGER_TEMPLATES.values():
|
|
150
|
+
known_project_files.update(templates.values())
|
|
151
|
+
known_project_files.update({PROJECT_MARKER, "__init__.py", "uv.lock"})
|
|
152
|
+
conflicts = [
|
|
153
|
+
name for name in sorted(known_project_files)
|
|
154
|
+
if (directory / name).exists()
|
|
155
|
+
]
|
|
156
|
+
if conflicts:
|
|
157
|
+
raise FileExistsError(
|
|
158
|
+
"pam init only supports a new project directory; existing files: "
|
|
159
|
+
+ ", ".join(conflicts)
|
|
160
|
+
)
|
|
57
161
|
|
|
58
|
-
def read_template_text(src: str) -> str:
|
|
59
|
-
template_dir = resources.files("pam") / "templates"
|
|
60
|
-
src_file = os.path.join(template_dir, src)
|
|
61
|
-
with open(src_file, "r", encoding="utf-8") as file:
|
|
62
|
-
return file.read()
|
|
63
162
|
|
|
163
|
+
def _requirement_name(requirement: str) -> str:
|
|
164
|
+
return re.split(r"[<>=!~;\s\[]", requirement, maxsplit=1)[0].lower()
|
|
64
165
|
|
|
65
|
-
def replace_template_content(service_name, class_name, file_name):
|
|
66
|
-
file_path = os.path.join(service_name, file_name)
|
|
67
|
-
with open(file_path, 'r+', encoding='utf-8') as file:
|
|
68
|
-
filedata = file.read()
|
|
69
|
-
updated_data = filedata.replace('#CLASS_NAME#', class_name)
|
|
70
|
-
updated_data = updated_data.replace('#MODULE_NAME#', service_name)
|
|
71
|
-
file.seek(0) # Move the file pointer to the beginning of the file
|
|
72
|
-
file.write(updated_data)
|
|
73
|
-
file.truncate() # Remove any leftover content after the replacement
|
|
74
166
|
|
|
167
|
+
def _assert_fresh_uv_project(directory: Path):
|
|
168
|
+
pyproject_path = directory / "pyproject.toml"
|
|
169
|
+
try:
|
|
170
|
+
project = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"]
|
|
171
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
|
|
172
|
+
raise FileExistsError(f"Invalid fresh uv project: {exc}") from exc
|
|
173
|
+
|
|
174
|
+
dependencies = project.get("dependencies", [])
|
|
175
|
+
if not isinstance(dependencies, list) or not any(
|
|
176
|
+
isinstance(item, str) and _requirement_name(item) == "pam-python"
|
|
177
|
+
for item in dependencies
|
|
178
|
+
):
|
|
179
|
+
raise FileExistsError(
|
|
180
|
+
"Run `uv add pam-python` before `uv run pam init`"
|
|
181
|
+
)
|
|
75
182
|
|
|
76
|
-
|
|
77
|
-
if not
|
|
78
|
-
raise
|
|
79
|
-
"
|
|
80
|
-
"numbers, and underscores"
|
|
183
|
+
python_version = directory / ".python-version"
|
|
184
|
+
if not python_version.is_file() or not python_version.read_text().strip().startswith("3.12"):
|
|
185
|
+
raise FileExistsError(
|
|
186
|
+
"A fresh uv project must pin Python 3.12; run `uv init --python 3.12`"
|
|
81
187
|
)
|
|
82
|
-
if os.path.exists(name):
|
|
83
|
-
response = input(
|
|
84
|
-
f"Service {name} already exists. Do you want to overwrite it? (y/N): "
|
|
85
|
-
).strip().lower()
|
|
86
|
-
if response == 'y':
|
|
87
|
-
shutil.rmtree(name)
|
|
88
|
-
else:
|
|
89
|
-
print("Cancelled.")
|
|
90
|
-
return
|
|
91
188
|
|
|
92
|
-
|
|
93
|
-
|
|
189
|
+
allowed = {
|
|
190
|
+
".git",
|
|
191
|
+
".gitignore",
|
|
192
|
+
".python-version",
|
|
193
|
+
".venv",
|
|
194
|
+
"README.md",
|
|
195
|
+
"main.py",
|
|
196
|
+
"pyproject.toml",
|
|
197
|
+
"uv.lock",
|
|
198
|
+
}
|
|
199
|
+
unexpected = sorted(path.name for path in directory.iterdir() if path.name not in allowed)
|
|
200
|
+
if unexpected:
|
|
201
|
+
raise FileExistsError(
|
|
202
|
+
"pam init only accepts a fresh uv project; existing files: "
|
|
203
|
+
+ ", ".join(unexpected)
|
|
204
|
+
)
|
|
94
205
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
206
|
+
readme = directory / "README.md"
|
|
207
|
+
if readme.is_file() and readme.read_text(encoding="utf-8").strip():
|
|
208
|
+
raise FileExistsError("pam init will not overwrite a non-empty README.md")
|
|
209
|
+
|
|
210
|
+
main_file = directory / "main.py"
|
|
211
|
+
if main_file.is_file():
|
|
212
|
+
source = main_file.read_text(encoding="utf-8")
|
|
213
|
+
uv_placeholder = re.fullmatch(
|
|
214
|
+
r'def main\(\):\n'
|
|
215
|
+
r' print\("Hello from [a-z0-9-]+!"\)\n\n\n'
|
|
216
|
+
r'if __name__ == "__main__":\n'
|
|
217
|
+
r' main\(\)\n?',
|
|
218
|
+
source,
|
|
219
|
+
)
|
|
220
|
+
if uv_placeholder is None:
|
|
221
|
+
raise FileExistsError("pam init will not overwrite an edited main.py")
|
|
99
222
|
|
|
100
|
-
cpy("service/service.yaml", os.path.join(name, "service.yaml"))
|
|
101
|
-
cpy("service/functions.tmpl", os.path.join(name, "functions.py"))
|
|
102
|
-
cpy("service/service.test.tmpl", os.path.join(name, f"test_{name}.py"))
|
|
103
223
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
224
|
+
def _run_uv(args: list[str], cwd: Path):
|
|
225
|
+
uv = shutil.which("uv")
|
|
226
|
+
if uv is None:
|
|
227
|
+
raise RuntimeError("uv is required for this project but was not found on PATH")
|
|
228
|
+
subprocess.run([uv, *args], cwd=cwd, check=True)
|
|
110
229
|
|
|
111
|
-
print(f"Service {name} created.")
|
|
112
|
-
print(
|
|
113
|
-
f"Run the generated run_unit_test script for your shell with `{name}` "
|
|
114
|
-
"to test the service."
|
|
115
|
-
)
|
|
116
230
|
|
|
231
|
+
def init_project(package_manager: str | None = None):
|
|
232
|
+
destination = Path.cwd()
|
|
233
|
+
preinitialized_uv = (destination / "pyproject.toml").is_file()
|
|
234
|
+
if preinitialized_uv:
|
|
235
|
+
if package_manager not in (None, "uv"):
|
|
236
|
+
raise FileExistsError("A uv project cannot be initialized as a pip project")
|
|
237
|
+
_assert_fresh_uv_project(destination)
|
|
238
|
+
package_manager = "uv"
|
|
239
|
+
else:
|
|
240
|
+
package_manager = package_manager or _select_package_manager()
|
|
241
|
+
_assert_new_project(destination)
|
|
242
|
+
|
|
243
|
+
if package_manager not in PACKAGE_MANAGER_TEMPLATES:
|
|
244
|
+
raise ValueError(f"Unsupported package manager: {package_manager}")
|
|
245
|
+
|
|
246
|
+
if package_manager == "uv" and shutil.which("uv") is None:
|
|
247
|
+
raise RuntimeError("uv is required for this project but was not found on PATH")
|
|
248
|
+
|
|
249
|
+
replacements = {
|
|
250
|
+
"#PAM_VERSION#": _get_installed_pam_version(),
|
|
251
|
+
"#PROJECT_NAME#": _project_name(destination),
|
|
252
|
+
}
|
|
253
|
+
templates = {
|
|
254
|
+
**COMMON_PROJECT_TEMPLATES,
|
|
255
|
+
**PACKAGE_MANAGER_TEMPLATES[package_manager],
|
|
256
|
+
}
|
|
257
|
+
if preinitialized_uv:
|
|
258
|
+
templates = {
|
|
259
|
+
src: dest for src, dest in templates.items()
|
|
260
|
+
if dest not in {".gitignore", ".python-version", "pyproject.toml"}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
with tempfile.TemporaryDirectory(prefix=".pam-init-", dir=destination) as temp_dir:
|
|
264
|
+
staging = Path(temp_dir)
|
|
265
|
+
for src, dest in templates.items():
|
|
266
|
+
_write_rendered_template(src, staging / dest, replacements)
|
|
267
|
+
(staging / "__init__.py").touch()
|
|
268
|
+
(staging / PROJECT_MARKER).write_text(
|
|
269
|
+
json.dumps(
|
|
270
|
+
{
|
|
271
|
+
"template_version": 1,
|
|
272
|
+
"package_manager": package_manager,
|
|
273
|
+
},
|
|
274
|
+
indent=2,
|
|
275
|
+
sort_keys=True,
|
|
276
|
+
) + "\n",
|
|
277
|
+
encoding="utf-8",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if package_manager == "uv" and not preinitialized_uv:
|
|
281
|
+
_run_uv(["lock"], staging)
|
|
117
282
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
cpy("init/gitignore.tmpl", ".gitignore")
|
|
125
|
-
cpy("init/dockerignore.tmpl", ".dockerignore")
|
|
126
|
-
cpy("init/run_unit_test.sh", "run_unit_test.sh")
|
|
127
|
-
cpy("init/run_unit_test.bat", "run_unit_test.bat")
|
|
128
|
-
cpy("init/run_unit_test.ps1", "run_unit_test.ps1")
|
|
129
|
-
cpy("init/AGENT.md", "AGENT.md")
|
|
130
|
-
cpy("init/REPORTS.md", "REPORTS.md")
|
|
131
|
-
|
|
132
|
-
requirements_path = "requirements.txt"
|
|
133
|
-
has_requirements = os.path.exists(requirements_path)
|
|
134
|
-
template_requirements = _build_project_requirements().splitlines()
|
|
135
|
-
|
|
136
|
-
mode = "overwrite"
|
|
137
|
-
if has_requirements:
|
|
138
|
-
print("requirements.txt already exists. Choose how to proceed:")
|
|
139
|
-
print("1) overwrite")
|
|
140
|
-
print("2) keep")
|
|
141
|
-
print("3) merge")
|
|
142
|
-
selection = input("Select [1-3] (default: 1): ").strip()
|
|
143
|
-
if selection == "2":
|
|
144
|
-
mode = "keep"
|
|
145
|
-
elif selection == "3":
|
|
146
|
-
mode = "merge"
|
|
147
|
-
|
|
148
|
-
if mode == "keep":
|
|
149
|
-
print("Keeping existing requirements.txt")
|
|
283
|
+
for path in staging.iterdir():
|
|
284
|
+
os.replace(path, destination / path.name)
|
|
285
|
+
|
|
286
|
+
print(f"--- Welcome to PAM ({package_manager}) ---\n")
|
|
287
|
+
if package_manager == "uv":
|
|
288
|
+
print("Next:\n uv sync\n uv run pam new service <service_name>\n")
|
|
150
289
|
else:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
with open(requirements_path, "r", encoding="utf-8") as f:
|
|
156
|
-
existing_lines = f.read().splitlines()
|
|
157
|
-
merged_lines = _merge_requirements(existing_lines, template_requirements)
|
|
158
|
-
with open(requirements_path, "w", encoding="utf-8") as f:
|
|
159
|
-
f.write("\n".join(merged_lines) + ("\n" if merged_lines else ""))
|
|
290
|
+
print(
|
|
291
|
+
"Next:\n python -m pip install -r requirements.txt\n"
|
|
292
|
+
" pam new service <service_name>\n"
|
|
293
|
+
)
|
|
160
294
|
|
|
161
|
-
if not os.path.exists("__init__.py"):
|
|
162
|
-
open("__init__.py", 'a', encoding='utf-8').close()
|
|
163
295
|
|
|
164
|
-
|
|
165
|
-
|
|
296
|
+
def _load_project_config(directory: Path) -> dict:
|
|
297
|
+
marker = directory / PROJECT_MARKER
|
|
298
|
+
if not marker.is_file():
|
|
299
|
+
raise RuntimeError("This is not a PAM project created by the current `pam init`")
|
|
300
|
+
try:
|
|
301
|
+
config = json.loads(marker.read_text(encoding="utf-8"))
|
|
302
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
303
|
+
raise RuntimeError(f"Invalid {PROJECT_MARKER}: {exc}") from exc
|
|
304
|
+
package_manager = config.get("package_manager")
|
|
305
|
+
if package_manager not in PACKAGE_MANAGER_TEMPLATES:
|
|
306
|
+
raise RuntimeError(f"Invalid package manager in {PROJECT_MARKER}")
|
|
307
|
+
return config
|
|
166
308
|
|
|
167
309
|
|
|
168
310
|
def _parse_requirement_name(line: str) -> str:
|
|
@@ -172,15 +314,103 @@ def _parse_requirement_name(line: str) -> str:
|
|
|
172
314
|
if line.startswith("-"):
|
|
173
315
|
return line
|
|
174
316
|
line = line.split(";", 1)[0].strip()
|
|
175
|
-
|
|
176
|
-
splitters = ["==", ">=", "<=", "~=", ">", "<"]
|
|
177
|
-
for splitter in splitters:
|
|
317
|
+
for splitter in ["==", ">=", "<=", "~=", ">", "<"]:
|
|
178
318
|
if splitter in line:
|
|
179
319
|
line = line.split(splitter, 1)[0].strip()
|
|
180
320
|
break
|
|
181
321
|
return line.split("[", 1)[0].strip().lower()
|
|
182
322
|
|
|
183
323
|
|
|
324
|
+
def _add_pip_dependencies(dependencies: list[str], directory: Path):
|
|
325
|
+
requirements_path = directory / "requirements.txt"
|
|
326
|
+
if not requirements_path.is_file():
|
|
327
|
+
raise RuntimeError("Pip PAM project is missing requirements.txt")
|
|
328
|
+
existing = requirements_path.read_text(encoding="utf-8").splitlines()
|
|
329
|
+
existing_names = {_parse_requirement_name(line) for line in existing}
|
|
330
|
+
additions = [
|
|
331
|
+
dependency for dependency in dependencies
|
|
332
|
+
if _parse_requirement_name(dependency) not in existing_names
|
|
333
|
+
]
|
|
334
|
+
if additions:
|
|
335
|
+
content = requirements_path.read_text(encoding="utf-8")
|
|
336
|
+
if content and not content.endswith("\n"):
|
|
337
|
+
content += "\n"
|
|
338
|
+
requirements_path.write_text(
|
|
339
|
+
content + "\n".join(additions) + "\n",
|
|
340
|
+
encoding="utf-8",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _add_engine_dependencies(
|
|
345
|
+
engine: str,
|
|
346
|
+
package_manager: str,
|
|
347
|
+
directory: Path,
|
|
348
|
+
):
|
|
349
|
+
dependencies = ENGINE_DEPENDENCIES[engine]
|
|
350
|
+
if package_manager == "uv":
|
|
351
|
+
_run_uv(["add", *dependencies], directory)
|
|
352
|
+
else:
|
|
353
|
+
_add_pip_dependencies(dependencies, directory)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def create_service(name, dataframe_engine: str | None = None):
|
|
357
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"service_name must start with a letter and contain only letters, "
|
|
360
|
+
"numbers, and underscores"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
service_path = Path(name)
|
|
364
|
+
if service_path.exists():
|
|
365
|
+
response = input(
|
|
366
|
+
f"Service {name} already exists. Do you want to overwrite it? (y/N): "
|
|
367
|
+
).strip().lower()
|
|
368
|
+
if response != "y":
|
|
369
|
+
print("Cancelled.")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
dataframe_engine = dataframe_engine or _select_dataframe_engine()
|
|
373
|
+
if dataframe_engine not in SERVICE_TEMPLATES:
|
|
374
|
+
raise ValueError(f"Unsupported dataframe engine: {dataframe_engine}")
|
|
375
|
+
|
|
376
|
+
project_config = _load_project_config(Path.cwd())
|
|
377
|
+
_add_engine_dependencies(
|
|
378
|
+
dataframe_engine,
|
|
379
|
+
project_config["package_manager"],
|
|
380
|
+
Path.cwd(),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if service_path.exists():
|
|
384
|
+
shutil.rmtree(service_path)
|
|
385
|
+
service_path.mkdir()
|
|
386
|
+
(service_path / "__init__.py").touch()
|
|
387
|
+
|
|
388
|
+
class_name = to_pascal_case(name) + "Svc"
|
|
389
|
+
replacements = {
|
|
390
|
+
"#CLASS_NAME#": class_name,
|
|
391
|
+
"#MODULE_NAME#": name,
|
|
392
|
+
}
|
|
393
|
+
for src, destination_template in SERVICE_TEMPLATES[dataframe_engine].items():
|
|
394
|
+
destination_name = destination_template
|
|
395
|
+
for placeholder, value in replacements.items():
|
|
396
|
+
destination_name = destination_name.replace(placeholder, value)
|
|
397
|
+
_write_rendered_template(
|
|
398
|
+
src,
|
|
399
|
+
service_path / destination_name,
|
|
400
|
+
replacements,
|
|
401
|
+
)
|
|
402
|
+
_write_rendered_template(
|
|
403
|
+
"service/common/service.yaml",
|
|
404
|
+
service_path / "service.yaml",
|
|
405
|
+
replacements,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
print(f"Service {name} created with {dataframe_engine}.")
|
|
409
|
+
if project_config["package_manager"] == "pip":
|
|
410
|
+
print("Install updated dependencies with `python -m pip install -r requirements.txt`.")
|
|
411
|
+
print(f"Run the generated service test for `{name}` before editing the template.")
|
|
412
|
+
|
|
413
|
+
|
|
184
414
|
def _get_installed_pam_version() -> str:
|
|
185
415
|
try:
|
|
186
416
|
from pam import __version__
|
|
@@ -195,32 +425,5 @@ def _get_installed_pam_version() -> str:
|
|
|
195
425
|
return "0.0.0"
|
|
196
426
|
|
|
197
427
|
|
|
198
|
-
def _build_project_requirements() -> str:
|
|
199
|
-
pam_version = _get_installed_pam_version()
|
|
200
|
-
template = read_template_text("init/requirements.txt")
|
|
201
|
-
rendered = template.replace("#PAM_VERSION#", pam_version)
|
|
202
|
-
if not rendered.endswith("\n"):
|
|
203
|
-
rendered += "\n"
|
|
204
|
-
return rendered
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _merge_requirements(existing_lines, frozen_lines):
|
|
208
|
-
"""
|
|
209
|
-
Merge frozen requirements into existing lines while keeping existing entries.
|
|
210
|
-
"""
|
|
211
|
-
existing_keys = {_parse_requirement_name(line) for line in existing_lines}
|
|
212
|
-
existing_keys.discard("")
|
|
213
|
-
|
|
214
|
-
merged = list(existing_lines)
|
|
215
|
-
for line in frozen_lines:
|
|
216
|
-
key = _parse_requirement_name(line)
|
|
217
|
-
if not key:
|
|
218
|
-
continue
|
|
219
|
-
if key not in existing_keys:
|
|
220
|
-
merged.append(line)
|
|
221
|
-
existing_keys.add(key)
|
|
222
|
-
return merged
|
|
223
|
-
|
|
224
|
-
|
|
225
428
|
if __name__ == "__main__":
|
|
226
429
|
main()
|
pam/models/request_command.py
CHANGED
|
@@ -26,6 +26,7 @@ import zipfile
|
|
|
26
26
|
from flask import Request
|
|
27
27
|
from pam.utils import log
|
|
28
28
|
from pam.temp_file_utils import TempfileUtils
|
|
29
|
+
from pam.request_file_format import RequestFileFormat
|
|
29
30
|
|
|
30
31
|
@dataclass
|
|
31
32
|
class RequestCommand:
|
|
@@ -43,6 +44,7 @@ class RequestCommand:
|
|
|
43
44
|
next: str
|
|
44
45
|
input_files: list[str]
|
|
45
46
|
service_name: str
|
|
47
|
+
file_format: RequestFileFormat = RequestFileFormat.CSV
|
|
46
48
|
log_api: str = ""
|
|
47
49
|
sqlite_download_v2: str = ""
|
|
48
50
|
|
|
@@ -152,6 +154,7 @@ class RequestCommand:
|
|
|
152
154
|
|
|
153
155
|
is_end = RequestCommand.__safe_str_to_bool(params.pop("is_end", "true"))
|
|
154
156
|
next_page = params.pop("next", "")
|
|
157
|
+
file_format = RequestFileFormat.parse(params.pop("file_format", "csv"))
|
|
155
158
|
|
|
156
159
|
input_files = []
|
|
157
160
|
error_message = ""
|
|
@@ -179,6 +182,7 @@ class RequestCommand:
|
|
|
179
182
|
next=next_page,
|
|
180
183
|
input_files=input_files,
|
|
181
184
|
service_name=service_name,
|
|
185
|
+
file_format=file_format,
|
|
182
186
|
log_api=log_api,
|
|
183
187
|
sqlite_download_v2=sqlite_download_v2
|
|
184
188
|
), error_message
|
pam/request_file_format.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
|
|
3
3
|
class RequestFileFormat(Enum):
|
|
4
|
-
CSV="csv"
|
|
5
|
-
PARQUET="parquet"
|
|
4
|
+
CSV = "csv"
|
|
5
|
+
PARQUET = "parquet"
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def parse(cls, value):
|
|
9
|
+
if isinstance(value, cls):
|
|
10
|
+
return value
|
|
11
|
+
try:
|
|
12
|
+
return cls(str(value).strip().lower())
|
|
13
|
+
except ValueError:
|
|
14
|
+
return cls.CSV
|