Skip to content

Commit

Permalink
Internalized custom-functions-code endpoint, closes #167
Browse files Browse the repository at this point in the history
  • Loading branch information
fzumstein committed Oct 27, 2024
1 parent e9254f5 commit c37c0c5
Show file tree
Hide file tree
Showing 3 changed files with 277 additions and 4 deletions.
27 changes: 23 additions & 4 deletions app/routers/xlwings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
import json
import logging
from textwrap import dedent
from typing import Optional

import xlwings as xw
Expand Down Expand Up @@ -46,11 +47,29 @@ async def custom_functions_meta():

@router.get("/custom-functions-code")
async def custom_functions_code():
custom_functions_call_path = f"{settings.app_path}/xlwings/custom-functions-call"
js = (settings.static_dir / "js" / "core" / "custom-functions-code.js").read_text()
# format string would require to double all curly braces
js = js.replace("placeholder_xlwings_version", xw.__version__).replace(
"placeholder_custom_functions_call_path", custom_functions_call_path
)
for name, obj in inspect.getmembers(custom_functions):
if hasattr(obj, "__xlfunc__"):
xlfunc = obj.__xlfunc__
func_name = xlfunc["name"]
streaming = "true" if inspect.isasyncgenfunction(obj) else "false"
js += dedent(
f"""\
async function {func_name}() {{
let args = ["{func_name}", {streaming}]
args.push.apply(args, arguments);
return await base.apply(null, args);
}}
CustomFunctions.associate("{func_name.upper()}", {func_name});
"""
)
return Response(
content=xlwings.server.custom_functions_code(
custom_functions,
custom_functions_call_path=f"{settings.app_path}/xlwings/custom-functions-call",
),
content=js,
media_type="text/javascript",
)

Expand Down
253 changes: 253 additions & 0 deletions app/static/js/core/custom-functions-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
const debug = false;
let invocations = new Set();
let bodies = new Set();
let runtime;
let contentLanguage;
let socket = null;

Office.onReady(function (info) {
// Socket.io
socket = globalThis.socket ? globalThis.socket : null;

if (socket !== null) {
socket.on("disconnect", () => {
if (debug) {
console.log("disconnect");
}
for (let invocation of invocations) {
invocation.setResult([["Stream disconnected"]]);
}
invocations.clear();
});

socket.on("connect", () => {
// Without this, you'd have to hit Ctrl+Alt+F9, which isn't available on the web
if (debug) {
console.log("connect");
}
for (let body of bodies) {
socket.emit("xlwings:function-call", body);
}
});
}

// Runtime version
if (
Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.4")
) {
runtime = "1.4";
} else if (
Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.3")
) {
runtime = "1.3";
} else if (
Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.2")
) {
runtime = "1.2";
} else {
runtime = "1.1";
}

// Content Language
contentLanguage = Office.context.contentLanguage;
});

function flattenArray(arr) {
const result = [];
const indices = [];

function recursiveFlatten(item, path) {
if (Array.isArray(item)) {
item.forEach((subItem, index) => {
recursiveFlatten(subItem, [...path, index]);
});
} else {
result.push(item);
indices.push(path);
}
}

recursiveFlatten(arr, []);
return { result, indices };
}

// Workbook name
let cachedWorkbookName = null;

async function getWorkbookName() {
if (cachedWorkbookName) {
return cachedWorkbookName;
}
const context = new Excel.RequestContext();
const workbook = context.workbook;
workbook.load("name");
await context.sync();
cachedWorkbookName = workbook.name;
return cachedWorkbookName;
}

class Semaphore {
constructor(maxConcurrency) {
this.maxConcurrency = maxConcurrency;
this.currentConcurrency = 0;
this.queue = [];
}

async acquire() {
if (this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency++;
return;
}
return new Promise((resolve) => this.queue.push(resolve));
}

release() {
this.currentConcurrency--;
if (this.queue.length > 0) {
this.currentConcurrency++;
const nextResolve = this.queue.shift();
nextResolve();
}
}
}

