@anvil-works/anvil-cli 0.7.0-canary.17 → 0.7.0-canary.18
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.
- package/README.md +9 -3
- package/dist/cli.js +321 -33
- package/dist/index.js +198 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/validatePython.d.ts +10 -0
- package/dist/validatePython.d.ts.map +1 -0
- package/dist/validators.d.ts +2 -1
- package/dist/validators.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,17 +170,18 @@ Useful options:
|
|
|
170
170
|
|
|
171
171
|
### `anvil validate`
|
|
172
172
|
|
|
173
|
-
Check an Anvil app file before syncing, or when the CLI reports a validation error.
|
|
173
|
+
Check an Anvil app file or directory before syncing, or when the CLI reports a validation error.
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
anvil validate <
|
|
176
|
+
anvil validate <path>
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
When given a file, the command chooses the validator from the file path. When given a directory, it recursively validates supported files under that directory. It supports:
|
|
180
180
|
|
|
181
181
|
- `anvil.yaml`
|
|
182
182
|
- `.yaml` files under `client_code/`, including `client_code/**/form_template.yaml`
|
|
183
183
|
- `.html` files under `client_code/`, including `client_code/**/form_template.html` and module-form `client_code/<Form>.html`
|
|
184
|
+
- `.py` files under `client_code/` and `server_code/`
|
|
184
185
|
|
|
185
186
|
Common examples:
|
|
186
187
|
|
|
@@ -190,10 +191,15 @@ anvil validate client_code/Form1/form_template.yaml
|
|
|
190
191
|
anvil validate client_code/Form1.yaml
|
|
191
192
|
anvil validate client_code/Form1/form_template.html
|
|
192
193
|
anvil validate client_code/Form1.html
|
|
194
|
+
anvil validate client_code/Helpers.py
|
|
195
|
+
anvil validate server_code/ServerModule.py
|
|
196
|
+
anvil validate .
|
|
193
197
|
```
|
|
194
198
|
|
|
195
199
|
HTML validation parses the template with `@anvil-works/form-template-parser`, checks optional YAML frontmatter, and reports semantic issues such as duplicate explicit component names or invalid component types.
|
|
196
200
|
|
|
201
|
+
Python validation checks syntax using a local Python interpreter (`ANVIL_PYTHON`, `python3`, or `python`). Form Python under `client_code/` also checks for the matching designer-template import and form class inheritance when a sibling form template exists.
|
|
202
|
+
|
|
197
203
|
When the file is valid, the command prints a success message and exits with status `0`. When the file is invalid, it prints path-specific validation issues and exits with a non-zero status.
|
|
198
204
|
|
|
199
205
|
Use `--json` with `anvil validate` to emit structured NDJSON output for scripts or LLM tooling.
|
package/dist/cli.js
CHANGED
|
@@ -16288,6 +16288,167 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16288
16288
|
ok: true
|
|
16289
16289
|
};
|
|
16290
16290
|
}
|
|
16291
|
+
const PYTHON_VALIDATOR_SCRIPT = String.raw`
|
|
16292
|
+
import ast
|
|
16293
|
+
import json
|
|
16294
|
+
import sys
|
|
16295
|
+
|
|
16296
|
+
|
|
16297
|
+
def issue_path(error):
|
|
16298
|
+
if getattr(error, "lineno", None) is None:
|
|
16299
|
+
return "root"
|
|
16300
|
+
if getattr(error, "offset", None) is None:
|
|
16301
|
+
return f"line {error.lineno}"
|
|
16302
|
+
return f"line {error.lineno}, column {error.offset}"
|
|
16303
|
+
|
|
16304
|
+
|
|
16305
|
+
def base_name(node):
|
|
16306
|
+
if isinstance(node, ast.Name):
|
|
16307
|
+
return node.id
|
|
16308
|
+
if isinstance(node, ast.Attribute):
|
|
16309
|
+
return node.attr
|
|
16310
|
+
return None
|
|
16311
|
+
|
|
16312
|
+
|
|
16313
|
+
def imports_template_from_anvil_designer(node, template_name):
|
|
16314
|
+
if node.level < 1:
|
|
16315
|
+
return False
|
|
16316
|
+
if node.module not in ("_anvil_designer", None):
|
|
16317
|
+
return False
|
|
16318
|
+
return any(alias.name == template_name for alias in node.names)
|
|
16319
|
+
|
|
16320
|
+
|
|
16321
|
+
file_path = sys.argv[1]
|
|
16322
|
+
class_name = sys.argv[2]
|
|
16323
|
+
template_name = f"{class_name}Template" if class_name else ""
|
|
16324
|
+
source = sys.stdin.read()
|
|
16325
|
+
issues = []
|
|
16326
|
+
|
|
16327
|
+
try:
|
|
16328
|
+
compile(source, file_path, "exec")
|
|
16329
|
+
except SyntaxError as error:
|
|
16330
|
+
issues.append({"path": issue_path(error), "message": error.msg})
|
|
16331
|
+
print(json.dumps({"issues": issues}))
|
|
16332
|
+
sys.exit(0)
|
|
16333
|
+
|
|
16334
|
+
if class_name:
|
|
16335
|
+
tree = ast.parse(source, filename=file_path)
|
|
16336
|
+
has_template_import = False
|
|
16337
|
+
has_form_class = False
|
|
16338
|
+
|
|
16339
|
+
for node in ast.walk(tree):
|
|
16340
|
+
if isinstance(node, ast.ImportFrom) and imports_template_from_anvil_designer(node, template_name):
|
|
16341
|
+
has_template_import = True
|
|
16342
|
+
elif isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
16343
|
+
if any(base_name(base) == template_name for base in node.bases):
|
|
16344
|
+
has_form_class = True
|
|
16345
|
+
|
|
16346
|
+
if not has_template_import:
|
|
16347
|
+
issues.append({
|
|
16348
|
+
"path": "root",
|
|
16349
|
+
"message": f"missing {chr(96)}from ._anvil_designer import {template_name}{chr(96)}",
|
|
16350
|
+
})
|
|
16351
|
+
if not has_form_class:
|
|
16352
|
+
issues.append({
|
|
16353
|
+
"path": "root",
|
|
16354
|
+
"message": f"missing class {chr(96)}{class_name}{chr(96)} inheriting from {chr(96)}{template_name}{chr(96)}",
|
|
16355
|
+
})
|
|
16356
|
+
|
|
16357
|
+
print(json.dumps({"issues": issues}))
|
|
16358
|
+
`;
|
|
16359
|
+
function findPythonInterpreter() {
|
|
16360
|
+
const candidates = [
|
|
16361
|
+
process.env.ANVIL_PYTHON,
|
|
16362
|
+
"python3",
|
|
16363
|
+
"python"
|
|
16364
|
+
].filter((candidate)=>"string" == typeof candidate && candidate.length > 0);
|
|
16365
|
+
for (const candidate of candidates){
|
|
16366
|
+
const result = (0, external_child_process_namespaceObject.spawnSync)(candidate, [
|
|
16367
|
+
"--version"
|
|
16368
|
+
], {
|
|
16369
|
+
encoding: "utf8"
|
|
16370
|
+
});
|
|
16371
|
+
if (!result.error && 0 === result.status) return candidate;
|
|
16372
|
+
}
|
|
16373
|
+
return null;
|
|
16374
|
+
}
|
|
16375
|
+
function isClientFormPython(filePath) {
|
|
16376
|
+
if ("__init__.py" === external_path_default().basename(filePath)) {
|
|
16377
|
+
const formDir = external_path_default().dirname(filePath);
|
|
16378
|
+
return external_fs_.existsSync(external_path_default().join(formDir, "form_template.html")) || external_fs_.existsSync(external_path_default().join(formDir, "form_template.yaml"));
|
|
16379
|
+
}
|
|
16380
|
+
const parsed = external_path_default().parse(filePath);
|
|
16381
|
+
return external_fs_.existsSync(external_path_default().join(parsed.dir, `${parsed.name}.html`)) || external_fs_.existsSync(external_path_default().join(parsed.dir, `${parsed.name}.yaml`));
|
|
16382
|
+
}
|
|
16383
|
+
function expectedFormClassName(filePath) {
|
|
16384
|
+
if (!isClientFormPython(filePath)) return null;
|
|
16385
|
+
if ("__init__.py" === external_path_default().basename(filePath)) return external_path_default().basename(external_path_default().dirname(filePath));
|
|
16386
|
+
return external_path_default().basename(filePath, ".py");
|
|
16387
|
+
}
|
|
16388
|
+
function isUnderDirectory(filePath, directoryPath) {
|
|
16389
|
+
const relativePath = external_path_default().relative(directoryPath, filePath);
|
|
16390
|
+
return relativePath.length > 0 && !relativePath.startsWith("..") && !external_path_default().isAbsolute(relativePath);
|
|
16391
|
+
}
|
|
16392
|
+
function validatePython(filePath, fileContent, appRoot) {
|
|
16393
|
+
const interpreter = findPythonInterpreter();
|
|
16394
|
+
if (!interpreter) return {
|
|
16395
|
+
ok: false,
|
|
16396
|
+
message: "Python file could not be validated",
|
|
16397
|
+
issues: [
|
|
16398
|
+
{
|
|
16399
|
+
path: "root",
|
|
16400
|
+
message: "Python validation requires python3 or python on PATH, or ANVIL_PYTHON to be set"
|
|
16401
|
+
}
|
|
16402
|
+
]
|
|
16403
|
+
};
|
|
16404
|
+
const absolutePath = external_path_default().resolve(filePath);
|
|
16405
|
+
const clientCodeRoot = appRoot ? external_path_default().join(appRoot, "client_code") : null;
|
|
16406
|
+
const formClassName = clientCodeRoot && isUnderDirectory(absolutePath, clientCodeRoot) ? expectedFormClassName(absolutePath) : null;
|
|
16407
|
+
const result = (0, external_child_process_namespaceObject.spawnSync)(interpreter, [
|
|
16408
|
+
"-c",
|
|
16409
|
+
PYTHON_VALIDATOR_SCRIPT,
|
|
16410
|
+
filePath,
|
|
16411
|
+
formClassName ?? ""
|
|
16412
|
+
], {
|
|
16413
|
+
encoding: "utf8",
|
|
16414
|
+
input: fileContent,
|
|
16415
|
+
maxBuffer: 1048576
|
|
16416
|
+
});
|
|
16417
|
+
if (result.error || 0 !== result.status) return {
|
|
16418
|
+
ok: false,
|
|
16419
|
+
message: "Python file could not be validated",
|
|
16420
|
+
issues: [
|
|
16421
|
+
{
|
|
16422
|
+
path: "root",
|
|
16423
|
+
message: result.error?.message ?? (result.stderr.trim() || "Python validator failed")
|
|
16424
|
+
}
|
|
16425
|
+
]
|
|
16426
|
+
};
|
|
16427
|
+
let parsed;
|
|
16428
|
+
try {
|
|
16429
|
+
parsed = JSON.parse(result.stdout);
|
|
16430
|
+
} catch (error) {
|
|
16431
|
+
return {
|
|
16432
|
+
ok: false,
|
|
16433
|
+
message: "Python file could not be validated",
|
|
16434
|
+
issues: [
|
|
16435
|
+
{
|
|
16436
|
+
path: "root",
|
|
16437
|
+
message: error instanceof Error ? error.message : String(error)
|
|
16438
|
+
}
|
|
16439
|
+
]
|
|
16440
|
+
};
|
|
16441
|
+
}
|
|
16442
|
+
const issues = parsed.issues ?? [];
|
|
16443
|
+
if (issues.length > 0) return {
|
|
16444
|
+
ok: false,
|
|
16445
|
+
message: "Python file is invalid for Anvil",
|
|
16446
|
+
issues
|
|
16447
|
+
};
|
|
16448
|
+
return {
|
|
16449
|
+
ok: true
|
|
16450
|
+
};
|
|
16451
|
+
}
|
|
16291
16452
|
const validators_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16292
16453
|
const DB_ACCESS_LEVELS = new Set([
|
|
16293
16454
|
"none",
|
|
@@ -16685,6 +16846,13 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16685
16846
|
const relativeToClientCode = normalizedPath.slice(startIndex + clientCodePrefix.length);
|
|
16686
16847
|
if (relativeToClientCode.endsWith(".yaml")) return "form_template.yaml";
|
|
16687
16848
|
if (relativeToClientCode.endsWith(".html")) return "form_template.html";
|
|
16849
|
+
if (relativeToClientCode.endsWith(".py")) return "python";
|
|
16850
|
+
}
|
|
16851
|
+
const serverCodePrefix = "server_code/";
|
|
16852
|
+
if (normalizedPath.startsWith(serverCodePrefix) || normalizedPath.includes(`/${serverCodePrefix}`)) {
|
|
16853
|
+
const startIndex = normalizedPath.lastIndexOf(serverCodePrefix);
|
|
16854
|
+
const relativeToServerCode = normalizedPath.slice(startIndex + serverCodePrefix.length);
|
|
16855
|
+
if (relativeToServerCode.endsWith(".py")) return "python";
|
|
16688
16856
|
}
|
|
16689
16857
|
return null;
|
|
16690
16858
|
}
|
|
@@ -16792,6 +16960,35 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16792
16960
|
target
|
|
16793
16961
|
};
|
|
16794
16962
|
}
|
|
16963
|
+
if ("python" === target) {
|
|
16964
|
+
if (existingPath && appRoot) {
|
|
16965
|
+
const clientCodeRoot = external_path_default().join(appRoot, "client_code");
|
|
16966
|
+
const serverCodeRoot = external_path_default().join(appRoot, "server_code");
|
|
16967
|
+
const relativeToClientCode = external_path_default().relative(clientCodeRoot, existingPath);
|
|
16968
|
+
const relativeToServerCode = external_path_default().relative(serverCodeRoot, existingPath);
|
|
16969
|
+
const isInsideClientCode = relativeToClientCode.length > 0 && !relativeToClientCode.startsWith("..") && !external_path_default().isAbsolute(relativeToClientCode);
|
|
16970
|
+
const isInsideServerCode = relativeToServerCode.length > 0 && !relativeToServerCode.startsWith("..") && !external_path_default().isAbsolute(relativeToServerCode);
|
|
16971
|
+
if (!isInsideClientCode && !isInsideServerCode || !existingPath.endsWith(".py")) return {
|
|
16972
|
+
ok: false,
|
|
16973
|
+
target,
|
|
16974
|
+
message: "File is not an Anvil app Python file",
|
|
16975
|
+
issues: [
|
|
16976
|
+
{
|
|
16977
|
+
path: "",
|
|
16978
|
+
message: `expected a .py file under ${clientCodeRoot} or ${serverCodeRoot}`
|
|
16979
|
+
}
|
|
16980
|
+
]
|
|
16981
|
+
};
|
|
16982
|
+
}
|
|
16983
|
+
const result = validatePython(filePath, fileContent, appRoot);
|
|
16984
|
+
return result.ok ? {
|
|
16985
|
+
ok: true,
|
|
16986
|
+
target
|
|
16987
|
+
} : {
|
|
16988
|
+
...result,
|
|
16989
|
+
target
|
|
16990
|
+
};
|
|
16991
|
+
}
|
|
16795
16992
|
return {
|
|
16796
16993
|
ok: false,
|
|
16797
16994
|
target: "unknown",
|
|
@@ -16799,7 +16996,7 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16799
16996
|
issues: [
|
|
16800
16997
|
{
|
|
16801
16998
|
path: "",
|
|
16802
|
-
message: "supported paths are anvil.yaml, client_code/**/form_template.yaml, client_code/**/*.yaml,
|
|
16999
|
+
message: "supported paths are anvil.yaml, client_code/**/form_template.yaml, client_code/**/*.yaml, client_code/**/*.html, client_code/**/*.py, and server_code/**/*.py"
|
|
16803
17000
|
}
|
|
16804
17001
|
]
|
|
16805
17002
|
};
|
|
@@ -22921,6 +23118,123 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
22921
23118
|
}
|
|
22922
23119
|
}).catch(()=>{});
|
|
22923
23120
|
}
|
|
23121
|
+
function collectValidationFiles(dir) {
|
|
23122
|
+
const files = [];
|
|
23123
|
+
const ignoredDirectories = new Set([
|
|
23124
|
+
".anvil",
|
|
23125
|
+
".git",
|
|
23126
|
+
"node_modules",
|
|
23127
|
+
"__pycache__"
|
|
23128
|
+
]);
|
|
23129
|
+
function walk(currentDir) {
|
|
23130
|
+
for (const entry of (0, external_fs_.readdirSync)(currentDir, {
|
|
23131
|
+
withFileTypes: true
|
|
23132
|
+
})){
|
|
23133
|
+
const entryPath = (0, external_path_namespaceObject.join)(currentDir, entry.name);
|
|
23134
|
+
if (entry.isDirectory()) {
|
|
23135
|
+
if (!ignoredDirectories.has(entry.name)) walk(entryPath);
|
|
23136
|
+
continue;
|
|
23137
|
+
}
|
|
23138
|
+
if (entry.isFile() && inferValidationTarget(entryPath)) files.push(entryPath);
|
|
23139
|
+
}
|
|
23140
|
+
}
|
|
23141
|
+
walk(dir);
|
|
23142
|
+
return files.sort();
|
|
23143
|
+
}
|
|
23144
|
+
function validateSingleFile(file) {
|
|
23145
|
+
let fileContent;
|
|
23146
|
+
try {
|
|
23147
|
+
fileContent = (0, external_fs_.readFileSync)(file, "utf8");
|
|
23148
|
+
} catch (error) {
|
|
23149
|
+
return {
|
|
23150
|
+
file,
|
|
23151
|
+
type: "unknown",
|
|
23152
|
+
valid: false,
|
|
23153
|
+
error: `Failed to read ${file}: ${error.message}`,
|
|
23154
|
+
issues: [
|
|
23155
|
+
{
|
|
23156
|
+
path: "",
|
|
23157
|
+
message: error.message
|
|
23158
|
+
}
|
|
23159
|
+
]
|
|
23160
|
+
};
|
|
23161
|
+
}
|
|
23162
|
+
const result = validatePath(file, fileContent);
|
|
23163
|
+
if (result.ok) return {
|
|
23164
|
+
file,
|
|
23165
|
+
type: result.target,
|
|
23166
|
+
valid: true
|
|
23167
|
+
};
|
|
23168
|
+
return {
|
|
23169
|
+
file,
|
|
23170
|
+
type: result.target,
|
|
23171
|
+
valid: false,
|
|
23172
|
+
error: result.message,
|
|
23173
|
+
issues: result.issues
|
|
23174
|
+
};
|
|
23175
|
+
}
|
|
23176
|
+
function logValidationFailure(fileResult) {
|
|
23177
|
+
logger_logger.error(`${fileResult.file} failed validation: ${fileResult.error ?? "Validation failed"}`);
|
|
23178
|
+
for (const issue of fileResult.issues ?? []){
|
|
23179
|
+
const issuePath = issue.path.length > 0 ? issue.path : "root";
|
|
23180
|
+
logger_logger.error(` - ${issuePath}: ${issue.message}`);
|
|
23181
|
+
}
|
|
23182
|
+
}
|
|
23183
|
+
function handleValidateFile(file) {
|
|
23184
|
+
const fileResult = validateSingleFile(file);
|
|
23185
|
+
if (fileResult.valid) {
|
|
23186
|
+
if (getGlobalOutputConfig().jsonMode) logJsonResult(true, {
|
|
23187
|
+
data: {
|
|
23188
|
+
file: fileResult.file,
|
|
23189
|
+
type: fileResult.type,
|
|
23190
|
+
valid: true
|
|
23191
|
+
}
|
|
23192
|
+
});
|
|
23193
|
+
else logger_logger.success(`${fileResult.file} is valid (${fileResult.type})`);
|
|
23194
|
+
return;
|
|
23195
|
+
}
|
|
23196
|
+
if (getGlobalOutputConfig().jsonMode) logJsonResult(false, {
|
|
23197
|
+
error: fileResult.error,
|
|
23198
|
+
data: {
|
|
23199
|
+
file: fileResult.file,
|
|
23200
|
+
type: fileResult.type,
|
|
23201
|
+
issues: fileResult.issues ?? []
|
|
23202
|
+
}
|
|
23203
|
+
});
|
|
23204
|
+
else logValidationFailure(fileResult);
|
|
23205
|
+
process.exitCode = 1;
|
|
23206
|
+
}
|
|
23207
|
+
function handleValidateDirectory(dir) {
|
|
23208
|
+
const files = collectValidationFiles(dir);
|
|
23209
|
+
if (0 === files.length) {
|
|
23210
|
+
const error = `No supported files found under ${dir}`;
|
|
23211
|
+
if (getGlobalOutputConfig().jsonMode) logJsonResult(false, {
|
|
23212
|
+
error,
|
|
23213
|
+
data: {
|
|
23214
|
+
file: dir,
|
|
23215
|
+
valid: false,
|
|
23216
|
+
files: []
|
|
23217
|
+
}
|
|
23218
|
+
});
|
|
23219
|
+
else logger_logger.error(error);
|
|
23220
|
+
process.exitCode = 1;
|
|
23221
|
+
return;
|
|
23222
|
+
}
|
|
23223
|
+
const fileResults = files.map(validateSingleFile);
|
|
23224
|
+
const failedResults = fileResults.filter((result)=>!result.valid);
|
|
23225
|
+
if (getGlobalOutputConfig().jsonMode) logJsonResult(0 === failedResults.length, {
|
|
23226
|
+
error: failedResults.length > 0 ? "Validation failed" : void 0,
|
|
23227
|
+
data: {
|
|
23228
|
+
file: dir,
|
|
23229
|
+
valid: 0 === failedResults.length,
|
|
23230
|
+
count: fileResults.length,
|
|
23231
|
+
files: fileResults
|
|
23232
|
+
}
|
|
23233
|
+
});
|
|
23234
|
+
else for (const fileResult of fileResults)if (fileResult.valid) logger_logger.success(`${fileResult.file} is valid (${fileResult.type})`);
|
|
23235
|
+
else logValidationFailure(fileResult);
|
|
23236
|
+
if (failedResults.length > 0) process.exitCode = 1;
|
|
23237
|
+
}
|
|
22924
23238
|
function getUpdateInstructions(platform) {
|
|
22925
23239
|
if ("win32" === platform) return [
|
|
22926
23240
|
"try { irm https://anvil.works/install-cli.ps1 | iex } catch { npm install -g @anvil-works/anvil-cli@latest }",
|
|
@@ -23034,43 +23348,17 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
23034
23348
|
program.command("update").description("Update anvil to the latest version").alias("u").action(async ()=>{
|
|
23035
23349
|
await handleUpdateCommand();
|
|
23036
23350
|
});
|
|
23037
|
-
program.command("validate").description("Validate
|
|
23038
|
-
let
|
|
23351
|
+
program.command("validate").description("Validate Anvil app files by path or directory (anvil.yaml, client_code/**/*.yaml, client_code/**/*.html, client_code/**/*.py, or server_code/**/*.py)").argument("<path>", "Path to file or directory").action((file)=>{
|
|
23352
|
+
let pathStat;
|
|
23039
23353
|
try {
|
|
23040
|
-
|
|
23354
|
+
pathStat = (0, external_fs_.statSync)(file);
|
|
23041
23355
|
} catch (error) {
|
|
23042
|
-
logger_logger.error(`Failed to
|
|
23356
|
+
logger_logger.error(`Failed to access ${file}: ${error.message}`);
|
|
23043
23357
|
process.exitCode = 1;
|
|
23044
23358
|
return;
|
|
23045
23359
|
}
|
|
23046
|
-
|
|
23047
|
-
|
|
23048
|
-
if (getGlobalOutputConfig().jsonMode) logJsonResult(true, {
|
|
23049
|
-
data: {
|
|
23050
|
-
file,
|
|
23051
|
-
type: result.target,
|
|
23052
|
-
valid: true
|
|
23053
|
-
}
|
|
23054
|
-
});
|
|
23055
|
-
else logger_logger.success(`${file} is valid (${result.target})`);
|
|
23056
|
-
return;
|
|
23057
|
-
}
|
|
23058
|
-
if (getGlobalOutputConfig().jsonMode) logJsonResult(false, {
|
|
23059
|
-
error: result.message,
|
|
23060
|
-
data: {
|
|
23061
|
-
file,
|
|
23062
|
-
type: result.target,
|
|
23063
|
-
issues: result.issues
|
|
23064
|
-
}
|
|
23065
|
-
});
|
|
23066
|
-
else {
|
|
23067
|
-
logger_logger.error(`${file} failed validation: ${result.message}`);
|
|
23068
|
-
for (const issue of result.issues){
|
|
23069
|
-
const issuePath = issue.path.length > 0 ? issue.path : "root";
|
|
23070
|
-
logger_logger.error(` - ${issuePath}: ${issue.message}`);
|
|
23071
|
-
}
|
|
23072
|
-
}
|
|
23073
|
-
process.exitCode = 1;
|
|
23360
|
+
if (pathStat.isDirectory()) return void handleValidateDirectory(file);
|
|
23361
|
+
handleValidateFile(file);
|
|
23074
23362
|
});
|
|
23075
23363
|
if (watchCommand) {
|
|
23076
23364
|
const watchOptions = watchCommand.options.map((opt)=>{
|
package/dist/index.js
CHANGED
|
@@ -16317,6 +16317,167 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16317
16317
|
ok: true
|
|
16318
16318
|
};
|
|
16319
16319
|
}
|
|
16320
|
+
const PYTHON_VALIDATOR_SCRIPT = String.raw`
|
|
16321
|
+
import ast
|
|
16322
|
+
import json
|
|
16323
|
+
import sys
|
|
16324
|
+
|
|
16325
|
+
|
|
16326
|
+
def issue_path(error):
|
|
16327
|
+
if getattr(error, "lineno", None) is None:
|
|
16328
|
+
return "root"
|
|
16329
|
+
if getattr(error, "offset", None) is None:
|
|
16330
|
+
return f"line {error.lineno}"
|
|
16331
|
+
return f"line {error.lineno}, column {error.offset}"
|
|
16332
|
+
|
|
16333
|
+
|
|
16334
|
+
def base_name(node):
|
|
16335
|
+
if isinstance(node, ast.Name):
|
|
16336
|
+
return node.id
|
|
16337
|
+
if isinstance(node, ast.Attribute):
|
|
16338
|
+
return node.attr
|
|
16339
|
+
return None
|
|
16340
|
+
|
|
16341
|
+
|
|
16342
|
+
def imports_template_from_anvil_designer(node, template_name):
|
|
16343
|
+
if node.level < 1:
|
|
16344
|
+
return False
|
|
16345
|
+
if node.module not in ("_anvil_designer", None):
|
|
16346
|
+
return False
|
|
16347
|
+
return any(alias.name == template_name for alias in node.names)
|
|
16348
|
+
|
|
16349
|
+
|
|
16350
|
+
file_path = sys.argv[1]
|
|
16351
|
+
class_name = sys.argv[2]
|
|
16352
|
+
template_name = f"{class_name}Template" if class_name else ""
|
|
16353
|
+
source = sys.stdin.read()
|
|
16354
|
+
issues = []
|
|
16355
|
+
|
|
16356
|
+
try:
|
|
16357
|
+
compile(source, file_path, "exec")
|
|
16358
|
+
except SyntaxError as error:
|
|
16359
|
+
issues.append({"path": issue_path(error), "message": error.msg})
|
|
16360
|
+
print(json.dumps({"issues": issues}))
|
|
16361
|
+
sys.exit(0)
|
|
16362
|
+
|
|
16363
|
+
if class_name:
|
|
16364
|
+
tree = ast.parse(source, filename=file_path)
|
|
16365
|
+
has_template_import = False
|
|
16366
|
+
has_form_class = False
|
|
16367
|
+
|
|
16368
|
+
for node in ast.walk(tree):
|
|
16369
|
+
if isinstance(node, ast.ImportFrom) and imports_template_from_anvil_designer(node, template_name):
|
|
16370
|
+
has_template_import = True
|
|
16371
|
+
elif isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
16372
|
+
if any(base_name(base) == template_name for base in node.bases):
|
|
16373
|
+
has_form_class = True
|
|
16374
|
+
|
|
16375
|
+
if not has_template_import:
|
|
16376
|
+
issues.append({
|
|
16377
|
+
"path": "root",
|
|
16378
|
+
"message": f"missing {chr(96)}from ._anvil_designer import {template_name}{chr(96)}",
|
|
16379
|
+
})
|
|
16380
|
+
if not has_form_class:
|
|
16381
|
+
issues.append({
|
|
16382
|
+
"path": "root",
|
|
16383
|
+
"message": f"missing class {chr(96)}{class_name}{chr(96)} inheriting from {chr(96)}{template_name}{chr(96)}",
|
|
16384
|
+
})
|
|
16385
|
+
|
|
16386
|
+
print(json.dumps({"issues": issues}))
|
|
16387
|
+
`;
|
|
16388
|
+
function findPythonInterpreter() {
|
|
16389
|
+
const candidates = [
|
|
16390
|
+
process.env.ANVIL_PYTHON,
|
|
16391
|
+
"python3",
|
|
16392
|
+
"python"
|
|
16393
|
+
].filter((candidate)=>"string" == typeof candidate && candidate.length > 0);
|
|
16394
|
+
for (const candidate of candidates){
|
|
16395
|
+
const result = (0, external_child_process_namespaceObject.spawnSync)(candidate, [
|
|
16396
|
+
"--version"
|
|
16397
|
+
], {
|
|
16398
|
+
encoding: "utf8"
|
|
16399
|
+
});
|
|
16400
|
+
if (!result.error && 0 === result.status) return candidate;
|
|
16401
|
+
}
|
|
16402
|
+
return null;
|
|
16403
|
+
}
|
|
16404
|
+
function isClientFormPython(filePath) {
|
|
16405
|
+
if ("__init__.py" === external_path_default().basename(filePath)) {
|
|
16406
|
+
const formDir = external_path_default().dirname(filePath);
|
|
16407
|
+
return external_fs_.existsSync(external_path_default().join(formDir, "form_template.html")) || external_fs_.existsSync(external_path_default().join(formDir, "form_template.yaml"));
|
|
16408
|
+
}
|
|
16409
|
+
const parsed = external_path_default().parse(filePath);
|
|
16410
|
+
return external_fs_.existsSync(external_path_default().join(parsed.dir, `${parsed.name}.html`)) || external_fs_.existsSync(external_path_default().join(parsed.dir, `${parsed.name}.yaml`));
|
|
16411
|
+
}
|
|
16412
|
+
function expectedFormClassName(filePath) {
|
|
16413
|
+
if (!isClientFormPython(filePath)) return null;
|
|
16414
|
+
if ("__init__.py" === external_path_default().basename(filePath)) return external_path_default().basename(external_path_default().dirname(filePath));
|
|
16415
|
+
return external_path_default().basename(filePath, ".py");
|
|
16416
|
+
}
|
|
16417
|
+
function isUnderDirectory(filePath, directoryPath) {
|
|
16418
|
+
const relativePath = external_path_default().relative(directoryPath, filePath);
|
|
16419
|
+
return relativePath.length > 0 && !relativePath.startsWith("..") && !external_path_default().isAbsolute(relativePath);
|
|
16420
|
+
}
|
|
16421
|
+
function validatePython(filePath, fileContent, appRoot) {
|
|
16422
|
+
const interpreter = findPythonInterpreter();
|
|
16423
|
+
if (!interpreter) return {
|
|
16424
|
+
ok: false,
|
|
16425
|
+
message: "Python file could not be validated",
|
|
16426
|
+
issues: [
|
|
16427
|
+
{
|
|
16428
|
+
path: "root",
|
|
16429
|
+
message: "Python validation requires python3 or python on PATH, or ANVIL_PYTHON to be set"
|
|
16430
|
+
}
|
|
16431
|
+
]
|
|
16432
|
+
};
|
|
16433
|
+
const absolutePath = external_path_default().resolve(filePath);
|
|
16434
|
+
const clientCodeRoot = appRoot ? external_path_default().join(appRoot, "client_code") : null;
|
|
16435
|
+
const formClassName = clientCodeRoot && isUnderDirectory(absolutePath, clientCodeRoot) ? expectedFormClassName(absolutePath) : null;
|
|
16436
|
+
const result = (0, external_child_process_namespaceObject.spawnSync)(interpreter, [
|
|
16437
|
+
"-c",
|
|
16438
|
+
PYTHON_VALIDATOR_SCRIPT,
|
|
16439
|
+
filePath,
|
|
16440
|
+
formClassName ?? ""
|
|
16441
|
+
], {
|
|
16442
|
+
encoding: "utf8",
|
|
16443
|
+
input: fileContent,
|
|
16444
|
+
maxBuffer: 1048576
|
|
16445
|
+
});
|
|
16446
|
+
if (result.error || 0 !== result.status) return {
|
|
16447
|
+
ok: false,
|
|
16448
|
+
message: "Python file could not be validated",
|
|
16449
|
+
issues: [
|
|
16450
|
+
{
|
|
16451
|
+
path: "root",
|
|
16452
|
+
message: result.error?.message ?? (result.stderr.trim() || "Python validator failed")
|
|
16453
|
+
}
|
|
16454
|
+
]
|
|
16455
|
+
};
|
|
16456
|
+
let parsed;
|
|
16457
|
+
try {
|
|
16458
|
+
parsed = JSON.parse(result.stdout);
|
|
16459
|
+
} catch (error) {
|
|
16460
|
+
return {
|
|
16461
|
+
ok: false,
|
|
16462
|
+
message: "Python file could not be validated",
|
|
16463
|
+
issues: [
|
|
16464
|
+
{
|
|
16465
|
+
path: "root",
|
|
16466
|
+
message: error instanceof Error ? error.message : String(error)
|
|
16467
|
+
}
|
|
16468
|
+
]
|
|
16469
|
+
};
|
|
16470
|
+
}
|
|
16471
|
+
const issues = parsed.issues ?? [];
|
|
16472
|
+
if (issues.length > 0) return {
|
|
16473
|
+
ok: false,
|
|
16474
|
+
message: "Python file is invalid for Anvil",
|
|
16475
|
+
issues
|
|
16476
|
+
};
|
|
16477
|
+
return {
|
|
16478
|
+
ok: true
|
|
16479
|
+
};
|
|
16480
|
+
}
|
|
16320
16481
|
const validators_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16321
16482
|
const DB_ACCESS_LEVELS = new Set([
|
|
16322
16483
|
"none",
|
|
@@ -16714,6 +16875,13 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16714
16875
|
const relativeToClientCode = normalizedPath.slice(startIndex + clientCodePrefix.length);
|
|
16715
16876
|
if (relativeToClientCode.endsWith(".yaml")) return "form_template.yaml";
|
|
16716
16877
|
if (relativeToClientCode.endsWith(".html")) return "form_template.html";
|
|
16878
|
+
if (relativeToClientCode.endsWith(".py")) return "python";
|
|
16879
|
+
}
|
|
16880
|
+
const serverCodePrefix = "server_code/";
|
|
16881
|
+
if (normalizedPath.startsWith(serverCodePrefix) || normalizedPath.includes(`/${serverCodePrefix}`)) {
|
|
16882
|
+
const startIndex = normalizedPath.lastIndexOf(serverCodePrefix);
|
|
16883
|
+
const relativeToServerCode = normalizedPath.slice(startIndex + serverCodePrefix.length);
|
|
16884
|
+
if (relativeToServerCode.endsWith(".py")) return "python";
|
|
16717
16885
|
}
|
|
16718
16886
|
return null;
|
|
16719
16887
|
}
|
|
@@ -16821,6 +16989,35 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16821
16989
|
target
|
|
16822
16990
|
};
|
|
16823
16991
|
}
|
|
16992
|
+
if ("python" === target) {
|
|
16993
|
+
if (existingPath && appRoot) {
|
|
16994
|
+
const clientCodeRoot = external_path_default().join(appRoot, "client_code");
|
|
16995
|
+
const serverCodeRoot = external_path_default().join(appRoot, "server_code");
|
|
16996
|
+
const relativeToClientCode = external_path_default().relative(clientCodeRoot, existingPath);
|
|
16997
|
+
const relativeToServerCode = external_path_default().relative(serverCodeRoot, existingPath);
|
|
16998
|
+
const isInsideClientCode = relativeToClientCode.length > 0 && !relativeToClientCode.startsWith("..") && !external_path_default().isAbsolute(relativeToClientCode);
|
|
16999
|
+
const isInsideServerCode = relativeToServerCode.length > 0 && !relativeToServerCode.startsWith("..") && !external_path_default().isAbsolute(relativeToServerCode);
|
|
17000
|
+
if (!isInsideClientCode && !isInsideServerCode || !existingPath.endsWith(".py")) return {
|
|
17001
|
+
ok: false,
|
|
17002
|
+
target,
|
|
17003
|
+
message: "File is not an Anvil app Python file",
|
|
17004
|
+
issues: [
|
|
17005
|
+
{
|
|
17006
|
+
path: "",
|
|
17007
|
+
message: `expected a .py file under ${clientCodeRoot} or ${serverCodeRoot}`
|
|
17008
|
+
}
|
|
17009
|
+
]
|
|
17010
|
+
};
|
|
17011
|
+
}
|
|
17012
|
+
const result = validatePython(filePath, fileContent, appRoot);
|
|
17013
|
+
return result.ok ? {
|
|
17014
|
+
ok: true,
|
|
17015
|
+
target
|
|
17016
|
+
} : {
|
|
17017
|
+
...result,
|
|
17018
|
+
target
|
|
17019
|
+
};
|
|
17020
|
+
}
|
|
16824
17021
|
return {
|
|
16825
17022
|
ok: false,
|
|
16826
17023
|
target: "unknown",
|
|
@@ -16828,7 +17025,7 @@ Promise.resolve(executeGitCredentialOperation(process.argv[2] || "get", {
|
|
|
16828
17025
|
issues: [
|
|
16829
17026
|
{
|
|
16830
17027
|
path: "",
|
|
16831
|
-
message: "supported paths are anvil.yaml, client_code/**/form_template.yaml, client_code/**/*.yaml,
|
|
17028
|
+
message: "supported paths are anvil.yaml, client_code/**/form_template.yaml, client_code/**/*.yaml, client_code/**/*.html, client_code/**/*.py, and server_code/**/*.py"
|
|
16832
17029
|
}
|
|
16833
17030
|
]
|
|
16834
17031
|
};
|
package/dist/program.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../src/program.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../src/program.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA2LpC,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,GAAG,MAAM,EAAE,CAezE;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAwFzD;AAiBD,wBAAgB,YAAY,IAAI,OAAO,CAkGtC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ValidationIssue } from "./validators";
|
|
2
|
+
export type PythonValidationResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
message: string;
|
|
7
|
+
issues: ValidationIssue[];
|
|
8
|
+
};
|
|
9
|
+
export declare function validatePython(filePath: string, fileContent: string, appRoot: string | null): PythonValidationResult;
|
|
10
|
+
//# sourceMappingURL=validatePython.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validatePython.d.ts","sourceRoot":"","sources":["../src/validatePython.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,MAAM,MAAM,sBAAsB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IACI,EAAE,EAAE,KAAK,CAAC;IACV,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC7B,CAAC;AAqHR,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,sBAAsB,CAiEpH"}
|
package/dist/validators.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type AnvilYamlValidationResult = {
|
|
|
16
16
|
message: string;
|
|
17
17
|
issues: ValidationIssue[];
|
|
18
18
|
};
|
|
19
|
-
export type ValidationTarget = "anvil.yaml" | "form_template.yaml" | "form_template.html";
|
|
19
|
+
export type ValidationTarget = "anvil.yaml" | "form_template.yaml" | "form_template.html" | "python";
|
|
20
20
|
export type ValidatePathResult = {
|
|
21
21
|
ok: true;
|
|
22
22
|
target: ValidationTarget;
|
|
@@ -43,6 +43,7 @@ export declare function findAnvilAppRoot(filePath: string): string | null;
|
|
|
43
43
|
* - `anvil.yaml`
|
|
44
44
|
* - any form YAML under `client_code/` with a `.yaml` suffix
|
|
45
45
|
* - any form HTML under `client_code/` with a `.html` suffix
|
|
46
|
+
* - any Python file under `client_code/` or `server_code/`
|
|
46
47
|
*/
|
|
47
48
|
export declare function validatePath(filePath: string, fileContent: string): ValidatePathResult;
|
|
48
49
|
//# sourceMappingURL=validators.d.ts.map
|
package/dist/validators.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../src/validators.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../src/validators.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,eAAe,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAClC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IACI,EAAE,EAAE,KAAK,CAAC;IACV,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC7B,CAAC;AAER,MAAM,MAAM,yBAAyB,GAC/B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IACI,EAAE,EAAE,KAAK,CAAC;IACV,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC7B,CAAC;AAER,MAAM,MAAM,gBAAgB,GAAG,YAAY,GAAG,oBAAoB,GAAG,oBAAoB,GAAG,QAAQ,CAAC;AAErG,MAAM,MAAM,kBAAkB,GACxB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,GACtC;IACI,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC7B,CAAC;AAoER,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,UAEhD;AAgBD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,4BAA4B,CA0BtF;AAuZD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,yBAAyB,CAkFhF;AAED,iBAAS,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CA4BxE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAOjC,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBhE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,kBAAkB,CAoItF"}
|