const fs = require('fs-extra');
const path = require('path');
const runtime = require('../dialects/otree/runtime.js');
const { fail } = require('./util.js');
/*
* oTree projects as jtree's oTree Studio editor (admin/otree, #/studio) shows them, after oTree
* Studio: a project's session configs and settings (settings.py), and each app's constants,
* Subsession, Group and Player fields, functions, pages (with their templates) and page sequence
* (its __init__.py). Python reads the files (dialects/otree/python/otree_studio.py), saying where
* each part is; an edit rewrites only that part, so code written by hand stays as it was, and the
* app is still the oTree app it was, to run in oTree too.
*/
/** The studio's Python, once Python has started. */
async function py() {
await runtime.start();
return runtime.py.pyimport('otree_studio');
}
const appModel = async (source) => JSON.parse((await py()).model(String(source)));
const settingsModel = async (source) => JSON.parse((await py()).settings_model(String(source)));
// --- Edits on source -----------------------------------------------------------------------------
const splice = (source, start, end, text) => source.slice(0, start) + text + source.slice(end);
/** A statement's whole lines: from its line's start (if only spaces are before it) past its line end. */
function lines(source, r) {
let start = r.start;
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
if (source.slice(lineStart, start).trim() === '') start = lineStart;
let end = r.end;
const rest = /^[ \t]*(#[^\n]*)?(\r?\n|$)/.exec(source.slice(end));
if (rest) end += rest[0].length;
return { start, end };
}
/** Takes out the statement at r, with its lines, and blank lines after it that would double up. */
function cut(source, r, blankAfter = 0) {
const { start, end } = lines(source, r);
let e = end;
for (let i = 0; i < blankAfter; i++) {
const m = /^[ \t]*\r?\n/.exec(source.slice(e));
if (!m) break;
e += m[0].length;
}
return splice(source, start, e, '');
}
/** Code with each line indented by indent (blank lines left blank). */
const indentBy = (code, indent) => String(code).replace(/\r\n/g, '\n').split('\n').map((l) => (l.trim() === '' ? '' : indent + l)).join('\n');
/** Inserts lines (no trailing newline) as statements after the line r ends on. */
function insertAfter(source, r, text) {
const { end } = lines(source, r);
const lead = end > 0 && source[end - 1] !== '\n' ? '\n' : '';
return splice(source, end, end, lead + text + '\n');
}
/** A statement in a class: replaces its `pass`, or goes after the last of afterRanges (else at the class's end). */
function addToClass(source, cls, text, afterRanges) {
const body = indentBy(text, cls.indent);
if (cls.passRange) return splice(source, ...Object.values(lines(source, cls.passRange)), body + '\n');
const after = afterRanges.length ? afterRanges.reduce((a, b) => (a.end > b.end ? a : b)) : cls.range;
return insertAfter(source, after, body);
}
const isName = (s) => /^[A-Za-z_]\w*$/.test(String(s || ''));
function fieldSource(name, f, indent) {
const args = [...(f.posargs || []), ...(f.args || []).filter((a) => a.code !== '' && a.code != null).map((a) => a.name + '=' + a.code)];
const one = name + ' = models.' + f.type + '(' + args.join(', ') + ')';
if (indent.length + one.length <= 88 || !args.length) return one;
return name + ' = models.' + f.type + '(\n ' + args.join(',\n ') + ',\n)';
}
function defSource(name, params, body, decorators = []) {
const b = String(body || '').trim() === '' ? 'pass' : String(body).replace(/\s+$/, '');
return [...decorators.map((d) => '@' + d), 'def ' + name + '(' + params.join(', ') + '):', indentBy(b, ' ')].join('\n');
}
/** Where top-level code of a kind goes: functions before the pages, pages before the page sequence. */
function topInsert(m, kind) {
const last = (list) => (list.length ? list[list.length - 1].range : null);
if (kind === 'function') return last(m.functions) || (m.pages[0] && { before: m.pages[0].range }) || (m.pageSequence && { before: m.pageSequence.range }) || null;
return last(m.pages) || (m.pageSequence && { before: m.pageSequence.range }) || last(m.functions) || null;
}
function addTop(source, m, kind, text) {
const at = topInsert(m, kind);
if (at == null) return source.replace(/\s*$/, '') + '\n\n\n' + text + '\n';
if (at.before) {
const { start } = lines(source, at.before);
return splice(source, start, start, text + '\n\n\n');
}
const { end } = lines(source, at);
return splice(source, end, end, '\n\n' + text + '\n');
}
const findPage = (m, name) => m.pages.find((p) => p.name === name) || fail('no page ' + name);
const findModel = (m, name) => m.models[name] || fail('no class ' + name + ' in the app');
/**
* The app's source with op done. Ops (code: Python source):
* - {op: 'setConstant', name, code}, {op: 'removeConstant', name}
* - {op: 'setField', model, name, field: {type, args: [{name, code}]}, to?}, {op: 'removeField', model, name}
* - {op: 'setFunction', name, params, body}, {op: 'removeFunction', name}
* - {op: 'addPage', name, kind: 'Page'|'WaitPage', after?}, {op: 'removePage', name}, {op: 'renamePage', name, to}
* - {op: 'setPageSequence', names}
* - {op: 'setPageAttr', page, name, code}, {op: 'removePageAttr', page, name}
* - {op: 'setPageMethod', page, name, params, body}, {op: 'removePageMethod', page, name}
*/
async function apply(source, op) {
const m = await appModel(source);
if (m.error) fail('the app does not parse (line ' + m.error.line + '): ' + m.error.message);
switch (op.op) {
case 'setConstant': {
if (!isName(op.name)) fail('a constant needs a name (letters, digits, _)');
const c = m.constants || fail('the app has no class C (its constants)');
const item = c.items.find((i) => i.name === op.name);
if (item) return splice(source, item.valueRange.start, item.valueRange.end, op.code);
return addToClass(source, c, op.name + ' = ' + op.code, c.items.map((i) => i.range));
}
case 'removeConstant': {
const item = (m.constants?.items || []).find((i) => i.name === op.name);
if (!item) return source;
if (m.constants.items.length === 1 && !m.constants.other.length && !m.constants.docstring) return splice(source, ...Object.values(lines(source, item.range)), m.constants.indent + 'pass\n');
return cut(source, item.range);
}
case 'setField': {
const cls = findModel(m, op.model);
const name = op.to || op.name;
if (!isName(name)) fail('a field needs a name (letters, digits, _)');
const existing = cls.fields.find((f) => f.name === op.name);
if (op.to && op.to !== op.name && cls.fields.some((f) => f.name === op.to)) fail(op.model + ' already has a field ' + op.to);
const text = fieldSource(name, op.field, cls.indent);
if (existing) {
const r = lines(source, existing.range);
return splice(source, r.start, r.end, indentBy(text, cls.indent) + '\n');
}
return addToClass(source, cls, text, cls.fields.map((f) => f.range));
}
case 'removeField': {
const cls = findModel(m, op.model);
const f = cls.fields.find((x) => x.name === op.name);
if (!f) return source;
if (cls.fields.length === 1 && !cls.other.length && !cls.docstring) return splice(source, ...Object.values(lines(source, f.range)), cls.indent + 'pass\n');
return cut(source, f.range);
}
case 'setFunction': {
if (!isName(op.name)) fail('a function needs a name (letters, digits, _)');
const fn = m.functions.find((f) => f.name === op.name);
const text = defSource(op.name, op.params || [], op.body, fn ? fn.decorators : []);
if (fn) {
const r = lines(source, fn.range);
return splice(source, r.start, r.end, text + '\n');
}
return addTop(source, m, 'function', text);
}
case 'removeFunction': {
const fn = m.functions.find((f) => f.name === op.name);
return fn ? cut(source, fn.range, 2) : source;
}
case 'addPage': {
if (!isName(op.name)) fail('a page needs a name (letters, digits, _)');
if (m.pages.some((p) => p.name === op.name) || m.functions.some((f) => f.name === op.name)) fail('there is already a ' + op.name);
const kind = op.kind === 'WaitPage' ? 'WaitPage' : 'Page';
let out = addTop(source, m, 'page', 'class ' + op.name + '(' + kind + '):\n pass');
const m2 = await appModel(out);
const names = m2.pageSequence ? m2.pageSequence.names.slice() : [];
const at = op.after != null && names.includes(op.after) ? names.indexOf(op.after) + 1 : names.length;
names.splice(at, 0, op.name);
return setSequence(out, m2, names);
}
case 'removePage': {
const page = findPage(m, op.name);
let out = cut(source, page.range, 2);
const m2 = await appModel(out);
return m2.pageSequence ? setSequence(out, m2, m2.pageSequence.names.filter((n) => n !== op.name)) : out;
}
case 'renamePage': {
const page = findPage(m, op.name);
if (!isName(op.to)) fail('a page needs a name (letters, digits, _)');
if (m.pages.some((p) => p.name === op.to)) fail('there is already a page ' + op.to);
const head = source.slice(page.range.start, page.range.end);
const renamed = head.replace(new RegExp('^(\\s*class\\s+)' + op.name + '\\b'), '$1' + op.to);
let out = splice(source, page.range.start, page.range.end, renamed);
const m2 = await appModel(out);
return m2.pageSequence ? setSequence(out, m2, m2.pageSequence.names.map((n) => (n === op.name ? op.to : n))) : out;
}
case 'setPageSequence':
return setSequence(source, m, op.names);
case 'setPageAttr': {
const page = findPage(m, op.page);
if (!isName(op.name)) fail('not a name: ' + op.name);
const attr = page.attrs.find((a) => a.name === op.name);
if (attr) return splice(source, attr.valueRange.start, attr.valueRange.end, op.code);
return addToClass(source, page, op.name + ' = ' + op.code, page.attrs.map((a) => a.range));
}
case 'removePageAttr': {
const page = findPage(m, op.page);
const attr = page.attrs.find((a) => a.name === op.name);
if (!attr) return source;
if (page.attrs.length === 1 && !page.methods.length && !page.other.length && !page.docstring) return splice(source, ...Object.values(lines(source, attr.range)), page.indent + 'pass\n');
return cut(source, attr.range);
}
case 'setPageMethod': {
const page = findPage(m, op.page);
if (!isName(op.name)) fail('not a name: ' + op.name);
const method = page.methods.find((x) => x.name === op.name);
const text = defSource(op.name, op.params || [], op.body, method ? method.decorators : ['staticmethod']);
if (method) {
const r = lines(source, method.range);
return splice(source, r.start, r.end, indentBy(text, page.indent) + '\n');
}
// After the attributes and methods, with a blank line before it, as oTree's apps have them.
return addToClass(source, page, (page.passRange ? '' : '\n') + text, [...page.attrs, ...page.methods, ...page.other].map((x) => x.range));
}
case 'removePageMethod': {
const page = findPage(m, op.page);
const method = page.methods.find((x) => x.name === op.name);
if (!method) return source;
if (page.methods.length === 1 && !page.attrs.length && !page.other.length && !page.docstring) return splice(source, ...Object.values(lines(source, method.range)), page.indent + 'pass\n');
return cut(source, method.range, 1);
}
default:
fail('unknown edit: ' + op.op);
}
}
function setSequence(source, m, names) {
for (const n of names) if (!isName(n)) fail('not a page: ' + n);
const text = '[' + names.join(', ') + ']';
if (m.pageSequence) return splice(source, m.pageSequence.valueRange.start, m.pageSequence.valueRange.end, text);
return source.replace(/\s*$/, '') + '\n\n\npage_sequence = ' + text + '\n';
}
// --- Settings ------------------------------------------------------------------------------------
function configSource(entries, style, indent) {
const inner = indent + ' ';
if (style === 'literal') return '{\n' + entries.map((e) => inner + JSON.stringify(e.key) + ': ' + e.code + ',').join('\n') + '\n' + indent + '}';
return 'dict(\n' + entries.map((e) => inner + e.key + '=' + e.code + ',').join('\n') + '\n' + indent + ')';
}
/**
* The settings' source with op done:
* - {op: 'setConfig', index, entries: [{key, code}]}, {op: 'addConfig', entries}, {op: 'removeConfig', index}
* - {op: 'setSetting', name, code}, {op: 'removeSetting', name}
*/
async function applySettings(source, op) {
const m = await settingsModel(source);
if (m.error) fail('settings.py does not parse (line ' + m.error.line + '): ' + m.error.message);
for (const e of op.entries || []) if (!isName(e.key)) fail('not a name: ' + e.key);
switch (op.op) {
case 'setConfig': {
const c = (m.configs?.items || [])[op.index] || fail('no session config ' + op.index);
return splice(source, c.range.start, c.range.end, configSource(op.entries, c.style || 'call', m.configs.indent));
}
case 'addConfig': {
if (!m.configs) return source.replace(/\s*$/, '') + '\n\nSESSION_CONFIGS = [\n ' + configSource(op.entries, 'call', ' ') + ',\n]\n';
const items = m.configs.items;
const indent = m.configs.indent;
const text = configSource(op.entries, items[0]?.style || 'call', indent);
if (!items.length) return splice(source, m.configs.valueRange.start, m.configs.valueRange.end, '[\n' + indent + text + ',\n]');
const last = items[items.length - 1].range;
const comma = /^\s*,/.exec(source.slice(last.end));
const at = last.end + (comma ? comma[0].length : 0);
return splice(source, at, at, (comma ? '' : ',') + '\n' + indent + text + ',');
}
case 'removeConfig': {
const c = (m.configs?.items || [])[op.index] || fail('no session config ' + op.index);
const comma = /^\s*,/.exec(source.slice(c.range.end));
const r = lines(source, { start: c.range.start, end: c.range.end + (comma ? comma[0].length : 0) });
return splice(source, r.start, r.end, '');
}
case 'setSetting': {
if (!isName(op.name)) fail('not a name: ' + op.name);
const s = m.settings.find((x) => x.name === op.name);
if (s) return splice(source, s.valueRange.start, s.valueRange.end, op.code);
return source.replace(/\s*$/, '') + '\n' + op.name + ' = ' + op.code + '\n';
}
case 'removeSetting': {
const s = m.settings.find((x) => x.name === op.name);
return s ? cut(source, s.range) : source;
}
default:
fail('unknown edit: ' + op.op);
}
}
// --- Templates -----------------------------------------------------------------------------------
const BLOCK = (name) => new RegExp('(\\{[{%]-?\\s*block\\s+' + name + '\\s*-?[}%]\\})([\\s\\S]*?)(\\{[{%]-?\\s*endblock\\s*-?[}%]\\})');
/** A page's template as the editor shows it: its title and content blocks, if it has them as oTree's do. */
function template(text) {
const title = BLOCK('title').exec(text);
const content = BLOCK('content').exec(text);
if (!title || !content) return { text, title: null, content: null };
const strip = (s) => s.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
const dedent = (s) => {
const ls = strip(s).split('\n');
const cut_ = Math.min(...ls.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)[0].length).concat([1e9]));
return ls.map((l) => l.slice(cut_ === 1e9 ? 0 : cut_)).join('\n');
};
return { text, title: strip(title[2]).trim(), content: dedent(content[2]) };
}
/** A template with its title and content set: in the blocks it has, else a new oTree template. */
function withBlocks(text, title, content) {
const body = '\n ' + String(content).replace(/\r\n/g, '\n').split('\n').join('\n ').replace(/[ \t]+\n/g, '\n') + '\n\n';
if (text && BLOCK('title').test(text) && BLOCK('content').test(text)) {
// Only a block that changed is written again; the rest stays as it was written.
const before = template(text);
let out = text;
if (before.title !== String(title).trim()) out = out.replace(BLOCK('title'), (m, a, b, c) => a + '\n ' + title + '\n' + c);
if (before.content !== String(content).replace(/\r\n/g, '\n')) out = out.replace(BLOCK('content'), (m, a, b, c) => a + '\n' + body + c);
return out;
}
return '{{ block title }}\n ' + title + '\n{{ endblock }}\n{{ block content }}\n' + body + '{{ endblock }}\n';
}
// --- Files ---------------------------------------------------------------------------------------
/** The projects (folders with an oTree settings.py) and apps in the app folders. */
function scan(roots) {
const projects = [];
const loose = [];
const seen = new Set();
const isApp = (dir) => ['__init__.py', 'models.py'].some((f) => {
try { return /\bfrom\s+otree\.api\s+import\b|\bimport\s+otree\.api\b/.test(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (e) { return false; }
});
const appInfo = (dir) => ({ name: path.basename(dir), dir, format: fs.existsSync(path.join(dir, '__init__.py')) && isAppFile(path.join(dir, '__init__.py')) ? 'current' : 'old' });
const isAppFile = (f) => { try { return /otree\.api/.test(fs.readFileSync(f, 'utf8')); } catch (e) { return false; } };
const walk = (dir, depth) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
const settings = path.join(dir, 'settings.py');
if (entries.some((e) => e.name === 'settings.py') && /\bSESSION_CONFIGS\b/.test(fs.readFileSync(settings, 'utf8'))) {
const apps = entries.filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && isApp(path.join(dir, e.name)))
.map((e) => appInfo(path.join(dir, e.name))).sort((a, b) => a.name.localeCompare(b.name));
apps.forEach((a) => seen.add(a.dir));
projects.push({ name: path.basename(dir), dir, settingsPath: settings, apps });
}
if (depth >= 5) return;
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
const sub = path.join(dir, e.name);
if (!seen.has(sub) && isApp(sub) && !fs.existsSync(path.join(dir, 'settings.py'))) loose.push(appInfo(sub));
walk(sub, depth + 1);
}
};
roots.forEach((r) => walk(r, 0));
return { projects: projects.sort((a, b) => a.dir.localeCompare(b.dir)), apps: loose.filter((a) => !seen.has(a.dir)) };
}
/** A new app's __init__.py, as oTree Studio starts one. */
function appTemplate(name) {
return `from otree.api import *
doc = """
Your app description
"""
class C(BaseConstants):
NAME_IN_URL = '${name}'
PLAYERS_PER_GROUP = None
NUM_ROUNDS = 1
class Subsession(BaseSubsession):
pass
class Group(BaseGroup):
pass
class Player(BasePlayer):
pass
# PAGES
class MyPage(Page):
pass
class ResultsWaitPage(WaitPage):
pass
class Results(Page):
pass
page_sequence = [MyPage, ResultsWaitPage, Results]
`;
}
const SETTINGS_TEMPLATE = `from os import environ
SESSION_CONFIGS = [
]
# if you set a property in SESSION_CONFIG_DEFAULTS, it will be inherited by all configs
# in SESSION_CONFIGS, except those that explicitly override it.
# the session config can be accessed from methods in your apps as self.session.config,
# e.g. self.session.config['participation_fee']
SESSION_CONFIG_DEFAULTS = dict(
real_world_currency_per_point=1.00, participation_fee=0.00, doc=""
)
PARTICIPANT_FIELDS = []
SESSION_FIELDS = []
# ISO-639 code
# for example: de, fr, ja, ko, zh-hans
LANGUAGE_CODE = 'en'
# e.g. EUR, GBP, CNY, JPY
REAL_WORLD_CURRENCY_CODE = 'USD'
USE_POINTS = True
ADMIN_USERNAME = 'admin'
# for security, best to set admin password in an environment variable
ADMIN_PASSWORD = environ.get('OTREE_ADMIN_PASSWORD')
DEMO_PAGE_INTRO_HTML = """ """
SECRET_KEY = '${'{SECRET}'}'
`;
/** The page templates in an app's folder, by page name. */
function templates(dir, pages) {
const out = {};
for (const p of pages) {
const file = path.join(dir, p.name + '.html');
if (fs.existsSync(file)) out[p.name] = template(fs.readFileSync(file, 'utf8'));
}
return out;
}
function appDir(ctx, dir) {
const d = ctx.resolve(dir);
if (!fs.existsSync(path.join(d, '__init__.py')) && !fs.existsSync(path.join(d, 'models.py'))) fail('not an oTree app: ' + dir);
return d;
}
const actions = {
/** {} → {projects, apps}: what the studio lists. */
async list(body, ctx) {
return scan(ctx.roots());
},
/** {settingsPath} → {settingsPath, source, model} */
async openProject({ settingsPath }, ctx) {
const file = ctx.resolve(settingsPath);
if (path.basename(file) !== 'settings.py' || !fs.existsSync(file)) fail('not a project\'s settings.py: ' + settingsPath);
const source = fs.readFileSync(file, 'utf8');
return { settingsPath: file, source, model: await settingsModel(source) };
},
/** {dir} → {dir, name, format, source, model, templates}: an app; one in oTree's older format (models.py) has no model. */
async openApp({ dir }, ctx) {
const d = appDir(ctx, dir);
const init = path.join(d, '__init__.py');
if (!fs.existsSync(init) || fs.existsSync(path.join(d, 'models.py'))) {
return { dir: d, name: path.basename(d), format: 'old', source: null, model: null, templates: {} };
}
const source = fs.readFileSync(init, 'utf8');
const model = await appModel(source);
return { dir: d, name: path.basename(d), format: 'current', source, model, templates: model.error ? {} : templates(d, model.pages) };
},
/** {source} → {model} */
async model({ source }) {
return { model: await appModel(source) };
},
/** {source, op | ops} → {source, model} */
async apply({ source, op, ops }) {
let out = String(source);
for (const o of ops || [op]) out = await apply(out, o);
return { source: out, model: await appModel(out) };
},
/** {source, op | ops} → {source, model}, for settings.py */
async applySettings({ source, op, ops }) {
let out = String(source);
for (const o of ops || [op]) out = await applySettings(out, o);
return { source: out, model: await settingsModel(out) };
},
/** {source} → {model}, for settings.py */
async settingsModel({ source }) {
return { model: await settingsModel(source) };
},
/**
* {dir, source, templates: {Page: {title, content} | {text} | null}} → {dir}: writes the app's
* __init__.py and its pages' templates (null: the template goes).
*/
async saveApp({ dir, source, templates: pages = {} }, ctx) {
const d = appDir(ctx, dir);
const error = (await py()).check(String(source));
if (error) fail('__init__.py does not parse, ' + error);
ctx.write(path.join(d, '__init__.py'), String(source));
for (const [name, t] of Object.entries(pages)) {
if (!isName(name)) fail('not a page: ' + name);
const file = path.join(d, name + '.html');
if (t == null) { fs.removeSync(file); continue; }
const text = t.text != null && t.title == null ? t.text : withBlocks(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '', t.title || '', t.content || '');
ctx.write(file, text);
}
ctx.reload();
return { dir: d };
},
/** {settingsPath, source} → {settingsPath} */
async saveProject({ settingsPath, source }, ctx) {
const file = ctx.resolve(settingsPath);
if (path.basename(file) !== 'settings.py') fail('not a settings.py: ' + settingsPath);
const error = (await py()).check(String(source));
if (error) fail('settings.py does not parse, ' + error);
ctx.write(file, String(source));
ctx.reload();
return { settingsPath: file };
},
/** {projectDir, name} → what openApp gives: a new app in a project, as oTree Studio starts one. */
async createApp({ projectDir, name }, ctx) {
if (!/^[a-z_][a-z0-9_]*$/.test(String(name || ''))) fail('an app\'s name is lower case letters, digits and _, as a Python package\'s');
const d = path.join(ctx.resolve(projectDir), name);
if (fs.existsSync(d)) fail('there is already a folder ' + name);
ctx.write(path.join(d, '__init__.py'), appTemplate(name));
ctx.write(path.join(d, 'MyPage.html'), withBlocks('', 'Page title', ''));
ctx.write(path.join(d, 'Results.html'), withBlocks('', 'Results', ''));
ctx.reload();
return actions.openApp({ dir: d }, ctx);
},
/** {name} → {settingsPath, dir}: a new project (a folder with a settings.py) in the first app folder. */
async createProject({ name }, ctx) {
if (!/^[A-Za-z_][\w-]*$/.test(String(name || ''))) fail('a project\'s name is letters, digits, - and _');
const d = ctx.resolve(name);
if (fs.existsSync(d)) fail('there is already a folder ' + name);
const secret = require('crypto').randomBytes(16).toString('hex');
ctx.write(path.join(d, 'settings.py'), SETTINGS_TEMPLATE.replace('{SECRET}', secret));
ctx.write(path.join(d, 'requirements.txt'), 'otree>=5.4.0\n');
ctx.reload();
return { settingsPath: path.join(d, 'settings.py'), dir: d };
},
};
module.exports = { apply, applySettings, appModel, settingsModel, template, withBlocks, scan, appTemplate, actions };