├── __init__.py ├── MANIFEST.in ├── saspy ├── doc │ ├── source │ │ ├── _static │ │ │ └── .gitkeep │ │ ├── index.rst │ │ ├── adding-procedures.rst │ │ ├── api.rst │ │ ├── install.rst │ │ ├── limitations.rst │ │ ├── license.rst │ │ └── conf.py │ ├── make.bat │ └── Makefile ├── version.py ├── java │ ├── saspyiom.jar │ ├── pyiom │ │ ├── cancel.class │ │ ├── saspy2j.class │ │ └── cancel.java │ ├── thirdparty │ │ ├── pfl-tf.jar │ │ ├── pfl-basic.jar │ │ ├── glassfish-corba-orb.jar │ │ ├── glassfish-corba-omgapi.jar │ │ ├── glassfish-corba-internal-api.jar │ │ └── NOTICE.md │ └── iomclient │ │ ├── sas.core.jar │ │ ├── log4j-api-2.12.4.jar │ │ ├── log4j-api-2.17.1.jar │ │ ├── log4j-core-2.12.4.jar │ │ ├── log4j-core-2.17.1.jar │ │ ├── sas.security.sspi.jar │ │ ├── log4j-1.2-api-2.12.4.jar │ │ ├── log4j-1.2-api-2.17.1.jar │ │ └── sas.svc.connection.jar ├── scripts │ ├── readme │ └── run_sas.py ├── tests │ ├── readme │ ├── test_symget.py │ ├── util.py │ ├── test_sasmagic.py │ ├── test_sasutil.py │ ├── test_sasml.py │ ├── test_sasViyaML.py │ ├── test_sasexceptions.py │ ├── test_io.py │ ├── test_sasconfig.py │ ├── test_sassession.py │ └── test_sastabulate.py ├── SASLogLexer.py ├── __init__.py ├── autocfg.py ├── libname_gen.sas ├── sasexceptions.py ├── sasresults.py ├── sasdecorator.py ├── sas_magic.py ├── sasqc.py ├── sascfg.py └── sastabulate.py ├── pyproject.toml ├── .gitignore ├── SECURITY.md ├── .github ├── workflows │ ├── OoO.notyml │ └── codeql.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── setup.py ├── ContributorAgreement.txt ├── README.md └── CONTRIBUTING.md /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /saspy/doc/source/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /saspy/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '5.104.0' 2 | -------------------------------------------------------------------------------- /saspy/java/saspyiom.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/saspyiom.jar -------------------------------------------------------------------------------- /saspy/scripts/readme: -------------------------------------------------------------------------------- 1 | This directory is for external scripts that can be used for running SASPy. 2 | -------------------------------------------------------------------------------- /saspy/java/pyiom/cancel.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/pyiom/cancel.class -------------------------------------------------------------------------------- /saspy/java/pyiom/saspy2j.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/pyiom/saspy2j.class -------------------------------------------------------------------------------- /saspy/java/thirdparty/pfl-tf.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/thirdparty/pfl-tf.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/sas.core.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/sas.core.jar -------------------------------------------------------------------------------- /saspy/java/thirdparty/pfl-basic.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/thirdparty/pfl-basic.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-api-2.12.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-api-2.12.4.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-api-2.17.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-api-2.17.1.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-core-2.12.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-core-2.12.4.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-core-2.17.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-core-2.17.1.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/sas.security.sspi.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/sas.security.sspi.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-1.2-api-2.12.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-1.2-api-2.12.4.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/log4j-1.2-api-2.17.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/log4j-1.2-api-2.17.1.jar -------------------------------------------------------------------------------- /saspy/java/iomclient/sas.svc.connection.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/iomclient/sas.svc.connection.jar -------------------------------------------------------------------------------- /saspy/java/thirdparty/glassfish-corba-orb.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/thirdparty/glassfish-corba-orb.jar -------------------------------------------------------------------------------- /saspy/java/thirdparty/glassfish-corba-omgapi.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/thirdparty/glassfish-corba-omgapi.jar -------------------------------------------------------------------------------- /saspy/java/thirdparty/glassfish-corba-internal-api.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassoftware/saspy/HEAD/saspy/java/thirdparty/glassfish-corba-internal-api.jar -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Minimum requirements for the build system to execute. 3 | requires = ["setuptools", "wheel"] # PEP 508 specifications. 4 | build-backend = "setuptools.build_meta" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bak 2 | *.sas7bcat 3 | .ipynb_checkpoints/ 4 | *.sas7bitm 5 | *.pyc 6 | SAS_kernel.egg-info/ 7 | saspy.egg-info/ 8 | *.rej 9 | sascfg_personal.py 10 | 11 | # IntelliJ project files 12 | .idea 13 | *.iml 14 | out 15 | gen 16 | .idea/ 17 | build/ 18 | dist/ 19 | misc/ 20 | saspy/__pycache__/ 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Any security issues are addressed as found and the current production release will have all fixes. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Simply report any vulnerability via an issue: https://github.com/sassoftware/saspy/issues 10 | -------------------------------------------------------------------------------- /.github/workflows/OoO.notyml: -------------------------------------------------------------------------------- 1 | name: Auto message for PR's and Issues 2 | on: [pull_request, issues] 3 | jobs: 4 | build: 5 | name: Out of Office 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: jd-0001/gh-action-comment-on-new-issue@v2.0.3 9 | with: 10 | message: "Hey, I'm OOO till Jan 29th. I will reply to this then! Sorry for the delay. Thanks, Tom" 11 | -------------------------------------------------------------------------------- /saspy/tests/readme: -------------------------------------------------------------------------------- 1 | The tests in this directory can be run by changing to the saspy_pip directory (above saspy) and 2 | running the following command. There may be other ways to run these, but this works so far. You 3 | can specify specific tests on this command instead of the discovery, like in the second example. 4 | Specs for unittest: https://docs.python.org/3.5/library/unittest.html 5 | 6 | python3 -m unittest discover -s saspy/tests 7 | 8 | python3 -m unittest saspy/tests/test_test1.py 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm would like SASPy to be able to do [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /saspy/tests/test_symget.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import saspy 3 | from saspy.sasexceptions import (SASIOConnectionTerminated, 4 | SASHTTPsubmissionError 5 | ) 6 | 7 | class TestSASViyaML(unittest.TestCase): 8 | 9 | def testSymgetOnDeadSession(self): 10 | sas = saspy.SASsession() 11 | 12 | try: 13 | ll = sas.submit("endsas;") 14 | 15 | sas.SYSINFO() 16 | except ValueError as e: 17 | self.assertFalse(str(e) != "Failed to execute symexist. Your session may have prematurely terminated.") 18 | except (SASIOConnectionTerminated, SASHTTPsubmissionError) as e: 19 | print("Terminated as expected") 20 | 21 | if __name__ == '__main__': 22 | unittest.main() 23 | -------------------------------------------------------------------------------- /saspy/java/pyiom/cancel.java: -------------------------------------------------------------------------------- 1 | package pyiom; 2 | import java.io.IOException; 3 | import java.io.InputStreamReader; 4 | import java.net.*; 5 | import com.sas.iom.SAS.ILanguageService; 6 | 7 | public class cancel implements Runnable 8 | { 9 | private Socket sc; 10 | private ILanguageService lang = null; 11 | 12 | 13 | public cancel(Socket sc, ILanguageService lang) 14 | { 15 | this.sc = sc; 16 | this.lang = lang; 17 | } 18 | 19 | 20 | public void run() 21 | { 22 | InputStreamReader inc; 23 | int op; 24 | 25 | try 26 | { 27 | inc = new InputStreamReader(this.sc.getInputStream()); 28 | } 29 | catch (IOException e2) 30 | { 31 | return; 32 | } 33 | 34 | while(true) 35 | { 36 | try 37 | { 38 | op = inc.read(); 39 | if (op == 'C') 40 | try 41 | { 42 | lang.Async(true); 43 | lang.Cancel(); 44 | lang.Async(false); 45 | } 46 | catch (org.omg.CORBA.OBJECT_NOT_EXIST e1) 47 | { 48 | return; 49 | } 50 | else 51 | return; 52 | } 53 | catch (IOException e) 54 | { 55 | return; 56 | } 57 | catch (Exception e) 58 | { 59 | return; 60 | } 61 | catch (Error e) 62 | { 63 | return; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /saspy/tests/util.py: -------------------------------------------------------------------------------- 1 | import saspy 2 | 3 | class Utilities: 4 | def __init__(self, session = None): 5 | if session is None: 6 | self.sas = saspy.SASsession(cfgname=saspy.SASsession().sascfg.SAScfg.SAS_config_names[0]) 7 | else: 8 | self.sas = session 9 | 10 | def procExists(self, plist: list) -> bool: 11 | """ 12 | Checks to see if the given list of procs exist on the instance. 13 | :param plist: A list of procs. 14 | :return: True if all procs are found, False otherwise 15 | """ 16 | assert isinstance(plist, list) 17 | for proc in plist: 18 | res = self.sas.submit("proc %s; run;" % proc) 19 | log = res['LOG'].splitlines() 20 | for line in log: 21 | if line == 'ERROR: Procedure %s not found.' % proc.upper(): 22 | return False 23 | return True 24 | 25 | def procLicensed(self, plist): 26 | """ 27 | Checks to see if the given list of procs are licensed on the instance. 28 | :param plist: A list of procs. 29 | :return: True if all procs are licensed, false otherwise. 30 | """ 31 | assert isinstance(plist, list) 32 | for proc in plist: 33 | res = self.sas.submit("proc %s; run;" % proc) 34 | log = res['LOG'].splitlines() 35 | for line in log: 36 | if line == 'ERROR: Bad product ID for procedure %s.' % proc.upper(): 37 | return False 38 | return True 39 | 40 | def procFound(self, plist): 41 | """ 42 | Determines if the procs listed are usable on the available sas instance. 43 | """ 44 | return self.procExists(plist) and self.procLicensed(plist) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of the bug. Then please show the code and run it providing all of the output (don't just cut snippets out of the output). Also, please execute the following and provide all of the output; you only need the print() statements if not running in interactive mode, otherwise they display their info automatically just by submitting the objects/methods: 12 | ``` 13 | import saspy 14 | print(saspy) 15 | print(saspy.SAScfg) 16 | print(saspy.list_configs()) 17 | # if you can establish a SASSession, do so then print it too 18 | # if you can't then provide version information for SASPy with your config info below 19 | sas = saspy.SASsession() 20 | print(sas) 21 | ``` 22 | 23 | **To Reproduce** 24 | Steps to reproduce the behavior: 25 | 1. Submit the following code '...' 26 | 2. See that the results are '...' instead of '...' 27 | 3. Or, see error '...' instead of it working 28 | 29 | **Expected behavior** 30 | A clear and concise description of what you expected to happen if it's not obvious. 31 | 32 | **Screenshots** 33 | If applicable, add screenshots to help explain your problem. But, paste code if it's something I will need to run; I can't cut-n-paste from pictures, and don't like to have to transcribe code from a picture :) 34 | 35 | **Configuration information. Please provide the configuration you're trying to use (your sascfg_personal.py file) as well as what client system you are on and what kind of SAS deployment you're trying to connect to and where it's deployed (local to the client or remote). ** 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright SAS Institute 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the License); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | exec(open('./saspy/version.py').read()) 19 | 20 | try: 21 | from setuptools import setup 22 | except ImportError: 23 | from distutils.core import setup 24 | 25 | with open('README.md') as f: 26 | readme = f.read() 27 | 28 | setup(name='saspy', 29 | version = __version__, 30 | description = 'A Python interface to SAS', 31 | long_description = readme, 32 | author = 'Tom Weber', 33 | author_email = 'Tom.Weber@sas.com', 34 | url = 'https://github.com/sassoftware/saspy', 35 | packages = ['saspy'], 36 | cmdclass = {}, 37 | package_data = {'': ['*.js', '*.md', '*.yaml', '*.css', '*.rst'], 'saspy': ['*.sas', 'scripts/*.*', 'java/*.*', 'java/pyiom/*.*', 'java/iomclient/*.*', 'java/thirdparty/*.*']}, 38 | install_requires = [], 39 | extras_require = {'iomcom': ['pypiwin32'], 'colorLOG': ['pygments'], 'parquet':['pyarrow'], 'pandas':['pandas']}, 40 | classifiers = [ 41 | 'Programming Language :: Python :: 3', 42 | "Programming Language :: Python :: 3.4", 43 | "Programming Language :: Python :: 3.5", 44 | "Topic :: System :: Shells", 45 | 'License :: OSI Approved :: Apache Software License' 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /saspy/doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Copyright SAS Institute 2 | 3 | :tocdepth: 5 4 | 5 | .. image:: https://user-images.githubusercontent.com/17710182/171252212-4af121a6-72d9-4234-b6cf-2a0d31eb8bf7.png 6 | 7 | ===== 8 | SASPy 9 | ===== 10 | 11 | **Date**: |today| **Version**: |version| 12 | 13 | **Source Repository:** ``_ 14 | 15 | **Issues and Ideas:** ``_ 16 | 17 | **Example Repo:** ``_ 18 | 19 | 20 | What is this? 21 | ============= 22 | 23 | This module provides Python APIs to the SAS system. You can start a 24 | SAS session and run analytics from Python through a combination of 25 | object-oriented methods or explicit SAS code submission. You can move 26 | data between SAS data sets and Pandas dataframes and exchange values between 27 | python variables and SAS macro variables. 28 | 29 | The APIs provide interfaces for the following: 30 | 31 | * Start a SAS session on the same host as Python or a remote host. 32 | * Exchange data between SAS data sets and Pandas data frames. 33 | * Use familiar methods such as ``describe()`` and ``head()`` to work with data. 34 | 35 | Additional functionality such as machine learning, econometrics, and quality 36 | control are organized in Python classes. 37 | 38 | See :doc:`getting-started` for programming examples. 39 | 40 | 41 | Dependencies 42 | ============ 43 | 44 | - Python3.4 or higher. 45 | - SAS 9.4 or higher. SAS Viya 3.1 or higher is also supported. 46 | - To use the integrated object method (IOM) access method (one of four connection methods) 47 | requires Java 7 or higher on the client. 48 | 49 | You can connect to SAS on any platform that is supported for the specified SAS 50 | releases. 51 | 52 | 53 | .. toctree:: 54 | :maxdepth: 5 55 | :hidden: 56 | 57 | install 58 | configuration 59 | getting-started 60 | api 61 | advanced-topics 62 | adding-procedures 63 | limitations 64 | troubleshooting 65 | license 66 | 67 | 68 | Index 69 | ===== 70 | 71 | * :ref:`genindex` 72 | 73 | -------------------------------------------------------------------------------- /saspy/SASLogLexer.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | from pygments.lexer import RegexLexer 17 | import pygments.token 18 | from pygments.style import Style 19 | 20 | 21 | class SASLogStyle(Style): 22 | default_style = "" 23 | styles = { 24 | pygments.token.Comment: '#0000FF', 25 | pygments.token.Keyword: 'bold #ff0000', 26 | pygments.token.Name: '#008000', 27 | pygments.token.String: '#111' 28 | } 29 | 30 | 31 | class SASLogLexer(RegexLexer): 32 | __all__ = ['SASLogLexer'] 33 | name = 'Lexer to Color SAS Logs equivalent to DMS' 34 | tokens = { 35 | 'root': [ 36 | (r'^\d+.*((\n|\t|\n\t)[ ]([^WEN].*)(.*))*', pygments.token.String), 37 | (r'^NOTE.*((\n|\t|\n\t)[ ]([^WEN].*)(.*))*', pygments.token.Comment.Multiline, 'note'), 38 | (r'^ERROR.*((\n|\t|\n\t)[ ]([^WEN].*)(.*))*', pygments.token.Keyword.Multiline, 'error'), 39 | (r'^WARNING.*((\n|\t|\n\t)[ ]([^WEN].*)(.*))*', pygments.token.Name.Multiline, 'warning'), 40 | (r'\s', pygments.token.Text) 41 | ], 42 | 'error': [ 43 | (r'^\s+.*$', pygments.token.Keyword.Multiline), 44 | (r'^\S+.*$', pygments.token.Keyword.Multiline, '#pop') 45 | ], 46 | 'note': [ 47 | (r'^\s+.*$', pygments.token.Comment.Multiline), 48 | (r'^\S+.*$', pygments.token.Comment.Multiline, '#pop') 49 | ], 50 | 'warning': [ 51 | (r'^\s+.*$', pygments.token.Name.Multiline), 52 | (r'^\S+.*$', pygments.token.Name.Multiline, '#pop') 53 | ] 54 | 55 | } 56 | -------------------------------------------------------------------------------- /saspy/java/thirdparty/NOTICE.md: -------------------------------------------------------------------------------- 1 | # Notices for Eclipse ORB 2 | 3 | This content is produced and maintained by the Eclipse ORB project. 4 | 5 | * Project home: https://projects.eclipse.org/projects/ee4j.orb 6 | 7 | ## Trademarks 8 | 9 | Eclipse ORB is a trademark of the Eclipse Foundation. 10 | 11 | ## Copyright 12 | 13 | All content is the property of the respective authors or their employers. For 14 | more information regarding authorship of content, please consult the listed 15 | source code repository logs. 16 | 17 | ## Declared Project Licenses 18 | 19 | This program and the accompanying materials are made available under the terms 20 | of the Eclipse Public License v. 2.0 which is available at 21 | http://www.eclipse.org/legal/epl-2.0, or the Eclipse Distribution License v. 1.0 22 | which is available at http://www.eclipse.org/org/documents/edl-v10.php. This 23 | Source Code may also be made available under the following Secondary Licenses 24 | when the conditions for such availability set forth in the Eclipse Public 25 | License v. 2.0 are satisfied: GNU General Public License, version 2 with the GNU 26 | Classpath Exception which is available at 27 | https://www.gnu.org/software/classpath/license.html. 28 | 29 | SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause OR GPL-2.0 WITH 30 | Classpath-exception-2.0 31 | 32 | ## Source Code 33 | 34 | The project maintains the following source code repositories: 35 | 36 | * https://github.com/eclipse-ee4j/orb 37 | * https://github.com/eclipse-ee4j/orb-gmbal 38 | * https://github.com/eclipse-ee4j/orb-gmbal-commons 39 | * https://github.com/eclipse-ee4j/orb-gmbal-pfl 40 | 41 | ## Third-party Content 42 | 43 | This project leverages the following third party content. 44 | 45 | ASM (6.0) 46 | 47 | * License: BSD-3-Clause 48 | 49 | hamcrest-all (1.3) 50 | 51 | * License: New BSD License 52 | 53 | JUnit (4.12) 54 | 55 | * License: Eclipse Public License 56 | 57 | OSGi Service Platform Core Companion Code (6.0) 58 | 59 | * License: Apache License, 2.0 60 | 61 | ## Cryptography 62 | 63 | Content may contain encryption software. The country in which you are currently 64 | may have restrictions on the import, possession, and use, and/or re-export to 65 | another country, of encryption software. BEFORE using any encryption software, 66 | please check the country's laws, regulations and policies concerning the import, 67 | possession, or use, and re-export of encryption software, to see if this is 68 | permitted. 69 | 70 | -------------------------------------------------------------------------------- /saspy/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | from __future__ import absolute_import 19 | from __future__ import division 20 | from __future__ import print_function 21 | from saspy.version import __version__ 22 | from saspy.sasbase import SASsession, SASconfig, list_configs 23 | from saspy.sasdata import SASdata 24 | from saspy.sasexceptions import SASIONotSupportedError, SASConfigNotFoundError, SASConfigNotValidError 25 | from saspy.sasproccommons import SASProcCommons 26 | from saspy.sastabulate import Tabulate 27 | from saspy.sasresults import SASresults 28 | 29 | import os, sys 30 | 31 | import logging 32 | logger = logging.getLogger(__name__) 33 | logger.addHandler(logging.StreamHandler(sys.stdout)) 34 | logger.setLevel(logging.INFO) 35 | logger.propagate=False 36 | 37 | def _isnotebook(): 38 | try: 39 | shell = get_ipython().__class__.__name__ 40 | if shell == 'ZMQInteractiveShell': 41 | return True # Jupyter notebook or qtconsole 42 | elif shell == 'TerminalInteractiveShell': 43 | return False # Terminal running IPython 44 | else: 45 | return False # Other type (?) 46 | except NameError: 47 | return False # Probably standard Python interpreter 48 | 49 | if _isnotebook(): 50 | from saspy.sas_magic import SASMagic 51 | get_ipython().register_magics(SASMagic) 52 | 53 | def _find_cfg(): 54 | sp = [] 55 | sp[:] = sys.path 56 | sp[0] = os.path.abspath(sp[0]) 57 | sp.insert(1, os.path.expanduser('~/.config/saspy')) 58 | sp.insert(0, __file__.rsplit(os.sep+'__init__.py')[0]) 59 | 60 | cfg = 'Not found' 61 | 62 | for dir in sp: 63 | f1 = dir+os.sep+'sascfg_personal.py' 64 | if os.path.isfile(f1): 65 | cfg = f1 66 | break 67 | 68 | if cfg == 'Not found': 69 | f1 =__file__.rsplit('__init__.py')[0]+'sascfg.py' 70 | if os.path.isfile(f1): 71 | cfg = f1 72 | 73 | return cfg 74 | 75 | SAScfg = _find_cfg() 76 | -------------------------------------------------------------------------------- /saspy/tests/test_sasmagic.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock, PropertyMock 3 | from saspy.sas_magic import SASMagic 4 | from saspy import SASsession 5 | 6 | class TestSASMagic(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | pass 10 | 11 | @classmethod 12 | def tearDownClass(cls): 13 | pass 14 | 15 | def setUp(self): 16 | pass 17 | 18 | def tearDown(self): 19 | pass 20 | 21 | def test_sas_magic_existing_session(self): 22 | # mocked with existing session (mva) 23 | # shape: {shell: {user_ns: []}, mva: {submit: func()}} 24 | mock = MagicMock() 25 | mva = MagicMock() 26 | shell = MagicMock() 27 | submit = MagicMock(return_value=dict(LOG='log', LST='list')) 28 | 29 | type(mva).submit = PropertyMock(return_value=submit) 30 | type(mock).mva = PropertyMock(return_value=mva) 31 | type(shell).user_ns = PropertyMock(return_value=[]) 32 | type(mock).shell = PropertyMock(return_value=shell) 33 | 34 | test_cell = 'proc print data=work.test; run;' 35 | SASMagic.SAS(mock, '', test_cell) 36 | 37 | self.assertEqual(submit.call_count, 1, msg=u'sas code was not submitted') 38 | submit.assert_called_with(test_cell) 39 | 40 | def test_sas_magic_supplied_session(self): 41 | # mocked with session option existing in namespace 42 | # shape: {shell: {user_ns: [existing_session]}, mva: None} 43 | mock = MagicMock() 44 | shell = MagicMock() 45 | 46 | mva = MagicMock(spec=SASsession) 47 | submit = MagicMock(return_value=dict(LOG='log', LST='list')) 48 | type(mva).submit = PropertyMock(return_value=submit) 49 | 50 | # existing_session (mva) is in user namespace 51 | user_ns = dict(existing_session=mva) 52 | type(shell).user_ns = PropertyMock(return_value=user_ns) 53 | type(mock).shell = PropertyMock(return_value=shell) 54 | # no preset mva 55 | type(mock).mva = PropertyMock(return_value=None) 56 | 57 | test_cell = 'proc datasets; run;' 58 | SASMagic.SAS(mock, 'existing_session', test_cell) 59 | 60 | self.assertEqual(submit.called, True, msg=u'sas code was not submitted') 61 | submit.assert_any_call(test_cell) 62 | 63 | # now test with non-existing session; expect error 64 | submit.reset_mock() 65 | output = SASMagic.SAS(mock, 'bad_session', test_cell) 66 | self.assertEqual(submit.called, False) 67 | self.assertIn('Invalid SAS Session', output) 68 | 69 | -------------------------------------------------------------------------------- /ContributorAgreement.txt: -------------------------------------------------------------------------------- 1 | Contributor Agreement 2 | 3 | Version 1.1 4 | 5 | Contributions to this software are accepted only when they are 6 | properly accompanied by a Contributor Agreement. The Contributor 7 | Agreement for this software is the Developer's Certificate of Origin 8 | 1.1 (DCO) as provided with and required for accepting contributions 9 | to the Linux kernel. 10 | 11 | In each contribution proposed to be included in this software, the 12 | developer must include a "sign-off" that denotes consent to the 13 | terms of the Developer's Certificate of Origin. The sign-off is 14 | a line of text in the description that accompanies the change, 15 | certifying that you have the right to provide the contribution 16 | to be included. For changes provided in source code control (for 17 | example, via a Git pull request) the sign-off must be included in 18 | the commit message in source code control. For changes provided 19 | in email or issue tracking, the sign-off must be included in the 20 | email or the issue, and the sign-off will be incorporated into the 21 | permanent commit message if the contribution is accepted into the 22 | official source code. 23 | 24 | If you can certify the below: 25 | 26 | Developer's Certificate of Origin 1.1 27 | 28 | By making a contribution to this project, I certify that: 29 | 30 | (a) The contribution was created in whole or in part by me and I 31 | have the right to submit it under the open source license 32 | indicated in the file; or 33 | 34 | (b) The contribution is based upon previous work that, to the best 35 | of my knowledge, is covered under an appropriate open source 36 | license and I have the right under that license to submit that 37 | work with modifications, whether created in whole or in part 38 | by me, under the same open source license (unless I am 39 | permitted to submit under a different license), as indicated 40 | in the file; or 41 | 42 | (c) The contribution was provided directly to me by some other 43 | person who certified (a), (b) or (c) and I have not modified 44 | it. 45 | 46 | (d) I understand and agree that this project and the contribution 47 | are public and that a record of the contribution (including all 48 | personal information I submit with it, including my sign-off) is 49 | maintained indefinitely and may be redistributed consistent with 50 | this project or the open source license(s) involved. 51 | 52 | then you just add a line saying 53 | 54 | Signed-off-by: Random J Developer 55 | 56 | using your real name (sorry, no pseudonyms or anonymous contributions.) 57 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '23 20 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | # - name: Autobuild 59 | # uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![224493-SASPy-logo-OL-01](https://user-images.githubusercontent.com/17710182/171252212-4af121a6-72d9-4234-b6cf-2a0d31eb8bf7.png) 2 | 3 | # Overview 4 | This package provides interfaces between Python and SAS. This package enables a Python developer to create mixed Python/SAS workflows to leverage the 5 | powers of both SAS and Python, by connecting a Python process to any of a variety of SAS deployments, where it will run SAS code. The SAS code is 6 | generated by the SASPy object and methods or explicitly user written. Results from SAS are returned as text, HTML5 documents (via SAS ODS), or as 7 | Pandas Data Frames. This package supports running analytics and returning the resulting graphics and result data to the Python process. It can convert 8 | data representations between SAS Data Sets and Pandas Data Frames. 9 | 10 | This package has multiple access methods which allow it to connect to local or remote Linux SAS, IOM SAS on Windows, Linux (Including Grid Manager), 11 | or MVS, and local PC SAS. It can run within various Notebooks platforms, or IDE's/UI's or in interactive line mode Python or in Python batch scripts. 12 | 13 | It is expected that the user community can, and will, contribute enhancements. 14 | 15 | # Badges 16 | [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/6716/badge)](https://bestpractices.coreinfrastructure.org/projects/6716) 17 | 18 | # Prerequisites 19 | - Python3.x or above 20 | - SAS 9.4 or above 21 | - SAS Viya 3 or above 22 | 23 | # Connecting offering 24 | - Linux SAS: local or remote, including Grid Manager 25 | - Windows SAS: local or remote 26 | - MVS SAS: remote 27 | - Jupyter, Databricks and/or Zeppelin Notebooks 28 | - Interactive Line mode, Python IDE's or other UI's 29 | - Batch Python scripts 30 | 31 | # Getting Started 32 | 33 | All of the documentation, including installalation and configuration information can be found at 34 | [sassoftware.github.io/saspy](https://sassoftware.github.io/saspy/). 35 | 36 | Also, example Notebooks and use cases can be found at 37 | [sassoftware/saspy-examples](https://github.com/sassoftware/saspy-examples/). 38 | 39 | # Contributing 40 | The [Contributing](https://github.com/sassoftware/saspy/blob/main/CONTRIBUTING.md) file explains the rules and conventions to follow while 41 | Contributing to this project. It also contains the **Contributor Agreement** instructions. 42 | 43 | # Licensing 44 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of 45 | the License at [LICENSE.txt](https://github.com/sassoftware/saspy/blob/main/LICENSE.md) 46 | 47 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES 48 | OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 49 | 50 | # Additional Resources 51 | - [Python Website](http://www.python.org/) 52 | - [SASPy Documentation](https://sassoftware.github.io/saspy/). 53 | - [SASPy Examples](https://github.com/sassoftware/saspy-examples) 54 | -------------------------------------------------------------------------------- /saspy/doc/source/adding-procedures.rst: -------------------------------------------------------------------------------- 1 | 2 | .. Copyright SAS Institute 3 | 4 | 5 | ======================== 6 | Contributing new methods 7 | ======================== 8 | 9 | Overview 10 | ======== 11 | This module is broken into product areas that largely follow the SAS product areas. 12 | There are many many procedures, which translate to object methods, that are not 13 | currently included in the package. The aim of this document is to outline the 14 | steps you can take to add additional methods (procedures). 15 | 16 | A copy of the process is included inline of each product file (sasstat.py, 17 | sasets.py, sasqc.py, and so on). The project maintainers expect that a new 18 | contribution should take less than 30 minutes the first time and less than 15 19 | minutes for subsequent methods. 20 | 21 | Your contribution and feedback is greatly appreciated! 22 | 23 | Process 24 | ~~~~~~~ 25 | 26 | To add a new procedure follow these steps: 27 | 28 | #. Identify the product of the procedure (SAS/STAT, SAS/ETS, SAS Enterprise Miner, etc). 29 | #. Find the corresponding file in saspy sasstat.py, sasets.py, sasml.py, etc. 30 | #. Create a set of valid statements. Here is an example: 31 | 32 | .. code-block:: ipython3 33 | 34 | lset = {'ARIMA', 'BY', 'ID', 'MACURVES', 'MONTHLY', 'OUTPUT', 'VAR'} 35 | 36 | The case and order of the items will be formated. 37 | #. Call the `doc_convert` method to generate then method call as well as the docstring markup 38 | 39 | .. code-block:: ipython3 40 | 41 | import saspy 42 | print(saspy.sasdecorator.procDecorator.doc_convert(lset, 'x11')['method_stmt']) 43 | print(saspy.sasdecorator.procDecorator.doc_convert(lset, 'x11')['markup_stmt']) 44 | 45 | 46 | The `doc_convert` method takes two arguments: a list of the valid statements and the proc name. It returns a dictionary with two keys, method_stmt and markup_stmt. These outputs can be copied into the appropriate product file. 47 | 48 | #. Add the proc decorator to the new method. 49 | The decorator should be on the line above the method declaration. 50 | The decorator takes one argument, the required statements for the procedure. If there are no required statements than an empty list `{}` should be passed. 51 | Here are two examples one with no required arguments: 52 | 53 | .. code-block:: ipython3 54 | 55 | @procDecorator.proc_decorator({}) 56 | def esm(self, data: ['SASdata', str] = None, ... 57 | 58 | And one with required arguments: 59 | 60 | .. code-block:: ipython3 61 | 62 | @procDecorator.proc_decorator({'model'}) 63 | def mixed(self, data: ['SASdata', str] = None, ... 64 | 65 | #. Add a link to the SAS documentation plus any additional details will be helpful to users 66 | 67 | #. Write at least one test to exercise the procedures and include it in the 68 | appropriate testing file. 69 | 70 | If you have questions, please open an issue in the GitHub repo and the maintainers will be happy to help. 71 | 72 | .. Example 73 | .. ======= 74 | .. Following the procedure above, I will add a method for the ADAPTIVEREG procedure. 75 | .. I assume you have forked this repository and it is in your home directory. 76 | 77 | .. video of forking the repository 78 | 79 | .. video of adding the procedure 80 | 81 | .. video of writing tests 82 | 83 | .. video of creating the pull request 84 | 85 | -------------------------------------------------------------------------------- /saspy/tests/test_sasutil.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import saspy 4 | from saspy.tests.util import Utilities 5 | 6 | 7 | class TestSASutil(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.sas = saspy.SASsession() 11 | util = Utilities(cls.sas) 12 | procNeeded = ['hpimpute', 'hpbin', 'hpsample', 'univariate'] 13 | if not util.procFound(procNeeded): 14 | cls.skipTest("Not all of these procedures were found: %s" % str(procNeeded)) 15 | 16 | @classmethod 17 | def tearDownClass(cls): 18 | if cls.sas: 19 | cls.sas._endsas() 20 | 21 | def test_hpimputeSmoke(self): 22 | util = self.sas.sasutil() 23 | d = self.sas.sasdata("hmeq", 'sampsio') 24 | out1 = util.hpimpute(data=d, input = 'mortdue value clage debtinc', impute= 'mortdue / value = 70000' ) 25 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpimpute had errors in the log") 26 | 27 | def test_hpimputeSmoke2(self): 28 | util = self.sas.sasutil() 29 | d = self.sas.sasdata("hmeq", 'sampsio') 30 | out1 = util.hpimpute(data=d, input='mortdue value clage debtinc', impute='mortdue / value = 70000', out='foo') 31 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpimpute had errors in the log") 32 | 33 | def test_hpbinSmoke(self): 34 | util = self.sas.sasutil() 35 | cars = self.sas.sasdata("cars", 'sashelp') 36 | out1 = util.hpbin(data=cars, output=True, input='msrp') 37 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpbin had errors in the log") 38 | 39 | def test_hpbinSmoke2(self): 40 | util = self.sas.sasutil() 41 | cars = self.sas.sasdata("cars", 'sashelp') 42 | out1 = util.hpbin(data=cars, out=True, input='msrp') 43 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpbin had errors in the log") 44 | 45 | def test_hpbinSmoke3(self): 46 | util = self.sas.sasutil() 47 | cars = self.sas.sasdata("cars", 'sashelp') 48 | out1 = util.hpbin(data=cars, output=True, input='msrp') 49 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpbin had errors in the log") 50 | 51 | def test_hpsampleSmoke(self): 52 | util = self.sas.sasutil() 53 | d = self.sas.sasdata("hmeq", 'sampsio') 54 | out1 = util.hpsample(data=d, output=True, cls='job reason', var='loan value delinq derog', 55 | procopts='samppct=50') 56 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpsample had errors in the log") 57 | 58 | def test_hpsampleSmoke2(self): 59 | util = self.sas.sasutil() 60 | d = self.sas.sasdata("hmeq", 'sampsio') 61 | out1 = util.hpsample(data=d, out=True, cls='job reason', var='loan value delinq derog', 62 | procopts='samppct=50') 63 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"hpsample had errors in the log") 64 | 65 | def test_univariateSmoke(self): 66 | util = self.sas.sasutil() 67 | d = self.sas.sasdata("hmeq", 'sampsio') 68 | out1 = util.univariate(data=d, var='loan value delinq derog', output=True) 69 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"univariate had errors in the log") 70 | 71 | def test_univariateSmoke2(self): 72 | util = self.sas.sasutil() 73 | d = self.sas.sasdata("hmeq", 'sampsio') 74 | out1 = util.univariate(data=d, var='loan value delinq derog', output=False) 75 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"univariate had errors in the log") 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /saspy/scripts/run_sas.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Author: Damjan Krstajic 4 | 5 | import argparse 6 | import saspy 7 | import os.path 8 | import sys 9 | 10 | # Usage: 11 | # ./run_sas.py -s example_1.sas 12 | # ./run_sas.py -s example_1.sas example_2.sas 13 | # ./run_sas.py -s example_1.sas -l out1.log -o out1.lst 14 | # ./run_sas.py -s example_1.sas -r TEXT 15 | # ./run_sas.py -s example_1.sas -r HTML 16 | # ./run_sas.py -s example_1.sas -r htMl -l out2.log -o out2.html 17 | # ./run_sas.py -s example_1.sas -r teXt -l out3.log -o out3.lst 18 | # ./run_sas.py -s /home/a/b/c/example_1.sas 19 | # ./run_sas.py -s example_1.sas -r text -l out4.log -o out4.lst -c ssh 20 | 21 | 22 | def main(): 23 | parser = argparse.ArgumentParser(description="It executes SAS code using saspy.") 24 | parser.add_argument('-s', '--sas_fname',nargs='+',help='Name of the SAS file to be executed.') 25 | parser.add_argument('-l', '--log_fname', help='Name of the output LOG file name. If not specified then it is the same as the sas_fname with .sas removed and .log added.') 26 | parser.add_argument('-o', '--lst_fname', help='Name of the output LST file. If not specified then it is the same as the sas_fname with .sas removed and .lst/.html added depending on the results format.') 27 | parser.add_argument('-r', '--results_format', help='Results format for sas_session.submit(). It may be either TEXT or HTML. If not specified it is TEXT by default. It is case incesensitive.') 28 | parser.add_argument('-c', '--cfgname', help='Name of the Configuration Definition to use for the SASsession. If not specified then just saspy.SASsession() is executed.') 29 | options = parser.parse_args() 30 | 31 | if options.sas_fname is None: 32 | parser.print_help() 33 | sys.exit(0) 34 | else: 35 | sas_fname = options.sas_fname 36 | for sfile in sas_fname: 37 | if(not os.path.isfile(sfile)): 38 | print("WARNING: SAS file (" + sfile + ") does not exist!") 39 | if(not os.path.isfile(sas_fname[-1])): 40 | print("\nThe last one of the SAS file(s) must be exist!\n") 41 | sys.exit(0) 42 | 43 | if options.log_fname is None: 44 | log_fname = os.path.splitext(sas_fname[-1])[0] + ".log" 45 | print("log_fname is " + log_fname ) 46 | else: 47 | log_fname = options.log_fname 48 | 49 | if options.results_format is None: 50 | results_format = 'TEXT' 51 | elif options.results_format.upper() in ('HTML','TEXT'): 52 | results_format = options.results_format 53 | else: 54 | parser.print_help() 55 | sys.exit(0) 56 | 57 | if options.lst_fname is None: 58 | if results_format == 'HTML': 59 | lst_fname = os.path.splitext(sas_fname[-1])[0] + ".html" 60 | else: 61 | lst_fname = os.path.splitext(sas_fname[-1])[0] + ".lst" 62 | print("lst_fname is " + lst_fname ) 63 | else: 64 | lst_fname = options.lst_fname 65 | 66 | sas_code_txt = "" 67 | for sfile in sas_fname: 68 | if(os.path.isfile(sfile)): 69 | sas_file = open(sfile,mode='r') 70 | sas_code_txt = sas_code_txt + "\n\n" + sas_file.read() 71 | sas_file.close() 72 | 73 | if options.cfgname is None: 74 | sas_session = saspy.SASsession() 75 | else: 76 | sas_session = saspy.SASsession(cfgname=options.cfgname) 77 | 78 | c = sas_session.submit(sas_code_txt,results=results_format) 79 | 80 | with open(log_fname, 'w') as f1: 81 | f1.write(c["LOG"]) 82 | 83 | with open(lst_fname, 'w') as f2: 84 | f2.write(c["LST"]) 85 | 86 | sas_session.endsas() 87 | 88 | if __name__ == '__main__': 89 | main() 90 | -------------------------------------------------------------------------------- /saspy/doc/source/api.rst: -------------------------------------------------------------------------------- 1 | 2 | .. Copyright SAS Institute 3 | 4 | 5 | ============= 6 | API Reference 7 | ============= 8 | 9 | .. automodule:: saspy 10 | :members: 11 | :undoc-members: 12 | :inherited-members: 13 | :show-inheritance: 14 | 15 | SAS Session Object 16 | ================== 17 | .. autoclass:: saspy.sasbase.SASsession 18 | :members: 19 | 20 | SAS Data Object 21 | =============== 22 | .. autoclass:: saspy.sasdata.SASdata 23 | :members: 24 | 25 | Procedure Syntax Statements 26 | =========================== 27 | .. autoclass:: saspy.sasproccommons.SASProcCommons 28 | :members: 29 | 30 | SAS Results 31 | =========== 32 | .. autoclass:: saspy.sasresults.SASresults 33 | :members: 34 | 35 | SAS Procedures 36 | ============== 37 | 38 | Utility 39 | ~~~~~~~ 40 | 41 | .. autoclass:: saspy.sasutil.SASutil 42 | :members: 43 | 44 | 45 | Machine Learning (SAS Enterprise Miner) 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | .. autoclass:: saspy.sasml.SASml 49 | :members: 50 | 51 | 52 | Statistics 53 | ~~~~~~~~~~ 54 | 55 | .. autoclass:: saspy.sasstat.SASstat 56 | :members: 57 | 58 | Econometic and Time Series 59 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | .. autoclass:: saspy.sasets.SASets 62 | :members: 63 | 64 | Quality Control 65 | ~~~~~~~~~~~~~~~ 66 | 67 | .. autoclass:: saspy.sasqc.SASqc 68 | :members: 69 | 70 | 71 | SAS Viya VDMML 72 | ~~~~~~~~~~~~~~~ 73 | 74 | .. autoclass:: saspy.sasViyaML.SASViyaML 75 | :members: 76 | 77 | 78 | SASPy Scripts 79 | ============= 80 | 81 | run_sas.py 82 | ~~~~~~~~~~ 83 | 84 | This user contributed script if for executing a .sas file and writing the LOG and LST to 85 | files, much like running a .sas file from SAS in batch mode. 86 | 87 | Usage: run_sas.py [-h] [-s SAS_FNAME] [-l LOG_FNAME] [-o LST_FNAME] 88 | [-r RESULTS_FORMAT] [-c CFGNAME] 89 | 90 | Required argument: 91 | -s SAS_FNAME, --sas_fname SAS_FNAME 92 | Name of the SAS file to be executed. 93 | Optional arguments: 94 | -h, --help show this help message and exit 95 | -l LOG_FNAME, --log_fname LOG_FNAME 96 | Name of the output LOG file name. If not specified 97 | then it is the same as the sas_fname with .sas removed 98 | and .log added. 99 | -o LST_FNAME, --lst_fname LST_FNAME 100 | Name of the output LST file. If not specified then it 101 | is the same as the sas_fname with .sas removed and 102 | .lst/.html added depending on the results format. 103 | -r RESULTS_FORMAT, --results_format RESULTS_FORMAT 104 | Results format for sas_session.submit(). It may be 105 | either TEXT or HTML. If not specified it is TEXT by 106 | default. It is case incesensitive. 107 | -c CFGNAME, --cfgname CFGNAME 108 | Name of the Configuration Definition to use for the 109 | SASsession. If not specified then just 110 | saspy.SASsession() is executed. 111 | 112 | Examples: 113 | 114 | .. code-block:: ipython3 115 | 116 | ./run_sas.py -s example_1.sas 117 | ./run_sas.py -s example_1.sas -l out1.log -o out1.lst 118 | ./run_sas.py -s example_1.sas -r TEXT 119 | ./run_sas.py -s example_1.sas -r HTML 120 | ./run_sas.py -s example_1.sas -r htMl -l out2.log -o out2.html 121 | ./run_sas.py -s example_1.sas -r teXt -l out3.log -o out3.lst 122 | ./run_sas.py -s /home/a/b/c/example_1.sas 123 | ./run_sas.py -s example_1.sas -r text -l out4.log -o out4.lst -c ssh 124 | 125 | -------------------------------------------------------------------------------- /saspy/doc/source/install.rst: -------------------------------------------------------------------------------- 1 | 2 | ============= 3 | Installation 4 | ============= 5 | 6 | This package can be installed via pip, uv, pixi, or conda. It is a pure Python package and works with Python 3.x installations. 7 | 8 | Installation via pip 9 | -------------------- 10 | 11 | pip is the default Python package manager that comes with Python when downloaded from python.org 12 | 13 | To install the latest version using `pip`, you execute the following:: 14 | 15 | pip install saspy 16 | 17 | or, for a specific release:: 18 | 19 | pip install http://github.com/sassoftware/saspy/releases/saspy-X.X.X.tar.gz 20 | 21 | or, for a given branch (put the name of the branch after @):: 22 | 23 | pip install git+https://git@github.com/sassoftware/saspy.git@branchname 24 | 25 | The best way to update and existing deployment to the latest SASPy version is to simply 26 | uninstall and then install, picking up the latest production version from PyPI: 27 | 28 | .. code-block:: ipython3 29 | 30 | pip uninstall -y saspy 31 | pip install saspy 32 | 33 | .. _python.org: https://www.python.org/ 34 | 35 | Installation via uv 36 | ------------------- 37 | 38 | `uv`_ Is a Python package and project manager, written in Rust.:: 39 | 40 | uv init name-of-project 41 | cd name-of-project 42 | uv add saspy # adds saspy to your project from PyPI 43 | 44 | Installing a specific release can be done from the `SASpy project releases page`_, where the X.X.X is the release version you want.:: 45 | 46 | uv init name-of-project 47 | cd name-of-project 48 | uv add https://github.com/sassoftware/saspy/archive/vX.X.X.tar.gz 49 | 50 | .. _uv: https://github.com/astral-sh/uv 51 | .. _SASpy project releases page: https://github.com/sassoftware/saspy/releases 52 | 53 | Installation via pixi 54 | --------------------- 55 | 56 | `pixi`_ is a language-agnostic and cross-platform package management tool built on the foundation of the conda ecosystem. You can install packages from the `conda-forge channel`_, or `PyPI`_.:: 57 | 58 | pixi init name-of-project 59 | pixi cd name-of-project 60 | pixi add saspy # Installs latest version from conda-forge channel by default. 61 | pixi add saspy==X.X.X # Where X.X.X is the version you'd like to install. 62 | 63 | # If you'd like to install saspy from PyPI. 64 | pixi add python # Python is a required dependency for packages installed from PyPI. 65 | pixi add --pypi saspy # Installs latest version from PyPI. 66 | pixi add --pypi saspy==X.X.X # Where X.X.X is the version you'd like to install. 67 | 68 | .. _pixi: https://github.com/prefix-dev/pixi 69 | .. _conda-forge channel: https://anaconda.org/conda-forge/saspy 70 | .. _PyPI: https://pypi.org/project/saspy/ 71 | 72 | Installation via conda 73 | ---------------------- 74 | 75 | `conda`_ is a cross-platform, language-agnostic binary package manager.:: 76 | 77 | conda create --name name-of-my-environment 78 | conda install --channel conda-forge saspy # Installs latest version of saspy from conda-forge channel. 79 | conda install --channel conda-forge saspy==X.X.X # Where X.X.X is the version you'd like to install. 80 | 81 | If you'd like to see more ways to install from the conda-forge channel please look to the repository for the saspy feedstock: 82 | 83 | see: https://github.com/conda-forge/saspy-feedstock#installing-saspy 84 | 85 | To use this module after installation, you need to copy the example sascfg.py file to a 86 | sascfg_personal.py and edit sascfg_personal.py per the instructions in the next section. 87 | 88 | * If you run into any problems, see :doc:`troubleshooting`. 89 | * If you have questions, open an issue at https://github.com/sassoftware/saspy/issues. 90 | 91 | .. _conda: https://github.com/conda/conda 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /saspy/tests/test_sasml.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import saspy 4 | from saspy.tests.util import Utilities 5 | 6 | 7 | class TestSASml(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.sas = saspy.SASsession(autoexec='options mprint DLCREATEDIR;') 11 | util = Utilities(cls.sas) 12 | procNeeded = ['hpforest', 'hp4score', 'hpclus', 'hpneural', 'treeboost', 'hpbnet'] 13 | if not util.procFound(procNeeded): 14 | cls.skipTest("Not all of these procedures were found: %s" % str(procNeeded)) 15 | 16 | @classmethod 17 | def tearDownClass(cls): 18 | if cls.sas: 19 | #print(cls.sas.saslog()) 20 | cls.sas._endsas() 21 | 22 | def testHPForestSmoke1(self): 23 | ml = self.sas.sasml() 24 | dt = self.sas.sasdata("class", "sashelp") 25 | out1 = ml.hpforest(data=dt, target='height', 26 | input={'interval': 'weight', "nominal": 'sex'} 27 | ) 28 | a = ['BASELINE', 'DATAACCESSINFO', 'FITSTATISTICS', 'LOG', 29 | 'MODELINFO', 'NOBS', 'PERFORMANCEINFO', 'VARIABLEIMPORTANCE'] 30 | self.assertEqual(sorted(a), sorted(out1.__dir__()), 31 | msg=u"Simple HPForest model failed to return correct objects expected:{0:s} returned:{1:s}".format( 32 | str(a), ' '.join(out1.__dir__()))) 33 | 34 | def testHPNeuralSmoke1(self): 35 | ml = self.sas.sasml() 36 | dt = self.sas.sasdata("class", "sashelp") 37 | out1 = ml.hpneural(data=dt, target='height', 38 | input={'interval': 'weight', "nominal": 'sex'}, 39 | train={'numtries': 3, 'maxiter': 300}, 40 | hidden=5) 41 | a = ['CLASSLEVELS', 'DATAACCESSINFO', 'ERRORSUMMARY', 'FITSTATISTICS', 'ITERATION', 'MODELINFORMATION', 'NOBS', 42 | 'PERFORMANCEINFO', 'TRAINING', 'LOG'] 43 | self.assertEqual(sorted(a), sorted(out1.__dir__()), 44 | msg=u"Simple HPNeural model failed to return correct objects expected:{0:s} returned:{1:s}".format( 45 | str(a), ' '.join(out1.__dir__()))) 46 | 47 | def testtreeboostSmoke1(self): 48 | ml = self.sas.sasml() 49 | dt = self.sas.sasdata("class", "sashelp") 50 | out1 = ml.treeboost(data=dt, target='height', 51 | input={'interval': 'weight', "nominal": 'sex'}, save=True) 52 | a = ['FIT', 'IMPORTANCE', 'MODEL', 'NODESTATS', 'RULES', 'LOG'] 53 | self.assertEqual(sorted(a), sorted(out1.__dir__()), 54 | msg=u"Simple treeboost model failed to return correct objects expected:{0:s} returned:{1:s}".format( 55 | str(a), ' '.join(out1.__dir__()))) 56 | 57 | def testHPBnetSmoke1(self): 58 | ml = self.sas.sasml() 59 | dt = self.sas.sasdata("iris", "sashelp") 60 | out1 = ml.hpbnet(data=dt, target='species', 61 | procopts='numbin=3 structure=Naive maxparents=1 prescreening=0 varselect=0', 62 | input={'interval': ['PetalWidth', "PetalLength", 'SepalLength', 'SepalWidth']}) 63 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"HPBNET had errors in the log") 64 | 65 | def testHP4scoreSmoke1(self): 66 | pass 67 | 68 | def testHPclusSmoke1(self): 69 | ml = self.sas.sasml() 70 | dt = self.sas.sasdata("iris", "sashelp") 71 | out1 = ml.hpclus(data=dt, 72 | id=['PetalWidth', "PetalLength", 'SepalLength', 'SepalWidth'], 73 | input={'interval': ['PetalWidth', "PetalLength", 'SepalLength', 'SepalWidth']}) 74 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"HPCLUSTER had errors in the log") 75 | -------------------------------------------------------------------------------- /saspy/autocfg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | from glob import glob 5 | from saspy import sascfg as ac 6 | 7 | def main(cfgfile: str = None, SASHome: str = None, java: str = None): 8 | 9 | if os.name != 'nt': 10 | print('This function will only run on Windows and create a saspy config file for an IOM Local connection') 11 | return 12 | 13 | saspydir = ac.__file__.replace('sascfg.py', '') 14 | 15 | if not cfgfile: 16 | cfgfile = saspydir+'sascfg_personal.py' 17 | 18 | # if the file already exists, don't replace it. 19 | if os.path.exists(cfgfile): 20 | print("CFGFILE ALREADY EXISTS: " + cfgfile) 21 | return 22 | 23 | # handles users who could have different versions 24 | if not SASHome: 25 | SASHome = "C:\\Program Files\\SASHome" 26 | 27 | if not java: 28 | java = 'java' 29 | 30 | depDir = SASHome+"\\SASDeploymentManager\\" 31 | 32 | try: 33 | dirList = os.listdir(depDir) 34 | except: 35 | while True: 36 | print("The following SASHome path wasn't found: "+SASHome) 37 | SASHome = input("Please enter the path to your SASHome directory " + 38 | "(or q to exit): " 39 | ) 40 | if SASHome == 'q': 41 | return 42 | try: 43 | print("Trying "+SASHome) 44 | depDir = SASHome+"\\SASDeploymentManager\\" 45 | dirList = os.listdir(depDir) 46 | break 47 | except: 48 | continue 49 | 50 | # prompts the user to enter the version of SAS they want to use if more 51 | # than one are detected 52 | if len(dirList) == 1: 53 | depDir += "{}\\products\\".format(dirList[0]) 54 | sspi = ( 55 | SASHome+"\\SASFoundation\\" + 56 | "{}\\core\\sasext\\sspiauth.dll".format(dirList[0]) 57 | ) 58 | else: 59 | while True: 60 | print(*os.listdir(depDir)) 61 | verFolder = input("Enter the SAS deployement you wish to use " + 62 | "(or q to exit): " 63 | ) 64 | if verFolder in os.listdir(depDir): 65 | depDir += "{}\\products\\".format(verFolder) 66 | # creates dll path 67 | sspi = ( 68 | SASHome+"\\SASFoundation\\" + 69 | "{}\\core\\sasext\\sspiauth.dll".format(verFolder) 70 | ) 71 | break 72 | elif verFolder == 'q': 73 | return 74 | else: 75 | print('This is not a valid SAS version for your machine.') 76 | continue 77 | 78 | # adds required config info to cfg 79 | cfg = ('SAS_config_names=["autogen_winlocal"]\n\n' + 80 | 'SAS_config_options = {\n\t"lock_down": False,' + 81 | '\n\t"verbose" : True\n\t}' + 82 | '\n\nautogen_winlocal = ' + 83 | '{\n\t"java" : "'+java+'",\n\t"encoding" : "windows-1252"' + '}' 84 | ) 85 | 86 | # if dll exists 87 | if os.path.isfile(sspi): 88 | cfg += '\n\nimport os\nos.environ["PATH"] += ";{}"'.format(sspi.rsplit('\\sspiauth.dll')[0]) 89 | else: 90 | print( 91 | "Couldn't find the sspiauth.dll path. You'll need to find that and " 92 | "add it to your system PATH variable.\n" 93 | ) 94 | 95 | cfg = cfg.replace('\\', '\\\\') 96 | 97 | fd = open(cfgfile, 'w') 98 | fd.write(cfg) 99 | fd.close() 100 | 101 | print("Generated configurations file: " + cfgfile + "\n") 102 | 103 | if __name__ == "__main__": 104 | for i in range(len(sys.argv)): 105 | if sys.argv[i] == 'None': 106 | sys.argv[i] = None 107 | main(*sys.argv[1:]) 108 | -------------------------------------------------------------------------------- /saspy/libname_gen.sas: -------------------------------------------------------------------------------- 1 | /* 2 | # 3 | # Copyright SAS Institute 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the License); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | */ 18 | *filename d1 url 'http://www-bcf.usc.edu/~gareth/ISL/Advertising.csv'; 19 | *proc import datafile=d1 out=work.data dbms=csv replace; run; 20 | options pagesize=max; 21 | /* 22 | %macro proccall(dset); 23 | proc reg data=&dset. plots(unpack)=all; 24 | model MSRP = weight wheelbase length; 25 | run; 26 | quit; 27 | %mend; 28 | */ 29 | 30 | /*Take an ods document*/ 31 | %macro mangobj1(objname,objtype,d); 32 | data _null_; 33 | guid=uuidgen(); 34 | tmpdir=dcreate(guid,getoption('work')); 35 | call symputx('tmpdir',tmpdir); 36 | run; 37 | libname &objname. base "&tmpdir."; 38 | ods _all_ close; 39 | /* ods html file="&tmpdir./&objname..html";*/ 40 | ods document name=&objname..&objname.(write); 41 | /*replace with code generation macro*/ 42 | %proccall(&d.); 43 | /*end replace with code generation macro*/ 44 | ods document close; 45 | /*create a libname using the document name*/ 46 | 47 | proc document name=&objname..&objname.; 48 | ods output Properties=&objname.._&objname.properties; 49 | list \ /levels=all; 50 | quit; 51 | filename file1 temp; 52 | data _null_; 53 | length path $1000; 54 | set &objname.._&objname.properties(where=(type = 'Dir')) end=last; 55 | file file1; 56 | if _n_=1 then do; 57 | put "libname _&objname. sasedoc ("; 58 | end; 59 | p=cat('"\&objname..&objname.', catt(path) , '"'); 60 | put p; 61 | if last then do; 62 | put ');'; 63 | end; 64 | run; 65 | /* concatenate all the directories in the ods document to the top level directory */ 66 | %include file1; 67 | %mend mangobj1; 68 | 69 | %macro mangobj2(objname,objtype,d); 70 | /* Create a table of all the datasets using sashelp.vmember */ 71 | data &objname.._&objname.filelist; 72 | length objtype $32 objname $32.; 73 | set sashelp.vmember(where=(lower(libname)=lower("_&objname."))) 74 | sashelp.vmember(where=(lower(libname)=lower("&objname."))); 75 | objtype="&objtype"; 76 | objname="&objname"; 77 | method=memname; 78 | keep objtype objname method; 79 | if length(method)>1 and char(method, 1) NE '_' then output; 80 | run; 81 | ods listing; 82 | %mend mangobj2; 83 | *%mangobj(cars,reg,sashelp.cars); 84 | /* 85 | %let d=sashelp.cars; 86 | %let objtype=reg; 87 | %let objname=cars; 88 | %let method=ANOVA; 89 | */ 90 | 91 | %macro getdata(objname, method, datatype); 92 | %if &datatype="DATA" %then %do; 93 | 94 | %end; 95 | proc document name=&objname..&objname.; 96 | replay \ (where=(lower(_name_)=lower("&method.")));; 97 | run; 98 | quit; 99 | %mend getdata; 100 | /* 101 | %getdata(cars,nobs); 102 | %getdata(cars,qqplot); 103 | %getdata(cars,diagnosticspanel); 104 | */; 105 | 106 | 107 | 108 | /*Full Test*/ 109 | /* 110 | %macro proccall(dset); proc reg data=&dset. plots(unpack)=all; model MSRP = weight wheelbase length; run; quit; %mend; 111 | 112 | %mangobj(cars,reg,sashelp.cars); 113 | 114 | %listdata(cars); 115 | 116 | %getdata(cars,COOKSDPLOT); 117 | */ 118 | 119 | /*Full Test2 */ 120 | /* 121 | %macro proccall(d); 122 | proc hpsplit plots=all data=sashelp.cars;model mpg_city = msrp cylinders length wheelbase weight;run; %mend; 123 | %mangobj(hps1,hpsplit,cars); 124 | 125 | %listdata(hps1); 126 | 127 | %getdata(hps1,COOKSDPLOT); 128 | */ 129 | -------------------------------------------------------------------------------- /saspy/tests/test_sasViyaML.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import saspy 3 | from saspy.tests.util import Utilities 4 | 5 | 6 | class TestSASViyaML(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.sas = saspy.SASsession() 10 | util = Utilities(cls.sas) 11 | procNeeded = ['factmac', 'fastknn', 'forest', 'gradboost', 'nnet', 'svdd', 'svmachine'] 12 | if not util.procFound(procNeeded): 13 | cls.skipTest("Not all of these procedures were found: %s" % str(procNeeded)) 14 | cls.sas.submit(""" 15 | cas mysession; 16 | libname mycas cas; 17 | data mycas.class; 18 | set sashelp.class; 19 | run; 20 | """) 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | if cls.sas: 25 | cls.sas._endsas() 26 | 27 | def testFactmacSmoke1(self): 28 | # TODO endable test 29 | self.skipTest("can't find shipped dataset that works") 30 | viya = self.sas.sasviyaml() 31 | dt = self.sas.sasdata("class", "mycas") 32 | out1 = viya.factmac(data=dt, target='height', input={'interval': 'weight', "nominal": 'sex'}) 33 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"factmac had errors in the log") 34 | 35 | @unittest.skip("this is just syntax errors") 36 | def testFastknnSmoke1(self): 37 | viya = self.sas.sasviyaml() 38 | # sas.saslib(engine='cas', libref='mycas') 39 | self.sas.submit(""" 40 | data mycas.hmeq; 41 | set sampsio.hmeq(obs=4000); 42 | id=_n_; 43 | run; 44 | data mycas.query; 45 | set sampsio.hmeq(firstobs=4001 obs=4100); 46 | id=_n_; 47 | run; 48 | """) 49 | hmeq = self.sas.sasdata('hmeq','mycas') 50 | out1 = viya.fastknn(data=hmeq, input={'interval': ['loan', 'mortdue', 'value']}, 51 | id='id', 52 | procopts='query = mycas.query', 53 | output=self.sas.sasdata('knn_out', 'mycas')) 54 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"fastknn had errors in the log") 55 | 56 | def testForestSmoke1(self): 57 | viya = self.sas.sasviyaml() 58 | dt = self.sas.sasdata("class", "mycas") 59 | out1 = viya.forest(data=dt, target='height', input={'interval': 'weight', "nominal": 'sex'}) 60 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"forest had errors in the log") 61 | 62 | def testGradboostSmoke1(self): 63 | viya = self.sas.sasviyaml() 64 | dt = self.sas.sasdata("class", "mycas") 65 | out1 = viya.gradboost(data=dt, target='height', input={'interval': 'weight', "nominal": 'sex'}) 66 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"gradboost had errors in the log") 67 | 68 | def testNnetSmoke1(self): 69 | viya = self.sas.sasviyaml() 70 | dt = self.sas.sasdata("class", "mycas") 71 | out1 = viya.nnet(data=dt, target='height', 72 | input={'interval': 'weight', "nominal": 'sex'}, 73 | train='outmodel=mycas.nnetmodel1', 74 | hidden=5) 75 | out2 = viya.nnet(data=dt, target='height', 76 | input={'interval': 'weight', "nominal": 'sex'}, 77 | train={'outmodel':'mycas.nnetmodel1'}, 78 | hidden=5) 79 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"nnet had errors in the log") 80 | 81 | def testSvddSmoke1(self): 82 | viya = self.sas.sasviyaml() 83 | dt = self.sas.sasdata("class", "mycas") 84 | out1 = viya.svdd(data=dt, input={'interval': 'weight', "nominal": 'sex'}, kernel = "RBF / bw=2") 85 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"svdd had errors in the log") 86 | 87 | def testSvmachineSmoke1(self): 88 | viya = self.sas.sasviyaml() 89 | dt = self.sas.sasdata("class", "mycas") 90 | out1 = viya.svmachine(data=dt, target='sex', input={'interval': ['weight', 'height']}) 91 | self.assertFalse('ERROR_LOG' in out1.__dir__(), msg=u"svmachine had errors in the log") 92 | 93 | if __name__ == '__main__': 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /saspy/tests/test_sasexceptions.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import unittest 3 | import saspy 4 | import sys 5 | import tempfile 6 | import os 7 | 8 | 9 | CONFIG_STDIO = """ 10 | SAS_config_names = ['default'] 11 | default = {'saspath': '/opt/sasinside/SASHome/SASFoundation/9.4/bin/sas_u8'} 12 | """ 13 | CONFIG_SSH = """ 14 | SAS_config_names = ['ssh'] 15 | ssh = {'saspath': '/opt/sasinside/SASHome/SASFoundation/9.4/bin/sas_en', 16 | 'ssh': '/usr/bin/ssh', 17 | 'host': 'remote.linux.host', 18 | 'encoding': 'latin1', 19 | 'options': ["-fullstimer"]} 20 | """ 21 | CONFIG_IOMWIN = """ 22 | SAS_config_names = ['iomwin'] 23 | iomwin = {'java': '/usr/bin/java', 24 | 'iomhost': 'windows.iom.host', 25 | 'iomport': 8591, 26 | 'encoding': 'windows-1252', 27 | 'classpath': '/dummy/path/to/saspyiom.jar'} 28 | """ 29 | CONFIG_INVALID = """ 30 | SAS_config_names = ['not_supported'] 31 | not_supported = {'whatever': 'some value'} 32 | """ 33 | 34 | 35 | class TestSASExceptions(unittest.TestCase): 36 | @classmethod 37 | def setUpClass(cls): 38 | """ 39 | Create dummy config files for test cases. Use `NamedTemporaryFile` 40 | instead of in-memory `StringIO` because `SASsession` expects a file 41 | path, not a file-like object when passed a `cfgfile`. 42 | """ 43 | # STDIO config file 44 | with tempfile.NamedTemporaryFile('w', delete=False) as tf: 45 | tf.write(CONFIG_STDIO) 46 | cls.config_stdio = tf.name 47 | 48 | # SSH config file 49 | with tempfile.NamedTemporaryFile('w', delete=False) as tf: 50 | tf.write(CONFIG_SSH) 51 | cls.config_ssh = tf.name 52 | 53 | # Windows IOM config file 54 | with tempfile.NamedTemporaryFile('w', delete=False) as tf: 55 | tf.write(CONFIG_IOMWIN) 56 | cls.config_iomwin = tf.name 57 | 58 | # Invalid config file 59 | with tempfile.NamedTemporaryFile('w', delete=False) as tf: 60 | tf.write(CONFIG_INVALID) 61 | cls.config_invalid = tf.name 62 | 63 | # Empty config file 64 | with tempfile.NamedTemporaryFile('w', delete=False) as tf: 65 | tf.write('') 66 | cls.config_empty = tf.name 67 | 68 | @classmethod 69 | def tearDownClass(cls): 70 | """ 71 | Clean up named temp files 72 | """ 73 | os.unlink(cls.config_stdio) 74 | os.unlink(cls.config_ssh) 75 | os.unlink(cls.config_iomwin) 76 | os.unlink(cls.config_invalid) 77 | os.unlink(cls.config_empty) 78 | 79 | def tearDown(self): 80 | """ 81 | Remove `sascfgfile` module after test. 82 | """ 83 | if 'sascfgfile' in sys.modules: 84 | del sys.modules['sascfgfile'] 85 | 86 | @mock.patch('os.name', 'nt') 87 | def test_raises_SASIONotSupportedError_stdio(self): 88 | """ 89 | Test passing STDIO config option on Windows raises 90 | SASIONotSupportedError. Patch os.name to always return 'nt' 91 | even on non-Windows systems. 92 | """ 93 | with self.assertRaises(saspy.SASIONotSupportedError): 94 | sas = saspy.SASsession(cfgfile=self.config_stdio) 95 | 96 | def test_raises_SASConfigNotValidError_invalid(self): 97 | """ 98 | Test that passing an invalid config raises SASConfigNotValidError. 99 | """ 100 | with self.assertRaises(saspy.SASConfigNotValidError): 101 | sas = saspy.SASsession(cfgfile=self.config_invalid) 102 | 103 | def test_raises_SASConfigNotValidError_empty(self): 104 | """ 105 | Test that passing an empty config raises SASConfigNotValidError. 106 | """ 107 | with self.assertRaises(saspy.SASConfigNotValidError): 108 | sas = saspy.SASsession(cfgfile=self.config_empty) 109 | 110 | def test_raises_SASConfigNotFoundError(self): 111 | """ 112 | Test that an invalid config path raises SASConfigNotFoundError. 113 | """ 114 | with self.assertRaises(saspy.SASConfigNotFoundError): 115 | sas = saspy.SASsession(cfgfile='path/to/nowhere.py') 116 | -------------------------------------------------------------------------------- /saspy/sasexceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | class SASConfigNotFoundError(Exception): 19 | def __init__(self, path: str): 20 | self.path = path 21 | 22 | def __str__(self): 23 | return 'Configuration path {} does not exist.'.format(self.path) 24 | 25 | 26 | class SASConfigNotValidError(Exception): 27 | def __init__(self, defn: str, msg: str=None): 28 | self.defn = defn if defn else 'N/A' 29 | self.msg = msg 30 | 31 | def __str__(self): 32 | return 'Configuration definition {} is not valid. {}'.format(self.defn, self.msg) 33 | 34 | 35 | class SASIONotSupportedError(Exception): 36 | def __init__(self, method: str, alts: list=None): 37 | self.method = method 38 | self.alts = alts 39 | 40 | def __str__(self): 41 | if self.alts is not None: 42 | alt_text = 'Try the following: {}'.format(', '.join(self.alts)) 43 | else: 44 | alt_text = '' 45 | 46 | extra = "\n\nPlease refer to the Configuration Instructions in the SASPy Documentation at " 47 | extra += "https://sassoftware.github.io/saspy/configuration\n" 48 | extra += "You can also look for the error you've recieved in the Troublshooting guide at " 49 | extra += "https://sassoftware.github.io/saspy/troubleshooting\n" 50 | extra += "If you need more help, please open an Issue on the SASPy GitHub site at " 51 | extra += "https://github.com/sassoftware/saspy/issues" 52 | 53 | return 'Cannot use {} I/O module on Windows. {}'.format(self.method, alt_text+extra) 54 | 55 | 56 | class SASIOConnectionError(Exception): 57 | def __init__(self, msg: str): 58 | self.msg = msg 59 | 60 | def __str__(self): 61 | extra = "\n\nPlease refer to the Configuration Instructions in the SASPy Documentation at " 62 | extra += "https://sassoftware.github.io/saspy/configuration\n" 63 | extra += "You can also look for the error you've recieved in the Troublshooting guide at " 64 | extra += "https://sassoftware.github.io/saspy/troubleshooting\n" 65 | extra += "If you need more help, please open an Issue on the SASPy GitHub site at " 66 | extra += "https://github.com/sassoftware/saspy/issues" 67 | 68 | return 'Failure establishing SASsession.\n{}'.format(self.msg+extra) 69 | 70 | 71 | class SASIOConnectionTerminated(Exception): 72 | def __init__(self, msg: str): 73 | self.msg = msg 74 | 75 | def __str__(self): 76 | return 'No SAS process attached. SAS process has terminated unexpectedly.\n{}'.format(self.msg) 77 | 78 | 79 | class SASHTTPauthenticateError(Exception): 80 | def __init__(self, msg: str): 81 | self.msg = msg 82 | 83 | def __str__(self): 84 | return 'Failure in GET AuthToken.\n{}'.format(self.msg) 85 | 86 | 87 | class SASHTTPconnectionError(Exception): 88 | def __init__(self, msg: str): 89 | self.msg = msg 90 | 91 | def __str__(self): 92 | return 'Failure in GET Connection.\n{}'.format(self.msg) 93 | 94 | class SASHTTPsubmissionError(Exception): 95 | def __init__(self, msg: str): 96 | self.msg = msg 97 | 98 | def __str__(self): 99 | return 'Failure in submit().\n{}'.format(self.msg) 100 | 101 | class SASResultsError(Exception): 102 | def __init__(self, msg: str): 103 | self.msg = msg 104 | 105 | def __str__(self): 106 | return 'Failure creating SASResults object.\n{}'.format(self.msg) 107 | 108 | class SASDFNamesToLongError(Exception): 109 | def __init__(self, msg: str): 110 | self.msg = msg 111 | 112 | def __str__(self): 113 | return 'Column name(s) in DataFrame are too long for SAS. Rename to 32 bytes (in SAS Session encoding) or less.\n' 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /saspy/tests/test_io.py: -------------------------------------------------------------------------------- 1 | import saspy 2 | import inspect 3 | import unittest 4 | 5 | 6 | class TestSASIO(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.sas = saspy.SASsession() 10 | cls.io = cls.sas._io 11 | 12 | @classmethod 13 | def tearDownClass(cls): 14 | cls.sas._endsas() 15 | 16 | def is_method(self, obj, method): 17 | """ 18 | Helper function. Tests whether a provided argument is an object with 19 | a given method. 20 | """ 21 | return hasattr(obj, method) and inspect.ismethod(getattr(obj, method)) 22 | 23 | def test_sasio_mexist_dataframe2sasdata(self): 24 | """ 25 | Test that the SAS IO object has a `dataframe2sasdata` method. 26 | """ 27 | self.assertTrue(self.is_method(self.io, 'dataframe2sasdata')) 28 | 29 | def test_sasio_mexist_exist(self): 30 | """ 31 | Test that the SAS IO object has an `exist` method. 32 | """ 33 | self.assertTrue(self.is_method(self.io, 'exist')) 34 | 35 | def test_sasio_mexist_sasdata2dataframe(self): 36 | """ 37 | Test that the SAS IO object has a `sasdata2dataframe` method. 38 | """ 39 | self.assertTrue(self.is_method(self.io, 'sasdata2dataframe')) 40 | 41 | def test_sasio_mexist_sasdata2dataframeCSV(self): 42 | """ 43 | Test that the SAS IO object has a `sasdata2dataframeCSV` method. 44 | """ 45 | self.assertTrue(self.is_method(self.io, 'sasdata2dataframeCSV')) 46 | 47 | def test_sasio_mexist_saslog(self): 48 | """ 49 | Test that the SAS IO object has a `saslog` method. 50 | """ 51 | self.assertTrue(self.is_method(self.io, 'saslog')) 52 | 53 | def test_sasio_mexist_submit(self): 54 | """ 55 | Test that the SAS IO object has a `submit` method. 56 | """ 57 | self.assertTrue(self.is_method(self.io, 'submit')) 58 | 59 | def test_sasio_mexist_download(self): 60 | """ 61 | Test that the SAS IO object has a `download` method. 62 | """ 63 | self.assertTrue(self.is_method(self.io, 'download')) 64 | 65 | def test_sasio_mexist_upload(self): 66 | """ 67 | Test that the SAS IO object has an `upload` method. 68 | """ 69 | self.assertTrue(self.is_method(self.io, 'upload')) 70 | 71 | def test_sasio_mexist__asubmit(self): 72 | """ 73 | Test that the SAS IO object has an `_asubmit` method. 74 | 75 | NOTE: `_asubmit` is considered a private function based on Python 76 | conventions (see PEP8). However, due to public usage in the library 77 | the function must be defined in the IO object. 78 | """ 79 | self.assertTrue(self.is_method(self.io, '_asubmit')) 80 | 81 | def test_sasio_mexist__endsas(self): 82 | """ 83 | Test that the SAS IO object has an `_endsas` method. 84 | 85 | NOTE: `_endsas` is considered a private function based on Python 86 | conventions (see PEP8). However, due to public usage in the library 87 | the function must be defined in the IO object. 88 | """ 89 | self.assertTrue(self.is_method(self.io, '_endsas')) 90 | 91 | def test_sasio_mexist__getlog(self): 92 | """ 93 | Test that the SAS IO object has a `_getlog` method. 94 | 95 | NOTE: `_getlog` is considered a private function based on Python 96 | conventions (see PEP8). However, due to public usage in the library 97 | the function must be defined in the IO object. 98 | """ 99 | self.assertTrue(self.is_method(self.io, '_getlog')) 100 | 101 | def test_sasio_mexist__getlst(self): 102 | """ 103 | Test that the SAS IO object has a `_getlst` method. 104 | 105 | NOTE: `_getlst` is considered a private function based on Python 106 | conventions (see PEP8). However, due to public usage in the library 107 | the function must be defined in the IO object. 108 | """ 109 | self.assertTrue(self.is_method(self.io, '_getlst')) 110 | 111 | def test_sasio_mexist__startsas(self): 112 | """ 113 | Test that the SAS IO object has a `_startsas` method. 114 | 115 | NOTE: `_startsas` is considered a private function based on Python 116 | conventions (see PEP8). However, due to public usage in the library 117 | the function must be defined in the IO object. 118 | """ 119 | self.assertTrue(self.is_method(self.io, '_startsas')) 120 | -------------------------------------------------------------------------------- /saspy/tests/test_sasconfig.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import unittest 3 | import saspy 4 | import builtins 5 | import importlib 6 | import inspect 7 | import os 8 | import shutil 9 | import sys 10 | import tempfile 11 | 12 | 13 | _real_import = builtins.__import__ 14 | _real_import_module = importlib.import_module 15 | 16 | def _patch_import_none(name, globals=None, locals=None, fromlist=(), level=0): 17 | """ 18 | Patch the __import__ function to always raise an exception for any 19 | `sascfg_personal` imports 20 | """ 21 | if name in ('saspy.sascfg_personal', 'sascfg_personal'): 22 | raise ImportError 23 | else: 24 | return _real_import(name, globals=globals, locals=locals, fromlist=fromlist, level=level) 25 | 26 | def _patch_import_module_none(name, package=None): 27 | """ 28 | Patch the importlib.import_module function to always raise an exception 29 | for any `sascfg_personal` imports 30 | """ 31 | if name in ('saspy.sascfg_personal', 'sascfg_personal'): 32 | raise ImportError 33 | else: 34 | return _real_import_module(name, package=package) 35 | 36 | def _patch_import_nolocal(name, globals=None, locals=None, fromlist=(), level=0): 37 | """ 38 | Patch the __import__ function to always raise an exception for any 39 | local `sascfg_personal` imports 40 | """ 41 | if name in ('sascfg_personal'): 42 | raise ImportError 43 | else: 44 | return _real_import(name, globals=globals, locals=locals, fromlist=fromlist, level=level) 45 | 46 | def _patch_import_module_nolocal(name, package=None): 47 | """ 48 | Patch the importlib.import_module function to always raise an exception 49 | for any local `sascfg_personal` imports 50 | """ 51 | if name in ('sascfg_personal'): 52 | raise ImportError 53 | else: 54 | return _real_import_module(name, package=package) 55 | 56 | 57 | class TestSASConfig(unittest.TestCase): 58 | @classmethod 59 | def setUpClass(cls): 60 | """ 61 | Store the install path for the library 62 | """ 63 | home = os.path.expanduser('~/.config/saspy') 64 | install = os.path.dirname(inspect.getfile(saspy)) 65 | 66 | cls.cfg_global_standard_path = os.path.join(install, 'sascfg.py') 67 | cls.cfg_global_personal_path = os.path.join(install, 'sascfg_personal.py') 68 | cls.cfg_home_path = os.path.join(home, 'sascfg_personal.py') 69 | 70 | @mock.patch('builtins.__import__', _patch_import_none) 71 | @mock.patch('importlib.import_module', _patch_import_module_none) 72 | def test_config_find_config_global_sascfg(self): 73 | """ 74 | Test that the global `sascfg.py` file is read if no other configuration 75 | path is satisfied. 76 | """ 77 | importlib.reload(saspy) 78 | cfg_manager = saspy.SASconfig() 79 | cfg_module = cfg_manager._find_config() 80 | 81 | cfg_src = inspect.getfile(cfg_module) 82 | 83 | self.assertEqual(cfg_src, self.cfg_global_standard_path) 84 | 85 | @mock.patch('builtins.__import__', _patch_import_nolocal) 86 | @mock.patch('importlib.import_module', _patch_import_module_nolocal) 87 | def test_config_find_config_global_sascfg_personal(self): 88 | """ 89 | Test that the global `sascfg_personal.py` file is read if no other configuration 90 | path is satisfied. 91 | """ 92 | importlib.reload(saspy) 93 | cfg_manager = saspy.SASconfig() 94 | cfg_module = cfg_manager._find_config() 95 | 96 | cfg_src = inspect.getfile(cfg_module) 97 | 98 | self.assertEqual(cfg_src, self.cfg_global_personal_path) 99 | 100 | def test_config_find_config_parameter_exists(self): 101 | """ 102 | Test that the config file passed as a parameter to `_find_config` 103 | is used. 104 | """ 105 | PATHS = (self.cfg_global_standard_path, 106 | self.cfg_global_personal_path, 107 | self.cfg_home_path) 108 | 109 | tmpdir = tempfile.TemporaryDirectory() 110 | tmpcfg = os.path.join(tmpdir.name, 'saspy_test_config.py') 111 | 112 | shutil.copy(inspect.getfile(saspy.sascfg), tmpcfg) 113 | 114 | cfg_manager = saspy.SASconfig() 115 | cfg_module = cfg_manager._find_config(tmpcfg) 116 | 117 | cfg_src = inspect.getfile(cfg_module) 118 | 119 | tmpdir.cleanup() 120 | 121 | self.assertNotIn(cfg_src, PATHS) 122 | 123 | def test_config_find_config_parameter_noexists(self): 124 | """ 125 | Test that a n invalid config file path passed to `_find_config` 126 | raises a SASConfigFileNotFoundError. 127 | """ 128 | with self.assertRaises(saspy.SASConfigNotFoundError): 129 | cfg_manager = saspy.SASconfig() 130 | cfg_module = cfg_manager._find_config('/not/a/valid/config.py') 131 | -------------------------------------------------------------------------------- /saspy/tests/test_sassession.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import saspy 3 | import os 4 | import tempfile 5 | 6 | 7 | class TestSASsessionObject(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.sas = saspy.SASsession() 11 | cls.sas.set_batch(True) 12 | 13 | cls.tempdir = tempfile.TemporaryDirectory() 14 | 15 | @classmethod 16 | def tearDownClass(cls): 17 | cls.sas._endsas() 18 | cls.tempdir.cleanup() 19 | 20 | def test_sassession(self): 21 | self.assertIsInstance(self.sas, saspy.SASsession) 22 | 23 | def test_sassession_exist_true(self): 24 | """ 25 | Test method exist returns True for a dataset that exists 26 | """ 27 | exists = self.sas.exist('cars', libref='sashelp') 28 | self.assertTrue(exists) 29 | 30 | def test_sassession_exist_false(self): 31 | """ 32 | Test method exist returns False for a dataset that does not exist 33 | """ 34 | exists = self.sas.exist('notable', libref='sashelp') 35 | self.assertFalse(exists) 36 | 37 | def test_sassession_csv_read(self): 38 | """ 39 | Test method read_csv properly imports a csv file 40 | """ 41 | EXPECTED = ['1', 'Acura', 'MDX', 'SUV', 'Asia', 'All', '$36,945', '$33,337', '3.5'] 42 | 43 | fname = os.path.join(self.sas.workpath, 'sas_csv_test.csv') 44 | self.sas.write_csv(fname, 'cars', libref='sashelp') 45 | 46 | csvdata = self.sas.read_csv(fname, 'csvcars', results='text') 47 | 48 | ll = csvdata.head() 49 | 50 | rows = ll['LST'].splitlines() 51 | retrieved = [x.split() for x in rows] 52 | 53 | self.assertIn(EXPECTED, retrieved, msg="csvcars.head() result didn't contain row 1") 54 | 55 | def test_sassession_csv_write(self): 56 | """ 57 | Test method write_csv properly exports a csv file 58 | """ 59 | fname = os.path.join(self.sas.workpath, 'sas_csv_test.csv') 60 | log = self.sas.write_csv(fname, 'cars', libref='sashelp') 61 | 62 | self.assertNotIn("ERROR", log, msg="sas.write_csv() failed") 63 | 64 | def test_sassession_upload(self): 65 | """ 66 | Test method upload properly uploads a file 67 | """ 68 | local_file = os.path.join(self.tempdir.name, 'simple_csv.csv') 69 | remote_file = self.sas.workpath + 'simple_csv.csv' 70 | 71 | with open(local_file, 'w') as f: 72 | f.write("""A,B,C,D\n1,2,3,4\n5,6,7,8""") 73 | 74 | self.sas.upload(local_file, remote_file) 75 | 76 | # `assertTrue` fails on empty dict or None, which is returned 77 | # by `file_info` 78 | self.assertTrue(self.sas.file_info(remote_file)) 79 | 80 | def test_sassession_download(self): 81 | """ 82 | Test method download properly downloads a file 83 | """ 84 | local_file_1 = os.path.join(self.tempdir.name, 'simple_csv.csv') 85 | local_file_2 = os.path.join(self.tempdir.name, 'simple_csv_2.csv') 86 | remote_file = self.sas.workpath + 'simple_csv.csv' 87 | 88 | with open(local_file_1, 'w') as f: 89 | f.write("""A,B,C,D\n1,2,3,4\n5,6,7,8""") 90 | 91 | self.sas.upload(local_file_1, remote_file) 92 | self.sas.download(local_file_2, remote_file) 93 | 94 | self.assertTrue(os.path.exists(local_file_2)) 95 | 96 | def test_sassession_datasets_work(self): 97 | """ 98 | Test method datasets can identify that the WORK library exists 99 | """ 100 | EXPECTED = ['Libref', 'WORK'] 101 | 102 | log = self.sas.datasets('work') 103 | rows = log.splitlines() 104 | retrieved = [x.split() for x in rows] 105 | 106 | self.assertIn(EXPECTED, retrieved) 107 | 108 | def test_sassession_datasets_sashelp(self): 109 | """ 110 | Test method datasets can identify that the SASHELP library exists 111 | """ 112 | EXPECTED = ['Libref', 'SASHELP'] 113 | 114 | log = self.sas.datasets('sashelp') 115 | rows = log.splitlines() 116 | retrieved = [x.split() for x in rows] 117 | 118 | self.assertIn(EXPECTED, retrieved) 119 | 120 | def test_sassession_hasstat(self): 121 | """ 122 | Test method sasstat() returns a SASstat object. 123 | """ 124 | stat = self.sas.sasstat() 125 | 126 | self.assertIsInstance(stat, saspy.sasstat.SASstat, msg="stat = self.sas.sasstat() failed") 127 | 128 | def test_sassession_hasets(self): 129 | """ 130 | Test method sasets() returns a SASets object. 131 | """ 132 | ets = self.sas.sasets() 133 | 134 | self.assertIsInstance(ets, saspy.sasets.SASets, msg="ets = self.sas.sasets() failed") 135 | 136 | def test_sassession_hasqc(self): 137 | """ 138 | Test method sasqc() returns a SASqc object. 139 | """ 140 | qc = self.sas.sasqc() 141 | 142 | self.assertIsInstance(qc, saspy.sasqc.SASqc, msg="qc = self.sas.sasqc() failed") 143 | 144 | def test_sassession_hasml(self): 145 | """ 146 | Test method sasml() returns a SASml object. 147 | """ 148 | ml = self.sas.sasml() 149 | 150 | self.assertIsInstance(ml, saspy.sasml.SASml, msg="ml = self.sas.sasml() failed") 151 | 152 | def test_sassession_hasutil(self): 153 | """ 154 | Test method sasutil() returns a SASutil object. 155 | """ 156 | util = self.sas.sasutil() 157 | 158 | self.assertIsInstance(util, saspy.sasutil.SASutil, msg="util = self.sas.sasutil() failed") 159 | -------------------------------------------------------------------------------- /saspy/sasresults.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | try: 17 | from pygments.formatters import HtmlFormatter 18 | from pygments import highlight 19 | from saspy.SASLogLexer import SASLogStyle, SASLogLexer 20 | except: 21 | pass 22 | 23 | class SASresults(object): 24 | """Return results from a SAS Model object""" 25 | 26 | def __init__(self, attrs, session, objname, nosub=False, log=''): 27 | 28 | if len(attrs) > 0: 29 | self._names = attrs 30 | if len(log) > 0: 31 | self._names.append("LOG") 32 | else: 33 | self._names = ['ERROR_LOG'] 34 | 35 | # no valid names start with _ and some procs somehow cause this. causes recursion error 36 | for item in self._names: 37 | if item.startswith('_'): 38 | self._names.remove(item) 39 | 40 | self._name = objname 41 | self.sas = session 42 | self.nosub = nosub 43 | self._log = log 44 | 45 | try: 46 | if SASLogLexer: 47 | self.nopyg = False 48 | else: 49 | self.nopyg = True 50 | except: 51 | self.nopyg = True 52 | 53 | def __dir__(self) -> list: 54 | """Overload dir method to return the attributes""" 55 | return self._names 56 | 57 | def __getattr__(self, attr, all=False): 58 | if attr.startswith('_'): 59 | return getattr(self, attr) 60 | if attr.upper() == 'LOG' or attr.upper() == 'ERROR_LOG': 61 | if self.sas.sascfg.display.lower() == 'zeppelin': 62 | if not self.sas.batch and not self.nopyg: 63 | self.sas.DISPLAY(self.sas.HTML(self._colorLog(self._log))) 64 | else: 65 | print(self._log) 66 | return 67 | else: 68 | if not self.sas.batch and not self.nopyg: 69 | return self.sas.HTML(self._colorLog(self._log)) 70 | else: 71 | return self._log 72 | 73 | if attr.upper() in self._names: 74 | data = self._go_run_code(attr) 75 | else: 76 | if self.nosub: 77 | print('This SAS Result object was created in teach_me_SAS mode, so it has no results') 78 | return None 79 | else: 80 | print("Result named "+attr+" not found. Valid results are:"+str(self._names)) 81 | return None 82 | 83 | if not self.sas.batch: 84 | if not isinstance(data, dict): 85 | return data 86 | else: 87 | self.sas.DISPLAY(self.sas.HTML('

