@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 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 <file>
176
+ anvil validate <path>
177
177
  ```
178
178
 
179
- The command chooses the validator from the file path. It supports:
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, and client_code/**/*.html"
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 an Anvil app file by path (anvil.yaml, client_code/**/*.yaml, or client_code/**/*.html)").argument("<file>", "Path to file").action((file)=>{
23038
- let fileContent;
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
- fileContent = (0, external_fs_.readFileSync)(file, "utf8");
23354
+ pathStat = (0, external_fs_.statSync)(file);
23041
23355
  } catch (error) {
23042
- logger_logger.error(`Failed to read ${file}: ${error.message}`);
23356
+ logger_logger.error(`Failed to access ${file}: ${error.message}`);
23043
23357
  process.exitCode = 1;
23044
23358
  return;
23045
23359
  }
23046
- const result = validatePath(file, fileContent);
23047
- if (result.ok) {
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, and client_code/**/*.html"
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
  };
@@ -1 +1 @@
1
- {"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../src/program.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAuDpC,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,CAmHtC"}
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"}
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../src/validators.ts"],"names":[],"mappings":"AAWA,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,CAAC;AAE1F,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,CAiBxE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAOjC,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,kBAAkB,CAoGtF"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anvil-works/anvil-cli",
3
- "version": "0.7.0-canary.17",
3
+ "version": "0.7.0-canary.18",
4
4
  "description": "CLI tool for developing Anvil apps locally",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/api.d.ts",