diff --git a/client/src/commands/authorize.ts b/client/src/commands/authorize.ts index 9a1b1d090..b8debbdab 100644 --- a/client/src/commands/authorize.ts +++ b/client/src/commands/authorize.ts @@ -43,6 +43,10 @@ export const checkProfileAndAuthorize = commands.executeCommand("setContext", "SAS.librariesDisplayed", true); libraryNavigator.refresh(); return finishAuthorization(profileConfig); + case ConnectionType.SASPY: + commands.executeCommand("setContext", "SAS.librariesDisplayed", true); + libraryNavigator.refresh(); + return finishAuthorization(profileConfig); default: return finishAuthorization(profileConfig); } diff --git a/client/src/components/LibraryNavigator/LibraryAdapterFactory.ts b/client/src/components/LibraryNavigator/LibraryAdapterFactory.ts index c4e354339..fedb5e753 100644 --- a/client/src/components/LibraryNavigator/LibraryAdapterFactory.ts +++ b/client/src/components/LibraryNavigator/LibraryAdapterFactory.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import ItcLibraryAdapter from "../../connection/itc/ItcLibraryAdapter"; import RestLibraryAdapter from "../../connection/rest/RestLibraryAdapter"; +import SaspyLibraryAdapter from "../../connection/saspy/SaspyLibraryAdapter"; import { ConnectionType } from "../profile"; import { LibraryAdapter } from "./types"; @@ -11,6 +12,8 @@ class LibraryAdapterFactory { case ConnectionType.IOM: case ConnectionType.COM: return new ItcLibraryAdapter(); + case ConnectionType.SASPY: + return new SaspyLibraryAdapter(); case ConnectionType.Rest: default: return new RestLibraryAdapter(); diff --git a/client/src/components/profile.ts b/client/src/components/profile.ts index 50438153b..01a599fdc 100644 --- a/client/src/components/profile.ts +++ b/client/src/components/profile.ts @@ -20,12 +20,14 @@ enum ConnectionOptions { SAS9COM = "SAS 9.4 (local)", SAS9IOM = "SAS 9.4 (remote - IOM)", SAS9SSH = "SAS 9.4 (remote - SSH)", + SAS9SASPY = "SAS 9.4 (SASPy)", SASViya = "SAS Viya", } const CONNECTION_PICK_OPTS: string[] = [ ConnectionOptions.SASViya, ConnectionOptions.SAS9SSH, + ConnectionOptions.SAS9SASPY, ConnectionOptions.SAS9IOM, ConnectionOptions.SAS9COM, ]; @@ -60,6 +62,7 @@ export enum ConnectionType { IOM = "iom", Rest = "rest", SSH = "ssh", + SASPY = "saspy", } /** @@ -91,6 +94,12 @@ export interface SSHProfile extends BaseProfile { privateKeyFilePath?: string; } +export interface SASPYProfile extends BaseProfile { + connectionType: ConnectionType.SASPY; + cfgname: string; + pythonpath: string; +} + export interface COMProfile extends BaseProfile { connectionType: ConnectionType.COM; host: string; @@ -103,7 +112,12 @@ export interface IOMProfile extends BaseProfile { port: number; } -export type Profile = ViyaProfile | SSHProfile | COMProfile | IOMProfile; +export type Profile = + | ViyaProfile + | SSHProfile + | SASPYProfile + | COMProfile + | IOMProfile; export enum AutoExecType { File = "file", @@ -469,6 +483,15 @@ export class ProfileConfig { pv.error = l10n.t("Missing username in active profile."); return pv; } + } else if (profile.connectionType === ConnectionType.SASPY) { + // if (!profile.cfgname) { + // pv.error = l10n.t("Missing cfgname in active profile."); + // return pv; + // } + // if (!profile.pythonpath) { + // pv.error = l10n.t("Missing Python path in active profile."); + // return pv; + // } } pv.profile = profileDetail.profile; @@ -590,6 +613,10 @@ export class ProfileConfig { profileClone.privateKeyFilePath = keyPath; } + await this.upsertProfile(name, profileClone); + } else if (profileClone.connectionType === ConnectionType.SASPY) { + profileClone.cfgname = ""; + // profileClone.sasOptions = []; await this.upsertProfile(name, profileClone); } else if (profileClone.connectionType === ConnectionType.COM) { profileClone.sasOptions = []; @@ -668,6 +695,8 @@ export enum ProfilePromptType { Port, Username, PrivateKeyFilePath, + Cfgname, + PYTHONpath, } /** @@ -810,6 +839,16 @@ const input: ProfilePromptInput = { placeholder: l10n.t("Enter the local private key file path"), description: l10n.t("To use the SSH Agent or a password, leave blank."), }, + [ProfilePromptType.Cfgname]: { + title: l10n.t("SAS Server Cfgname"), + placeholder: l10n.t("Enter your cfgname"), + description: l10n.t("Enter your SAS server cfgname."), + }, + [ProfilePromptType.PYTHONpath]: { + title: l10n.t("Server Path"), + placeholder: l10n.t("Enter the server path"), + description: l10n.t("Enter the server path of the PYTHON Executable."), + } }; /** @@ -828,6 +867,8 @@ function mapQuickPickToEnum(connectionTypePickInput: string): ConnectionType { return ConnectionType.Rest; case ConnectionOptions.SAS9SSH: return ConnectionType.SSH; + case ConnectionOptions.SAS9SASPY: + return ConnectionType.SASPY; case ConnectionOptions.SAS9COM: return ConnectionType.COM; case ConnectionOptions.SAS9IOM: diff --git a/client/src/connection/index.ts b/client/src/connection/index.ts index 8a085cab6..d56053403 100644 --- a/client/src/connection/index.ts +++ b/client/src/connection/index.ts @@ -17,6 +17,7 @@ import { LogLine as ComputeLogLine, LogLineTypeEnum as ComputeLogLineTypeEnum, } from "./rest/api/compute"; +import { getSession as getSASPYSession } from "./saspy"; import { Session } from "./session"; import { getSession as getSSHSession } from "./ssh"; @@ -54,6 +55,8 @@ export function getSession(): Session { return getRestSession(toRestConfig(validProfile.profile)); case ConnectionType.SSH: return getSSHSession(validProfile.profile); + case ConnectionType.SASPY: + return getSASPYSession(validProfile.profile); case ConnectionType.COM: return getITCSession(validProfile.profile, ITCProtocol.COM); case ConnectionType.IOM: diff --git a/client/src/connection/saspy/CodeRunner.ts b/client/src/connection/saspy/CodeRunner.ts new file mode 100644 index 000000000..8e8b6bbb9 --- /dev/null +++ b/client/src/connection/saspy/CodeRunner.ts @@ -0,0 +1,82 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { commands } from "vscode"; + +import { SASPYSession } from "."; +import { LogLine, getSession } from ".."; +import { useRunStore } from "../../store"; + +let wait: Promise | undefined; + +export async function runCode( + code: string, + startTag: string = "", + endTag: string = "", +): Promise { + const task = () => _runCode(code, startTag, endTag); + + wait = wait ? wait.then(task) : task(); + return wait; +} + +async function _runCode( + code: string, + startTag: string = "", + endTag: string = "", +): Promise { + // If we're already executing code, lets wait for it + // to finish up. + let unsubscribe; + if (useRunStore.getState().isExecutingCode) { + await new Promise((resolve) => { + unsubscribe = useRunStore.subscribe( + (state) => state.isExecutingCode, + (isExecutingCode) => !isExecutingCode && resolve(true), + ); + }); + } + + const { setIsExecutingCode } = useRunStore.getState(); + setIsExecutingCode(true, false); + commands.executeCommand("setContext", "SAS.running", true); + const session = getSession(); + + let logText = ""; + const onExecutionLogFn = session.onExecutionLogFn; + const outputLines = []; + + const addLine = (logLines: LogLine[]) => + outputLines.push(...logLines.map(({ line }) => line)); + + try { + await session.setup(true); + + // Lets capture output to use it on + session.onExecutionLogFn = addLine; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + await (session as SASPYSession).run(code, true) + + const logOutput = outputLines.filter((line) => line.trim()).join(""); + + logText = + startTag && endTag + ? logOutput + .slice( + logOutput.lastIndexOf(startTag), + logOutput.lastIndexOf(endTag), + ) + .replace(startTag, "") + .replace(endTag, "") + : logOutput; + } finally { + unsubscribe && unsubscribe(); + // Lets update our session to write to the log + session.onExecutionLogFn = onExecutionLogFn; + + setIsExecutingCode(false); + commands.executeCommand("setContext", "SAS.running", false); + } + + return logText; +} diff --git a/client/src/connection/saspy/SaspyLibraryAdapter.ts b/client/src/connection/saspy/SaspyLibraryAdapter.ts new file mode 100644 index 000000000..52ccbffd0 --- /dev/null +++ b/client/src/connection/saspy/SaspyLibraryAdapter.ts @@ -0,0 +1,301 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { commands, l10n } from "vscode"; + +import { ChildProcessWithoutNullStreams } from "child_process"; + +import { onRunError } from "../../commands/run"; +import { + LibraryAdapter, + LibraryItem, + TableData, + TableRow, +} from "../../components/LibraryNavigator/types"; +import { Column, ColumnCollection } from "../rest/api/compute"; +import { runCode } from "./CodeRunner"; +import { Config, LineCodes } from "./types"; + +class SaspyLibraryAdapter implements LibraryAdapter { + protected hasEstablishedConnection: boolean = false; + protected shellProcess: ChildProcessWithoutNullStreams; + protected pollingForLogResults: boolean = false; + protected log: string[] = []; + protected endTag: string = ""; + protected outputFinished: boolean = false; + protected config: Config; + + public async connect(): Promise { + this.hasEstablishedConnection = true; + } + + public async setup(): Promise { + if (this.hasEstablishedConnection) { + return; + } + + await this.connect(); + } + + public async deleteTable(item: LibraryItem): Promise { + const code = ` + proc datasets library=${item.library} nolist nodetails; delete ${item.name}; run; + `; + + await this.runCode(code); + } + + public async getColumns(item: LibraryItem): Promise { + const sql = ` + %let OUTPUT; + proc sql; + select catx(',', name, type, varnum) as column into: OUTPUT separated by '~' + from sashelp.vcolumn + where libname='${item.library}' and memname='${item.name}' + order by varnum; + quit; + %put &OUTPUT; %put ; + `; + + const columnLines = processQueryRows( + await this.runCode(sql, "", ""), + ); + + const columns = columnLines.map((lineText): Column => { + const [name, type, index] = lineText.split(","); + + return { + name, + type, + index: parseInt(index, 10), + }; + }); + + return { + items: columns, + count: -1, + }; + } + + public async getLibraries(): Promise<{ + items: LibraryItem[]; + count: number; + }> { + const sql = ` + %let OUTPUT; + proc sql; + select catx(',', libname, readonly) as libname_target into: OUTPUT separated by '~' + from sashelp.vlibnam order by libname asc; + quit; + %put &OUTPUT; %put ; + `; + + const libNames = processQueryRows( + await this.runCode(sql, "", ""), + ); + + const libraries = libNames.map((lineText): LibraryItem => { + const [libName, readOnlyValue] = lineText.split(","); + + return { + type: "library", + uid: libName, + id: libName, + name: libName, + readOnly: readOnlyValue === "yes", + }; + }); + + return { + items: libraries, + count: -1, + }; + } + + public async getRows( + item: LibraryItem, + start: number, + limit: number, + ): Promise { + const { rows: rawRowValues, count } = await this.getDatasetInformation( + item, + start, + limit, + ); + + const rows = rawRowValues.map((line, idx: number): TableRow => { + const rowData = [`${start + idx + 1}`].concat(line); + return { cells: rowData }; + }); + + return { + rows, + count, + }; + } + + public async getRowsAsCSV( + item: LibraryItem, + start: number, + limit: number, + ): Promise { + // We only need the columns for the first page of results + const columns = + start === 0 + ? { + columns: ["INDEX"].concat( + (await this.getColumns(item)).items.map((column) => column.name), + ), + } + : {}; + + const { rows } = await this.getRows(item, start, limit); + + rows.unshift(columns); + + // Fetching csv doesn't rely on count. Instead, we get the count + // upfront via getTableRowCount + return { rows, count: -1 }; + } + + public async getTableRowCount( + item: LibraryItem, + ): Promise<{ rowCount: number; maxNumberOfRowsToRead: number }> { + const code = ` + proc sql; + SELECT COUNT(1) into: COUNT FROM ${item.library}.${item.name}; + quit; + %put &COUNT; + `; + + const output = await this.runCode(code, "", ""); + const rowCount = parseInt(output.replace(/[^0-9]/g, ""), 10); + + return { rowCount, maxNumberOfRowsToRead: 100 }; + } + + public async getTables(item: LibraryItem): Promise<{ + items: LibraryItem[]; + count: number; + }> { + const sql = ` + %let OUTPUT; + proc sql; + select memname into: OUTPUT separated by '~' + from sashelp.vtable + where libname='${item.name!}' + order by memname asc; + quit; + %put &OUTPUT; %put ; + `; + + const tableNames = processQueryRows( + await this.runCode(sql, "", ""), + ); + + const tables = tableNames.map((lineText): LibraryItem => { + const [table] = lineText.split(","); + + return { + type: "table", + uid: `${item.name!}.${table}`, + id: table, + name: table, + library: item.name, + readOnly: item.readOnly, + }; + }); + + return { items: tables, count: -1 }; + } + + protected async getDatasetInformation( + item: LibraryItem, + start: number, + limit: number, + ): Promise<{ rows: Array; count: number }> { + const maxTableNameLength = 32; + const tempTable = `${item.name}${hms()}${start}`.substring( + 0, + maxTableNameLength, + ); + const code = ` + options nonotes nosource nodate nonumber; + %let COUNT; + proc sql; + SELECT COUNT(1) into: COUNT FROM ${item.library}.${item.name}; + quit; + data work.${tempTable}; + set ${item.library}.${item.name}; + if ${start + 1} <= _N_ <= ${start + limit} then output; + run; + + filename out temp; + proc json nokeys out=out pretty; export work.${tempTable}; run; + + %put ; + %put &COUNT; + data _null_; infile out; input; put _infile_; run; + %put ; + proc datasets library=work nolist nodetails; delete ${tempTable}; run; quit; + options notes source date number; + `; + + let output = await this.runCode(code, "", ""); + + // Remove LogLineStarter + const rxLineType: RegExp = new RegExp(`${LineCodes.LogLineStarter}=(\\w+):LINE=`, 'igm'); + output = output.replace(rxLineType, ""); + + // Extract result count + const countRegex = /(.*)<\/Count>/; + const countMatches = output.match(countRegex); + const count = parseInt(countMatches[1].replace(/\s|\n/gm, ""), 10); + output = output.replace(countRegex, ""); + + const rows = output.replace(/\n|\t/gm, "").slice(output.indexOf("{")); + try { + const tableData = JSON.parse(rows); + return { rows: tableData[`SASTableData+${tempTable}`], count }; + } catch (e) { + console.warn("Failed to load table data with error", e); + console.warn("Raw output", rows); + throw new Error( + l10n.t( + "An error was encountered when loading table data. This usually happens when a table is too large or the data couldn't be processed. See console for more details.", + ), + ); + } + } + + protected async runCode( + code: string, + startTag: string = "", + endTag: string = "", + ): Promise { + try { + return await runCode(code, startTag, endTag); + } catch (e) { + onRunError(e); + commands.executeCommand("setContext", "SAS.librariesDisplayed", false); + return ""; + } + } +} + +const processQueryRows = (response: string): string[] => { + const processedResponse = response.trim().replace(/\n|\t/gm, ""); + if (!processedResponse) { + return []; + } + + return processedResponse + .split("~") + .filter((value, index, array) => array.indexOf(value) === index); +}; + +const hms = () => { + const date = new Date(); + return `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; +}; + +export default SaspyLibraryAdapter; diff --git a/client/src/connection/saspy/index.ts b/client/src/connection/saspy/index.ts new file mode 100644 index 000000000..c7224364f --- /dev/null +++ b/client/src/connection/saspy/index.ts @@ -0,0 +1,580 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Uri, workspace } from "vscode"; + +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; + +import { LogLineTypeEnum, RunResult } from ".."; +import { getGlobalStorageUri } from "../../components/ExtensionContext"; +import { updateStatusBarItem } from "../../components/StatusBarItem"; +import { Session } from "../session"; +import { extractOutputHtmlFileName } from "../util"; +// import { scriptContent } from "./script"; +import { LineParser } from "../itc/LineParser"; +import { + WORK_DIR_END_TAG, + WORK_DIR_START_TAG, +} from "../itc/const"; +import { Config, LineCodes } from "./types"; +import { saspyGetHtmlStyleValue } from "./util"; + +const LogLineTypes: LogLineTypeEnum[] = [ + "normal", + "hilighted", + "source", + "title", + "byline", + "footnote", + "error", + "warning", + "note", + "message", +]; + +// from SASpy +const LogLineTypeArray: string[] = [ + "Normal", + "Hilighted", + "Source", + "Title", + "Byline", + "Footnote", + "Error", + "Warning", + "Note", + "Message", +]; + +let sessionInstance: SASPYSession; + +export class SASPYSession extends Session { + private _config: Config; + private _shellProcess: ChildProcessWithoutNullStreams; + private _html5FileName: string; + private _runResolve: ((value?) => void) | undefined; + private _runReject: ((reason?) => void) | undefined; + private _workDirectory: string; + private _pollingForLogResults: boolean; + private _logLineType = 0; + private _workDirectoryParser: LineParser; + + constructor() { + super(); + this._workDirectoryParser = new LineParser( + WORK_DIR_START_TAG, + WORK_DIR_END_TAG, + false, + ); + } + + public set config(value: Config) { + this._config = value; + } + + /** + * Initialization logic that should be performed prior to execution. + * @returns void promise. + */ + protected establishConnection = async (): Promise => { + const setupPromise = new Promise((resolve, reject) => { + this._runResolve = resolve; + this._runReject = reject; + }); + + if (this._shellProcess && !this._shellProcess.killed) { + this._runResolve(); + return; // manually terminate to avoid executing the code below + } + + this._shellProcess = spawn( + `${this._config.pythonpath}`, + ["-i", "-q", "-X utf8"], + { + //shell: true, + // env: process.env, + env: { + ...process.env, + //PATH: process.env.PATH + require('path').delimiter + __dirname, + PYTHONIOENCODING: "utf-8", + }, + }, + ); + this._shellProcess.stdout.on("data", this.onShellStdOut); + this._shellProcess.stderr.on("data", this.onShellStdErr); + const saspyWorkDir = ` +%let __workDir = %sysfunc(pathname(work)); +%put ${WORK_DIR_START_TAG}; +%put &__workDir.; +%put ${WORK_DIR_END_TAG}; +%let rc = %sysfunc(dlgcdir("&__workDir")); +run; +`; + const saspyHtmlStyle = saspyGetHtmlStyleValue() ?? "Illuminate"; + + const cfgname = + this._config.cfgname?.length > 0 ? this._config.cfgname : ""; + const scriptContent = ` +import saspy +from packaging.version import parse + +_cfgname = "${cfgname}" + +if(not _cfgname): + try: + sas + if sas is None: + sas = saspy.SASsession(cfgname=_cfgname, results='HTML') + elif not sas.SASpid: + sas = saspy.SASsession(cfgname=_cfgname, results='HTML') + except NameError: + sas = saspy.SASsession(cfgname=_cfgname, results='HTML') +else: + try: + sas + if sas is None: + sas = saspy.SASsession(results='HTML') + elif not sas.SASpid: + sas = saspy.SASsession(results='HTML') + except NameError: + sas = saspy.SASsession(results='HTML') + + +try: + sas +except NameError: + raise Exception("Setup error") + +enable_diagnostic = parse(saspy.__version__) >= parse("5.14.0") +enable_diagnostic + +sas.HTML_Style = '${saspyHtmlStyle}' + +vscode_saspy_code = r""" +${saspyWorkDir} +""" + +ll_init=sas.submit(vscode_saspy_code) +if ll_init is not None: + print(ll_init['LOG']) + ll_init = None + +print("${LineCodes.SessionCreatedCode}") + +`; + + this._shellProcess.stdin.write(scriptContent + "\n", this.onWriteComplete); + + /* + * There are cases where the higher level run command will invoke setup multiple times. + * Avoid re-initializing the session when this happens. In a first run scenario a work dir + * will not exist. The work dir should only be deleted when close is invoked. + */ + if (!this._workDirectory) { + // this._shellProcess.stdin.write(`sas\n`); + + // FIXME: Logically, the code for workdirectory should be here + // this._shellProcess.stdin.write(` + // ll_init=sas.submit(vscode_saspy_code) + // if ll_init is not None: + // print(ll_init['LOG']) + // ll_init = None + + // `, this.onWriteComplete); + + this._workDirectoryParser.reset(); + + if (this._config.sasOptions?.length > 0) { + const sasOptsInput = `$sasOpts=${this.formatSASOptions( + this._config.sasOptions, + )}\n`; + this._shellProcess.stdin.write(sasOptsInput, this.onWriteComplete); + this._shellProcess.stdin.write( + `sas.submit($sasOpts)\n`, + this.onWriteComplete, + ); + } + } + + // free objects in the scripting env + process.on("exit", async () => { + close(); + }); + + return setupPromise; + }; + + /** + * Executes the given input code. + * @param code A string of SAS code to execute. + * @param onLog A callback handler responsible for marshalling log lines back to the higher level extension API. + * @returns A promise that eventually resolves to contain the given {@link RunResult} for the input code execution. + */ + public run = async ( + code: string, + skipPageHeaders?: boolean, + ): Promise => { + const runPromise = new Promise((resolve, reject) => { + this._runResolve = resolve; + this._runReject = reject; + }); + + //write ODS output to work so that the session cleans up after itself + const codeWithODSPath = code.replace( + /\bods html5\(id=vscode\)([^;]*;)/i, + `ods html5(id=vscode) path="${this._workDirectory}" $1`, + ); + // Remove ods close code from SASPy + const codeWithODSPath2 = codeWithODSPath.replace(/\bods _all_ close;/i, ``); + + //write an end mnemonic so that the handler knows when execution has finished + const codeWithEnd = `${codeWithODSPath2}\n%put ${LineCodes.RunEndCode};`; + const codeToRun = `codeToRun=r""" +${codeWithEnd} +""" +`; + + this._html5FileName = ""; + this._shellProcess.stdin.write(codeToRun); + this._pollingForLogResults = true; + await this._shellProcess.stdin.write( + // Below SASPy V5.14.0, we can't get the log line type + // `ll=sas.submit(codeToRun, results='HTML')\n`, + // from SASPy V5.14.0, it provides an option to get line type in log + ` +if enable_diagnostic: + ll=sas.submit(codeToRun, results='HTML', loglines=True) +else: + ll=sas.submit(codeToRun, results='HTML') + +`, + async (error) => { + if (error) { + this._runReject(error); + } + + await this.fetchLog(skipPageHeaders); + }, + ); + + return runPromise; + }; + + /** + * Cleans up resources for the given SAS session. + * @returns void promise. + */ + public close = async (): Promise => { + return new Promise((resolve) => { + if (this._shellProcess) { + this._shellProcess.stdin.write( + "sas.endsas()\nquit()\n", + this.onWriteComplete, + ); + this._shellProcess.kill(); + this._shellProcess = undefined; + + this._workDirectory = undefined; + this._runReject = undefined; + this._runResolve = undefined; + } + resolve(); + updateStatusBarItem(false); + }); + }; + + /** + * Cancels a running SAS program + */ + public cancel = async () => { + this._pollingForLogResults = false; + this._shellProcess.stdin.write(`sas.submit("""\n%abort cancel;\n""")\n`, async (error) => { + if (error) { + this._runReject(error); + } + + await this.fetchLog(); + }); + }; + + /** + * Formats the SAS Options provided in the profile into a format + * that the shell process can understand. + * @param sasOptions SAS Options array from the connection profile. + * @returns a string denoting powershell syntax for an array literal. + */ + private formatSASOptions = (sasOptions: string[]): string => { + const optionsVariable = `r"""\n${sasOptions.join(`","`)}\n"""`; + return optionsVariable; + }; + + /** + * Flushes the SAS log in chunks of [chunkSize] length, + * writing each chunk to stdout. + */ + private fetchLog = async (skipPageHeaders?: boolean): Promise => { + // Below SASPy V5.14.0, we can't get the log line type + // this._shellProcess.stdin.write(`print(ll['LOG'])\n`, this.onWriteComplete); + // from SASPy V5.14.0, it provides an option to get line type in log + // FIXME: The log of code for work directory should be diagnoticed together with + // the first run code, otherwise, as current implentation, the diagnotitics would + // think the actual code has completed after parsing the log of code for working + // directory + // - update unsubscribe, or + // - delay the parsing log of code for working directory + const skipPageHeadersValue = skipPageHeaders ? "True" : "False"; + this._shellProcess.stdin.write( + ` +if ll_init is not None: + print(ll_init['LOG']) + ll_init = None + +if enable_diagnostic: + for lln in ll["LOG"]: + if ${skipPageHeadersValue}: + if lln["type"] != "Title": + print("${LineCodes.LogLineStarter}=", lln["type"], ":LINE=", lln["line"], sep="", end='\\n') + else: + print("${LineCodes.LogLineStarter}=", lln["type"], ":LINE=", lln["line"], sep="", end='\\n') +else: + print(ll['LOG']) + +`, + this.onWriteComplete); + }; + + /** + * Handles stderr output from the powershell child process. + * @param chunk a buffer of stderr output from the child process. + */ + private onShellStdErr = (chunk: Buffer): void => { + const msg = chunk.toString("utf8"); + console.warn("shellProcess stderr: " + msg); + if (/[^.> ]/.test(msg)) { + this._runReject( + new Error( + "There was an error executing the SAS Program.\nSee console log for more details.", + ), + ); + } + // If we encountered an error in setup, we need to go through everything again + if ( + /^We failed in getConnection|Setup error|spawn .+ ENOENT: Error/i.test( + msg, + ) + ) { + this._shellProcess.kill(); + this._workDirectory = undefined; + } + }; + + private fetchWorkDirectory = (line: string): string | undefined => { + let foundWorkDirectory = ""; + if ( + !line.includes(`%put ${WORK_DIR_START_TAG};`) && + !line.includes(`%put &__workDir.;`) && + !line.includes(`%put ${WORK_DIR_END_TAG};`) + ) { + foundWorkDirectory = this._workDirectoryParser.processLine(line); + } else { + // If the line is the put statement, we don't need to log that + return; + } + // const foundWorkDirectory = this._workDirectoryParser.processLine(line); + // We don't want to output any of the captured lines + if (this._workDirectoryParser.isCapturingLine()) { + return; + } + + return foundWorkDirectory || ""; + }; + + /** + * Handles stdout output from the powershell child process. + * @param data a buffer of stdout output from the child process. + */ + private onShellStdOut = (data: Buffer): void => { + const output = data.toString().trimEnd(); + const outputLines = output.split(/\n|\r\n/); + + outputLines.forEach((line: string) => { + if (!line) { + return; + } + + if (!this.processLineCodes(line)) { + if (!this._workDirectory) { + const foundWorkDirectory = this.fetchWorkDirectory(line); + if (foundWorkDirectory === undefined) { + return; + } + + if (foundWorkDirectory) { + this._workDirectory = foundWorkDirectory.trim(); + this._runResolve(); + updateStatusBarItem(true); + return; + } + } + + this._html5FileName = extractOutputHtmlFileName( + line, + this._html5FileName, + ); + + if (this._workDirectory) { + this._onExecutionLogFn?.([{ type: this.getLogLineType(line), line: this.getLogLineLog(line) }]); + } else { + this._onSessionLogFn?.([{ type: this.getLogLineType(line), line: this.getLogLineLog(line) }]); + } + } + }); + }; + + private processLineCodes(line: string): boolean { + if (line.endsWith(LineCodes.RunEndCode)) { + // run completed + this.fetchResults(); + return true; + } + + if (line.includes(LineCodes.SessionCreatedCode)) { + return true; + } + + if (line.includes(LineCodes.ResultsFetchedCode)) { + this.displayResults(); + return true; + } + + if (line.includes(LineCodes.RunCancelledCode)) { + this._runResolve({}); + return true; + } + + if (line.includes(LineCodes.LogLineType)) { + const start = + line.indexOf(LineCodes.LogLineType) + LineCodes.LogLineType.length + 1; + this._logLineType = parseInt(line.slice(start, start + 1)); + return true; + } + + return false; + } + + private getLogLineType(line: string): LogLineTypeEnum { + this._logLineType = 0; + const rx: RegExp = new RegExp(`^${LineCodes.LogLineStarter}=(\\w+):LINE=.*i`, 'i'); + if (rx.test(line)) { + const lineType = line.match(rx); + this._logLineType = LogLineTypeArray.indexOf(lineType[1]); + } + const result = LogLineTypes[this._logLineType]; + this._logLineType = 0; + return result; + } + + private getLogLineLog(line: string): string { + const rx: RegExp = new RegExp(`^${LineCodes.LogLineStarter}=\\w+:LINE=(.*)`, 'i'); + const result = rx.test(line) ? line.match(rx)[1] : line; + return result; + } + + /** + * Generic call for use on stdin write completion. + * @param err The error encountered on the write attempt. Undefined if no error occurred. + */ + private onWriteComplete = (err: Error): void => { + if (err) { + this._runReject?.(err); + } + }; + + /** + * Not implemented. + */ + public sessionId = (): string => { + throw new Error("Not Implemented"); + }; + + /** + * Fetches the ODS output results for the latest html results file. + */ + private fetchResults = async () => { + if (!this._html5FileName) { + this._pollingForLogResults = false; + return this._runResolve({}); + } + + const globalStorageUri = getGlobalStorageUri(); + try { + await workspace.fs.readDirectory(globalStorageUri); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + await workspace.fs.createDirectory(globalStorageUri); + } + + this._pollingForLogResults = false; + const outputFileUri = Uri.joinPath( + globalStorageUri, + `${this._html5FileName}.htm`, + ); + // const directorySeparator = + // this._workDirectory.lastIndexOf("/") !== -1 ? "/" : "\\"; + // const filePath = + // this._config.protocol === ITCProtocol.COM + // ? resolve(this._workDirectory, this._html5FileName + ".htm") + // : `${this._workDirectory}${directorySeparator}${this._html5FileName}.htm`; + await this._shellProcess.stdin.write( + ` +with open(r"${outputFileUri.fsPath}", 'w', encoding='utf8') as f1: + f1.write(ll['LST']) + +print(r""" +${LineCodes.ResultsFetchedCode} +""" +) + +`, + this.onWriteComplete, + ); + }; + + private displayResults = async () => { + const globalStorageUri = getGlobalStorageUri(); + const outputFileUri = Uri.joinPath( + globalStorageUri, + `${this._html5FileName}.htm`, + ); + const file = await workspace.fs.readFile(outputFileUri); + + const htmlResults = (file || "").toString(); + if (file) { + workspace.fs.delete(outputFileUri); + } + + const runResult: RunResult = {}; + if (htmlResults.search('<*id="IDX*.+">') !== -1) { + runResult.html5 = htmlResults; + runResult.title = "Result"; + } + this._runResolve?.(runResult); + }; +} + +/** + * Creates a new SAS 9 Session. + * @param c Instance denoting configuration parameters for this connection profile. + * @returns created COM session. + */ +export const getSession = (c: Partial): Session => { + const defaults = { + cfgname: "", + pythonpath: "python", + }; + + if (!sessionInstance) { + sessionInstance = new SASPYSession(); + } + sessionInstance.config = { ...defaults, ...c }; + return sessionInstance; +}; diff --git a/client/src/connection/saspy/types.ts b/client/src/connection/saspy/types.ts new file mode 100644 index 000000000..96db6d4b2 --- /dev/null +++ b/client/src/connection/saspy/types.ts @@ -0,0 +1,20 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BaseConfig } from ".."; + +export enum LineCodes { + ResultsFetchedCode = "--vscode-sas-extension-results-fetched--", + RunCancelledCode = "--vscode-sas-extension-run-cancelled--", + RunEndCode = "--vscode-sas-extension-submit-end--", + SessionCreatedCode = "--vscode-sas-extension-session-created--", + LogLineType = "--vscode-sas-extension-log-line-type--", + LogLineStarter = "--vscode-sas-extension-log-line-starter--", +} + +/** + * Configuration parameters for this connection provider + */ +export interface Config extends BaseConfig { + cfgname: string; + pythonpath: string; +} diff --git a/client/src/connection/saspy/util.ts b/client/src/connection/saspy/util.ts new file mode 100644 index 000000000..4a52c57a4 --- /dev/null +++ b/client/src/connection/saspy/util.ts @@ -0,0 +1,33 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + ColorThemeKind, + window, +} from "vscode"; + +import { getHtmlStyle } from "../../components/utils/settings"; + +// To change the html style of SASPy +export const saspyGetHtmlStyleValue = (): string => { + const htmlStyleSetting = getHtmlStyle(); + + switch (htmlStyleSetting) { + case "(auto)": + switch (window.activeColorTheme.kind) { + case ColorThemeKind.Light: + return "Illuminate"; + case ColorThemeKind.Dark: + return "Ignite"; + case ColorThemeKind.HighContrast: + return "HighContrast"; + case ColorThemeKind.HighContrastLight: + return "Illuminate"; + default: + return ""; + } + case "(server default)": + return ""; + default: + return htmlStyleSetting; + } +} diff --git a/website/docs/Configurations/Profiles/additional.md b/website/docs/Configurations/Profiles/additional.md index ae342f992..1d9bbcf1c 100644 --- a/website/docs/Configurations/Profiles/additional.md +++ b/website/docs/Configurations/Profiles/additional.md @@ -65,6 +65,20 @@ SAS system options can be set for each connection profile. Changes to the SAS sy } ``` +- SAS 9.4 (SASPy): + + ```json + { + "profiles": { + "sas9SASPY": { + "cfgname": "winlocal", + "sasOptions": ["NONEWS", "ECHOAUTO", "PAGESIZE=MAX"], + "ConnectionType": "saspy" + } + } + } + ``` + ## SAS Autoexec Settings For SAS Viya connection profiles, you can set up autoexec code that executes each time you start a new session. Changes to the autoexec code do not take effect until you close and restart your SAS session. The Autoexec option supports different modes for how to define the SAS lines that should run: diff --git a/website/docs/Configurations/Profiles/sas9py.md b/website/docs/Configurations/Profiles/sas9py.md new file mode 100644 index 000000000..5d192b773 --- /dev/null +++ b/website/docs/Configurations/Profiles/sas9py.md @@ -0,0 +1,20 @@ +--- +sidebar_position: 5 +--- + +# SAS 9.4 (SASPy) Connection Profile + +To use SAS 9.4 (SASPy) connection type, you need to have Python and [SASPy](https://sassoftware.github.io/saspy) installed locally (the machine VS Code is installed on) or remotely (if you are using VScode remote development). SASPy fully supports connection methods of STDIO (Unix only), SSH, IOM (Remote and Local) and HTTP, and partially supports connection method of COM. You can visit the following [link](https://sassoftware.github.io/saspy) to find out how to install SASPy and how to configure it. + + +## Profile Anatomy + +A SAS 9.4 (SASPy) connection profile includes the following parameters: + +`"connectionType": "saspy"` + +| Name | Description | Additional Notes | +| -------------- | -------------- | ----------------------------------------------------------------------------- | +| `pythonpath` | Path to python | Defaults to `python` | +| `cfgname` | sascfg name | Visit [link](sassoftware.github.io/saspy/configuration.html) for introduction | +