const semaphore = new Semaphore(1000);

async function base() {
await Office.onReady(); // Block execution until office.js is ready
// Arguments
let argsArr = Array.prototype.slice.call(arguments);
let funcName = argsArr[0];
let isStreaming = argsArr[1];
let args = argsArr.slice(2, -1);
let invocation = argsArr[argsArr.length - 1];

const workbookName = await getWorkbookName();
const officeApiClient = localStorage.getItem("Office API client");

// For arguments that are Entities, replace the arg with their address (cache key).
// The issues is that invocation.parameterAddresses returns a flat list while args
// contains a nested array for varargs (in Office.js called 'repeating').
const { result: flatArgs, indices } = flattenArray(args);

flatArgs.forEach((item, index) => {
if (item?.type === "Entity") {
const address = `${officeApiClient}[${workbookName}]${invocation.parameterAddresses[index]}`;

let target = args;
const path = indices[index];

for (let i = 0; i < path.length - 1; i++) {
target = target[path[i]];
}

const lastIndex = path[path.length - 1];
target[lastIndex] = address;
}
});

// Body
let body = {
func_name: funcName,
args: args,
caller_address: `${officeApiClient}[${workbookName}]${invocation.address}`, // not available for streaming functions
content_language: contentLanguage,
version: "placeholder_xlwings_version",
runtime: runtime,
};

// Streaming functions communicate via socket.io
if (isStreaming) {
if (socket === null) {
console.error(
"To enable streaming functions, you need to load the socket.io js client before xlwings.min.js and custom-functions-code",
);
return;
}
let taskKey = `${funcName}_${args}`;
body.task_key = taskKey;
socket.emit("xlwings:function-call", body);
if (debug) {
console.log(`emit xlwings:function-call ${funcName}`);
}
invocation.setResult([["Waiting for stream..."]]);

socket.off(`xlwings:set-result-${taskKey}`);
socket.on(`xlwings:set-result-${taskKey}`, (data) => {
invocation.setResult(data.result);
if (debug) {
console.log(`Set Result`);
}
});

invocations.add(invocation);
bodies.add(body);

return;
}

// Normal functions communicate via REST API
return await makeApiCall(body);
}

async function makeApiCall(body) {
const MAX_RETRIES = 5;
let attempt = 0;

while (attempt < MAX_RETRIES) {
attempt++;
let headers = {
"Content-Type": "application/json",
Authorization:
typeof globalThis.getAuth === "function"
? await globalThis.getAuth()
: "",
sid: socket && socket.id ? socket.id.toString() : null,
};

await semaphore.acquire();
try {
let response = await fetch(
window.location.origin + "placeholder_custom_functions_call_path",
{
method: "POST",
headers: headers,
body: JSON.stringify(body),
},
);

if (!response.ok) {
let errMsg = await response.text();
console.error(`Attempt ${attempt}: ${errMsg}`);
if (attempt === MAX_RETRIES) {
return showError(errMsg);
}
} else {
let responseData = await response.json();
return responseData.result;
}
} catch (error) {
console.error(`Attempt ${attempt}: ${error.toString()}`);
if (attempt === MAX_RETRIES) {
return showError(error.toString());
}
} finally {
semaphore.release();
}
}
}

function showError(errorMessage) {
if (
Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.2")
) {
// Error message is only visible by hovering over the error flag!
let excelError = new CustomFunctions.Error(
CustomFunctions.ErrorCode.invalidValue,
errorMessage,
);
throw excelError;
} else {
return [[errorMessage]];
}
}
1 change: 1 addition & 0 deletions scripts/build_static_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def should_process_file(self, path: Path) -> bool:
"fonts/",
"vendor/@microsoft/office-js/dist/",
"images/ribbon/",
"custom-functions-code.js",
}

path_str = str(path)
Expand Down

0 comments on commit c37c0c5

Please sign in to comment.