' + attr + '

' + data['LST'])) 88 | return None 89 | else: 90 | return data 91 | 92 | def _colorLog(self,log:str)-> str: 93 | color_log = highlight(log, SASLogLexer(), HtmlFormatter(full=True, style=SASLogStyle, lineseparator="
")) 94 | return color_log 95 | 96 | def _go_run_code(self, attr) -> dict: 97 | lastlog = len(self.sas._io._log) 98 | graphics = ['PLOT', 'OGRAM', 'PANEL', 'BY', 'MAP'] 99 | if any(x in attr for x in graphics): 100 | code = '%%getdata(%s, %s);' % (self._name, attr) 101 | res = self.sas._io.submit(code) 102 | self.sas._lastlog = self.sas._io._log[lastlog:] 103 | return res 104 | else: 105 | if self.sas.exist(attr, '_'+self._name): 106 | lref = '_'+self._name 107 | else: 108 | lref = self._name 109 | 110 | if self.sas.results.upper() == 'PANDAS': 111 | df = self.sas.sasdata2dataframe(attr, libref=lref) 112 | else: 113 | code = '%%getdata(%s, %s);' % (self._name, attr) 114 | df = self.sas._io.submit(code) 115 | 116 | self.sas._lastlog = self.sas._io._log[lastlog:] 117 | return df 118 | 119 | 120 | def sasdata(self, table) -> object: 121 | x = self.sas.sasdata(table, '_' + self._name) 122 | return x 123 | 124 | def ALL(self): 125 | """ 126 | This method shows all the results attributes for a given object 127 | """ 128 | lastlog = len(self.sas._io._log) 129 | if not self.sas.batch: 130 | for i in self._names: 131 | if i.upper() != 'LOG' and i.upper() != 'ERROR_LOG': 132 | x = self.__getattr__(i) 133 | if x is not None: 134 | if self.sas.sascfg.display.lower() == 'zeppelin': 135 | print("%text "+i+"\n"+str(x)+"\n") 136 | else: 137 | self.sas.DISPLAY(x) 138 | self.sas._lastlog = self.sas._io._log[lastlog:] 139 | else: 140 | ret = [] 141 | for i in self._names: 142 | if i.upper()!='LOG': 143 | ret.append(self.__getattr__(i)) 144 | self.sas._lastlog = self.sas._io._log[lastlog:] 145 | return ret 146 | 147 | -------------------------------------------------------------------------------- /saspy/sasdecorator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inspect 3 | import sys 4 | from functools import wraps 5 | import warnings 6 | from .sasproccommons import SASProcCommons 7 | # from pdb import set_trace as bp 8 | 9 | class procDecorator: 10 | def __init__(self): 11 | self.logger = logging.getLogger(__name__) 12 | self.logger.setLevel(logging.WARN) 13 | if sys.version_info[0] < 3 or (sys.version_info[0] >= 3 and sys.version_info[1] < 4): 14 | warnings.warn('Python 3.4+ is required to get correct tab complete and docstring ' 15 | 'information for methods') 16 | 17 | def proc_decorator(req_set): 18 | """ 19 | Decorator that provides the wrapped function with an attribute 'actual_kwargs' 20 | containing just those keyword arguments actually passed in to the function. 21 | """ 22 | 23 | def decorator(func): 24 | @wraps(func) 25 | def inner(self, *args, **kwargs): 26 | proc = func.__name__.lower() 27 | inner.proc_decorator = kwargs 28 | self.logger.debug("processing proc:{}".format(func.__name__)) 29 | self.logger.debug(req_set) 30 | self.logger.debug("kwargs type: " + str(type(kwargs))) 31 | if proc in ['hplogistic', 'hpreg']: 32 | kwargs['ODSGraphics'] = kwargs.get('ODSGraphics', False) 33 | #if proc == 'hpcluster': 34 | # proc = 'hpclus' 35 | # read the signature for the proc and use that as the legal set - kwargs and args 36 | # legal_set = set(kwargs.keys()) 37 | legal_set = set(inspect.signature(self.__getattribute__(proc)).parameters.keys() - {'kwargs', 'args'}) 38 | self.logger.debug(legal_set) 39 | return SASProcCommons._run_proc(self, proc, req_set, legal_set, **kwargs) 40 | return inner 41 | return decorator 42 | 43 | def doc_convert(ls, proc: str = '') -> dict: 44 | """ 45 | The `doc_convert` method takes two arguments: a list of the valid statements and the proc name. 46 | It returns a dictionary with two keys, method_stmt and markup_stmt. 47 | These outputs can be copied into the appropriate product file. 48 | 49 | :param proc: str 50 | :return: dict with two keys method_stmt and markup_stmt 51 | """ 52 | 53 | generic_terms = ['procopts', 'stmtpassthrough'] 54 | assert isinstance(ls, set) 55 | ls_list = [x.lower() for x in ls] 56 | doc_list = [] 57 | doc_markup = [] 58 | for i in [j for j in ls_list if j not in generic_terms]: 59 | if i.lower() == 'class': 60 | i = 'cls' 61 | doc_mstr = ''.join([':parm ', i, ': The {} variable can only be a string type.'.format(i)]) 62 | doc_str = ': str = None,' 63 | 64 | if i.lower() in ['target', 'input']: 65 | doc_mstr = ''.join([':parm ', i, 66 | ': The {} variable can be a string, list or dict type. It refers to the dependent, y, or label variable.'.format(i)]) 67 | doc_str = ': (str, list, dict) = None,' 68 | if i.lower() == 'score': 69 | doc_str = ": (str, bool, 'SASdata') = True," 70 | if i.lower() in ['output', 'out']: 71 | doc_str = ": (str, bool, 'SASdata') = None," 72 | doc_mstr = ''.join([':parm ', i, 73 | ': The {} variable can be a string, boolean or SASdata type. The member name for a boolean is "_output".'.format(i)]) 74 | if i.lower() in ['cls']: 75 | doc_mstr = ''.join([':parm ', i, 76 | ': The {} variable can be a string or list type. It refers to the categorical, or nominal variables.'.format(i)]) 77 | doc_str = ': (str, list) = None,' 78 | if i.lower() in ['id', 'by']: 79 | doc_mstr = ''.join([':parm ', i, ': The {} variable can be a string or list type. '.format(i)]) 80 | doc_str = ': (str, list) = None,' 81 | if i.lower() in ['level', 'irregular', 'slope', 'estimate']: 82 | doc_str = ": (str, bool) = True," 83 | 84 | doc_list.append(''.join([i, doc_str, '\n'])) 85 | doc_markup.append(''.join([doc_mstr, '\n'])) 86 | doc_list.sort() 87 | doc_markup.sort() 88 | # add procopts and stmtpassthrough last for each proc 89 | for j in generic_terms: 90 | doc_list.append(''.join([j, doc_str, '\n'])) 91 | doc_mstr = ''.join([':parm ', j, 92 | ': The {} variable is a generic option available for advanced use. It can only be a string type.'.format(j)]) 93 | doc_markup.append(''.join([doc_mstr, '\n'])) 94 | 95 | doc_markup.insert(0, ''.join([':param data: SASdata object or string. This parameter is required..', '\n'])) 96 | first_line = ''.join(["data: ('SASdata', str) = None,", '\n']) 97 | if len(proc) > 0: 98 | first_line = ''.join(["def {}(self, data: ('SASdata', str) = None,".format(proc), '\n']) 99 | doc_markup.insert(0, ''.join(['Python method to call the {} procedure.\n'.format(proc.upper()), 100 | '\n', 'Documentation link:', '\n\n'])) 101 | doc_list.insert(0, first_line) 102 | doc_list.append("**kwargs: dict) -> 'SASresults':") 103 | 104 | doc_markup.append(''.join([':return: SAS Result Object', '\n'])) 105 | 106 | return {'method_stmt' : ''.join(doc_list), 'markup_stmt' : ''.join(doc_markup)} 107 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing Rules 2 | 3 | ### Opening Issues 4 | * Feel free to open an issue to discuss any questions you have or ask about ideas for contributions 5 | * If you have an idea but don't want to code it up yourself, feel free to run it by us. We can implement it for you if you'd prefer, and we think it's useful - then we have to follow the rules and write the tests 6 | 7 | ### Tips 8 | * There should be enough examples of each of these patterns in the code already, so 'cut, paste, adjust' is a good way to go about adding something 9 | 10 | * The goal of all of this is to be as Pythonic as we can, yet still provide all of the SAS functionality required 11 | 1. For example, we don't have a SASlibname object. This is on purpose because it just doesn't provide much in python and would just add extra 'SASisms' that aren't necessary. Added a datasets() method to the SASsession so you can see the tables for any libref, and a saslib method to assign a libref; that's about all you need. This is less SAS like and more Python like. 12 | 13 | ### Regression Testing 14 | * Contributions must pass the existing regression tests located in saspy/tests 15 | 16 | ### Unit test creation/update 17 | * Contributions must add unit tests to saspy/tests to validate the changes being added in the code 18 | * if there's already a test file where your tests would make sense; put them in there 19 | * if it's something new or you feel it needs its own file, create a new file 20 | 21 | ### Consistent Architecture 22 | * contributions should follow the conventions of the saspy architecture 23 | 1. sasbase.py is the main module containing SASsession and SASdata objects 24 | 1. sasio*.py are the access method specific modules that sasbase calls through to 25 | 1. anything that can be common across access methods should be put in sasbase only 26 | 1. if it just generates access method independent SAS code to submit, it goes here 27 | 1. anything that needs to be implemented differently in each access method module follows this: 28 | 1. add entry in sasbase, and do any common checks or generation there 29 | 1. call the access method specific code (should have common signatures/returns) 30 | 1. return the same thing (object, results, ...) regardless of which io module was called 31 | 32 | ### Adding methods 33 | * The existing conventions in the code provide the expected format. 34 | * All added methods must support the following attributes: 35 | 1. **teach_me_sas** : 36 | * can use the 'nosub' (no submit) attr. 37 | * If this is set, the method will return the generated code but not execute it. 38 | 1. **batch** : 39 | * the batch attr requires that you do not display results 40 | * in batch, results must be returned as an object/Dict so they can be processed by the user code. 41 | 1. **results=** (HTML attr of SASdata) : 42 | * Used for getting HTML results or TEXT results which is usually based upon whether the code is running in a notebook (Jupyter) or command line, or batch. 43 | 44 | 45 | ### Adding PROCs 46 | * To add procs in the sasstat, sasets, sasqc, or sasml modules, follow the directions in the respective modules 47 | 48 | ### Certificate of Origin 49 | 50 | * Contributions to this software are accepted only when they are 51 | properly accompanied by a Contributor Agreement. 52 | 53 | The Contributor 54 | Agreement for this software is the Developer's Certificate of Origin 55 | 1.1 (DCO) as provided with and required for accepting contributions 56 | to the Linux kernel. 57 | 58 | In each contribution proposed to be included in this software, the 59 | developer must include a "sign-off" that denotes consent to the 60 | terms of the Developer's Certificate of Origin. The sign-off is 61 | a line of text in the description that accompanies the change, 62 | certifying that you have the right to provide the contribution 63 | to be included. For changes provided in source code control (for 64 | example, via a Git pull request) the sign-off must be included in 65 | the commit message in source code control. For changes provided 66 | in email or issue tracking, the sign-off must be included in the 67 | email or the issue, and the sign-off will be incorporated into the 68 | permanent commit message if the contribution is accepted into the 69 | official source code. 70 | 71 | If you can certify the below: 72 | 73 | Developer's Certificate of Origin 1.1 74 | 75 | By making a contribution to this project, I certify that: 76 | 77 | (a) The contribution was created in whole or in part by me and I 78 | have the right to submit it under the open source license 79 | indicated in the file; or 80 | 81 | (b) The contribution is based upon previous work that, to the best 82 | of my knowledge, is covered under an appropriate open source 83 | license and I have the right under that license to submit that 84 | work with modifications, whether created in whole or in part 85 | by me, under the same open source license (unless I am 86 | permitted to submit under a different license), as indicated 87 | in the file; or 88 | 89 | (c) The contribution was provided directly to me by some other 90 | person who certified (a), (b) or (c) and I have not modified 91 | it. 92 | 93 | (d) I understand and agree that this project and the contribution 94 | are public and that a record of the contribution (including all 95 | personal information I submit with it, including my sign-off) is 96 | maintained indefinitely and may be redistributed consistent with 97 | this project or the open source license(s) involved. 98 | 99 | * Then you just add a line like below, using your real name(sorry, no pseudonyms or anonymous contributions.) 100 | 101 | Signed-off-by: Random J Developer 102 | 103 | 104 | 105 | And **thanks** for contributing! It will make this project better for everyone! 106 | -------------------------------------------------------------------------------- /saspy/sas_magic.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | from IPython.display import HTML 17 | import IPython.core.magic as ipym 18 | import re 19 | from saspy.SASLogLexer import SASLogStyle, SASLogLexer 20 | from saspy.sasbase import SASsession 21 | from pygments.formatters import HtmlFormatter 22 | from pygments import highlight 23 | 24 | 25 | @ipym.magics_class 26 | class SASMagic(ipym.Magics): 27 | """ 28 | A set of magics useful for interactive work with SAS via saspy 29 | All the SAS magic cells in a single notebook share a SAS session 30 | """ 31 | 32 | def __init__(self, shell): 33 | super(SASMagic, self).__init__(shell) 34 | self.lst_len = -99 # initialize the length to a negative number to trigger function 35 | self.mva = None 36 | if self.lst_len < 0 and isinstance(self.mva, SASsession) : 37 | self._get_lst_len() 38 | 39 | @ipym.cell_magic 40 | def SAS(self, line, cell): 41 | """ 42 | %%SAS - send the code in the cell to a SAS Server 43 | 44 | This cell magic will execute the contents of the cell in a SAS 45 | session and return any generated output 46 | 47 | Example: 48 | %%SAS 49 | proc print data=sashelp.class; 50 | run; 51 | data a; 52 | set sashelp.cars; 53 | run; 54 | """ 55 | 56 | mva = self.mva 57 | if len(line): # session supplied 58 | names = line.split('.') 59 | _mva = None 60 | for i,name in enumerate(names): 61 | if i==0: 62 | if name in self.shell.user_ns: 63 | _mva = self.shell.user_ns[name] 64 | else: 65 | break 66 | else: 67 | try: 68 | _mva = getattr(_mva, name) 69 | except Exception as e: 70 | print(e) 71 | break 72 | 73 | if isinstance(_mva, SASsession): 74 | mva = _mva 75 | else: 76 | return 'Invalid SAS Session object supplied' 77 | elif len(line) and not line in self.shell.user_ns: # string supplied but not a session 78 | return 'Invalid SAS Session object supplied' 79 | else: # no string should default to unnamed session 80 | try: 81 | if mva is None: 82 | mva = SASsession() 83 | self.mva = mva # save the session for reuse 84 | else: 85 | mva = self.mva 86 | except: 87 | return "this shouldn't happen" 88 | saveOpts="proc optsave out=__jupyterSASKernel__; run;" 89 | restoreOpts="proc optload data=__jupyterSASKernel__; run;" 90 | if len(line)>0: # Save current SAS Options 91 | mva.submit(saveOpts) 92 | 93 | if line.lower()=='smalllog': 94 | mva.submit("options nosource nonotes;") 95 | 96 | elif line is not None and line.startswith('option'): 97 | mva.submit(line + ';') 98 | 99 | res = mva.submit(cell) 100 | dis = self._which_display(mva, res['LOG'], res['LST']) 101 | 102 | if len(line)>0: # Restore SAS options 103 | mva.submit(restoreOpts) 104 | 105 | return dis 106 | 107 | @ipym.cell_magic 108 | def IML(self,line,cell): 109 | """ 110 | %%IML - send the code in the cell to a SAS Server 111 | for processing by PROC IML 112 | 113 | This cell magic will execute the contents of the cell in a 114 | PROC IML session and return any generated output. The leading 115 | PROC IML and trailing QUIT; are submitted automatically. 116 | 117 | Example: 118 | %%IML 119 | a = I(6); * 6x6 identity matrix; 120 | b = j(5,5,0); *5x5 matrix of 0's; 121 | c = j(6,1); *6x1 column vector of 1's; 122 | d=diag({1 2 4}); 123 | e=diag({1 2, 3 4}); 124 | 125 | """ 126 | res = self.mva.submit("proc iml; " + cell + " quit;") 127 | dis = self._which_display(self.mva, res['LOG'], res['LST']) 128 | return dis 129 | 130 | @ipym.cell_magic 131 | def OPTMODEL(self, line, cell): 132 | """ 133 | %%OPTMODEL - send the code in the cell to a SAS Server 134 | for processing by PROC OPTMODEL 135 | 136 | This cell magic will execute the contents of the cell in a 137 | PROC OPTMODEL session and return any generated output. The leading 138 | PROC OPTMODEL and trailing QUIT; are submitted automatically. 139 | 140 | Example: 141 | proc optmodel; 142 | /* declare variables */ 143 | var choco >= 0, toffee >= 0; 144 | 145 | /* maximize objective function (profit) */ 146 | maximize profit = 0.25*choco + 0.75*toffee; 147 | 148 | /* subject to constraints */ 149 | con process1: 15*choco +40*toffee <= 27000; 150 | con process2: 56.25*toffee <= 27000; 151 | con process3: 18.75*choco <= 27000; 152 | con process4: 12*choco +50*toffee <= 27000; 153 | /* solve LP using primal simplex solver */ 154 | solve with lp / solver = primal_spx; 155 | /* display solution */ 156 | print choco toffee; 157 | quit; 158 | 159 | """ 160 | res = self.mva.submit("proc optmodel; " + cell + " quit;") 161 | dis = self._which_display(self.mva, res['LOG'], res['LST']) 162 | return dis 163 | 164 | def _get_lst_len(self): 165 | code="data _null_; run;" 166 | res = self.mva.submit(code) 167 | assert isinstance(res, dict) 168 | self.lst_len=len(res['LST']) 169 | assert isinstance(self.lst_len,int) 170 | return 171 | 172 | @staticmethod 173 | def _which_display(mva, log, output): 174 | lst_len = 30762 175 | lines = re.split(r'[\n]\s*', log) 176 | i = 0 177 | elog = [] 178 | for line in lines: 179 | i += 1 180 | e = [] 181 | if re.search(r'^ERROR[ \d-]*:', line[mva.logoffset:]): 182 | e = lines[(max(i - 15, 0)):(min(i + 16, len(lines)))] 183 | elog = elog + e 184 | if len(elog) == 0 and len(output) > lst_len: # no error and LST output 185 | return HTML(output) 186 | elif len(elog) == 0 and len(output) <= lst_len: # no error and no LST 187 | color_log = highlight(log, SASLogLexer(), HtmlFormatter(full=True, style=SASLogStyle, lineseparator="
")) 188 | return HTML(color_log) 189 | elif len(elog) > 0 and len(output) <= lst_len: # error and no LST 190 | color_log = highlight(log, SASLogLexer(), HtmlFormatter(full=True, style=SASLogStyle, lineseparator="
")) 191 | return HTML(color_log) 192 | else: # errors and LST 193 | color_log = highlight(log, SASLogLexer(), HtmlFormatter(full=True, style=SASLogStyle, lineseparator="
")) 194 | return HTML(color_log + output) 195 | 196 | 197 | def load_ipython_extension(ipython): 198 | """Load the extension in Jupyter""" 199 | ipython.register_magics(SASMagic) 200 | 201 | 202 | if __name__ == '__main__': 203 | from IPython import get_ipython 204 | 205 | get_ipython().register_magics(SASMagic) 206 | -------------------------------------------------------------------------------- /saspy/doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% source 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Pipefitter.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Pipefitter.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end -------------------------------------------------------------------------------- /saspy/doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " epub3 to make an epub3" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | @echo " dummy to check syntax errors of document sources" 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf $(BUILDDIR)/* 55 | 56 | .PHONY: html 57 | html: 58 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 61 | 62 | .PHONY: dirhtml 63 | dirhtml: 64 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 65 | @echo 66 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 67 | 68 | .PHONY: singlehtml 69 | singlehtml: 70 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 71 | @echo 72 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 73 | 74 | .PHONY: pickle 75 | pickle: 76 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 77 | @echo 78 | @echo "Build finished; now you can process the pickle files." 79 | 80 | .PHONY: json 81 | json: 82 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 83 | @echo 84 | @echo "Build finished; now you can process the JSON files." 85 | 86 | .PHONY: htmlhelp 87 | htmlhelp: 88 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 89 | @echo 90 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 91 | ".hhp project file in $(BUILDDIR)/htmlhelp." 92 | 93 | .PHONY: qthelp 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Pipefitter.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Pipefitter.qhc" 102 | 103 | .PHONY: applehelp 104 | applehelp: 105 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 106 | @echo 107 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 108 | @echo "N.B. You won't be able to view it unless you put it in" \ 109 | "~/Library/Documentation/Help or install it in your application" \ 110 | "bundle." 111 | 112 | .PHONY: devhelp 113 | devhelp: 114 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 115 | @echo 116 | @echo "Build finished." 117 | @echo "To view the help file:" 118 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Pipefitter" 119 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Pipefitter" 120 | @echo "# devhelp" 121 | 122 | .PHONY: epub 123 | epub: 124 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 125 | @echo 126 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 127 | 128 | .PHONY: epub3 129 | epub3: 130 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 131 | @echo 132 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 133 | 134 | .PHONY: latex 135 | latex: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo 138 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 139 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 140 | "(use \`make latexpdf' here to do that automatically)." 141 | 142 | .PHONY: latexpdf 143 | latexpdf: 144 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 145 | @echo "Running LaTeX files through pdflatex..." 146 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 147 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 148 | 149 | .PHONY: latexpdfja 150 | latexpdfja: 151 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 152 | @echo "Running LaTeX files through platex and dvipdfmx..." 153 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 154 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 155 | 156 | .PHONY: text 157 | text: 158 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 159 | @echo 160 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 161 | 162 | .PHONY: man 163 | man: 164 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 165 | @echo 166 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 167 | 168 | .PHONY: texinfo 169 | texinfo: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo 172 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 173 | @echo "Run \`make' in that directory to run these through makeinfo" \ 174 | "(use \`make info' here to do that automatically)." 175 | 176 | .PHONY: info 177 | info: 178 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 179 | @echo "Running Texinfo files through makeinfo..." 180 | make -C $(BUILDDIR)/texinfo info 181 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 182 | 183 | .PHONY: gettext 184 | gettext: 185 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 186 | @echo 187 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 188 | 189 | .PHONY: changes 190 | changes: 191 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 192 | @echo 193 | @echo "The overview file is in $(BUILDDIR)/changes." 194 | 195 | .PHONY: linkcheck 196 | linkcheck: 197 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 198 | @echo 199 | @echo "Link check complete; look for any errors in the above output " \ 200 | "or in $(BUILDDIR)/linkcheck/output.txt." 201 | 202 | .PHONY: doctest 203 | doctest: 204 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 205 | @echo "Testing of doctests in the sources finished, look at the " \ 206 | "results in $(BUILDDIR)/doctest/output.txt." 207 | 208 | .PHONY: coverage 209 | coverage: 210 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 211 | @echo "Testing of coverage in the sources finished, look at the " \ 212 | "results in $(BUILDDIR)/coverage/python.txt." 213 | 214 | .PHONY: xml 215 | xml: 216 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 217 | @echo 218 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 219 | 220 | .PHONY: pseudoxml 221 | pseudoxml: 222 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 223 | @echo 224 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 225 | 226 | .PHONY: dummy 227 | dummy: 228 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 229 | @echo 230 | @echo "Build finished. Dummy builder generates no files." -------------------------------------------------------------------------------- /saspy/doc/source/limitations.rst: -------------------------------------------------------------------------------- 1 | 2 | .. Copyright SAS Institute 3 | 4 | .. currentmodule:: saspy 5 | 6 | 7 | ========================================== 8 | Limitations, restrictions and work arounds 9 | ========================================== 10 | 11 | This chapter covers specific use cases that are problematic, not allowed for some reason, or 12 | require special handling to be able to work correctly. Hopefully this will be a short chapter. 13 | It was initiated while working on `Issue 294 `_. 14 | That issue was about running batch SAS scripts via the submit() method and some problems that 15 | ensued. 16 | 17 | Let me preface this section by describing some aspects of saspy that warrant this section. saspy 18 | was designed to be a python interface to an interactive SAS session. Its many methods generate 19 | SAS code, which it submits to the SAS session and then retrieves both the SASLOG (LOG) and any listing/results 20 | (LST) and then provides that back as objects or by directly rendering in interactive sessions. 21 | 22 | Many methods require saspy to query the SAS session to gather information about session, data, configuration, 23 | the environment, and other things. There is no API for these 'queries'. Rather, saspy generates specific 24 | SAS code to gather this information and has it written to the LOG to then parse out on the Python side 25 | after retirving the LOG. That is a common mode of access. This, then, demands that saspy have access to 26 | the LOG. That is perhaps the first, most important requirement. 27 | 28 | SAS has no end of options, configurations, and coding possibilities that allow it to be able to do 29 | just about anything. saspy couldn't possibly provide methods to do 'everything', so there is a submit() 30 | method which allows for running 'any' SAS code needed that isn't already provided by a saspy method. 31 | 32 | This then, is the crux of the matter. There can be SAS code submitted that would then cause problems 33 | for saspy to function correctly. These are the things that will be addresses in this section. saspy has 34 | multiple access methods, for connecting to SAS deployed in different ways. Each of these access methods 35 | is implemented very differently, yet they each provide the same functionality and capabilities; at least 36 | to the best of my ability to make that the case. Any divergences between those will also be identified here. 37 | 38 | 39 | SASLOG 40 | ====== 41 | 42 | Proc Printto 43 | ~~~~~~~~~~~~ 44 | 45 | Let's start with the first requirement that saspy has access to the SAS Log. SAS has a procedure which 46 | allows you to redirect the LOG and/or LST out from under the currently existing locations, to files or other locations: 47 | `proc printto `_. 48 | 49 | If this is used to redirect the LST, then you just won't get any results back from any methods in saspy. 50 | Your choice, I suppose. However, if redirecting the LOG, then saspy may hang, may have any number of failures 51 | or exceptions in various methods, and will generally be useless other than for other submit() methods, which won't 52 | return anything. 53 | 54 | Not the intent of the design. However, intent not being everything, providing a way to allow for the use of this, 55 | if needed, while addressing this restriction is possible. This would be considered a work around. 56 | 57 | Proc Printto has an 'undo' version where you can reset the LOG and LST back to their previous settings. So, to 58 | successfully use Proc Printto within a saspy submit() method, you are simply required to submit 59 | 'Proc Printto;run;' (undo) in your code (presumably at the end) that you run within a submit() method. This will 60 | return the LOG and LST back to saspy which will then continue to function correctly. Of course, you won't get 61 | any part of the log or any results that happened while the redirection was enabled, but you knew that. Keep reading 62 | to see, below, that 'you' don't have to do this, there's an option on submit(..., printto=True) which will do this 63 | for you. 64 | 65 | One parting though on this is that you can use saspy's download() method to pull the file(s) you redirected things 66 | to back to the client and then access them in saspy. Don't know why you would, but you could. Maybe there's a use 67 | case where that makes sense. 68 | 69 | 70 | 71 | Terminating SAS out from under saspy 72 | ==================================== 73 | 74 | %abort macro and abort statement 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | SAS also has statements you can submit which will cause the SAS session to immediately terminate; yes, really. 78 | So, if you execute one of these statements, guess what? The interactive SAS session saspy that started and is connected to 79 | vanishes. It's not there anymore. saspy will no longer get any results, and won't get the log from that submit() method 80 | that executed SAS :) (lol) Well, executed the statement that terminated SAS. 81 | 82 | The SAS macro `%abort `_ 83 | and the data step statement `abort `_ 84 | each have various arguments which cause them to behave differently, and depending upon how the SASsession was started the 85 | behavior can vary as well. 86 | 87 | There are two general behaviors these statements can produce. The first ts to terminate SAS. The other is to stop processing 88 | (some) remaining code that was submitted, but not terminate the SAS session. This second behavior is complicated by the nature of 89 | the SAS session itself. As termination of the SAS session is pretty cut and dry, the following will be addressing the second behavior. 90 | 91 | Canceling submitted statements 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | There are two variations of this second behavior. The first variation is when you supply no argument to the abort statement 95 | or macro. In this case it only stops executing the current macro and/or data step, but any following submitted statements 96 | continue to be executed normally. This case is generally not a problem for any access method of saspy. 97 | 98 | The second variant is specifying the CANCEL argument: '[%]abort CANCEL;'. This version stops executing all of the following 99 | 'submitted statements', and the meaning of 'submitted statements' varies. For both the IOM and HTTP access methods, there is 100 | an actual API to submit code to the SAS session and that submit maps 1:1 with the saspy submit() method. In these cases, all of the 101 | statements you submitted in the submit() method, following the Abort CANCEL, are not executed. But, subsequent submit() methods, 102 | of more code, will be executed. 103 | 104 | For STDIO, there is no API of any kind, and what SAS considers to be the 'following submitted statements' is effectively, all 105 | statements to the end of the session. There is no way to 'group' sets of statements into 'submissions'. The STDIN stream itself 106 | is a single 'submit'. In this case, the SASsession is no longer functional; it will execute no more code and nothing can be done but 107 | terminated it (endsas()). That is a SASism, and nothing that can be changed or fixed from the Python side to solve this. 108 | 109 | 110 | Perfect Storm 111 | ============= 112 | 113 | Combining proc printto and abort cancel 114 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 115 | 116 | What happens if you issue a the following code, having proc printto to redirect LOG/LST, and have an abort CANCEL, which gets executed, 117 | and you have the undo for the proc printto (proc printto;run;) at the end of your code? 118 | 119 | .. code-block:: ipython3 120 | 121 | >>> sas.submitLOG(''' 122 | proc printto LOG='./mylogfile';run; 123 | 124 | /* some SAS code */ 125 | 126 | /* some conditional check which turns out to be true */ 127 | if (true) then 128 | abort cancel; 129 | 130 | /* some more SAS code */ 131 | 132 | /* give LOG/LST back to saspy */ 133 | proc printto;run; 134 | ''') 135 | 136 | In this case, neither 'some more SAS code' nor the proc printto;run; will be executed. 137 | 138 | 139 | So, in this case, the 'undo' won't happen so saspy won't have it's LOG back. In this case, you could 140 | code it in your program before each 'abort cancel;' that could execute. 141 | 142 | So, for IOM and HTTP, this will solve this case: 143 | 144 | .. code-block:: ipython3 145 | 146 | >>> sas.submitLOG(''' 147 | proc printto LOG='./mylogfile';run; 148 | 149 | /* some SAS code */ 150 | 151 | /* some conditional check which turns out to be true - return the log before canceling */ 152 | if true then 153 | do; 154 | proc printto;run; 155 | abort cancel; 156 | end; 157 | 158 | /* some more SAS code */ 159 | 160 | /* give LOG/LST back to saspy - this only happens if abort cancel didn't execute */ 161 | proc printto;run; 162 | ''') 163 | >>> # the rest of your Python program ... 164 | 165 | 166 | printto= option on submit methods 167 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 168 | 169 | Now, odds are that if you are submitting code like this, you didn't type it into saspy. You probably 170 | are reading in some existing SAS batch script (.sas file) and just submitting it, as is. You may not 171 | even know what SAS code is even in it. In this case, you don't want to have to edit the file to modify 172 | it as above. For this situation, I've added an option on the submit* mehtods; printto=[False | True]. 173 | 174 | When set to True, saspy will submit the undo ('proc printto;run;') in a second API submit (IOM and HTTP), 175 | so that, in case there was a redirecting proc printto, and perhapse an abort cancel, the log will be 176 | given back to saspy and it will continue to funtion correctly. This 'undo' will also be submitted in the 177 | STDIO access method, though it's not a seperate API call, since, as described above, there is no API. 178 | 179 | Submitting 'proc printto;run;' has no ill effects in any case; even if there had been no original proc 180 | printto submitted, or if there has already been 'proc printo;run;' previously submitted. 181 | 182 | .. code-block:: ipython3 183 | 184 | >>> fd = open('MyBattchSreipt.sas'); code = fd.read(); fd.close() 185 | >>> sas.submitLOG(code, printto=True) 186 | >>> # the rest of your Python program ... you are covered either way 187 | 188 | The default for the printto= option is False, as that is the existing behavior, so no regressions are possible. 189 | And this is actually a non-standard use case, though is can be allowed and supported by setting the option to True. 190 | 191 | -------------------------------------------------------------------------------- /saspy/sasqc.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import logging 18 | from saspy.sasdecorator import procDecorator 19 | from saspy.sasresults import SASresults 20 | from saspy.sasdata import SASdata 21 | 22 | 23 | class SASqc: 24 | """ 25 | This class is for SAS/QC procedures to be called as python3 objects and use SAS as the computational engine 26 | This class and all the useful work in this package require a licensed version of SAS. 27 | 28 | #. Identify the product of the procedure (SAS/STAT, SAS/ETS, SAS Enterprise Miner, etc). 29 | #. Find the corresponding file in saspy sasstat.py, sasets.py, sasml.py, etc. 30 | #. Create a set of valid statements. Here is an example: 31 | 32 | .. code-block:: ipython3 33 | 34 | lset = {'ARIMA', 'BY', 'ID', 'MACURVES', 'MONTHLY', 'OUTPUT', 'VAR'} 35 | 36 | The case and order of the items will be formated. 37 | #. Call the `doc_convert` method to generate then method call as well as the docstring markup 38 | 39 | .. code-block:: ipython3 40 | 41 | import saspy 42 | print(saspy.sasdecorator.procDecorator.doc_convert(lset, 'x11')['method_stmt']) 43 | print(saspy.sasdecorator.procDecorator.doc_convert(lset, 'x11')['markup_stmt']) 44 | 45 | 46 | The `doc_convert` method takes two arguments: a list of the valid statements and the proc name. It returns a dictionary with two keys, method_stmt and markup_stmt. These outputs can be copied into the appropriate product file. 47 | 48 | #. Add the proc decorator to the new method. 49 | The decorator should be on the line above the method declaration. 50 | The decorator takes one argument, the required statements for the procedure. If there are no required statements than an empty list `{}` should be passed. 51 | Here are two examples one with no required arguments: 52 | 53 | .. code-block:: ipython3 54 | 55 | @procDecorator.proc_decorator({}) 56 | def esm(self, data: ['SASdata', str] = None, ... 57 | 58 | And one with required arguments: 59 | 60 | .. code-block:: ipython3 61 | 62 | @procDecorator.proc_decorator({'model'}) 63 | def mixed(self, data: ['SASdata', str] = None, ... 64 | 65 | #. Add a link to the SAS documentation plus any additional details will be helpful to users 66 | 67 | #. Write at least one test to exercise the procedures and include it in the 68 | appropriate testing file. 69 | 70 | If you have questions, please open an issue in the GitHub repo and the maintainers will be happy to help. 71 | """ 72 | 73 | def __init__(self, session, *args, **kwargs): 74 | """Submit an initial set of macros to prepare the SAS system""" 75 | self.sasproduct = "qc" 76 | # create logging 77 | # logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p', level=logging.DEBUG) 78 | self.logger = logging.getLogger(__name__) 79 | self.logger.setLevel(logging.WARN) 80 | self.sas = session 81 | self.logger.debug("Initialization of SAS Macro: " + self.sas.saslog()) 82 | 83 | @procDecorator.proc_decorator({}) 84 | def cusum(self, data: ('SASdata', str) = None, 85 | by: str = None, 86 | inset: str = None, 87 | xchart: str = None, 88 | procopts: str = None, 89 | stmtpassthrough: str = None, 90 | **kwargs: dict) -> SASresults: 91 | """ 92 | Python method to call the CUSUM procedure 93 | 94 | Documentation link: 95 | https://go.documentation.sas.com/?cdcId=pgmsascdc&cdcVersion=9.4_3.4&docsetId=qcug&docsetTarget=qcug_cusum_toc.htm&locale=en 96 | :param data: SASdata object or string. This parameter is required. 97 | :parm by: The by variable can only be a string type. 98 | :parm inset: The inset variable can only be a string type. 99 | :parm xchart: The xchart variable can only be a string type. 100 | :parm procopts: The procopts variable is a generic option available for advanced use. It can only be a string type. 101 | :parm stmtpassthrough: The stmtpassthrough variable is a generic option available for advanced use. It can only be a string type. 102 | :return: SAS Result Object 103 | """ 104 | 105 | @procDecorator.proc_decorator({}) 106 | def macontrol(self, data: ('SASdata', str) = None, 107 | ewmachart: str = None, 108 | machart: str = None, 109 | procopts: str = None, 110 | stmtpassthrough: str = None, 111 | **kwargs: dict) -> SASresults: 112 | """ 113 | Python method to call the MACONTROL procedure 114 | 115 | Documentation link: 116 | https://go.documentation.sas.com/?cdcId=pgmsascdc&cdcVersion=9.4_3.4&docsetId=qcug&docsetTarget=qcug_macontrol_toc.htm&locale=en 117 | 118 | :param data: SASdata object or string. This parameter is required. 119 | :parm ewmachart: The ewmachart variable can only be a string type. 120 | :parm machart: The machart variable can only be a string type. 121 | :parm procopts: The procopts variable is a generic option available for advanced use. It can only be a string type. 122 | :parm stmtpassthrough: The stmtpassthrough variable is a generic option available for advanced use. It can only be a string type. 123 | :return: SAS Result Object 124 | """ 125 | 126 | @procDecorator.proc_decorator({}) 127 | def capability(self, data: ('SASdata', str) = None, 128 | by: str = None, 129 | cdfplot: str = None, 130 | comphist: str = None, 131 | freq: str = None, 132 | histogram: str = None, 133 | id: str = None, 134 | inset: str = None, 135 | intervals: str = None, 136 | output: (str, bool, 'SASdata') = None, 137 | ppplot: str = None, 138 | probplot: str = None, 139 | qqplot: str = None, 140 | spec: str = None, 141 | weight: str = None, 142 | procopts: str = None, 143 | stmtpassthrough: str = None, 144 | **kwargs: dict) -> SASresults: 145 | """ 146 | Python method to call the CAPABILITY procedure 147 | 148 | Documentation link: 149 | https://go.documentation.sas.com/?cdcId=pgmsascdc&cdcVersion=9.4_3.4&docsetId=qcug&docsetTarget=qcug_capability_sect001.htm&locale=en 150 | 151 | :param data: SASdata object or string. This parameter is required. 152 | :parm by: The by variable can only be a string type. 153 | :parm cdfplot: The cdfplot variable can only be a string type. 154 | :parm comphist: The comphist variable can only be a string type. 155 | :parm freq: The freq variable can only be a string type. 156 | :parm histogram: The histogram variable can only be a string type. 157 | :parm id: The id variable can only be a string type. 158 | :parm inset: The inset variable can only be a string type. 159 | :parm intervals: The intervals variable can only be a string type. 160 | :parm output: The output variable can be a string, boolean or SASdata type. The member name for a boolean is "_output". 161 | :parm ppplot: The ppplot variable can only be a string type. 162 | :parm probplot: The probplot variable can only be a string type. 163 | :parm qqplot: The qqplot variable can only be a string type. 164 | :parm spec: The spec variable can only be a string type. 165 | :parm weight: The weight variable can only be a string type. 166 | :parm procopts: The procopts variable is a generic option available for advanced use. It can only be a string type. 167 | :parm stmtpassthrough: The stmtpassthrough variable is a generic option available for advanced use. It can only be a string type. 168 | :return: SAS Result Object 169 | """ 170 | 171 | @procDecorator.proc_decorator({}) 172 | def shewhart(self, data: ('SASdata', str) = None, 173 | boxchart: str = None, 174 | cchart: str = None, 175 | irchart: str = None, 176 | mchart: str = None, 177 | mrchart: str = None, 178 | npchart: str = None, 179 | pchart: str = None, 180 | rchart: str = None, 181 | schart: str = None, 182 | uchart: str = None, 183 | xrchart: str = None, 184 | xschart: str = None, 185 | procopts: str = None, 186 | stmtpassthrough: str = None, 187 | **kwargs: dict) -> SASresults: 188 | """ 189 | Python method to call the SHEWHART procedure 190 | 191 | Documentation link: 192 | https://go.documentation.sas.com/?cdcId=pgmsascdc&cdcVersion=9.4_3.4&docsetId=qcug&docsetTarget=qcug_shewhart_toc.htm&locale=en 193 | 194 | :param data: SASdata object or string. This parameter is required. 195 | :parm boxchart: The boxchart variable can only be a string type. 196 | :parm cchart: The cchart variable can only be a string type. 197 | :parm irchart: The irchart variable can only be a string type. 198 | :parm mchart: The mchart variable can only be a string type. 199 | :parm mrchart: The mrchart variable can only be a string type. 200 | :parm npchart: The npchart variable can only be a string type. 201 | :parm pchart: The pchart variable can only be a string type. 202 | :parm rchart: The rchart variable can only be a string type. 203 | :parm schart: The schart variable can only be a string type. 204 | :parm uchart: The uchart variable can only be a string type. 205 | :parm xrchart: The xrchart variable can only be a string type. 206 | :parm xschart: The xschart variable can only be a string type. 207 | :parm procopts: The procopts variable is a generic option available for advanced use. It can only be a string type. 208 | :parm stmtpassthrough: The stmtpassthrough variable is a generic option available for advanced use. It can only be a string type. 209 | :return: SAS Result Object 210 | """ 211 | -------------------------------------------------------------------------------- /saspy/tests/test_sastabulate.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from contextlib import redirect_stdout 3 | from io import StringIO 4 | from re import match 5 | import pandas as pd 6 | import saspy 7 | from saspy.sastabulate import Tabulate, Class, Var, Statistic, Grouping 8 | 9 | class TestSASTabulate(unittest.TestCase): 10 | def setUp(self): 11 | # Use the first entry in the configuration list 12 | self.sas = saspy.SASsession() #cfgname=saspy.SAScfg.SAS_config_names[0]) 13 | self.assertIsInstance(self.sas, saspy.SASsession, msg="sas = saspy.SASsession(...) failed") 14 | # load a sas-help dataset 15 | self.cars = self.sas.sasdata('cars', libref='sashelp', results='text') 16 | 17 | def tearDown(self): 18 | if self.sas: 19 | self.sas._endsas() 20 | 21 | def test_tabulate(self): 22 | # check for tabulate being available on data set 23 | self.assertIsInstance(self.cars.tabulate, Tabulate, msg="tabulate should be available on data sets") 24 | 25 | def test_classes(self): 26 | # extract a class with options 27 | by_drivetrain = self.cars.tabulate.as_class('drivetrain', label="Drive", all="Total") 28 | self.assertIsInstance(by_drivetrain, Class, msg=".as_class() method failed") 29 | self.assertEqual(by_drivetrain.label, "Drive", msg=".as_class() 'label' keyword not applied") 30 | self.assertEqual(by_drivetrain.all, "Total", msg=".as_class() 'all' keyword not applied") 31 | 32 | # test apply option functionally using .with_() 33 | with_adjusted_label = by_drivetrain.with_(label="Train") 34 | self.assertEqual(with_adjusted_label.label, "Train", msg=".with_() method did not apply keyword") 35 | # should not mutate original; intended for composition 36 | self.assertEqual(by_drivetrain.label, "Drive", msg=".with_() should clone, not mutate") 37 | 38 | # test basic serialization 39 | self.assertEqual(str(by_drivetrain), "(drivetrain='Drive' ALL='Total')", 40 | msg="error with serialization of tabulation class with arguments") 41 | 42 | # test get multiple classes as tuple 43 | by_origin, by_type = self.cars.tabulate.classes('origin', 'type') 44 | self.assertIsInstance(by_origin, Class, msg=".classes() method failed") 45 | self.assertIsInstance(by_type, Class, msg=".classes() method failed") 46 | 47 | def test_vars(self): 48 | # extract a variable with options 49 | horsepower = self.cars.tabulate.as_var('horsepower', label="Horse") 50 | self.assertIsInstance(horsepower, Var, msg=".as_var() method failed") 51 | self.assertEqual(horsepower.label, "Horse", msg=".as_var() 'label' keyword not applied") 52 | 53 | # test apply option functionally using .with_() 54 | with_adjusted_label = horsepower.with_(label="Power") 55 | self.assertEqual(with_adjusted_label.label, "Power", msg=".with_() method did not apply keyword") 56 | # should not mutate original; intended for composition 57 | self.assertEqual(horsepower.label, "Horse", msg=".with_() should clone, not mutate") 58 | 59 | # test basic serialization 60 | self.assertEqual(str(horsepower), "horsepower='Horse'", 61 | msg="error with serialization of tabulation var with arguments") 62 | 63 | # test get multiple vars as tuple 64 | enginesize, cylinders = self.cars.tabulate.vars('enginesize', 'cylinders') 65 | self.assertIsInstance(enginesize, Var, msg=".vars() method failed") 66 | self.assertIsInstance(cylinders, Var, msg=".vars() method failed") 67 | 68 | def test_stats(self): 69 | # create a statistic with options 70 | stdev = self.cars.tabulate.stat('std', label="StDev", format='5.2') 71 | self.assertIsInstance(stdev, Statistic, msg=".stat() method failed") 72 | self.assertEqual(stdev.label, "StDev", msg=".stat() 'label' keyword not applied") 73 | self.assertEqual(stdev.format, "5.2", msg=".stat() 'format' keyword not applied") 74 | 75 | # test apply option functionally using .with_() 76 | with_adjusted_format = stdev.with_(format="6.2") 77 | self.assertEqual(with_adjusted_format.format, "6.2", msg=".with_() method did not apply keyword") 78 | # should not mutate original; intended for composition 79 | self.assertEqual(stdev.format, "5.2", msg=".with_() should clone, not mutate") 80 | 81 | # test basic serialization 82 | self.assertEqual(str(stdev), "std='StDev'*f=5.2", 83 | msg="error with serialization of tabulation statistic with arguments") 84 | 85 | # test get multiple stats as tuple 86 | mean, n = self.cars.tabulate.stats('mean', 'n') 87 | self.assertIsInstance(mean, Statistic, msg=".stats() method failed") 88 | self.assertIsInstance(n, Statistic, msg=".stats() method failed") 89 | 90 | def test_hierarchy(self): 91 | by_origin, by_type = self.cars.tabulate.classes('origin', 'type') 92 | enginesize, cylinders = self.cars.tabulate.vars('enginesize', 'cylinders') 93 | mean, n = self.cars.tabulate.stats('mean', 'n') 94 | 95 | # test valid same-level concatenations 96 | concat_classes = by_origin | by_type 97 | self.assertIsInstance(concat_classes, Grouping, msg="concatenation of classes failed") 98 | concat_vars = enginesize | cylinders 99 | self.assertIsInstance(concat_vars, Grouping, msg="concatenation of vars failed") 100 | concat_stats = mean | n 101 | self.assertIsInstance(concat_stats, Grouping, msg="concatenation of stats failed") 102 | 103 | # test valid nestings; applies right side as child of left side 104 | nest_classes = by_origin * by_type 105 | self.assertIsInstance(nest_classes.child, Class, msg="nesting of classes failed") 106 | nest_class_var = by_origin * enginesize 107 | self.assertIsInstance(nest_class_var.child, Var, msg="nesting of var under class failed") 108 | nest_var_stat = enginesize * mean 109 | self.assertIsInstance(nest_var_stat.child, Statistic, msg="nesting of statistic under var failed") 110 | 111 | # nesting of concatenations should work 112 | nest_concats = (by_origin | by_type) * (mean | n) 113 | self.assertIsInstance(nest_concats, Grouping, msg="nesting of concatenated elements failed") 114 | self.assertIsInstance(nest_concats.child, Grouping, msg="nesting of concatenated elements failed") 115 | 116 | # test invalid nestings for appropriate rejection 117 | self.assertRaises(SyntaxError, lambda: enginesize * by_origin) # class under var 118 | self.assertRaises(SyntaxError, lambda: mean * enginesize) # var under stat 119 | self.assertRaises(SyntaxError, lambda: n * mean) # stat under stat 120 | self.assertRaises(SyntaxError, lambda: mean * by_origin) # class under stat 121 | 122 | def test_composition_serialization(self): 123 | by_origin, by_type, by_drivetrain = self.cars.tabulate.classes('origin', 'type', 'drivetrain') 124 | enginesize, cylinders = self.cars.tabulate.vars('enginesize', 'cylinders') 125 | mean, n = self.cars.tabulate.stats('mean', 'n') 126 | 127 | # compoase a larger fragment using all options, check its serialization 128 | my_tabulation = ( 129 | (by_origin | by_type) * by_drivetrain.with_(all="Total") * enginesize 130 | * (mean.with_(label="Average") | n) 131 | ) 132 | self.assertEqual( 133 | str(my_tabulation), 134 | "((origin type) * (drivetrain ALL='Total') * enginesize * (mean='Average' n))", 135 | msg="serialized table composition did not match expectation" 136 | ) 137 | 138 | def test_procedure(self): 139 | by_origin, by_type, by_drivetrain = self.cars.tabulate.classes('origin', 'type', 'drivetrain') 140 | enginesize, cylinders = self.cars.tabulate.vars('enginesize', 'cylinders') 141 | mean, n = self.cars.tabulate.stats('mean', 'n') 142 | 143 | # check the full generated syntax of a command 144 | def get_generated_code(method: str) -> dict: 145 | captured = StringIO() 146 | with redirect_stdout(captured): 147 | self.sas.teach_me_SAS(True) 148 | method() 149 | self.sas.teach_me_SAS(False) 150 | lines = captured.getvalue().split('\n') 151 | # break submitted code into statements for assertions 152 | match_keyword = '^\s*(\w+?)\s' 153 | return dict( 154 | (match(match_keyword, l).group(1), l) for l in lines if match(match_keyword, l) 155 | ) 156 | 157 | invocation = lambda: \ 158 | self.cars.tabulate.table( 159 | where="cylinders > 0", 160 | left= by_drivetrain.with_(all="Total") * by_type, 161 | top= by_origin * (enginesize | cylinders) * (mean | n), 162 | ) 163 | 164 | statements = get_generated_code(invocation) 165 | 166 | self.assertIn("proc tabulate data=sashelp.cars", statements['proc']) 167 | 168 | # gathered all classes used? 169 | expected_classes = {"drivetrain", "origin", "type"} 170 | classes_sent = statements['class'].replace(';','').split(' ') 171 | self.assertTrue(expected_classes.issubset(set(classes_sent)), msg="classes were not gathered") 172 | 173 | # gathered all vars used? 174 | expected_vars = {"cylinders", "enginesize"} 175 | vars_sent = statements['var'].replace(';','').split(' ') 176 | self.assertTrue(expected_vars.issubset(set(vars_sent)), msg="vars were not gathered") 177 | 178 | # passed the additional valid "where" option? 179 | self.assertIn('where cylinders > 0', statements['where'], msg="additional options (where) failed") 180 | 181 | # check table statement 182 | self.assertIn( 183 | "table (drivetrain ALL='Total') * type, origin * ((enginesize cylinders) * (mean n))", 184 | statements['table'], 185 | msg="generated table syntax did not match expectation" 186 | ) 187 | 188 | def test_to_dataframe(self): 189 | by_origin, by_type, by_drivetrain = self.cars.tabulate.classes('origin', 'type', 'drivetrain') 190 | enginesize, cylinders = self.cars.tabulate.vars('enginesize', 'cylinders') 191 | mean, n = self.cars.tabulate.stats('mean', 'n') 192 | 193 | # generate a MultiIndex DataFrame instead of printing results 194 | frame = self.cars.tabulate.to_dataframe( 195 | left= by_drivetrain.with_(all="Total") * by_type * 196 | by_origin * (enginesize | cylinders) * (mean | n), 197 | ) 198 | 199 | # verify that the frame was generated correctly 200 | self.assertIsInstance(frame, pd.DataFrame, msg=".to_dataframe() method failed") 201 | self.assertEqual(set(frame.index.names), {'Type', 'Origin', 'DriveTrain'}) 202 | self.assertEqual(set(frame.columns), {'Cylinders_N', 'Cylinders_Mean', 'EngineSize_Mean', 'EngineSize_N'}) 203 | -------------------------------------------------------------------------------- /saspy/doc/source/license.rst: -------------------------------------------------------------------------------- 1 | 2 | .. Copyright SAS Institute 3 | 4 | .. _license: 5 | 6 | 7 | ======= 8 | License 9 | ======= 10 | 11 | Apache 2.0 12 | ========== 13 | 14 | :Date: January 2004 15 | :URL: http://www.apache.org/licenses/ 16 | 17 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 18 | ============================================================ 19 | 20 | 1. Definitions. 21 | ~~~~~~~~~~~~~~~ 22 | 23 | **"License"** shall mean the terms and conditions for use, reproduction, and 24 | distribution as defined by Sections 1 through 9 of this document. 25 | 26 | **"Licensor"** shall mean the copyright owner or entity authorized by the 27 | copyright owner that is granting the License. 28 | 29 | **"Legal Entity"** shall mean the union of the acting entity and all other 30 | entities that control, are controlled by, or are under common control with that 31 | entity. For the purposes of this definition, "control" means *(i)* the power, 32 | direct or indirect, to cause the direction or management of such entity, 33 | whether by contract or otherwise, or *(ii)* ownership of fifty percent (50%) or 34 | more of the outstanding shares, or *(iii)* beneficial ownership of such entity. 35 | 36 | **"You"** (or **"Your"**) shall mean an individual or Legal Entity exercising 37 | permissions granted by this License. 38 | 39 | **"Source"** form shall mean the preferred form for making modifications, 40 | including but not limited to software source code, documentation source, and 41 | configuration files. 42 | 43 | **"Object"** form shall mean any form resulting from mechanical transformation 44 | or translation of a Source form, including but not limited to compiled object 45 | code, generated documentation, and conversions to other media types. 46 | 47 | **"Work"** shall mean the work of authorship, whether in Source or Object form, 48 | made available under the License, as indicated by a copyright notice that is 49 | included in or attached to the work (an example is provided in the Appendix 50 | below). 51 | 52 | **"Derivative Works"** shall mean any work, whether in Source or Object form, 53 | that is based on (or derived from) the Work and for which the editorial 54 | revisions, annotations, elaborations, or other modifications represent, as a 55 | whole, an original work of authorship. For the purposes of this License, 56 | Derivative Works shall not include works that remain separable from, or merely 57 | link (or bind by name) to the interfaces of, the Work and Derivative Works 58 | thereof. 59 | 60 | **"Contribution"** shall mean any work of authorship, including the original 61 | version of the Work and any modifications or additions to that Work or 62 | Derivative Works thereof, that is intentionally submitted to Licensor for 63 | inclusion in the Work by the copyright owner or by an individual or Legal 64 | Entity authorized to submit on behalf of the copyright owner. For the purposes 65 | of this definition, "submitted" means any form of electronic, verbal, or 66 | written communication sent to the Licensor or its representatives, including 67 | but not limited to communication on electronic mailing lists, source code 68 | control systems, and issue tracking systems that are managed by, or on behalf 69 | of, the Licensor for the purpose of discussing and improving the Work, but 70 | excluding communication that is conspicuously marked or otherwise designated in 71 | writing by the copyright owner as "Not a Contribution." 72 | 73 | **"Contributor"** shall mean Licensor and any individual or Legal Entity on 74 | behalf of whom a Contribution has been received by Licensor and subsequently 75 | incorporated within the Work. 76 | 77 | 2. Grant of Copyright License. 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | Subject to the terms and conditions of this License, each Contributor hereby 81 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 82 | irrevocable copyright license to reproduce, prepare Derivative Works of, 83 | publicly display, publicly perform, sublicense, and distribute the Work and 84 | such Derivative Works in Source or Object form. 85 | 86 | 3. Grant of Patent License. 87 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 88 | 89 | Subject to the terms and conditions of this License, each Contributor hereby 90 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 91 | irrevocable (except as stated in this section) patent license to make, have 92 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 93 | such license applies only to those patent claims licensable by such Contributor 94 | that are necessarily infringed by their Contribution(s) alone or by combination 95 | of their Contribution(s) with the Work to which such Contribution(s) was 96 | submitted. If You institute patent litigation against any entity (including a 97 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 98 | Contribution incorporated within the Work constitutes direct or contributory 99 | patent infringement, then any patent licenses granted to You under this License 100 | for that Work shall terminate as of the date such litigation is filed. 101 | 102 | 4. Redistribution. 103 | ~~~~~~~~~~~~~~~~~~ 104 | 105 | You may reproduce and distribute copies of the Work or Derivative Works thereof 106 | in any medium, with or without modifications, and in Source or Object form, 107 | provided that You meet the following conditions: 108 | 109 | - You must give any other recipients of the Work or Derivative Works a copy of 110 | this License; and 111 | 112 | - You must cause any modified files to carry prominent notices stating that You 113 | changed the files; and 114 | 115 | - You must retain, in the Source form of any Derivative Works that You 116 | distribute, all copyright, patent, trademark, and attribution notices from 117 | the Source form of the Work, excluding those notices that do not pertain to 118 | any part of the Derivative Works; and 119 | 120 | - If the Work includes a ``"NOTICE"`` text file as part of its distribution, 121 | then any Derivative Works that You distribute must include a readable copy of 122 | the attribution notices contained within such ``NOTICE`` file, excluding 123 | those notices that do not pertain to any part of the Derivative Works, in at 124 | least one of the following places: within a ``NOTICE`` text file distributed 125 | as part of the Derivative Works; within the Source form or documentation, if 126 | provided along with the Derivative Works; or, within a display generated by 127 | the Derivative Works, if and wherever such third-party notices normally 128 | appear. The contents of the ``NOTICE`` file are for informational purposes 129 | only and do not modify the License. You may add Your own attribution notices 130 | within Derivative Works that You distribute, alongside or as an addendum to 131 | the ``NOTICE`` text from the Work, provided that such additional attribution 132 | notices cannot be construed as modifying the License. You may add Your own 133 | copyright statement to Your modifications and may provide additional or 134 | different license terms and conditions for use, reproduction, or distribution 135 | of Your modifications, or for any such Derivative Works as a whole, provided 136 | Your use, reproduction, and distribution of the Work otherwise complies with 137 | the conditions stated in this License. 138 | 139 | 5. Submission of Contributions. 140 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 141 | 142 | Unless You explicitly state otherwise, any Contribution intentionally submitted 143 | for inclusion in the Work by You to the Licensor shall be under the terms and 144 | conditions of this License, without any additional terms or conditions. 145 | Notwithstanding the above, nothing herein shall supersede or modify the terms 146 | of any separate license agreement you may have executed with Licensor regarding 147 | such Contributions. 148 | 149 | 6. Trademarks. 150 | ~~~~~~~~~~~~~~ 151 | 152 | This License does not grant permission to use the trade names, trademarks, 153 | service marks, or product names of the Licensor, except as required for 154 | reasonable and customary use in describing the origin of the Work and 155 | reproducing the content of the ``NOTICE`` file. 156 | 157 | 7. Disclaimer of Warranty. 158 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 159 | 160 | Unless required by applicable law or agreed to in writing, Licensor provides 161 | the Work (and each Contributor provides its Contributions) on an **"AS IS" 162 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND**, either express or 163 | implied, including, without limitation, any warranties or conditions of 164 | **TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR 165 | PURPOSE**. You are solely responsible for determining the appropriateness of 166 | using or redistributing the Work and assume any risks associated with Your 167 | exercise of permissions under this License. 168 | 169 | 8. Limitation of Liability. 170 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 171 | 172 | In no event and under no legal theory, whether in tort (including negligence), 173 | contract, or otherwise, unless required by applicable law (such as deliberate 174 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 175 | liable to You for damages, including any direct, indirect, special, incidental, 176 | or consequential damages of any character arising as a result of this License 177 | or out of the use or inability to use the Work (including but not limited to 178 | damages for loss of goodwill, work stoppage, computer failure or malfunction, 179 | or any and all other commercial damages or losses), even if such Contributor 180 | has been advised of the possibility of such damages. 181 | 182 | 9. Accepting Warranty or Additional Liability. 183 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 184 | 185 | While redistributing the Work or Derivative Works thereof, You may choose to 186 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 187 | other liability obligations and/or rights consistent with this License. 188 | However, in accepting such obligations, You may act only on Your own behalf and 189 | on Your sole responsibility, not on behalf of any other Contributor, and only 190 | if You agree to indemnify, defend, and hold each Contributor harmless for any 191 | liability incurred by, or claims asserted against, such Contributor by reason 192 | of your accepting any such warranty or additional liability. 193 | 194 | **END OF TERMS AND CONDITIONS** 195 | 196 | APPENDIX: How to apply the Apache License to your work 197 | ====================================================== 198 | 199 | To apply the Apache License to your work, attach the following boilerplate 200 | notice, with the fields enclosed by brackets "[]" replaced with your own 201 | identifying information. (Don't include the brackets!) The text should be 202 | enclosed in the appropriate comment syntax for the file format. We also 203 | recommend that a file or class name and description of purpose be included on 204 | the same "printed page" as the copyright notice for easier identification within 205 | third-party archives. :: 206 | 207 | Copyright [yyyy] [name of copyright owner] 208 | 209 | Licensed under the Apache License, Version 2.0 (the "License"); 210 | you may not use this file except in compliance with the License. 211 | You may obtain a copy of the License at 212 | 213 | http://www.apache.org/licenses/LICENSE-2.0 214 | 215 | Unless required by applicable law or agreed to in writing, software 216 | distributed under the License is distributed on an "AS IS" BASIS, 217 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 218 | See the License for the specific language governing permissions and 219 | limitations under the License. 220 | 221 | -------------------------------------------------------------------------------- /saspy/sascfg.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright SAS Institute 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the License); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | # THIS IS AN EXAMPLE CONFIG FILE. PLEASE CREATE YOUR OWN sascfg_personal.py FILE USING THE APPROPRIATE TEMPLATES FROM BELOW 18 | # SEE THE CONFIGURATION DOC AT https://sassoftware.github.io/saspy/install.html#configuration 19 | 20 | 21 | # Configuration Names for SAS - python List 22 | # This is the list of allowed configuration definitions that can be used. The definition are defined below. 23 | # if there is more than one name in the list, and cfgname= is not specified in SASsession(), then the user 24 | # will be prompted to choose which configuration to use. 25 | # 26 | # The various options for the different access methods can be specified on the SASsession() i.e.: 27 | # sas = SASsession(cfgname='default', options='-fullstimer', user='me') 28 | # 29 | # Based upon the lock_down configuration option below, you may or may not be able to override option 30 | # that are defined already. Any necessary option (like user, pw for IOM or HTTP) that are not defined will be 31 | # prompted for at run time. To dissallow overrides of as OPTION, when you don't have a value, simply 32 | # specify options=''. This way it's specified so it can't be overridden, even though you don't have any 33 | # specific value you want applied. 34 | # 35 | #SAS_config_names = ['default', 'ssh', 'iomlinux', 'iomwin', 'winlocal', 'winiomlinux', 'winiomwin', 'httpsviya', 'httpviya', 'iomcom'] 36 | # 37 | 38 | SAS_config_names=['default'] 39 | 40 | 41 | 42 | # Configuration options for saspy - python Dict # not required unless changing any of the defaults 43 | # valid key are: 44 | # 45 | # 'lock_down' - True | False. True = Prevent runtime overrides of SAS_Config values below 46 | # 47 | # 'verbose' - True | False. True = Allow print statements for debug type messages 48 | # 49 | # 'prompt' - True | False. True = Allow prompting as necessary 50 | # 51 | SAS_config_options = {'lock_down': False, 52 | 'verbose' : True, 53 | 'prompt' : True 54 | } 55 | 56 | 57 | 58 | # Configuration options for SAS output. By default output is HTML 5.0 (using "ods html5" statement) but certain templates might not work 59 | # properly with HTML 5.0 so it can also be set to HTML 4.0 instead (using "ods html" statement). This option will only work when using IOM 60 | # in local mode. Note that HTML 4.0 will generate images separately which clutters the workspace and if you download the notebook as HTML, 61 | # the HTML file will need to be put in the same folder as the images for them to appear. 62 | # 63 | # Note that this configuration option (SAS_output_options) is NOT required unless changing any of the defaults 64 | # 65 | # valid keys are: 66 | # 67 | # 'output' = ['html5', 'html'] 68 | # 'style' = any valid style # this will be the default for SASsession.HTML_Style, which you can also change dynamically in your code 69 | # 'asis' = True # don't tweak the html document (as has always been done) to get it to display better in Jupyter; leave it as is 70 | # 71 | # 72 | SAS_output_options = {'output' : 'html5', # change the ODS output destination; not suggested, only for special use case 73 | 'style' : 'HTMLBlue', # defaults to SAS's default 74 | 'asis' : False # defaults to how this has always worked 75 | } 76 | 77 | 78 | # Configuration Definitions 79 | # 80 | # For STDIO and STDIO over SSH access methods 81 | # These need path to SASHome and optional startup options - python Dict 82 | # The default path to the sas start up script is: /opt/sasinside/SASHome/SASFoundation/9.4/sas 83 | # A usual install path is: /opt/sasinside/SASHome 84 | # 85 | # The encoding is figured out by saspy. You don't need to specify it, unless you just want to get rid of the message about which encoding was determined. 86 | # 87 | # valid keys are: 88 | # 'saspath' - [REQUIRED] path to SAS startup script i.e.: /opt/sasinside/SASHome/SASFoundation/9.4/sas 89 | # 'options' - SAS options to include in the start up command line - Python List 90 | # 'encoding' - This is the python encoding value that matches the SAS session encoding your SAS session is using 91 | # 92 | # For passwordless ssh connection, the following are also reuqired: 93 | # 'ssh' - [REQUIRED] the ssh command to run 94 | # 'host' - [REQUIRED] the host to connect to 95 | # 96 | # Additional valid keys for ssh: 97 | # 'port' - [integer] the remote ssh port 98 | # 'tunnel' - [integer] local port to open via reverse tunnel, if remote host cannot otherwise reach this client 99 | # 100 | default = {'saspath' : '/opt/sasinside/SASHome/SASFoundation/9.4/bin/sas_u8' 101 | } 102 | 103 | ssh = {'saspath' : '/opt/sasinside/SASHome/SASFoundation/9.4/bin/sas_en', 104 | 'ssh' : '/usr/bin/ssh', 105 | 'host' : 'remote.linux.host', 106 | 'encoding': 'latin1', 107 | 'options' : ["-fullstimer"] 108 | } 109 | 110 | 111 | # For IOM (Grid Manager or any IOM) and Local Windows via IOM access method 112 | # These configuration definitions are for connecting over IOM. This is designed to be used to connect to any Workspace server, including SAS Grid, via Grid Manager 113 | # and also to connect to a local Windows SAS session. The client side (python and java) for this access method can be either Linux or Windows. 114 | # The STDIO access method above is only for Linux. PC SAS requires this IOM interface. 115 | # 116 | # The absence of the iomhost option triggers local Windows SAS mode. In this case none of 'iomhost', 'iomport', 'omruser', 'omrpw' are needed. 117 | # a local SAS session is started up and connected to. 118 | # 119 | # The encoding is figured out by saspy. You don't need to specify it, unless you just want to get rid of the message about which encoding was determined. 120 | 121 | # NONE OF THE PATHS IN THESE EAMPLES ARE RIGHT FOR YOUT INSTALL. YOU HAVE TO CHANGE THE PATHS TO BE CORRECT FOR YOUR INSTALLATION 122 | # 123 | # valid keys are: 124 | # 'java' - [REQUIRED] the path to the java executable to use 125 | # 'iomhost' - [REQUIRED for remote IOM case, Don't specify to use a local Windows Session] the resolvable host name, or ip to the IOM server to connect to 126 | # 'iomport' - [REQUIRED for remote IOM case, Don't specify to use a local Windows Session] the port IOM is listening on 127 | # 'authkey' - identifier for user/password credentials to read from .authinfo file. Eliminates prompting for credentials. 128 | # 'omruser' - not suggested [REQUIRED for remote IOM case but PROMPTED for at runtime] Don't specify to use a local Windows Session 129 | # 'omrpw' - really not suggested [REQUIRED for remote IOM case but PROMPTED for at runtime] Don't specify to use a local Windows Session 130 | # 'encoding' - This is the python encoding value that matches the SAS session encoding of the IOM server you are connecting to 131 | # 'appserver' - name of physical workspace server (when more than one app server defined in OMR) i.e.: 'SASApp - Workspace Server' 132 | # 'sspi' - boolean. use IWA instead of user/pw to connect to the IOM workspace server 133 | 134 | 135 | iomlinux = {'java' : '/usr/bin/java', 136 | 'iomhost' : 'linux.iom.host', 137 | 'iomport' : 8591, 138 | } 139 | 140 | iomwin = {'java' : '/usr/bin/java', 141 | 'iomhost' : 'windows.iom.host', 142 | 'iomport' : 8591, 143 | } 144 | 145 | winlocal = {'java' : 'java', 146 | 'encoding' : 'windows-1252', 147 | } 148 | 149 | winiomlinux = {'java' : 'java', 150 | 'iomhost' : 'linux.iom.host', 151 | 'iomport' : 8591, 152 | } 153 | 154 | winiomwin = {'java' : 'java', 155 | 'iomhost' : 'windows.iom.host', 156 | 'iomport' : 8591, 157 | } 158 | 159 | winiomIWA = {'java' : 'java', 160 | 'iomhost' : 'windows.iom.host', 161 | 'iomport' : 8591, 162 | 'sspi' : True 163 | } 164 | 165 | 166 | # For Remote and Local IOM access methods using COM interface 167 | # These configuration definitions are for connecting over IOM using COM. This 168 | # access method is for Windows clients connecting to remote hosts. Local 169 | # SAS instances may also be supported. 170 | # 171 | # This access method does not require a Java dependency. 172 | # 173 | # Valid Keys: 174 | # iomhost - Required for remote connections only. The Resolvable SAS 175 | # server dns name. 176 | # iomport - Required for remote connections only. The SAS workspace 177 | # server port. Generally 8591 on standard remote 178 | # installations. For local connections, 0 is the default. 179 | # class_id - Required for remote connections only. The IOM workspace 180 | # server class identifier. Use `PROC IOMOPERATE` to identify 181 | # the correct value. This option is ignored on local connections. 182 | # provider - [REQUIRED] IOM provider. "sas.iomprovider" is recommended. 183 | # encoding - This is the python encoding value that matches the SAS 184 | # session encoding of the IOM server. 185 | # omruser - SAS user. This option is ignored on local connections. 186 | # omrpw - SAS password. This option is ignored on local connections. 187 | # authkey - Identifier for credentials to read from .authinfo file. 188 | 189 | iomcom = { 190 | 'iomhost' : 'mynode.mycompany.org', 191 | 'iomport' : 8591, 192 | 'provider': 'sas.iomprovider', 193 | 'encoding': 'windows-1252'} 194 | 195 | 196 | # HTTP access method to connect to the Compute Service 197 | # These need ip addr, other values will be prompted for - python Dict 198 | # valid keys are: 199 | # 'url' - (Required if ip not specified) The URL to Viya, of the form "http[s]://host.idenifier[:port]". 200 | # When this is specified, ip= will not be used, as the host's ip is retrieved from the url. Also, ssl= is 201 | # set based upon http or https and port= is also parsed from the url, if provided, else defaulted based 202 | # upon the derived ssl= value. So neither ip, port nor ssl are needed when url= is used. 203 | # 'ip' - (Required if url not specified) The resolvable host name, or IP address to the Viya Compute Service 204 | # 'port' - port; the code Defaults this to based upon the 'ssl' key; 443 default else 80 205 | # 'ssl' - whether to use HTTPS or just HTTP protocal. Default is True, using ssl and poort 443 206 | # 'context' - context name defined on the compute service [PROMTED for at runtime if more than one defined] 207 | # 'authkey' - identifier for user/password credentials to read from .authinfo file. Eliminates prompting for credentials. 208 | # 'options' - SAS options to include (no '-' (dashes), just option names and values) 209 | # 'user' - not suggested [REQUIRED but PROMTED for at runtime] 210 | # 'pw' - really not suggested [REQUIRED but PROMTED for at runtime] 211 | # 212 | # 213 | 214 | httpsviya = {'url' : 'https://viya.deployment.com', 215 | 'context' : 'SAS Studio compute context', 216 | 'authkey' : 'viya_user-pw', 217 | 'options' : ["fullstimer", "memsize=1G"] 218 | } 219 | 220 | httpviya = {'url' : 'https://sastpw.rndk8s.openstack.sas.com:23456', 221 | #'port' : 23456, # can put different port here or ^ if it's not using the default port 222 | 'context' : 'SAS Studio compute context', 223 | 'authkey' : 'viya_user-pw', 224 | 'options' : ["fullstimer", "memsize=1G"] 225 | } 226 | -------------------------------------------------------------------------------- /saspy/doc/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # SASPy documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Oct 28 10:08:55 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import saspy 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.autosummary', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.extlinks', 37 | 'sphinx.ext.todo', 38 | 'numpydoc', # used to parse numpy-style docstrings for autodoc 39 | 'IPython.sphinxext.ipython_directive', 40 | 'IPython.sphinxext.ipython_console_highlighting', 41 | 'sphinx.ext.intersphinx', 42 | 'sphinx.ext.coverage', 43 | 'sphinx.ext.mathjax', 44 | 'sphinx.ext.ifconfig', 45 | 'sphinx.ext.githubpages', 46 | ] 47 | 48 | autosummary_generate = True 49 | numpydoc_show_class_members = False 50 | autodoc_default_flags = ['show-inheritance'] 51 | autoclass_content = 'class' 52 | 53 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None), 54 | 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), 55 | 'numpy': ('https://docs.scipy.org/doc/numpy/', None), 56 | 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), 57 | # 'matplotlib': ('https://matplotlib.sourceforge.net/', None) 58 | } 59 | 60 | 61 | # Add any paths that contain templates here, relative to this directory. 62 | templates_path = ['_templates'] 63 | 64 | # The suffix(es) of source filenames. 65 | # You can specify multiple suffix as a list of string: 66 | # source_suffix = ['.rst', '.md'] 67 | source_suffix = '.rst' 68 | 69 | # The encoding of source files. 70 | #source_encoding = 'utf-8-sig' 71 | 72 | # The master toctree document. 73 | master_doc = 'index' 74 | 75 | # General information about the project. 76 | project = 'saspy' 77 | copyright = '2018 SAS Institute Inc. All Rights Reserved.' 78 | author = 'Jared Dean and Tom Weber' 79 | 80 | # The version info for the project you're documenting, acts as replacement for 81 | # |version| and |release|, also used in various other places throughout the 82 | # built documents. 83 | # 84 | 85 | # The short X.Y version. 86 | version = saspy.__version__ 87 | #version = '2.4.2' 88 | # The full version, including alpha/beta/rc tags. 89 | release = saspy.__version__ 90 | #release = '2.4.2' 91 | 92 | # The language for content autogenerated by Sphinx. Refer to documentation 93 | # for a list of supported languages. 94 | # 95 | # This is also used if you do content translation via gettext catalogs. 96 | # Usually you set "language" from the command line for these cases. 97 | #language = None 98 | 99 | # There are two options for replacing |today|: either, you set today to some 100 | # non-false value, then it is used: 101 | #today = '' 102 | # Else, today_fmt is used as the format for a strftime call. 103 | #today_fmt = '%B %d, %Y' 104 | 105 | # List of patterns, relative to source directory, that match files and 106 | # directories to ignore when looking for source files. 107 | # This patterns also effect to html_static_path and html_extra_path 108 | exclude_patterns = [] 109 | 110 | # The reST default role (used for this markup: `text`) to use for all 111 | # documents. 112 | #default_role = None 113 | 114 | # If true, '()' will be appended to :func: etc. cross-reference text. 115 | #add_function_parentheses = True 116 | 117 | # If true, the current module name will be prepended to all description 118 | # unit titles (such as .. function::). 119 | #add_module_names = True 120 | 121 | # If true, sectionauthor and moduleauthor directives will be shown in the 122 | # output. They are ignored by default. 123 | #show_authors = False 124 | 125 | # The name of the Pygments (syntax highlighting) style to use. 126 | pygments_style = 'sphinx' 127 | 128 | # A list of ignored prefixes for module index sorting. 129 | #modindex_common_prefix = [] 130 | 131 | # If true, keep warnings as "system message" paragraphs in the built documents. 132 | #keep_warnings = False 133 | 134 | # If true, `todo` and `todoList` produce output, else they produce nothing. 135 | todo_include_todos = False 136 | 137 | 138 | # -- Options for HTML output ---------------------------------------------- 139 | 140 | # The theme to use for HTML and HTML Help pages. See the documentation for 141 | # a list of builtin themes. 142 | import sphinx_rtd_theme 143 | 144 | html_theme = 'sphinx_rtd_theme' 145 | 146 | #html_context = { 147 | # 'css_files': ['_static/custom.css'], 148 | #} 149 | 150 | # Theme options are theme-specific and customize the look and feel of a theme 151 | # further. For a list of options available for each theme, see the 152 | # documentation. 153 | html_theme_options = { 154 | # Toc options 155 | 'collapse_navigation': False, 156 | 'sticky_navigation': True, 157 | 'navigation_depth': 5, 158 | 'includehidden': True, 159 | 'titles_only': False, 160 | 'globaltoc_maxdepth' : 5, 161 | 162 | } 163 | 164 | 165 | # Add any paths that contain custom themes here, relative to this directory. 166 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 167 | 168 | # The name for this set of Sphinx documents. 169 | # " v documentation" by default. 170 | #html_title = 'Pipefitter v0.1.0' 171 | 172 | # A shorter title for the navigation bar. Default is the same as html_title. 173 | #html_short_title = None 174 | 175 | # The name of an image file (relative to this directory) to place at the top 176 | # of the sidebar. 177 | #html_logo = None 178 | 179 | # The name of an image file (relative to this directory) to use as a favicon of 180 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 181 | # pixels large. 182 | #html_favicon = None 183 | 184 | # Add any paths that contain custom static files (such as style sheets) here, 185 | # relative to this directory. They are copied after the builtin static files, 186 | # so a file named "default.css" will overwrite the builtin "default.css". 187 | html_static_path = ['_static'] 188 | 189 | # Add any extra paths that contain custom files (such as robots.txt or 190 | # .htaccess) here, relative to this directory. These files are copied 191 | # directly to the root of the documentation. 192 | #html_extra_path = [] 193 | 194 | # If not None, a 'Last updated on:' timestamp is inserted at every page 195 | # bottom, using the given strftime format. 196 | # The empty string is equivalent to '%b %d, %Y'. 197 | #html_last_updated_fmt = None 198 | 199 | # If true, SmartyPants will be used to convert quotes and dashes to 200 | # typographically correct entities. 201 | #html_use_smartypants = True 202 | 203 | # Custom sidebar templates, maps document names to template names. 204 | #html_sidebars = {} 205 | 206 | # Additional templates that should be rendered to pages, maps page names to 207 | # template names. 208 | #html_additional_pages = {} 209 | 210 | # If false, no module index is generated. 211 | #html_domain_indices = True 212 | 213 | # If false, no index is generated. 214 | #html_use_index = True 215 | 216 | # If true, the index is split into individual pages for each letter. 217 | #html_split_index = False 218 | 219 | # If true, links to the reST sources are added to the pages. 220 | #html_show_sourcelink = True 221 | 222 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 223 | #html_show_sphinx = True 224 | 225 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 226 | #html_show_copyright = True 227 | 228 | # If true, an OpenSearch description file will be output, and all pages will 229 | # contain a tag referring to it. The value of this option must be the 230 | # base URL from which the finished HTML is served. 231 | #html_use_opensearch = '' 232 | 233 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 234 | #html_file_suffix = None 235 | 236 | # Language to be used for generating the HTML full-text search index. 237 | # Sphinx supports the following languages: 238 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 239 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 240 | #html_search_language = 'en' 241 | 242 | # A dictionary with options for the search language support, empty by default. 243 | # 'ja' uses this config value. 244 | # 'zh' user can custom change `jieba` dictionary path. 245 | #html_search_options = {'type': 'default'} 246 | 247 | # The name of a javascript file (relative to the configuration directory) that 248 | # implements a search results scorer. If empty, the default will be used. 249 | #html_search_scorer = 'scorer.js' 250 | 251 | # Output file base name for HTML help builder. 252 | #htmlhelp_basename = 'Pipefitterdoc' 253 | 254 | # -- Options for LaTeX output --------------------------------------------- 255 | 256 | latex_elements = { 257 | # The paper size ('letterpaper' or 'a4paper'). 258 | #'papersize': 'letterpaper', 259 | 260 | # The font size ('10pt', '11pt' or '12pt'). 261 | #'pointsize': '10pt', 262 | 263 | # Additional stuff for the LaTeX preamble. 264 | #'preamble': '', 265 | 266 | # Latex figure (float) alignment 267 | #'figure_align': 'htbp', 268 | } 269 | 270 | # Grouping the document tree into LaTeX files. List of tuples 271 | # (source start file, target name, title, 272 | # author, documentclass [howto, manual, or own class]). 273 | latex_documents = [ 274 | (master_doc, 'saspy.tex', 'SASPy', 275 | 'SAS Institute Inc.', 'manual'), 276 | ] 277 | 278 | # The name of an image file (relative to this directory) to place at the top of 279 | # the title page. 280 | #latex_logo = None 281 | 282 | # For "manual" documents, if this is true, then toplevel headings are parts, 283 | # not chapters. 284 | #latex_use_parts = False 285 | 286 | # If true, show page references after internal links. 287 | #latex_show_pagerefs = False 288 | 289 | # If true, show URL addresses after external links. 290 | #latex_show_urls = False 291 | 292 | # Documents to append as an appendix to all manuals. 293 | #latex_appendices = [] 294 | 295 | # If false, no module index is generated. 296 | #latex_domain_indices = True 297 | 298 | 299 | # -- Options for manual page output --------------------------------------- 300 | 301 | # One entry per manual page. List of tuples 302 | # (source start file, name, description, authors, manual section). 303 | man_pages = [ 304 | (master_doc, 'saspy', 'SASPy', 305 | [author], 1) 306 | ] 307 | 308 | # If true, show URL addresses after external links. 309 | #man_show_urls = False 310 | 311 | 312 | # -- Options for Texinfo output ------------------------------------------- 313 | 314 | # Grouping the document tree into Texinfo files. List of tuples 315 | # (source start file, target name, title, author, 316 | # dir menu entry, description, category) 317 | texinfo_documents = [ 318 | (master_doc, 'saspy', 'SASPy', 319 | author, 'SASPy', 'SASPy is a Python module that provides an interface to the SAS system.', 320 | 'Miscellaneous'), 321 | ] 322 | 323 | # Documents to append as an appendix to all manuals. 324 | #texinfo_appendices = [] 325 | 326 | # If false, no module index is generated. 327 | #texinfo_domain_indices = True 328 | 329 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 330 | #texinfo_show_urls = 'footnote' 331 | 332 | # If true, do not generate a @detailmenu in the "Top" node's menu. 333 | #texinfo_no_detailmenu = False 334 | 335 | 336 | # Example configuration for intersphinx: refer to the Python standard library. 337 | #intersphinx_mapping = {'https://docs.python.org/3': None} 338 | -------------------------------------------------------------------------------- /saspy/sastabulate.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | try: 4 | import pandas as pd 5 | except ImportError: 6 | pass 7 | 8 | from collections import ChainMap 9 | import saspy as sp 10 | 11 | from saspy.sasresults import SASresults 12 | 13 | 14 | class TabulationItem: 15 | def __init__(self, key, **kwargs): 16 | self.key = key 17 | self.child = None 18 | self.label = kwargs.get('label', None) 19 | # for chainable cloning 20 | self._args = [key] 21 | self._kwargs = ['label', 'child'] 22 | 23 | def with_(self, **kwargs): 24 | # clone with settable properties, merge new properties 25 | copy = self.__class__(*self._args, **{k: self.__getattribute__(k) for k in self._kwargs}) 26 | for key, value in kwargs.items(): 27 | copy.__setattr__(key, value) 28 | return copy 29 | 30 | def __or__(self, other): 31 | if isinstance(other, TabulationItem): 32 | return Grouping([self, other]) 33 | if type(other) == Grouping: 34 | return other.add(self, True) 35 | 36 | def __mul__(self, other): 37 | if self.child: 38 | self.child = self.child.__mul__(other) 39 | else: 40 | # always clone for composition purposes 41 | return self.with_(child=other) 42 | return self 43 | 44 | # override for entities needing to add top-level entries 45 | def _gather(self, collected): 46 | pass 47 | 48 | 49 | class Class(TabulationItem): 50 | def __init__(self, key, **kwargs): 51 | super().__init__(key, **kwargs) 52 | self.all = kwargs.get('all', None) 53 | self._kwargs += ['all'] 54 | 55 | def __str__(self): 56 | code = self.key 57 | if self.label != None: 58 | code += "='%s'" % self.label 59 | if self.all: 60 | code = "(%s ALL='%s')" % (code, self.all) 61 | if self.child: 62 | code += " * %s" % str(self.child) 63 | return code 64 | 65 | def _gather(self, collected): 66 | collected['classes'].add(self.key) 67 | if (self.child): 68 | self.child._gather(collected) 69 | 70 | 71 | class Var(TabulationItem): 72 | def __str__(self): 73 | code = self.key 74 | if self.label != None: 75 | code += "='%s'" % self.label 76 | if self.child: 77 | code += " * %s" % str(self.child) 78 | return code 79 | 80 | def __mul__(self, other): 81 | if isinstance(other, Class): 82 | raise SyntaxError('A Class variable cannot be a descendent of a Var') 83 | return super().__mul__(other) 84 | 85 | def _gather(self, collected): 86 | collected['vars'].add(self.key) 87 | if (self.child): 88 | self.child._gather(collected) 89 | 90 | 91 | class Statistic(TabulationItem): 92 | def __init__(self, key, **kwargs): 93 | super().__init__(key, **kwargs) 94 | self.format = kwargs.get('format', None) 95 | self._kwargs += ['format'] 96 | 97 | def __str__(self): 98 | code = self.key 99 | if self.label != None: 100 | code += "='%s'" % self.label 101 | if self.format: 102 | code += "*f=%s" % self.format 103 | return code 104 | 105 | def __mul__(self, other): 106 | raise SyntaxError("Statistics cannot have further descendents") 107 | 108 | 109 | class Grouping: 110 | def __init__(self, items, **kwargs): 111 | self.items = items 112 | self.child = None 113 | 114 | def __or__(self, other): 115 | if type(other) == Grouping: 116 | return other.add_all(self.items, True) 117 | return self.add(other) 118 | 119 | def __mul__(self, other): 120 | if self.child: 121 | self.child = self.child.__mul__(other) 122 | else: 123 | self.child = other 124 | return self 125 | 126 | def __str__(self): 127 | code = ' '.join([str(item) for item in self.items]) 128 | if self.child: 129 | code = '(%s) * %s' % (code, str(self.child)) 130 | return '(%s)' % code 131 | 132 | def add(self, other, prepend=False): 133 | if prepend: 134 | self.items.insert(0, other) 135 | else: 136 | self.items.append(other) 137 | return self 138 | 139 | def add_all(self, others, prepend=False): 140 | if prepend: 141 | self.items = others.concat(self.items) 142 | else: 143 | self.items = self.items.concat(others) 144 | return self 145 | 146 | def _gather(self, collected): 147 | for item in self.items: 148 | item._gather(collected) 149 | if (self.child): 150 | self.child._gather(collected) 151 | 152 | 153 | # distribute arg(as key) from value(as list, None, or False) 154 | def build_kwargs(key, value, n): 155 | if value == False: 156 | # mainly used for clearing all labels w/ label=False 157 | kwargs = [{key: ''} for i in range(n)] 158 | elif value and isinstance(value, str): 159 | kwargs = [{key: value} for i in range(n)] 160 | elif value and len(value) == n: 161 | kwargs = [{key: value[i]} for i in range(n)] 162 | else: 163 | kwargs = [dict() for i in range(n)] 164 | return kwargs 165 | 166 | 167 | class Tabulate: 168 | """ 169 | Adds tabulation functions to a SAS dataset 170 | """ 171 | 172 | def __init__(self, session, data): 173 | self.data = data 174 | self.sas = session 175 | self.logger = logging.getLogger(__name__) 176 | self.logger.setLevel(logging.WARN) 177 | self.sasproduct = 'base' 178 | self.logger.debug("Initialization of SAS Macro: " + self.sas.saslog()) 179 | 180 | @staticmethod 181 | def as_class(*args, **kwargs): 182 | return Class(*args, **kwargs) 183 | 184 | @staticmethod 185 | def classes(*args, labels: list = []): 186 | label_kwargs = build_kwargs('label', labels, len(args)) 187 | return [Class(args[i], **label_kwargs[i]) for i in range(len(args))] 188 | 189 | @staticmethod 190 | def as_var(*args, **kwargs): 191 | return Var(*args, **kwargs) 192 | 193 | @staticmethod 194 | def vars(*args, labels: list = []): 195 | label_kwargs = build_kwargs('label', labels, len(args)) 196 | return [Var(args[i], **label_kwargs[i]) for i in range(len(args))] 197 | 198 | @staticmethod 199 | def stat(*args, **kwargs): 200 | return Statistic(*args, **kwargs) 201 | 202 | @staticmethod 203 | def stats(*args, labels: list = [], formats: list = []): 204 | label_kwargs = build_kwargs('label', labels, len(args)) 205 | format_kwargs = build_kwargs('format', formats, len(args)) 206 | return [ 207 | Statistic(args[i], **dict(ChainMap(label_kwargs[i], format_kwargs[i]))) 208 | for i in range(len(args)) 209 | ] 210 | 211 | def table(self, **kwargs: dict) -> SASresults: 212 | """ 213 | Executes a PROC TABULATE statement and displays results in HTML 214 | 215 | :param left: the query for the left side of the table 216 | :param top: the query for the top of the table 217 | :return: 218 | """ 219 | return self.execute_table('HTML', **kwargs) 220 | 221 | def text_table(self, **kwargs: dict) -> SASresults: 222 | """ 223 | Executes a PROC TABULATE statement and displays results as plain text 224 | 225 | :param left: the query for the left side of the table 226 | :param top: the query for the top of the table 227 | :return: 228 | """ 229 | return self.execute_table('text', **kwargs) 230 | 231 | def to_dataframe(self, **kwargs: dict) -> SASresults: 232 | """ 233 | Executes a PROC TABULATE statement and converts results to a MultiIndex DataFrame 234 | 235 | :param left: the query for the left side of the table 236 | :param top: the query for the top of the table 237 | :return: 238 | """ 239 | return self.execute_table('Pandas', **kwargs) 240 | 241 | def execute_table(self, _output_type, **kwargs: dict) -> SASresults: 242 | """ 243 | executes a PROC TABULATE statement 244 | 245 | You must specify an output type to use this method, of 'HTML', 'text', or 'Pandas'. 246 | There are three convenience functions for generating specific output; see: 247 | .text_table() 248 | .table() 249 | .to_dataframe() 250 | 251 | :param _output_type: style of output to use 252 | :param left: the query for the left side of the table 253 | :param top: the query for the top of the table 254 | :return: 255 | """ 256 | 257 | left = kwargs.pop('left', None) 258 | top = kwargs.pop('top', None) 259 | sets = dict(classes=set(), vars=set()) 260 | left._gather(sets) 261 | if top: top._gather(sets) 262 | 263 | table = top \ 264 | and '%s, %s' % (str(left), str(top)) \ 265 | or str(left) 266 | 267 | proc_kwargs = dict( 268 | cls=' '.join(sets['classes']), 269 | var=' '.join(sets['vars']), 270 | table=table 271 | ) 272 | 273 | # permit additional valid options if passed; for now, just 'where' 274 | proc_kwargs.update(kwargs) 275 | 276 | # we can't easily use the SASProcCommons approach for submiting, 277 | # since this is merely an output / display proc for us; 278 | # but we can at least use it to check valid options in the canonical saspy way 279 | required_options = {'cls', 'var', 'table'} 280 | allowed_options = {'cls', 'var', 'table', 'where'} 281 | verifiedKwargs = sp.sasproccommons.SASProcCommons._stmt_check(self, required_options, allowed_options, 282 | proc_kwargs) 283 | 284 | if (_output_type == 'Pandas'): 285 | # for pandas, use the out= directive 286 | code = "proc tabulate data=%s.%s %s out=temptab;\n" % ( 287 | self.data.libref, self.data.table, self.data._dsopts()) 288 | else: 289 | code = "proc tabulate data=%s.%s %s;\n" % (self.data.libref, self.data.table, self.data._dsopts()) 290 | 291 | # build the code 292 | for arg, value in verifiedKwargs.items(): 293 | code += " %s %s;\n" % (arg == 'cls' and 'class' or arg, value) 294 | code += "run;" 295 | 296 | # teach_me_SAS 297 | if self.sas.nosub: 298 | print(code) 299 | return 300 | 301 | # submit the code 302 | ll = self.data._is_valid() 303 | 304 | if _output_type == 'HTML': 305 | if not ll: 306 | html = self.data.HTML 307 | self.data.HTML = 1 308 | ll = self.sas._io.submit(code) 309 | self.data.HTML = html 310 | if not self.sas.batch: 311 | self.sas.DISPLAY(self.sas.HTML(ll['LST'])) 312 | check, errorMsg = self.data._checkLogForError(ll['LOG']) 313 | if not check: 314 | raise ValueError("Internal code execution failed: " + errorMsg) 315 | else: 316 | return ll 317 | 318 | elif _output_type == 'text': 319 | if not ll: 320 | html = self.data.HTML 321 | self.data.HTML = 1 322 | ll = self.sas._io.submit(code, 'text') 323 | self.data.HTML = html 324 | print(ll['LST']) 325 | return 326 | 327 | elif _output_type == 'Pandas': 328 | if self.sas.sascfg.pandas: 329 | raise type(self.sas.sascfg.pandas)(self.sas.sascfg.pandas.msg) 330 | return self.to_nested_dataframe(code) 331 | 332 | def to_nested_dataframe(self, code): 333 | result = self.sas._io.submit(code) 334 | outdata = self.sas.sd2df('temptab') 335 | 336 | # slice groupings (classes) and stats from results table 337 | classes = outdata.columns[:outdata.columns.tolist().index('_TYPE_')].tolist() 338 | stats = outdata.columns[outdata.columns.tolist().index('_TABLE_') + 1:].tolist() 339 | 340 | # build frame with nested indices 341 | frame = pd.DataFrame.from_dict({ 342 | tuple([row['_TYPE_'][i] == '1' and row[c] or '_ALL_' for i, c in enumerate(classes)]): dict( 343 | (stat, row[stat]) for stat in stats) 344 | for row in outdata.to_dict(orient='records') 345 | }, orient='index') 346 | frame.index = frame.index.set_names(classes) 347 | 348 | return frame 349 | --------------------------------------------------------------------------------